|
| 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 | +} |
0 commit comments