This is an automated email from the ASF dual-hosted git repository. EnxDev pushed a commit to branch enxdev/feat/extension-import-delete in repository https://gitbox.apache.org/repos/asf/superset.git
commit e40f08afe015f140e3ccc7766c830c93879e2b3b Author: Enzo Martellucci <[email protected]> AuthorDate: Tue May 26 20:52:12 2026 +0200 feat(extensions): add import/delete UI and chatbot selection in ExtensionsList Backend: - POST /api/v1/extensions/ — admin-only upload of .supx bundles (zip validation, safe-zip check, persisted to EXTENSIONS_PATH) - DELETE /api/v1/extensions/<publisher>/<name> — admin-only removal; rejects LOCAL_EXTENSIONS configured entries Frontend: - Import button in SubMenu (file picker, .supx only) calls upload endpoint - Per-row delete action with confirmation dialog - Per-row star action to set/clear default chatbot via PUT /settings; filled star indicates the active chatbot, fires notifyExtensionSettingsChanged so ChatbotMount reacts without a page reload Settings pub/sub (notifyExtensionSettingsChanged / subscribeToExtensionSettings) allows components to react to admin settings changes without a page reload. Also fix scripts/oxlint.sh: [ -n "$output" ] && echo would exit 1 under set -e when output is empty (no lint errors). Use if/fi instead. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --- scripts/oxlint.sh | 2 +- superset-frontend/src/core/extensions/index.ts | 25 +++ .../src/extensions/ExtensionsList.tsx | 212 +++++++++++++++++++- superset/extensions/api.py | 221 ++++++++++++++++++++- 4 files changed, 454 insertions(+), 6 deletions(-) diff --git a/scripts/oxlint.sh b/scripts/oxlint.sh index 95f48afabb6..9baf026ddf2 100755 --- a/scripts/oxlint.sh +++ b/scripts/oxlint.sh @@ -55,7 +55,7 @@ if [ ${#js_ts_files[@]} -gt 0 ]; then echo "$output" >&2 exit 1 } - [ -n "$output" ] && echo "$output" + if [ -n "$output" ]; then echo "$output"; fi else echo "No JavaScript/TypeScript files to lint" fi diff --git a/superset-frontend/src/core/extensions/index.ts b/superset-frontend/src/core/extensions/index.ts index ae49a135aed..dedeff005ad 100644 --- a/superset-frontend/src/core/extensions/index.ts +++ b/superset-frontend/src/core/extensions/index.ts @@ -29,3 +29,28 @@ export const extensions: typeof extensionsApi = { getExtension, getAllExtensions, }; + +// Pub/sub for admin extension settings changes. Allows components like +// ChatbotMount to react when ExtensionsList persists a new settings payload, +// without coupling them through Redux or a shared API hook. +type SettingsListener = () => void; +const settingsListeners = new Set<SettingsListener>(); + +/** + * Notify all subscribers that extension settings have changed. + * Call this after a successful PUT /api/v1/extensions/settings. + */ +export const notifyExtensionSettingsChanged = (): void => { + settingsListeners.forEach(fn => fn()); +}; + +/** + * Subscribe to extension settings changes. + * Returns an unsubscribe function. + */ +export const subscribeToExtensionSettings = ( + listener: SettingsListener, +): (() => void) => { + settingsListeners.add(listener); + return () => settingsListeners.delete(listener); +}; diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx b/superset-frontend/src/extensions/ExtensionsList.tsx index 6f4f9c2f56d..31bba44f5da 100644 --- a/superset-frontend/src/extensions/ExtensionsList.tsx +++ b/superset-frontend/src/extensions/ExtensionsList.tsx @@ -17,17 +17,30 @@ * under the License. */ import { t } from '@apache-superset/core/translation'; -import { FunctionComponent, useMemo } from 'react'; +import { SupersetClient } from '@superset-ui/core'; +import { + FunctionComponent, + useCallback, + useEffect, + useRef, + useState, + useMemo, +} from 'react'; import { useListViewResource } from 'src/views/CRUD/hooks'; +import { createErrorHandler } from 'src/views/CRUD/utils'; import { ListView } from 'src/components'; import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; import withToasts from 'src/components/MessageToasts/withToasts'; +import { ConfirmStatusChange, Tooltip } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { notifyExtensionSettingsChanged } from 'src/core/extensions'; const PAGE_SIZE = 25; type Extension = { - id: number; + id: string; name: string; + publisher: string; enabled: boolean; }; @@ -40,6 +53,10 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({ addDangerToast, addSuccessToast, }) => { + const fileInputRef = useRef<HTMLInputElement>(null); + const [uploading, setUploading] = useState(false); + const [activeChatbotId, setActiveChatbotId] = useState<string | null>(null); + const { state: { loading, resourceCount, resourceCollection }, fetchData, @@ -50,6 +67,100 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({ addDangerToast, ); + // Load current active chatbot from settings on mount + useEffect(() => { + SupersetClient.get({ endpoint: '/api/v1/extensions/settings' }) + .then(({ json }) => { + setActiveChatbotId(json?.result?.active_chatbot_id ?? null); + }) + .catch(() => { + // non-fatal: leave activeChatbotId as null + }); + }, []); + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.name.endsWith('.supx')) { + addDangerToast(t('File must have a .supx extension.')); + e.target.value = ''; + return; + } + + const formData = new FormData(); + formData.append('bundle', file); + + setUploading(true); + SupersetClient.post({ + endpoint: '/api/v1/extensions/', + body: formData, + headers: { Accept: 'application/json' }, + }) + .then(() => { + addSuccessToast(t('Extension installed successfully.')); + refreshData(); + }) + .catch( + createErrorHandler(errMsg => + addDangerToast( + t('There was an issue installing the extension: %s', errMsg), + ), + ), + ) + .finally(() => { + setUploading(false); + e.target.value = ''; + }); + }; + + const handleDelete = (extension: Extension) => { + const { publisher, name } = extension; + SupersetClient.delete({ + endpoint: `/api/v1/extensions/${publisher}/${name}`, + }).then( + () => { + addSuccessToast(t('Deleted: %s', extension.name)); + refreshData(); + }, + createErrorHandler(errMsg => + addDangerToast( + t('There was an issue deleting %s: %s', extension.name, errMsg), + ), + ), + ); + }; + + const handleSetDefaultChatbot = useCallback( + (extension: Extension) => { + const newId = activeChatbotId === extension.id ? null : extension.id; + SupersetClient.put({ + endpoint: '/api/v1/extensions/settings', + jsonPayload: { active_chatbot_id: newId }, + }).then( + () => { + setActiveChatbotId(newId); + notifyExtensionSettingsChanged(); + addSuccessToast( + newId + ? t('%s set as default chatbot.', extension.name) + : t('Default chatbot cleared.'), + ); + }, + createErrorHandler(errMsg => + addDangerToast( + t('There was an issue updating chatbot settings: %s', errMsg), + ), + ), + ); + }, + [activeChatbotId, addDangerToast, addSuccessToast], + ); + const columns = useMemo( () => [ { @@ -63,18 +174,111 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({ }, }: any) => name, }, + { + Header: t('Publisher'), + accessor: 'publisher', + id: 'publisher', + Cell: ({ + row: { + original: { publisher }, + }, + }: any) => publisher, + }, + { + Header: t('Actions'), + id: 'actions', + disableSortBy: true, + Cell: ({ row: { original } }: any) => { + const isDefault = activeChatbotId === original.id; + return ( + <> + <Tooltip + id="set-chatbot-tooltip" + title={ + isDefault + ? t('Clear default chatbot') + : t('Set as default chatbot') + } + placement="bottom" + > + <span + role="button" + tabIndex={0} + className="action-button" + onClick={() => handleSetDefaultChatbot(original)} + > + {isDefault ? ( + <Icons.StarFilled iconSize="l" /> + ) : ( + <Icons.StarOutlined iconSize="l" /> + )} + </span> + </Tooltip> + <ConfirmStatusChange + title={t('Please confirm')} + description={ + <> + {t('Are you sure you want to delete')}{' '} + <b>{original.name}</b>? + </> + } + onConfirm={() => handleDelete(original)} + > + {(confirmDelete: () => void) => ( + <Tooltip + id="delete-extension-tooltip" + title={t('Delete')} + placement="bottom" + > + <span + role="button" + tabIndex={0} + className="action-button" + onClick={confirmDelete} + > + <Icons.DeleteOutlined iconSize="l" /> + </span> + </Tooltip> + )} + </ConfirmStatusChange> + </> + ); + }, + }, ], - [loading], // We need to monitor loading to avoid stale state in actions + [loading, activeChatbotId, handleSetDefaultChatbot], ); const menuData: SubMenuProps = { activeChild: 'Extensions', name: t('Extensions'), - buttons: [], + buttons: [ + { + name: ( + <Tooltip + id="import-extension-tooltip" + title={t('Import extension (.supx)')} + placement="bottomRight" + > + <Icons.DownloadOutlined iconSize="l" /> + </Tooltip> + ), + buttonStyle: 'link', + onClick: handleUploadClick, + loading: uploading, + }, + ], }; return ( <> + <input + ref={fileInputRef} + type="file" + accept=".supx" + style={{ display: 'none' }} + onChange={handleFileChange} + /> <SubMenu {...menuData} /> <ListView<Extension> columns={columns} diff --git a/superset/extensions/api.py b/superset/extensions/api.py index b1b5734979e..380e2c94165 100644 --- a/superset/extensions/api.py +++ b/superset/extensions/api.py @@ -16,21 +16,43 @@ # under the License. import mimetypes from io import BytesIO +from pathlib import Path from typing import Any +from zipfile import is_zipfile, ZipFile -from flask import send_file +from flask import current_app, request, send_file from flask.wrappers import Response from flask_appbuilder.api import BaseApi, expose, protect, safe +from superset.extensions import security_manager +from superset.extensions.settings import ( + get_extension_settings, + update_extension_settings, +) from superset.extensions.utils import ( build_extension_data, + get_bundle_files_from_zip, get_extensions, + get_loaded_extension, ) +from superset.utils.core import check_is_safe_zip class ExtensionsRestApi(BaseApi): allow_browser_login = True resource_name = "extensions" + class_permission_name = "Extensions" + base_permissions = [ + "can_get_list", + "can_get", + "can_put", + "can_post", + "can_delete", + "can_content", + "can_info", + "can_get_settings", + "can_put_settings", + ] def response(self, status_code: int, **kwargs: Any) -> Response: """Helper method to create JSON responses.""" @@ -167,6 +189,203 @@ class ExtensionsRestApi(BaseApi): extension_data = build_extension_data(extension) return self.response(200, result=extension_data) + @protect() + @safe + @expose("/", methods=("POST",)) + def post(self, **kwargs: Any) -> Response: + """Upload and install an extension bundle (.supx file). + --- + post: + summary: Upload a .supx extension bundle. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + bundle: + type: string + format: binary + description: The .supx extension bundle file. + responses: + 201: + description: Extension installed successfully. + content: + application/json: + schema: + type: object + properties: + result: + type: object + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 500: + $ref: '#/components/responses/500' + """ + if not security_manager.is_admin(): + return self.response(403, message="Admin access required.") + + extensions_path = current_app.config.get("EXTENSIONS_PATH") + if not extensions_path: + return self.response( + 400, + message=( + "EXTENSIONS_PATH is not configured. Set it in superset_config.py " + "to enable extension uploads." + ), + ) + + upload = request.files.get("bundle") + if not upload: + return self.response( + 400, message="No file provided. Send a 'bundle' field." + ) + + if not upload.filename or not upload.filename.endswith(".supx"): + return self.response(400, message="File must have a .supx extension.") + + stream = BytesIO(upload.read()) + if not is_zipfile(stream): + return self.response(400, message="File is not a valid ZIP archive.") + + stream.seek(0) + try: + with ZipFile(stream, "r") as zip_file: + check_is_safe_zip(zip_file) + files = list(get_bundle_files_from_zip(zip_file)) + extension = get_loaded_extension(files, source_base_path="upload://") + except Exception as ex: # pylint: disable=broad-except + return self.response(400, message=f"Invalid extension bundle: {ex}") + + # Persist to EXTENSIONS_PATH so the extension survives restarts. + dest_dir = Path(extensions_path) + dest_dir.mkdir(parents=True, exist_ok=True) + dest_file = dest_dir / f"{extension.manifest.id}.supx" + + stream.seek(0) + dest_file.write_bytes(stream.read()) + + return self.response(201, result=build_extension_data(extension)) + + @protect() + @safe + @expose("/<publisher>/<name>", methods=("DELETE",)) + def delete(self, publisher: str, name: str, **kwargs: Any) -> Response: + """Delete an uploaded extension bundle. + --- + delete: + summary: Delete an extension by its publisher and name. + parameters: + - in: path + schema: + type: string + name: publisher + - in: path + schema: + type: string + name: name + responses: + 200: + description: Extension deleted. + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + """ + if not security_manager.is_admin(): + return self.response(403, message="Admin access required.") + + composite_id = f"{publisher}.{name}" + extensions = get_extensions() + extension = extensions.get(composite_id) + if not extension: + return self.response_404() + + # LOCAL_EXTENSIONS are managed via config — cannot be deleted through the UI. + local_paths = { + str((Path(p) / "dist").resolve()) + for p in current_app.config.get("LOCAL_EXTENSIONS", []) + } + if extension.source_base_path in local_paths: + return self.response( + 400, + message=( + "Local extensions configured via LOCAL_EXTENSIONS cannot be " + "deleted through the UI. Remove them from your configuration." + ), + ) + + # Locate and remove the .supx file from EXTENSIONS_PATH. + extensions_path = current_app.config.get("EXTENSIONS_PATH") + if not extensions_path: + return self.response( + 400, + message="EXTENSIONS_PATH is not configured; cannot remove bundle file.", + ) + + supx_file = Path(extensions_path) / f"{composite_id}.supx" + if not supx_file.exists(): + return self.response_404() + + supx_file.unlink() + return self.response(200, message="Extension deleted.") + + @protect() + @safe + @expose("/settings", methods=("GET",)) + def get_settings(self, **kwargs: Any) -> Response: + """Get global extension admin settings. + --- + get: + summary: Get extension admin settings (active chatbot, enabled flags). + responses: + 200: + description: Extension settings + """ + return self.response(200, result=get_extension_settings()) + + @protect() + @safe + @expose("/settings", methods=("PUT",)) + def put_settings(self, **kwargs: Any) -> Response: + """Update global extension admin settings. + --- + put: + summary: Update extension admin settings. + requestBody: + content: + application/json: + schema: + type: object + properties: + active_chatbot_id: + type: string + nullable: true + enabled: + type: object + additionalProperties: + type: boolean + responses: + 200: + description: Updated settings + 403: + $ref: '#/components/responses/403' + """ + if not security_manager.is_admin(): + return self.response(403, message="Admin access required.") + body = request.get_json(silent=True) or {} + result = update_extension_settings(body) + return self.response(200, result=result) + @protect() @safe @expose("/<publisher>/<name>/<file>", methods=("GET",))
