diff --git a/apps/console/src/AppContent.tsx b/apps/console/src/AppContent.tsx index f8f09c44c..efdd604af 100644 --- a/apps/console/src/AppContent.tsx +++ b/apps/console/src/AppContent.tsx @@ -18,7 +18,6 @@ import { } from '@object-ui/providers'; const SystemHubPage = lazy(() => import('./pages/system/SystemHubPage').then(m => ({ default: m.SystemHubPage }))); -const DatasourceManagerPage = lazy(() => import('./pages/system/DatasourceManagerPage').then(m => ({ default: m.DatasourceManagerPage }))); const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage }))); const ProfilePage = lazy(() => import('./pages/system/ProfilePage').then(m => ({ default: m.ProfilePage }))); const ApprovalsInboxPage = lazy(() => import('./pages/system/ApprovalsInboxPage').then(m => ({ default: m.ApprovalsInboxPage }))); @@ -51,7 +50,7 @@ const DocsLayout = lazy(() => import('./pages/DocsLayout')); function ObjectRedirect() { const { objectName } = useParams<{ objectName?: string }>(); const location = useLocation(); - const prefix = location.pathname.replace(/\/objects(\/.*)?$/, ''); + const prefix = location.pathname.replace(/\/(system\/)?objects(\/.*)?$/, ''); const target = objectName ? `${prefix}/component/metadata/resource/${objectName}?type=object` : `${prefix}/component/metadata/resource?type=object`; @@ -67,7 +66,10 @@ function ObjectRedirect() { function MetadataRedirect() { const { metadataType, itemName } = useParams<{ metadataType?: string; itemName?: string }>(); const location = useLocation(); - const prefix = location.pathname.replace(/\/metadata(\/.*)?$/, ''); + // Strip an optional leading `system/` too — legacy nav uses + // `…/system/metadata/:type`, and the engine route lives at the app root + // (`…/component/metadata/resource`), not under `system/`. + const prefix = location.pathname.replace(/\/(system\/)?metadata(\/.*)?$/, ''); const base = `${prefix}/component/metadata/resource`; const target = !metadataType ? `${prefix}/component/metadata/directory` @@ -87,7 +89,6 @@ const systemRoutes = ( }>} /> }>} /> }>} /> - }>} /> }>} /> }>} /> }>} /> diff --git a/apps/console/src/pages/system/DatasourceManagerPage.tsx b/apps/console/src/pages/system/DatasourceManagerPage.tsx deleted file mode 100644 index baa688a42..000000000 --- a/apps/console/src/pages/system/DatasourceManagerPage.tsx +++ /dev/null @@ -1,218 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * DatasourceManagerPage — Studio surface for runtime datasources (managed by - * the framework `datasource-admin` service, which lives outside the generic - * metadata-admin engine). Lists datasources, tests connections, and — the - * headline — **syncs objects from a datasource**: introspect remote tables - * (`GET /datasources/:name/remote-tables`), pick which to import, generate an - * object definition for each (`POST …/object-draft`), and create it through - * the normal metadata channel (`MetadataClient.save('object', …)`). - */ - -import * as React from 'react'; -import { toast } from 'sonner'; -import { Loader2, Database, RefreshCw, Plug, Boxes } from 'lucide-react'; -import { - Button, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@object-ui/components'; -import { createAuthenticatedFetch } from '@object-ui/auth'; -import { MetadataClient } from '@object-ui/data-objectstack'; - -interface DatasourceRow { - name: string; - label?: string; - driver?: string; - status?: string; - origin?: string; - active?: boolean; -} -interface RemoteTable { name: string; schema?: string; columnCount?: number } - -const SERVER = ((import.meta as { env?: Record }).env?.VITE_SERVER_URL || '').replace(/\/+$/, ''); - -export function DatasourceManagerPage(): React.ReactElement { - const authFetch = React.useMemo(() => createAuthenticatedFetch(), []); - const metaClient = React.useMemo(() => new MetadataClient({ baseUrl: SERVER }), []); - - const [rows, setRows] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [busy, setBusy] = React.useState(null); - - const api = React.useCallback( - async (path: string, init?: RequestInit) => { - const res = await authFetch(`${SERVER}${path}`, { credentials: 'include', headers: { 'Content-Type': 'application/json' }, ...init }); - const text = await res.text(); - let body: any = text; - try { body = JSON.parse(text); } catch { /* keep text */ } - if (!res.ok) throw new Error((body && (body.message || body.error)) || `HTTP ${res.status}`); - return body; - }, - [authFetch], - ); - - const load = React.useCallback(async () => { - setLoading(true); - try { - const data = await api('/api/v1/datasources'); - setRows(Array.isArray(data?.datasources) ? data.datasources : []); - } catch (err) { - toast.error(`Load datasources: ${(err as Error).message}`); - setRows([]); - } finally { - setLoading(false); - } - }, [api]); - - React.useEffect(() => { void load(); }, [load]); - - const test = async (name: string) => { - setBusy(`test:${name}`); - try { - const r = await api(`/api/v1/datasources/${encodeURIComponent(name)}/test`, { method: 'POST', body: '{}' }); - toast.success(r?.ok === false ? `${name}: ${r?.error ?? 'failed'}` : `${name}: connection ok${r?.latencyMs != null ? ` (${r.latencyMs}ms)` : ''}`); - void load(); - } catch (err) { - toast.error(`${name}: ${(err as Error).message}`); - } finally { - setBusy(null); - } - }; - - // ── Sync dialog ────────────────────────────────────────────────────────── - const [syncName, setSyncName] = React.useState(null); - const [tables, setTables] = React.useState(null); - const [selected, setSelected] = React.useState>(new Set()); - const [syncing, setSyncing] = React.useState(false); - - const openSync = async (name: string) => { - setSyncName(name); - setTables(null); - setSelected(new Set()); - try { - const data = await api(`/api/v1/datasources/${encodeURIComponent(name)}/remote-tables`); - setTables(Array.isArray(data?.tables) ? data.tables : []); - } catch (err) { - toast.error(`Introspect ${name}: ${(err as Error).message}`); - setTables([]); - } - }; - - const toggle = (t: string) => - setSelected((prev) => { - const next = new Set(prev); - if (next.has(t)) next.delete(t); else next.add(t); - return next; - }); - - const runSync = async () => { - if (!syncName || selected.size === 0) return; - setSyncing(true); - let ok = 0; - const failures: string[] = []; - for (const table of selected) { - try { - const { draft } = await api(`/api/v1/datasources/${encodeURIComponent(syncName)}/object-draft`, { - method: 'POST', - body: JSON.stringify({ table }), - }); - await metaClient.save('object', draft.name, draft.definition); - ok += 1; - } catch (err) { - failures.push(`${table}: ${(err as Error).message}`); - } - } - setSyncing(false); - if (ok) toast.success(`Synced ${ok} object${ok > 1 ? 's' : ''} from ${syncName}.`); - if (failures.length) toast.error(`Failed: ${failures.join('; ')}`); - setSyncName(null); - }; - - return ( -
-
-
-

- Datasources -

-

Connect external databases and sync their tables in as objects.

-
- -
- - {loading ? ( -
Loading…
- ) : rows.length === 0 ? ( -
- No datasources yet. Create one via the API/CLI, then sync its tables here. -
- ) : ( -
- {rows.map((ds) => ( -
- -
-
- {ds.label || ds.name} - {ds.name} -
-
- {ds.driver} - {ds.status && · {ds.status}} - {ds.origin && · {ds.origin}} -
-
- - -
- ))} -
- )} - - { if (!o) setSyncName(null); }}> - - - Sync objects from “{syncName}” - Pick the remote tables to import as objects. Each becomes an object definition. - - {tables == null ? ( -
Introspecting…
- ) : tables.length === 0 ? ( -

No remote tables found on this datasource.

- ) : ( -
- {tables.map((t) => { - const key = t.schema ? `${t.schema}.${t.name}` : t.name; - return ( - - ); - })} -
- )} -
- - -
-
-
-
- ); -} diff --git a/apps/console/src/pages/system/SystemHubPage.tsx b/apps/console/src/pages/system/SystemHubPage.tsx index 9387d96f2..a1df11277 100644 --- a/apps/console/src/pages/system/SystemHubPage.tsx +++ b/apps/console/src/pages/system/SystemHubPage.tsx @@ -123,7 +123,7 @@ export function SystemHubPage() { title: 'Datasources', description: 'Connect external databases and sync their tables in as objects', icon: Boxes, - href: `${basePath}/system/datasources`, + href: `${basePath}/component/metadata/resource?type=datasource`, countLabel: '', count: null, }, diff --git a/packages/app-shell/src/layout/AppSidebar.tsx b/packages/app-shell/src/layout/AppSidebar.tsx index 918ed1b96..15aeccbf0 100644 --- a/packages/app-shell/src/layout/AppSidebar.tsx +++ b/packages/app-shell/src/layout/AppSidebar.tsx @@ -286,6 +286,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri } items.push( { id: 'sys-objects', label: t('layout.systemNav.objectManager', { defaultValue: 'Object Manager' }), type: 'url' as const, url: '/apps/setup/system/metadata/object', icon: 'database' }, + { id: 'sys-datasources', label: t('layout.systemNav.datasources', { defaultValue: 'Datasources' }), type: 'url' as const, url: '/apps/setup/component/metadata/resource?type=datasource', icon: 'database' }, { id: 'sys-users', label: t('layout.systemNav.users', { defaultValue: 'Users' }), type: 'url' as const, url: '/apps/setup/system/users', icon: 'users' }, { id: 'sys-orgs', label: t('layout.systemNav.organizations', { defaultValue: 'Organizations' }), type: 'url' as const, url: '/apps/setup/system/organizations', icon: 'building-2' }, { id: 'sys-roles', label: t('layout.systemNav.roles', { defaultValue: 'Roles' }), type: 'url' as const, url: '/apps/setup/system/roles', icon: 'shield' }, diff --git a/packages/app-shell/src/layout/UnifiedSidebar.tsx b/packages/app-shell/src/layout/UnifiedSidebar.tsx index 36bedee63..cd687271d 100644 --- a/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -237,6 +237,7 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { { id: 'sys-apps', label: t('layout.systemNav.applications', { defaultValue: 'Applications' }), type: 'url' as const, url: '/apps/setup/system/apps', icon: 'layout-grid' }, { id: 'sys-marketplace', label: t('layout.systemNav.appMarketplace', { defaultValue: 'App Marketplace' }), type: 'url' as const, url: '/apps/setup/system/marketplace', icon: 'store' }, { id: 'sys-objects', label: t('layout.systemNav.objectManager', { defaultValue: 'Object Manager' }), type: 'url' as const, url: '/apps/setup/system/metadata/object', icon: 'database' }, + { id: 'sys-datasources', label: t('layout.systemNav.datasources', { defaultValue: 'Datasources' }), type: 'url' as const, url: '/apps/setup/component/metadata/resource?type=datasource', icon: 'database' }, { id: 'sys-users', label: t('layout.systemNav.users', { defaultValue: 'Users' }), type: 'url' as const, url: '/apps/setup/system/users', icon: 'users' }, { id: 'sys-orgs', label: t('layout.systemNav.organizations', { defaultValue: 'Organizations' }), type: 'url' as const, url: '/apps/setup/system/organizations', icon: 'building-2' }, { id: 'sys-roles', label: t('layout.systemNav.roles', { defaultValue: 'Roles' }), type: 'url' as const, url: '/apps/setup/system/roles', icon: 'shield' }, diff --git a/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx b/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx new file mode 100644 index 000000000..230bc39a1 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx @@ -0,0 +1,600 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * DatasourceResourcePage — the `datasource` metadata type's custom ListPage, + * registered into the metadata-admin engine (`registerMetadataResource`) and + * reached via the engine route `…/component/metadata/resource?type=datasource` + * (and the setup left-nav "Datasources" item → `system/metadata/datasource`). + * + * datasource is a *side-effectful* metadata type: its records are managed by + * the framework `datasource-admin` service (secret encryption + connection-pool + * registration + origin gating), so create/edit/delete + test go through that + * service's REST (`/api/v1/datasources/*`) rather than the generic sys_metadata + * write path. The engine still owns the route, shell, and registry slot. + * + * Capabilities: + * - **List** datasources (driver/status/origin). + * - **Create / edit** a connection with a typed form generated from the + * selected driver's JSON-Schema `configSchema` (`GET /datasources/drivers`). + * Credential fields (`format: 'password'`) are routed to the top-level + * `secret` so they are encrypted into `sys_secret` and never persisted in + * `config` metadata. Edit prefills from `GET /datasources/:name` (which + * returns `config` + a `hasSecret` flag, never the credential itself). + * - **Test** a connection (saved → `POST …/:name/test`; draft in the editor → + * `POST …/test`). + * - **Sync objects**: introspect remote tables (`GET …/remote-tables`), pick + * which to import, generate an object definition for each (`POST …/object-draft`), + * and create it through the normal metadata channel (`MetadataClient.save('object', …)`). + * - **Delete** a runtime datasource (`DELETE …/:name`; blocked by the backend + * while objects are still bound). + * + * Code-defined (`origin: 'code'`) datasources are read-only: edit/delete are + * hidden for them. + */ + +import * as React from 'react'; +import { toast } from 'sonner'; +import { + Loader2, + Database, + RefreshCw, + Plug, + Boxes, + Plus, + Pencil, + Trash2, + CheckCircle2, + XCircle, +} from 'lucide-react'; +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + Input, + Label, + Switch, + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from '@object-ui/components'; +import { createAuthenticatedFetch } from '@object-ui/auth'; +import { useMetadataClient } from '../useMetadata'; + +interface DatasourceRow { + name: string; + label?: string; + driver?: string; + status?: string; + origin?: string; + active?: boolean; +} +interface RemoteTable { name: string; schema?: string; columnCount?: number } + +interface JsonProp { + type?: string; + title?: string; + description?: string; + default?: unknown; + format?: string; + enum?: unknown[]; +} +interface DriverEntry { + id: string; + label: string; + description?: string; + configSchema?: { properties?: Record; required?: string[] }; +} + +interface EditorForm { + name: string; + label: string; + driver: string; + schemaMode: string; + config: Record; + secret: string; +} + +const SCHEMA_MODES = [ + { value: 'managed', label: 'Managed (ObjectStack owns the schema)' }, + { value: 'external', label: 'External (read existing schema as-is)' }, + { value: 'validate-only', label: 'Validate only' }, +]; + +const SERVER = ((import.meta as { env?: Record }).env?.VITE_SERVER_URL || '').replace(/\/+$/, ''); + +const isPassword = (p: JsonProp) => p.format === 'password'; + +/** Build the default config (password props excluded — they live in `secret`). */ +function defaultConfig(driver: DriverEntry | undefined): Record { + const props = driver?.configSchema?.properties ?? {}; + const cfg: Record = {}; + for (const [key, prop] of Object.entries(props)) { + if (isPassword(prop)) continue; + if (prop.default !== undefined) cfg[key] = prop.default; + } + return cfg; +} + +export function DatasourceResourcePage(_props: { type?: string }): React.ReactElement { + const authFetch = React.useMemo(() => createAuthenticatedFetch(), []); + const metaClient = useMetadataClient(); + + const [rows, setRows] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [busy, setBusy] = React.useState(null); + + const api = React.useCallback( + async (path: string, init?: RequestInit) => { + const res = await authFetch(`${SERVER}${path}`, { credentials: 'include', headers: { 'Content-Type': 'application/json' }, ...init }); + const text = await res.text(); + let body: any = text; + try { body = JSON.parse(text); } catch { /* keep text */ } + if (!res.ok) throw new Error((body && (body.message || body.error)) || `HTTP ${res.status}`); + return body; + }, + [authFetch], + ); + + const load = React.useCallback(async () => { + setLoading(true); + try { + const data = await api('/api/v1/datasources'); + setRows(Array.isArray(data?.datasources) ? data.datasources : []); + } catch (err) { + toast.error(`Load datasources: ${(err as Error).message}`); + setRows([]); + } finally { + setLoading(false); + } + }, [api]); + + React.useEffect(() => { void load(); }, [load]); + + const test = async (name: string) => { + setBusy(`test:${name}`); + try { + const r = await api(`/api/v1/datasources/${encodeURIComponent(name)}/test`, { method: 'POST', body: '{}' }); + toast.success(r?.ok === false ? `${name}: ${r?.error ?? 'failed'}` : `${name}: connection ok${r?.latencyMs != null ? ` (${r.latencyMs}ms)` : ''}`); + void load(); + } catch (err) { + toast.error(`${name}: ${(err as Error).message}`); + } finally { + setBusy(null); + } + }; + + const remove = async (name: string) => { + if (!window.confirm(`Delete datasource “${name}”? This cannot be undone.`)) return; + setBusy(`del:${name}`); + try { + await api(`/api/v1/datasources/${encodeURIComponent(name)}`, { method: 'DELETE' }); + toast.success(`Deleted ${name}.`); + void load(); + } catch (err) { + toast.error(`Delete ${name}: ${(err as Error).message}`); + } finally { + setBusy(null); + } + }; + + // ── Connection editor (create / edit) ───────────────────────────────────── + const [drivers, setDrivers] = React.useState([]); + const [editorOpen, setEditorOpen] = React.useState(false); + const [editing, setEditing] = React.useState(null); + const [form, setForm] = React.useState(null); + const [hasSecret, setHasSecret] = React.useState(false); + const [savingEditor, setSavingEditor] = React.useState(false); + const [testMsg, setTestMsg] = React.useState<{ ok: boolean; text: string } | null>(null); + const [testingDraft, setTestingDraft] = React.useState(false); + + const ensureDrivers = React.useCallback(async (): Promise => { + if (drivers.length) return drivers; + try { + const data = await api('/api/v1/datasources/drivers'); + const list = Array.isArray(data?.drivers) ? (data.drivers as DriverEntry[]) : []; + setDrivers(list); + return list; + } catch (err) { + toast.error(`Load drivers: ${(err as Error).message}`); + return []; + } + }, [api, drivers]); + + const selectedDriver = React.useMemo( + () => drivers.find((d) => d.id === form?.driver), + [drivers, form?.driver], + ); + + const openCreate = async () => { + const list = await ensureDrivers(); + const first = list.find((d) => d.id !== 'memory') ?? list[0]; + setEditing(null); + setHasSecret(false); + setTestMsg(null); + setForm({ + name: '', + label: '', + driver: first?.id ?? '', + schemaMode: 'managed', + config: defaultConfig(first), + secret: '', + }); + setEditorOpen(true); + }; + + const openEdit = async (row: DatasourceRow) => { + const list = await ensureDrivers(); + setBusy(`edit:${row.name}`); + try { + const data = await api(`/api/v1/datasources/${encodeURIComponent(row.name)}`); + const ds = data?.datasource ?? {}; + setEditing(row.name); + setHasSecret(Boolean(ds.hasSecret)); + setTestMsg(null); + setForm({ + name: ds.name ?? row.name, + label: ds.label ?? '', + driver: ds.driver ?? row.driver ?? (list[0]?.id ?? ''), + schemaMode: ds.schemaMode ?? 'managed', + config: { ...(ds.config ?? {}) }, + secret: '', + }); + setEditorOpen(true); + } catch (err) { + toast.error(`Open ${row.name}: ${(err as Error).message}`); + } finally { + setBusy(null); + } + }; + + const onDriverChange = (id: string) => { + setForm((f) => (f ? { ...f, driver: id, config: defaultConfig(drivers.find((d) => d.id === id)), secret: '' } : f)); + setTestMsg(null); + }; + + const setConfigValue = (key: string, value: unknown) => + setForm((f) => (f ? { ...f, config: { ...f.config, [key]: value } } : f)); + + /** Assemble the request body, splitting the credential into `secret`. */ + const draftBody = (f: EditorForm) => { + const body: Record = { + label: f.label || undefined, + driver: f.driver, + schemaMode: f.schemaMode, + config: f.config, + }; + if (f.secret) body.secret = f.secret; + return body; + }; + + const testDraft = async () => { + if (!form) return; + setTestingDraft(true); + setTestMsg(null); + try { + const r = await api('/api/v1/datasources/test', { method: 'POST', body: JSON.stringify({ driver: form.driver, config: form.config, ...(form.secret ? { secret: form.secret } : {}) }) }); + const res = r?.result ?? r; + if (res?.ok === false) setTestMsg({ ok: false, text: res?.error ?? 'Connection failed' }); + else setTestMsg({ ok: true, text: `Connection ok${res?.latencyMs != null ? ` (${res.latencyMs}ms)` : ''}` }); + } catch (err) { + setTestMsg({ ok: false, text: (err as Error).message }); + } finally { + setTestingDraft(false); + } + }; + + const saveEditor = async () => { + if (!form) return; + setSavingEditor(true); + try { + if (editing) { + await api(`/api/v1/datasources/${encodeURIComponent(editing)}`, { method: 'PATCH', body: JSON.stringify(draftBody(form)) }); + toast.success(`Updated ${editing}.`); + } else { + await api('/api/v1/datasources', { method: 'POST', body: JSON.stringify({ name: form.name, ...draftBody(form) }) }); + toast.success(`Created ${form.name}.`); + } + setEditorOpen(false); + void load(); + } catch (err) { + toast.error(`Save: ${(err as Error).message}`); + } finally { + setSavingEditor(false); + } + }; + + const nameValid = editing ? true : /^[a-z_][a-z0-9_]*$/.test(form?.name ?? ''); + const canSave = !!form && !!form.driver && nameValid && !savingEditor; + + // ── Sync dialog ────────────────────────────────────────────────────────── + const [syncName, setSyncName] = React.useState(null); + const [tables, setTables] = React.useState(null); + const [selected, setSelected] = React.useState>(new Set()); + const [syncing, setSyncing] = React.useState(false); + + const openSync = async (name: string) => { + setSyncName(name); + setTables(null); + setSelected(new Set()); + try { + const data = await api(`/api/v1/datasources/${encodeURIComponent(name)}/remote-tables`); + setTables(Array.isArray(data?.tables) ? data.tables : []); + } catch (err) { + toast.error(`Introspect ${name}: ${(err as Error).message}`); + setTables([]); + } + }; + + const toggle = (t: string) => + setSelected((prev) => { + const next = new Set(prev); + if (next.has(t)) next.delete(t); else next.add(t); + return next; + }); + + const runSync = async () => { + if (!syncName || selected.size === 0) return; + setSyncing(true); + let ok = 0; + const failures: string[] = []; + for (const table of selected) { + try { + const { draft } = await api(`/api/v1/datasources/${encodeURIComponent(syncName)}/object-draft`, { + method: 'POST', + body: JSON.stringify({ table }), + }); + await metaClient.save('object', draft.name, draft.definition); + ok += 1; + } catch (err) { + failures.push(`${table}: ${(err as Error).message}`); + } + } + setSyncing(false); + if (ok) toast.success(`Synced ${ok} object${ok > 1 ? 's' : ''} from ${syncName}.`); + if (failures.length) toast.error(`Failed: ${failures.join('; ')}`); + setSyncName(null); + }; + + const renderField = (key: string, prop: JsonProp) => { + const label = prop.title || key; + const required = (selectedDriver?.configSchema?.required ?? []).includes(key); + if (isPassword(prop)) { + return ( +
+ + setForm((f) => (f ? { ...f, secret: e.target.value } : f))} + /> + {prop.description &&

{prop.description}

} +
+ ); + } + const value = form?.config[key]; + if (prop.type === 'boolean') { + return ( +
+
+ + {prop.description &&

{prop.description}

} +
+ setConfigValue(key, c)} /> +
+ ); + } + if (Array.isArray(prop.enum)) { + return ( +
+ + +
+ ); + } + const isNum = prop.type === 'number' || prop.type === 'integer'; + return ( +
+ + setConfigValue(key, isNum ? (e.target.value === '' ? undefined : Number(e.target.value)) : e.target.value)} + /> + {prop.description &&

{prop.description}

} +
+ ); + }; + + return ( +
+
+
+

+ Datasources +

+

Connect external databases and sync their tables in as objects.

+
+
+ + +
+
+ + {loading ? ( +
Loading…
+ ) : rows.length === 0 ? ( +
+ No datasources yet. Click New datasource to connect one. +
+ ) : ( +
+ {rows.map((ds) => { + const isCode = ds.origin === 'code'; + return ( +
+ +
+
+ {ds.label || ds.name} + {ds.name} +
+
+ {ds.driver} + {ds.status && · {ds.status}} + {ds.origin && · {ds.origin}} +
+
+ + + {!isCode && ( + <> + + + + )} +
+ ); + })} +
+ )} + + {/* Create / edit connection */} + { if (!o) setEditorOpen(false); }}> + + + {editing ? `Edit “${editing}”` : 'New datasource'} + + {editing ? 'Update the connection. Leave the credential blank to keep the current one.' : 'Connect an external database. Credentials are encrypted and never stored in metadata.'} + + + + {form && ( +
+ {!editing && ( +
+ + setForm((f) => (f ? { ...f, name: e.target.value } : f))} + /> + {!nameValid && form.name.length > 0 && ( +

Lowercase letters, digits, and underscores; must not start with a digit.

+ )} +
+ )} + +
+ + setForm((f) => (f ? { ...f, label: e.target.value } : f))} /> +
+ +
+ + + {selectedDriver?.description &&

{selectedDriver.description}

} +
+ + {/* Driver-specific connection fields */} + {Object.entries(selectedDriver?.configSchema?.properties ?? {}).map(([k, p]) => renderField(k, p))} + +
+ + +
+ + {testMsg && ( +
+ {testMsg.ok ? : } + {testMsg.text} +
+ )} +
+ )} + + + +
+ + +
+
+
+
+ + {/* Sync objects */} + { if (!o) setSyncName(null); }}> + + + Sync objects from “{syncName}” + Pick the remote tables to import as objects. Each becomes an object definition. + + {tables == null ? ( +
Introspecting…
+ ) : tables.length === 0 ? ( +

No remote tables found on this datasource.

+ ) : ( +
+ {tables.map((t) => { + const key = t.schema ? `${t.schema}.${t.name}` : t.name; + return ( + + ); + })} +
+ )} +
+ + +
+
+
+
+ ); +} diff --git a/packages/app-shell/src/views/metadata-admin/datasource/register.ts b/packages/app-shell/src/views/metadata-admin/datasource/register.ts new file mode 100644 index 000000000..767e10f88 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/datasource/register.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Registers the `datasource` metadata type as a custom-rendered resource in the + * metadata-admin engine. datasource is a *side-effectful* type (secret + + * connection pool + introspection), so it ships a bespoke ListPage that talks + * to the framework `datasource-admin` REST — but it lives inside the engine + * (engine route + registry slot + shell), reachable from the setup left-nav + * "Datasources" item and the `…/component/metadata/resource?type=datasource` + * route, instead of a separate hand-written System page. + */ + +import { registerMetadataResource } from '../registry'; +import { DatasourceResourcePage } from './DatasourceResourcePage'; + +let registered = false; + +export function registerDatasourceResource(): void { + if (registered) return; + registered = true; + registerMetadataResource({ + type: 'datasource', + label: 'Datasources', + domain: 'data', + ListPage: DatasourceResourcePage, + }); +} diff --git a/packages/app-shell/src/views/metadata-admin/index.ts b/packages/app-shell/src/views/metadata-admin/index.ts index ddaee86e4..185c6cfcf 100644 --- a/packages/app-shell/src/views/metadata-admin/index.ts +++ b/packages/app-shell/src/views/metadata-admin/index.ts @@ -61,6 +61,8 @@ registerBuiltinAnchors(); // until the framework wires Zod→JSONSchema generation into /meta/types. import { registerDefaultMetadataSchemas } from './default-schemas'; registerDefaultMetadataSchemas(); +import { registerDatasourceResource } from './datasource/register'; +registerDatasourceResource(); // Side-effect: register built-in Preview-tab renderers (page, view, // dashboard, report, app, object, email_template). Plugins can add or