From 1837ad17ea9dcf24e7e1fd320888120a192eb483 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 23 Jul 2026 14:39:52 -0700 Subject: [PATCH 1/4] [deploy] publish 0.8.0a4 From 74ef4465ebdd4f87bf7123cb46b45887caded9e7 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 23 Jul 2026 15:48:49 -0700 Subject: [PATCH 2/4] fixes --- py-src/data_formulator/data_connector.py | 33 +++ .../data_loader/athena_data_loader.py | 6 +- .../data_loader/azure_blob_data_loader.py | 2 +- .../data_loader/bigquery_data_loader.py | 2 +- .../data_loader/local_folder_data_loader.py | 2 + .../data_loader/mongodb_data_loader.py | 2 +- .../data_loader/mssql_data_loader.py | 1 + .../data_loader/mysql_data_loader.py | 2 +- .../data_loader/postgresql_data_loader.py | 2 +- src/app/connectorNames.ts | 38 ++++ src/app/utils.tsx | 1 + src/components/ComponentType.tsx | 1 + src/components/ConnectorFormCard.tsx | 22 +- src/views/DBTableManager.tsx | 173 ++++++++-------- src/views/UnifiedDataUploadDialog.tsx | 190 ++++++++++++++---- .../data/test_data_connector_framework.py | 17 ++ 16 files changed, 341 insertions(+), 153 deletions(-) create mode 100644 src/app/connectorNames.ts diff --git a/py-src/data_formulator/data_connector.py b/py-src/data_formulator/data_connector.py index 0ef27bf5..60763a88 100644 --- a/py-src/data_formulator/data_connector.py +++ b/py-src/data_formulator/data_connector.py @@ -1241,6 +1241,7 @@ def list_connectors(): "source": "admin" if is_admin else "user", "deletable": not is_admin, "source_type": connector._loader_class.__name__, + "type_name": connector._loader_class.DISPLAY_NAME or connector._icon, "display_name": connector._display_name, "icon": connector._icon, "connected": connected, @@ -1444,6 +1445,38 @@ def _remove_user_connector(identity: str, connector_id: str) -> None: logger.warning("Failed to remove connector spec '%s': %s", connector_id, e) +def _update_user_connector_display_name(identity: str, connector_id: str, display_name: str) -> None: + jail = _connectors_jail(identity, mkdir=False) + filename = f"{_safe_source_filename(connector_id)}.json" + path = jail.resolve(filename) + if not path.exists(): + raise AppError(ErrorCode.CONNECTOR_ERROR, f"Connector config not found: {connector_id}") + with open(path, "r", encoding="utf-8") as f: + entry = _json.load(f) + entry["display_name"] = display_name + with open(path, "w", encoding="utf-8") as f: + _json.dump(entry, f, ensure_ascii=False, indent=2) + + +@connectors_bp.route("/api/connectors/", methods=["PATCH"]) +def update_connector(connector_id: str): + """Rename a user connector without changing its stable source ID.""" + if connector_id in _ADMIN_CONNECTOR_IDS: + raise AppError(ErrorCode.ACCESS_DENIED, "Admin connectors cannot be renamed") + + display_name = str((request.get_json() or {}).get("display_name", "")).strip() + if not display_name: + raise AppError(ErrorCode.INVALID_REQUEST, "display_name is required") + if len(display_name) > 120: + raise AppError(ErrorCode.VALIDATION_ERROR, "display_name must be 120 characters or fewer") + + _registry_key, connector = _resolve_connector_with_key({"connector_id": connector_id}) + identity = DataConnector._get_identity() + _update_user_connector_display_name(identity, connector_id, display_name) + connector._display_name = display_name + return json_ok({"id": connector_id, "display_name": display_name}) + + @connectors_bp.route("/api/connectors/", methods=["DELETE"]) def delete_connector(connector_id: str): """Delete a **user** connector instance, clear vault credentials, and remove from config. diff --git a/py-src/data_formulator/data_loader/athena_data_loader.py b/py-src/data_formulator/data_loader/athena_data_loader.py index d52fa38f..259e7156 100644 --- a/py-src/data_formulator/data_loader/athena_data_loader.py +++ b/py-src/data_formulator/data_loader/athena_data_loader.py @@ -68,10 +68,10 @@ def list_params() -> list[dict[str, Any]]: {"name": "aws_secret_access_key", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "AWS secret access key (not required if using aws_profile)"}, {"name": "aws_session_token", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "AWS session token (required for temporary credentials)"}, {"name": "region_name", "type": "string", "required": True, "default": "us-east-1", "tier": "connection", "description": "AWS region name"}, - {"name": "workgroup", "type": "string", "required": False, "default": "primary", "tier": "connection", "description": "Athena workgroup name (output location is fetched from workgroup configuration)"}, - {"name": "output_location", "type": "string", "required": False, "default": "", "tier": "connection", "description": "S3 output location for query results (e.g., s3://bucket/path/). If empty, uses workgroup configuration."}, + {"name": "workgroup", "type": "string", "required": False, "default": "primary", "tier": "connection", "advanced": True, "description": "Athena workgroup name (output location is fetched from workgroup configuration)"}, + {"name": "output_location", "type": "string", "required": False, "default": "", "tier": "connection", "advanced": True, "description": "S3 output location for query results (e.g., s3://bucket/path/). If empty, uses workgroup configuration."}, {"name": "database", "type": "string", "required": False, "default": "", "tier": "filter", "description": "Default database/catalog to use for queries"}, - {"name": "query_timeout", "type": "number", "required": False, "default": 300, "tier": "connection", "description": "Query execution timeout in seconds (default: 300 = 5 minutes)"} + {"name": "query_timeout", "type": "number", "required": False, "default": 300, "tier": "connection", "advanced": True, "description": "Query execution timeout in seconds (default: 300 = 5 minutes)"} ] return params_list diff --git a/py-src/data_formulator/data_loader/azure_blob_data_loader.py b/py-src/data_formulator/data_loader/azure_blob_data_loader.py index 1e78d2b0..1ea45372 100644 --- a/py-src/data_formulator/data_loader/azure_blob_data_loader.py +++ b/py-src/data_formulator/data_loader/azure_blob_data_loader.py @@ -28,7 +28,7 @@ def list_params() -> list[dict[str, Any]]: {"name": "credential_chain", "type": "string", "required": False, "default": "cli;managed_identity;env", "tier": "auth", "description": "Ordered list of Azure credential providers (cli;managed_identity;env)"}, {"name": "account_key", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "Azure storage account key"}, {"name": "sas_token", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "Azure SAS token"}, - {"name": "endpoint", "type": "string", "required": False, "default": "blob.core.windows.net", "tier": "connection", "description": "Azure endpoint override"} + {"name": "endpoint", "type": "string", "required": False, "default": "blob.core.windows.net", "tier": "connection", "advanced": True, "description": "Azure endpoint override"} ] return params_list diff --git a/py-src/data_formulator/data_loader/bigquery_data_loader.py b/py-src/data_formulator/data_loader/bigquery_data_loader.py index 76e8e98a..632cda2d 100644 --- a/py-src/data_formulator/data_loader/bigquery_data_loader.py +++ b/py-src/data_formulator/data_loader/bigquery_data_loader.py @@ -23,7 +23,7 @@ def list_params() -> list[dict[str, Any]]: {"name": "project_id", "type": "text", "required": True, "tier": "connection", "description": "Google Cloud Project ID", "default": ""}, {"name": "dataset_id", "type": "text", "required": False, "tier": "filter", "description": "Dataset ID(s) - leave empty for all, or specify one (e.g., 'billing') or multiple separated by commas (e.g., 'billing,enterprise_collected,ga_api')", "default": ""}, {"name": "credentials_path", "type": "text", "required": False, "tier": "auth", "description": "Path to service account JSON file (optional)", "default": ""}, - {"name": "location", "type": "text", "required": False, "tier": "connection", "description": "BigQuery location (default: US)", "default": "US"} + {"name": "location", "type": "text", "required": False, "tier": "connection", "advanced": True, "description": "BigQuery location (default: US)", "default": "US"} ] @staticmethod diff --git a/py-src/data_formulator/data_loader/local_folder_data_loader.py b/py-src/data_formulator/data_loader/local_folder_data_loader.py index c21445eb..08617428 100644 --- a/py-src/data_formulator/data_loader/local_folder_data_loader.py +++ b/py-src/data_formulator/data_loader/local_folder_data_loader.py @@ -54,6 +54,7 @@ def list_params() -> list[dict[str, Any]]: "required": False, "default": "true", "tier": "connection", + "advanced": True, "description": "Include files in subdirectories", }, { @@ -62,6 +63,7 @@ def list_params() -> list[dict[str, Any]]: "required": False, "default": "", "tier": "connection", + "advanced": True, "description": "Glob pattern to filter files (e.g. '*.csv')", }, ] diff --git a/py-src/data_formulator/data_loader/mongodb_data_loader.py b/py-src/data_formulator/data_loader/mongodb_data_loader.py index e8a8029c..85fa706e 100644 --- a/py-src/data_formulator/data_loader/mongodb_data_loader.py +++ b/py-src/data_formulator/data_loader/mongodb_data_loader.py @@ -23,7 +23,7 @@ class MongoDBDataLoader(ExternalDataLoader): def list_params() -> list[dict[str, Any]]: params_list = [ {"name": "host", "type": "string", "required": True, "default": "localhost", "tier": "connection", "description": "server address"}, - {"name": "port", "type": "int", "required": False, "default": 27017, "tier": "connection", "description": "server port"}, + {"name": "port", "type": "int", "required": False, "default": 27017, "tier": "connection", "advanced": True, "description": "server port"}, {"name": "username", "type": "string", "required": False, "default": "", "tier": "auth", "description": "leave blank if no auth"}, {"name": "password", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "leave blank if no auth"}, {"name": "database", "type": "string", "required": True, "default": "", "tier": "connection", "description": "database name"}, diff --git a/py-src/data_formulator/data_loader/mssql_data_loader.py b/py-src/data_formulator/data_loader/mssql_data_loader.py index db75aa58..264c6912 100644 --- a/py-src/data_formulator/data_loader/mssql_data_loader.py +++ b/py-src/data_formulator/data_loader/mssql_data_loader.py @@ -58,6 +58,7 @@ def list_params() -> list[dict[str, Any]]: "required": False, "default": "1433", "tier": "connection", + "advanced": True, "description": "SQL Server port (default: 1433)", }, { diff --git a/py-src/data_formulator/data_loader/mysql_data_loader.py b/py-src/data_formulator/data_loader/mysql_data_loader.py index 4f09be47..1927ec7b 100644 --- a/py-src/data_formulator/data_loader/mysql_data_loader.py +++ b/py-src/data_formulator/data_loader/mysql_data_loader.py @@ -31,7 +31,7 @@ def list_params() -> list[dict[str, Any]]: {"name": "user", "type": "string", "required": True, "default": "root", "tier": "auth", "description": "MySQL username"}, {"name": "password", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "leave blank for no password"}, {"name": "host", "type": "string", "required": True, "default": "localhost", "tier": "connection", "description": "server address"}, - {"name": "port", "type": "int", "required": False, "default": 3306, "tier": "connection", "description": "server port"}, + {"name": "port", "type": "int", "required": False, "default": 3306, "tier": "connection", "advanced": True, "description": "server port"}, {"name": "database", "type": "string", "required": False, "default": "", "tier": "filter", "description": "Database name (leave empty to browse all databases)"} ] return params_list diff --git a/py-src/data_formulator/data_loader/postgresql_data_loader.py b/py-src/data_formulator/data_loader/postgresql_data_loader.py index 1016fc2b..98dcdd72 100644 --- a/py-src/data_formulator/data_loader/postgresql_data_loader.py +++ b/py-src/data_formulator/data_loader/postgresql_data_loader.py @@ -35,7 +35,7 @@ def list_params() -> list[dict[str, Any]]: {"name": "user", "type": "string", "required": True, "default": "postgres", "tier": "auth", "description": "PostgreSQL username"}, {"name": "password", "type": "string", "required": False, "default": "", "sensitive": True, "tier": "auth", "description": "leave blank for no password"}, {"name": "host", "type": "string", "required": True, "default": "localhost", "tier": "connection", "description": "PostgreSQL host"}, - {"name": "port", "type": "string", "required": False, "default": "5432", "tier": "connection", "description": "PostgreSQL port"}, + {"name": "port", "type": "string", "required": False, "default": "5432", "tier": "connection", "advanced": True, "description": "PostgreSQL port"}, {"name": "database", "type": "string", "required": False, "default": "", "tier": "filter", "description": "Database name (leave empty to browse all databases)"} ] return params_list diff --git a/src/app/connectorNames.ts b/src/app/connectorNames.ts new file mode 100644 index 00000000..24f06a2a --- /dev/null +++ b/src/app/connectorNames.ts @@ -0,0 +1,38 @@ +const CONNECTION_IDENTITY_KEYS = [ + 'host', + 'server', + 'server_hostname', + 'endpoint', + 'url', + 'account_name', + 'bucket', + 'project_id', + 'kusto_cluster', + 'database', + 'root_dir', +] as const; + +const conciseIdentity = (value: string): string => { + const trimmed = value.trim().replace(/\/$/, ''); + if (!trimmed) return ''; + + try { + const parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`); + return parsed.host || trimmed; + } catch { + return trimmed; + } +}; + +export const deriveConnectorDisplayName = ( + loaderName: string, + params: Record, +): string => { + for (const key of CONNECTION_IDENTITY_KEYS) { + const value = params[key]; + if (typeof value !== 'string') continue; + const identity = conciseIdentity(value); + if (identity) return `${loaderName} · ${identity}`; + } + return loaderName; +}; diff --git a/src/app/utils.tsx b/src/app/utils.tsx index cc9c73b0..dc8c4f8c 100644 --- a/src/app/utils.tsx +++ b/src/app/utils.tsx @@ -124,6 +124,7 @@ export const CONNECTOR_URLS = { DATA_LOADERS: '/api/data-loaders', LIST: '/api/connectors', CREATE: '/api/connectors', + UPDATE: (id: string) => `/api/connectors/${id}`, DELETE: (id: string) => `/api/connectors/${id}`, } as const; diff --git a/src/components/ComponentType.tsx b/src/components/ComponentType.tsx index f94f87be..38074308 100644 --- a/src/components/ComponentType.tsx +++ b/src/components/ComponentType.tsx @@ -585,6 +585,7 @@ export interface ConnectorAuthPath { export interface ConnectorInstance { id: string; source_type: string; + type_name?: string; display_name: string; icon: string; connected: boolean; diff --git a/src/components/ConnectorFormCard.tsx b/src/components/ConnectorFormCard.tsx index 13934f03..694f5646 100644 --- a/src/components/ConnectorFormCard.tsx +++ b/src/components/ConnectorFormCard.tsx @@ -22,6 +22,7 @@ import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import { useDispatch } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { apiRequest } from '../app/apiClient'; +import { deriveConnectorDisplayName } from '../app/connectorNames'; import { CONNECTOR_URLS } from '../app/utils'; import { dfActions } from '../app/dfSlice'; import { AppDispatch } from '../app/store'; @@ -59,12 +60,12 @@ export const ConnectorFormCard: React.FC = ({ messageId, const [metaError, setMetaError] = useState(''); const [loadingMeta, setLoadingMeta] = useState(true); const [expanded, setExpanded] = useState(defaultExpanded); - const [connectionName, setConnectionName] = useState(prompt.connectionName || ''); // Connected-state: collapsible details panel (non-sensitive only). const [connExpanded, setConnExpanded] = useState(false); const [connDetails, setConnDetails] = useState>([]); const createdIdRef = useRef(prompt.connectorId ?? null); + const generatedNameRef = useRef(prompt.connectionName || ''); const seededRef = useRef(false); // Fetch the connector's param/auth schema. The agent only sends the type; @@ -85,9 +86,6 @@ export const ConnectorFormCard: React.FC = ({ messageId, })); } setMeta(found); - if (found && !connectionName) { - setConnectionName(found.name); - } }) .catch(() => { if (!cancelled) { @@ -171,24 +169,26 @@ export const ConnectorFormCard: React.FC = ({ messageId, // create-on-connect: called by DataLoaderForm right before it connects. const handleBeforeConnect = useCallback(async (params: Record): Promise => { if (createdIdRef.current) return createdIdRef.current; + const displayName = deriveConnectorDisplayName(meta?.name || sourceType, params); const { data } = await apiRequest(CONNECTOR_URLS.CREATE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ loader_type: sourceType, - display_name: connectionName.trim() || meta?.name || sourceType, + display_name: displayName, icon: sourceType, params, persist: true, }), }); createdIdRef.current = data.id; + generatedNameRef.current = displayName; return data.id; - }, [sourceType, connectionName, meta]); + }, [sourceType, meta]); const handleConnected = useCallback(async () => { const cid = createdIdRef.current; - let resolvedName = connectionName.trim() || meta?.name || sourceType; + let resolvedName = generatedNameRef.current || meta?.name || sourceType; if (cid) { try { const { data } = await apiRequest(CONNECTOR_URLS.LIST, { method: 'GET' }); @@ -234,7 +234,7 @@ export const ConnectorFormCard: React.FC = ({ messageId, attachments: [], hidden: true, })); - }, [messageId, connectionName, meta, sourceType, dispatch, t]); + }, [messageId, meta, sourceType, dispatch, t]); const cardSx = { mt: 1, @@ -349,12 +349,6 @@ export const ConnectorFormCard: React.FC = ({ messageId, authPaths={meta.auth_paths} compact hideInstructions - connectionName={{ - label: t('upload.connectionNameLabel', { defaultValue: 'connection name' }), - value: connectionName, - placeholder: meta.name, - onChange: setConnectionName, - }} onImport={() => {}} onFinish={(status, message) => { dispatch(dfActions.addMessages({ diff --git a/src/views/DBTableManager.tsx b/src/views/DBTableManager.tsx index 5139f45a..0d0ff649 100644 --- a/src/views/DBTableManager.tsx +++ b/src/views/DBTableManager.tsx @@ -91,18 +91,10 @@ export const DataLoaderForm: React.FC<{ delegatedLogin?: { login_url: string; label?: string; params?: string[] } | null, authMode?: string, authPaths?: ConnectorAuthPath[], - connectionName?: { - label: string, - value: string, - placeholder: string, - onChange: (value: string) => void, - }, formTitle?: React.ReactNode, onImport: () => void, onFinish: (status: "success" | "error" | "warning", message: string, importedTables?: string[]) => void, onConnected?: () => void, - /** Called when the user clicks Delete. Receives the connectorId. */ - onDelete?: (connectorId: string) => void, /** Called before the connect step. Returns the effective connectorId to use. * Used by AddConnectionPanel to create the connector before connecting. */ onBeforeConnect?: (params: Record) => Promise, @@ -119,7 +111,7 @@ export const DataLoaderForm: React.FC<{ * the agent in chat. Populates the transient sensitive state so the user * needn't retype; never persisted (see the redux-persist transform). */ initialSensitiveParams?: Record, -}> = ({dataLoaderType, loaderType, paramDefs, authInstructions, connectorId, autoConnect, ssoAutoConnect, delegatedLogin, authMode, authPaths = [], connectionName, formTitle, onImport, onFinish, onConnected, onDelete, onBeforeConnect, hasStoredCredentials, compact = false, hideInstructions = false, initialSensitiveParams}) => { +}> = ({dataLoaderType, loaderType, paramDefs, authInstructions, connectorId, autoConnect, ssoAutoConnect, delegatedLogin, authMode, authPaths = [], formTitle, onImport, onFinish, onConnected, onBeforeConnect, hasStoredCredentials, compact = false, hideInstructions = false, initialSensitiveParams}) => { const { t } = useTranslation(); const dispatch = useDispatch(); const loaderTypeKey = loaderType || dataLoaderType; @@ -560,17 +552,18 @@ export const DataLoaderForm: React.FC<{ {/* Connection form. Catalog browsing + table loading live in the data-source sidebar — this dialog is for create / edit / re-auth only. */} - <> + {formTitle && ( - + {formTitle} )} - {!onBeforeConnect && ( - - {dataLoaderType} - - )} {(() => { const hasTiers = paramDefs.some(p => p.tier); const renderTimelineStep = ( @@ -639,6 +632,20 @@ export const DataLoaderForm: React.FC<{ ...formTextSx, color: 'text.secondary', }; + const fieldRowSx = { + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr)', + rowGap: 0.25, + alignItems: 'stretch', + width: '100%', + maxWidth: 420, + minWidth: 0, + }; + const fieldLabelSx = { + ...secondaryTextSx, + textAlign: 'left', + overflowWrap: 'anywhere', + }; // Typical Data Formulator body size (12px). Fields, labels // and placeholders all sit on this one scale. const inputSx = { @@ -690,27 +697,31 @@ export const DataLoaderForm: React.FC<{ loaderTypeKey === 'kusto' && name === 'kusto_cluster'; const isKustoDatabase = (name: string) => loaderTypeKey === 'kusto' && name === 'kusto_database'; - // Left label, right input box. The per-field hint lives - // inside the box as its placeholder, so each row stays a - // single clean line: "name [ value / hint ]". + // Keep labels above inputs so long localized descriptions + // cannot compete with or overlap the editable field. const renderFieldRow = (paramDef: typeof tierParams[number], input: React.ReactNode) => ( - + {paramDef.name}{paramDef.required ? ' *' : ''} {input} ); return ( - + {tierParams.map((paramDef) => ( isKustoCluster(paramDef.name) ? ( renderFieldRow(paramDef, @@ -891,42 +902,21 @@ export const DataLoaderForm: React.FC<{ ? t('db.createConnector', { defaultValue: 'Create Connector' }) : t('db.connect', { suffix: (params.table_filter || '').trim() ? t('db.withFilter') : '' }); let stepNumber = 0; - const connectionStep = connectionName || connectionParams.length > 0 ? ++stepNumber : 0; + const connectionStep = connectionParams.length > 0 ? ++stepNumber : 0; const scopeStep = filterParams.length > 0 ? ++stepNumber : 0; const authStep = ++stepNumber; + const showConnectAction = !hasDelegated || selectedAuthParams.length > 0; + const actionStep = showConnectAction ? ++stepNumber : 0; return ( - + {/* Connection identity and source coordinates belong together. */} - {(connectionName || connectionParams.length > 0) && ( + {connectionParams.length > 0 && ( renderTimelineStep( connectionStep, t('db.tierConnection'), - {connectionName && ( - - - {connectionName.label} - - - - - - )} - {connectionParams.length > 0 && renderParamGrid(connectionParams)} + {renderParamGrid(connectionParams)} {advancedConnectionParams.length > 0 && ( - )} - , + {paramDefs.length > 0 && ( + setPersistCredentials(event.target.checked)} + sx={{ p: 0.25, mr: 0.25 }} + /> + )} + label={( + + {t('db.rememberCredentials')} + + )} + /> + )} + , true, )} - - {paramDefs.length > 0 && ( - - setPersistCredentials(event.target.checked)} - sx={{ p: 0 }} - /> - )} - label={( - - {t('db.rememberCredentials')} - - )} - /> - - )} ); })()} @@ -1155,7 +1155,7 @@ export const DataLoaderForm: React.FC<{ sx={{ mt: 2, ml: 4.75, - maxWidth: 760, + maxWidth: 860, borderRadius: 1, bgcolor: 'action.hover', color: 'text.secondary', @@ -1206,18 +1206,7 @@ export const DataLoaderForm: React.FC<{ )} )} - {onDelete && connectorIdRef.current && ( - - - - )} - + ); } \ No newline at end of file diff --git a/src/views/UnifiedDataUploadDialog.tsx b/src/views/UnifiedDataUploadDialog.tsx index df926ab3..9a59293c 100644 --- a/src/views/UnifiedDataUploadDialog.tsx +++ b/src/views/UnifiedDataUploadDialog.tsx @@ -29,6 +29,7 @@ import { StreamIcon, getConnectorIcon, connectorSortOrder } from '../icons'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import AddIcon from '@mui/icons-material/Add'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import HistoryIcon from '@mui/icons-material/History'; import BoltOutlinedIcon from '@mui/icons-material/BoltOutlined'; import Paper from '@mui/material/Paper'; @@ -46,6 +47,7 @@ import { AgentChatInput } from './AgentChatInput'; import { buildDataLoadingSuggestions, buildDataLoadingQuickActions } from './dataLoadingSuggestions'; import { getUrls, CONNECTOR_URLS } from '../app/utils'; import { apiRequest } from '../app/apiClient'; +import { deriveConnectorDisplayName } from '../app/connectorNames'; import { generateUUID } from '../app/identity'; import { DataLoaderForm } from './DBTableManager'; import { MultiTablePreview } from './MultiTablePreview'; @@ -1020,7 +1022,6 @@ const AddConnectionPanel: React.FC<{ const [disabledLoaders, setDisabledLoaders] = useState>({}); const [pluginsInfo, setPluginsInfo] = useState(null); const [selectedType, setSelectedType] = useState(''); - const displayNameRef = useRef(''); const dispatch = useDispatch(); const identityKey = useSelector((state: DataFormulatorState) => `${state.identity.type}:${state.identity.id}`); // Track the created connector ID so DataLoaderForm can use it @@ -1047,7 +1048,6 @@ const AddConnectionPanel: React.FC<{ : undefined; const chosen = preferred || data.loaders[0]; setSelectedType(chosen.type); - displayNameRef.current = chosen.name; } }) .catch(() => { /* loader types unavailable — form will be empty */ }); @@ -1057,7 +1057,6 @@ const AddConnectionPanel: React.FC<{ const handleSelectLoader = (loader: LoaderType) => { setSelectedType(loader.type); - displayNameRef.current = loader.name; createdIdRef.current = null; }; @@ -1071,7 +1070,7 @@ const AddConnectionPanel: React.FC<{ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ loader_type: selectedType, - display_name: displayNameRef.current.trim() || selectedLoader?.name || selectedType, + display_name: deriveConnectorDisplayName(selectedLoader?.name || selectedType, params), icon: selectedType, params, persist: true, @@ -1104,14 +1103,16 @@ const AddConnectionPanel: React.FC<{ const sidebarButtonSx = (typeKey: string) => ({ fontSize: 12, textTransform: 'none' as const, - width: '100%', + width: { xs: 'auto', sm: '100%' }, + minWidth: 'max-content', justifyContent: 'flex-start', textAlign: 'left' as const, borderRadius: 0, py: 1, px: 2, color: selectedType === typeKey ? 'primary.main' : 'text.secondary', - borderRight: selectedType === typeKey ? 2 : 0, + borderRight: { xs: 0, sm: selectedType === typeKey ? 2 : 0 }, + borderBottom: { xs: selectedType === typeKey ? 2 : 0, sm: 0 }, borderColor: 'primary.main', }); @@ -1123,18 +1124,21 @@ const AddConnectionPanel: React.FC<{ } return ( - + {/* Left sidebar: loader types */} {t('upload.dataSourceTypes', { defaultValue: 'Data Sources' })} @@ -1185,7 +1189,8 @@ const AddConnectionPanel: React.FC<{ + + + ); }; diff --git a/tests/backend/data/test_data_connector_framework.py b/tests/backend/data/test_data_connector_framework.py index 81fe4d0b..c71035b6 100644 --- a/tests/backend/data/test_data_connector_framework.py +++ b/tests/backend/data/test_data_connector_framework.py @@ -451,6 +451,23 @@ def test_delete_connector_clears_status(self, connected_client): assert resp.status_code == 200 assert resp.get_json()["status"] == "success" + def test_rename_connector_preserves_stable_id(self, connected_client, source): + with patch.object(DataConnector, "_get_identity", return_value="test-user"), \ + patch("data_formulator.data_connector._update_user_connector_display_name") as persist: + resp = connected_client.patch( + "/api/connectors/mock_db", + json={"display_name": "Analytics warehouse"}, + ) + + assert resp.status_code == 200 + assert resp.get_json()["data"] == { + "id": "mock_db", + "display_name": "Analytics warehouse", + } + assert source._source_id == "mock_db" + assert source._display_name == "Analytics warehouse" + persist.assert_called_once_with("test-user", "mock_db", "Analytics warehouse") + def test_disconnect_connector_clears_loader_and_credentials(self, connected_client, source): """Disconnect keeps the connector but clears active and stored credentials.""" with patch.object(DataConnector, "_get_identity", return_value="test-user"), \ From 5ab33c8f5310417a974b1c9c9f9fb3595c082da0 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 23 Jul 2026 15:52:49 -0700 Subject: [PATCH 3/4] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- py-src/data_formulator/data_connector.py | 21 +++++++++++++++++++++ py-src/data_formulator/datalake/naming.py | 15 ++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/py-src/data_formulator/data_connector.py b/py-src/data_formulator/data_connector.py index 60763a88..f9aabbac 100644 --- a/py-src/data_formulator/data_connector.py +++ b/py-src/data_formulator/data_connector.py @@ -23,6 +23,7 @@ import inspect import json as _json import logging +import re import threading import time from pathlib import Path @@ -1403,6 +1404,24 @@ def _connectors_jail(identity: str, *, mkdir: bool = True) -> ConfinedDir: return ConfinedDir(_connectors_dir(identity), mkdir=mkdir) +_CONNECTOR_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,120}$") + + +def _validate_connector_id_for_fs(connector_id: str) -> str: + """Validate connector id before any filesystem-derived usage. + + Allows stable connector IDs used by this app (alnum plus ``_.:-``), + rejects separators/whitespace and other special characters. + """ + value = str(connector_id).strip() + if not _CONNECTOR_ID_RE.fullmatch(value): + raise AppError( + ErrorCode.VALIDATION_ERROR, + "Invalid connector_id format", + ) + return value + + def _safe_source_filename(source_id: str) -> str: """Sanitise a source_id into a safe, collision-resistant filename component. @@ -1446,6 +1465,7 @@ def _remove_user_connector(identity: str, connector_id: str) -> None: def _update_user_connector_display_name(identity: str, connector_id: str, display_name: str) -> None: + connector_id = _validate_connector_id_for_fs(connector_id) jail = _connectors_jail(identity, mkdir=False) filename = f"{_safe_source_filename(connector_id)}.json" path = jail.resolve(filename) @@ -1463,6 +1483,7 @@ def update_connector(connector_id: str): """Rename a user connector without changing its stable source ID.""" if connector_id in _ADMIN_CONNECTOR_IDS: raise AppError(ErrorCode.ACCESS_DENIED, "Admin connectors cannot be renamed") + connector_id = _validate_connector_id_for_fs(connector_id) display_name = str((request.get_json() or {}).get("display_name", "")).strip() if not display_name: diff --git a/py-src/data_formulator/datalake/naming.py b/py-src/data_formulator/datalake/naming.py index 772a6fd3..6f65c92f 100644 --- a/py-src/data_formulator/datalake/naming.py +++ b/py-src/data_formulator/datalake/naming.py @@ -13,6 +13,11 @@ from __future__ import annotations +import re + + +_SAFE_SOURCE_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$") + def safe_source_id(source_id: str) -> str: """Sanitise a ``source_id`` into a filesystem-safe, collision-resistant string. @@ -24,6 +29,7 @@ def safe_source_id(source_id: str) -> str: * ``/`` and ``\\`` → ``_`` (path separators) * ``:`` → ``--`` (Windows-unsafe, and preserves uniqueness so that ``mysql:prod`` and ``mysql_prod`` map to different filenames) + * final value must match ``[A-Za-z0-9._-]+`` and not be ``.`` or ``..`` Examples:: @@ -34,7 +40,14 @@ def safe_source_id(source_id: str) -> str: >>> safe_source_id("a:b/c\\\\d") 'a--b_c_d' """ - return source_id.replace("/", "_").replace("\\", "_").replace(":", "--") + sanitized = source_id.replace("/", "_").replace("\\", "_").replace(":", "--") + if not sanitized or sanitized in {".", ".."}: + raise ValueError("source_id must not be empty or relative-dot segments") + if len(sanitized) > 255: + raise ValueError("source_id is too long") + if not _SAFE_SOURCE_ID_RE.fullmatch(sanitized): + raise ValueError("source_id contains unsupported characters") + return sanitized __all__ = ["safe_source_id"] From cba4c1e58a937132a570d7ac1eeb1ff68476c9fa Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 23 Jul 2026 15:56:30 -0700 Subject: [PATCH 4/4] security fix --- py-src/data_formulator/data_connector.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/py-src/data_formulator/data_connector.py b/py-src/data_formulator/data_connector.py index f9aabbac..e4292519 100644 --- a/py-src/data_formulator/data_connector.py +++ b/py-src/data_formulator/data_connector.py @@ -1468,14 +1468,11 @@ def _update_user_connector_display_name(identity: str, connector_id: str, displa connector_id = _validate_connector_id_for_fs(connector_id) jail = _connectors_jail(identity, mkdir=False) filename = f"{_safe_source_filename(connector_id)}.json" - path = jail.resolve(filename) - if not path.exists(): + if not jail.exists(filename): raise AppError(ErrorCode.CONNECTOR_ERROR, f"Connector config not found: {connector_id}") - with open(path, "r", encoding="utf-8") as f: - entry = _json.load(f) + entry = _json.loads(jail.read_text(filename)) entry["display_name"] = display_name - with open(path, "w", encoding="utf-8") as f: - _json.dump(entry, f, ensure_ascii=False, indent=2) + jail.write_text(filename, _json.dumps(entry, ensure_ascii=False, indent=2)) @connectors_bp.route("/api/connectors/", methods=["PATCH"]) @@ -1493,7 +1490,7 @@ def update_connector(connector_id: str): _registry_key, connector = _resolve_connector_with_key({"connector_id": connector_id}) identity = DataConnector._get_identity() - _update_user_connector_display_name(identity, connector_id, display_name) + _update_user_connector_display_name(identity, connector._source_id, display_name) connector._display_name = display_name return json_ok({"id": connector_id, "display_name": display_name})