Skip to content

Commit d237e6f

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(console): Studio datasource manager — test + sync objects from a datasource (#1839)
Adds a System → Datasources surface that wraps the framework datasource-admin REST routes (a control-plane concern that lives outside the generic metadata-admin engine): - List runtime datasources (GET /api/v1/datasources) with driver/status/origin. - Test connection (POST /api/v1/datasources/:name/test) → toast with latency. - Sync objects: introspect remote tables (GET …/remote-tables), pick which to import, generate an object definition per table (POST …/object-draft), and create each through the normal metadata channel (MetadataClient.save('object', …)). Wires the route under system/datasources and adds a System hub card for discoverability. Verified end-to-end against a live sqlite datasource: test returns ok, and syncing customers/orders creates real object metadata with the introspected fields. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3084fec commit d237e6f

3 files changed

Lines changed: 229 additions & 0 deletions

File tree

apps/console/src/AppContent.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from '@object-ui/providers';
1919

2020
const SystemHubPage = lazy(() => import('./pages/system/SystemHubPage').then(m => ({ default: m.SystemHubPage })));
21+
const DatasourceManagerPage = lazy(() => import('./pages/system/DatasourceManagerPage').then(m => ({ default: m.DatasourceManagerPage })));
2122
const AppManagementPage = lazy(() => import('./pages/system/AppManagementPage').then(m => ({ default: m.AppManagementPage })));
2223
const ProfilePage = lazy(() => import('./pages/system/ProfilePage').then(m => ({ default: m.ProfilePage })));
2324
const ApprovalsInboxPage = lazy(() => import('./pages/system/ApprovalsInboxPage').then(m => ({ default: m.ApprovalsInboxPage })));
@@ -86,6 +87,7 @@ const systemRoutes = (
8687
<Route path="system/audit-log" element={<Suspense fallback={<LoadingScreen />}><AuditLogPage /></Suspense>} />
8788
<Route path="system/settings" element={<Suspense fallback={<LoadingScreen />}><SettingsHub /></Suspense>} />
8889
<Route path="system/settings/:namespace" element={<Suspense fallback={<LoadingScreen />}><SettingsView /></Suspense>} />
90+
<Route path="system/datasources" element={<Suspense fallback={<LoadingScreen />}><DatasourceManagerPage /></Suspense>} />
8991
<Route path="developer" element={<Suspense fallback={<LoadingScreen />}><DeveloperHubPage /></Suspense>} />
9092
<Route path="developer/api-console" element={<Suspense fallback={<LoadingScreen />}><ApiConsolePage /></Suspense>} />
9193
<Route path="developer/flow-runs" element={<Suspense fallback={<LoadingScreen />}><FlowRunsPage /></Suspense>} />
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* DatasourceManagerPage — Studio surface for runtime datasources (managed by
5+
* the framework `datasource-admin` service, which lives outside the generic
6+
* metadata-admin engine). Lists datasources, tests connections, and — the
7+
* headline — **syncs objects from a datasource**: introspect remote tables
8+
* (`GET /datasources/:name/remote-tables`), pick which to import, generate an
9+
* object definition for each (`POST …/object-draft`), and create it through
10+
* the normal metadata channel (`MetadataClient.save('object', …)`).
11+
*/
12+
13+
import * as React from 'react';
14+
import { toast } from 'sonner';
15+
import { Loader2, Database, RefreshCw, Plug, Boxes } from 'lucide-react';
16+
import {
17+
Button,
18+
Dialog,
19+
DialogContent,
20+
DialogHeader,
21+
DialogTitle,
22+
DialogDescription,
23+
} from '@object-ui/components';
24+
import { createAuthenticatedFetch } from '@object-ui/auth';
25+
import { MetadataClient } from '@object-ui/data-objectstack';
26+
27+
interface DatasourceRow {
28+
name: string;
29+
label?: string;
30+
driver?: string;
31+
status?: string;
32+
origin?: string;
33+
active?: boolean;
34+
}
35+
interface RemoteTable { name: string; schema?: string; columnCount?: number }
36+
37+
const SERVER = ((import.meta as { env?: Record<string, string> }).env?.VITE_SERVER_URL || '').replace(/\/+$/, '');
38+
39+
export function DatasourceManagerPage(): React.ReactElement {
40+
const authFetch = React.useMemo(() => createAuthenticatedFetch(), []);
41+
const metaClient = React.useMemo(() => new MetadataClient({ baseUrl: SERVER }), []);
42+
43+
const [rows, setRows] = React.useState<DatasourceRow[]>([]);
44+
const [loading, setLoading] = React.useState(true);
45+
const [busy, setBusy] = React.useState<string | null>(null);
46+
47+
const api = React.useCallback(
48+
async (path: string, init?: RequestInit) => {
49+
const res = await authFetch(`${SERVER}${path}`, { credentials: 'include', headers: { 'Content-Type': 'application/json' }, ...init });
50+
const text = await res.text();
51+
let body: any = text;
52+
try { body = JSON.parse(text); } catch { /* keep text */ }
53+
if (!res.ok) throw new Error((body && (body.message || body.error)) || `HTTP ${res.status}`);
54+
return body;
55+
},
56+
[authFetch],
57+
);
58+
59+
const load = React.useCallback(async () => {
60+
setLoading(true);
61+
try {
62+
const data = await api('/api/v1/datasources');
63+
setRows(Array.isArray(data?.datasources) ? data.datasources : []);
64+
} catch (err) {
65+
toast.error(`Load datasources: ${(err as Error).message}`);
66+
setRows([]);
67+
} finally {
68+
setLoading(false);
69+
}
70+
}, [api]);
71+
72+
React.useEffect(() => { void load(); }, [load]);
73+
74+
const test = async (name: string) => {
75+
setBusy(`test:${name}`);
76+
try {
77+
const r = await api(`/api/v1/datasources/${encodeURIComponent(name)}/test`, { method: 'POST', body: '{}' });
78+
toast.success(r?.ok === false ? `${name}: ${r?.error ?? 'failed'}` : `${name}: connection ok${r?.latencyMs != null ? ` (${r.latencyMs}ms)` : ''}`);
79+
void load();
80+
} catch (err) {
81+
toast.error(`${name}: ${(err as Error).message}`);
82+
} finally {
83+
setBusy(null);
84+
}
85+
};
86+
87+
// ── Sync dialog ──────────────────────────────────────────────────────────
88+
const [syncName, setSyncName] = React.useState<string | null>(null);
89+
const [tables, setTables] = React.useState<RemoteTable[] | null>(null);
90+
const [selected, setSelected] = React.useState<Set<string>>(new Set());
91+
const [syncing, setSyncing] = React.useState(false);
92+
93+
const openSync = async (name: string) => {
94+
setSyncName(name);
95+
setTables(null);
96+
setSelected(new Set());
97+
try {
98+
const data = await api(`/api/v1/datasources/${encodeURIComponent(name)}/remote-tables`);
99+
setTables(Array.isArray(data?.tables) ? data.tables : []);
100+
} catch (err) {
101+
toast.error(`Introspect ${name}: ${(err as Error).message}`);
102+
setTables([]);
103+
}
104+
};
105+
106+
const toggle = (t: string) =>
107+
setSelected((prev) => {
108+
const next = new Set(prev);
109+
if (next.has(t)) next.delete(t); else next.add(t);
110+
return next;
111+
});
112+
113+
const runSync = async () => {
114+
if (!syncName || selected.size === 0) return;
115+
setSyncing(true);
116+
let ok = 0;
117+
const failures: string[] = [];
118+
for (const table of selected) {
119+
try {
120+
const { draft } = await api(`/api/v1/datasources/${encodeURIComponent(syncName)}/object-draft`, {
121+
method: 'POST',
122+
body: JSON.stringify({ table }),
123+
});
124+
await metaClient.save('object', draft.name, draft.definition);
125+
ok += 1;
126+
} catch (err) {
127+
failures.push(`${table}: ${(err as Error).message}`);
128+
}
129+
}
130+
setSyncing(false);
131+
if (ok) toast.success(`Synced ${ok} object${ok > 1 ? 's' : ''} from ${syncName}.`);
132+
if (failures.length) toast.error(`Failed: ${failures.join('; ')}`);
133+
setSyncName(null);
134+
};
135+
136+
return (
137+
<div className="mx-auto max-w-4xl px-6 py-8">
138+
<div className="mb-6 flex items-center justify-between">
139+
<div>
140+
<h1 className="flex items-center gap-2 text-xl font-semibold">
141+
<Database className="h-5 w-5" /> Datasources
142+
</h1>
143+
<p className="mt-1 text-sm text-muted-foreground">Connect external databases and sync their tables in as objects.</p>
144+
</div>
145+
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
146+
<RefreshCw className={'mr-1.5 h-4 w-4' + (loading ? ' animate-spin' : '')} /> Refresh
147+
</Button>
148+
</div>
149+
150+
{loading ? (
151+
<div className="flex items-center gap-2 py-12 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" /> Loading…</div>
152+
) : rows.length === 0 ? (
153+
<div className="rounded-lg border border-dashed bg-muted/20 py-16 text-center text-sm text-muted-foreground">
154+
No datasources yet. Create one via the API/CLI, then sync its tables here.
155+
</div>
156+
) : (
157+
<div className="divide-y rounded-lg border bg-card">
158+
{rows.map((ds) => (
159+
<div key={ds.name} className="flex items-center gap-3 px-4 py-3">
160+
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
161+
<div className="min-w-0 flex-1">
162+
<div className="flex items-center gap-2">
163+
<span className="font-medium">{ds.label || ds.name}</span>
164+
<code className="text-[11px] text-muted-foreground">{ds.name}</code>
165+
</div>
166+
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
167+
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">{ds.driver}</span>
168+
{ds.status && <span>· {ds.status}</span>}
169+
{ds.origin && <span>· {ds.origin}</span>}
170+
</div>
171+
</div>
172+
<Button variant="ghost" size="sm" disabled={busy === `test:${ds.name}`} onClick={() => void test(ds.name)}>
173+
{busy === `test:${ds.name}` ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Plug className="mr-1.5 h-4 w-4" />} Test
174+
</Button>
175+
<Button variant="secondary" size="sm" onClick={() => void openSync(ds.name)}>
176+
<Boxes className="mr-1.5 h-4 w-4" /> Sync objects
177+
</Button>
178+
</div>
179+
))}
180+
</div>
181+
)}
182+
183+
<Dialog open={!!syncName} onOpenChange={(o) => { if (!o) setSyncName(null); }}>
184+
<DialogContent className="max-w-lg">
185+
<DialogHeader>
186+
<DialogTitle>Sync objects from “{syncName}</DialogTitle>
187+
<DialogDescription>Pick the remote tables to import as objects. Each becomes an object definition.</DialogDescription>
188+
</DialogHeader>
189+
{tables == null ? (
190+
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" /> Introspecting…</div>
191+
) : tables.length === 0 ? (
192+
<p className="py-8 text-center text-sm text-muted-foreground">No remote tables found on this datasource.</p>
193+
) : (
194+
<div className="max-h-[50vh] space-y-1 overflow-y-auto">
195+
{tables.map((t) => {
196+
const key = t.schema ? `${t.schema}.${t.name}` : t.name;
197+
return (
198+
<label key={key} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent">
199+
<input type="checkbox" checked={selected.has(t.name)} onChange={() => toggle(t.name)} className="h-4 w-4" />
200+
<span className="flex-1">{key}</span>
201+
{t.columnCount != null && <span className="text-[11px] text-muted-foreground">{t.columnCount} cols</span>}
202+
</label>
203+
);
204+
})}
205+
</div>
206+
)}
207+
<div className="mt-2 flex items-center justify-end gap-2">
208+
<Button variant="ghost" size="sm" onClick={() => setSyncName(null)}>Cancel</Button>
209+
<Button size="sm" disabled={syncing || selected.size === 0} onClick={() => void runSync()}>
210+
{syncing ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Boxes className="mr-1.5 h-4 w-4" />}
211+
Create {selected.size || ''} object{selected.size === 1 ? '' : 's'}
212+
</Button>
213+
</div>
214+
</DialogContent>
215+
</Dialog>
216+
</div>
217+
);
218+
}

apps/console/src/pages/system/SystemHubPage.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
Terminal,
4141
Database,
4242
LayoutGrid,
43+
Boxes,
4344
} from 'lucide-react';
4445
import { useAdapter } from '@object-ui/app-shell';
4546
import { useIsWorkspaceAdmin } from '@object-ui/auth';
@@ -118,6 +119,14 @@ export function SystemHubPage() {
118119
countLabel: '',
119120
count: null,
120121
},
122+
{
123+
title: 'Datasources',
124+
description: 'Connect external databases and sync their tables in as objects',
125+
icon: Boxes,
126+
href: `${basePath}/system/datasources`,
127+
countLabel: '',
128+
count: null,
129+
},
121130
];
122131

123132
// System admin cards (non-metadata, always present)

0 commit comments

Comments
 (0)