545 lines
24 KiB
TypeScript
545 lines
24 KiB
TypeScript
import type { RefObject, ReactNode } from "react";
|
|
import {
|
|
ArrowLeft,
|
|
Database,
|
|
History,
|
|
RefreshCw,
|
|
Save,
|
|
TestTube2,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { Button } from "../../components/ui/button";
|
|
import type {
|
|
CatalogDatabase,
|
|
CatalogSecretName,
|
|
CatalogSyncRun,
|
|
DatabaseBinding,
|
|
DatabaseTransport,
|
|
} from "../../api/catalog-databases";
|
|
import type {
|
|
DatabaseBusyAction,
|
|
DatabaseFormDraft,
|
|
DatabaseFormMode,
|
|
} from "./model";
|
|
import { statusLabel } from "./model";
|
|
import { DatabaseSyncMenu, type DatabaseSyncScope } from "./DatabaseSyncMenu";
|
|
|
|
const inputClass = "h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15 read-only:bg-muted/40 read-only:text-muted-foreground disabled:bg-muted disabled:text-muted-foreground";
|
|
const labelClass = "grid min-w-0 gap-1.5 text-xs font-medium text-foreground";
|
|
|
|
interface FieldProps {
|
|
label: string;
|
|
hint?: string;
|
|
children: ReactNode;
|
|
}
|
|
|
|
function Field({ label, hint, children }: FieldProps) {
|
|
return (
|
|
<label className={labelClass}>
|
|
<span>{label}</span>
|
|
{children}
|
|
{hint ? <span className="font-normal leading-4 text-muted-foreground">{hint}</span> : null}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
interface SecretFieldProps {
|
|
label: string;
|
|
name: CatalogSecretName;
|
|
row: CatalogDatabase;
|
|
draft: DatabaseFormDraft;
|
|
readOnly: boolean;
|
|
canManageSecrets: boolean;
|
|
onChange: (name: CatalogSecretName, value: string) => void;
|
|
multiline?: boolean;
|
|
}
|
|
|
|
function SecretField({
|
|
label,
|
|
name,
|
|
row,
|
|
draft,
|
|
readOnly,
|
|
canManageSecrets,
|
|
onChange,
|
|
multiline = false,
|
|
}: SecretFieldProps) {
|
|
if (readOnly) {
|
|
return (
|
|
<Field label={label} hint="Secret values are never displayed.">
|
|
<input
|
|
className={inputClass}
|
|
value={row.secrets[name] ? "Configured" : "Not configured"}
|
|
readOnly
|
|
aria-label={label}
|
|
/>
|
|
</Field>
|
|
);
|
|
}
|
|
|
|
const permissionHint = canManageSecrets
|
|
? undefined
|
|
: "You do not have permission to replace secret values.";
|
|
|
|
return (
|
|
<Field label={label} hint={permissionHint}>
|
|
{multiline ? (
|
|
<textarea
|
|
className={`${inputClass} min-h-24 resize-y py-2 font-mono text-xs leading-5`}
|
|
aria-label={label}
|
|
autoComplete="off"
|
|
value={draft.secrets[name] ?? ""}
|
|
disabled={!canManageSecrets}
|
|
placeholder={row.secrets[name] ? "Configured; leave blank to keep" : "Paste value"}
|
|
onChange={(event) => onChange(name, event.target.value)}
|
|
/>
|
|
) : (
|
|
<input
|
|
className={inputClass}
|
|
type="password"
|
|
aria-label={label}
|
|
autoComplete="new-password"
|
|
value={draft.secrets[name] ?? ""}
|
|
disabled={!canManageSecrets}
|
|
placeholder={row.secrets[name] ? "Configured; leave blank to keep" : "Not configured"}
|
|
onChange={(event) => onChange(name, event.target.value)}
|
|
/>
|
|
)}
|
|
</Field>
|
|
);
|
|
}
|
|
|
|
interface DatabaseFormProps {
|
|
mode: DatabaseFormMode;
|
|
row: CatalogDatabase;
|
|
draft: DatabaseFormDraft;
|
|
dirty: boolean;
|
|
canManage: boolean;
|
|
canManageSecrets: boolean;
|
|
busyAction: DatabaseBusyAction;
|
|
stale: boolean;
|
|
staleBannerOpen: boolean;
|
|
partialSecretFailure: string | null;
|
|
headingRef: RefObject<HTMLHeadingElement>;
|
|
onBack: () => void;
|
|
onFieldChange: (field: "databaseName" | "schema", value: string) => void;
|
|
onTransportChange: (transport: DatabaseTransport) => void;
|
|
onBindingChange: <K extends keyof DatabaseBinding>(key: K, value: DatabaseBinding[K]) => void;
|
|
onSecretChange: (name: CatalogSecretName, value: string) => void;
|
|
onSave: () => void;
|
|
onTest: () => void;
|
|
onDelete: () => void;
|
|
onReloadLatest: () => void;
|
|
onKeepEditing: () => void;
|
|
onRetrySecrets: () => void;
|
|
onOpenTables: (origin: HTMLButtonElement) => void;
|
|
onOpenRelationships: () => void;
|
|
onSync: (scope: DatabaseSyncScope) => void;
|
|
onOpenSync: () => void;
|
|
onOpenDescriptionHistory?: () => void;
|
|
onOpenSensitiveHistory?: () => void;
|
|
activeSyncRun?: CatalogSyncRun;
|
|
presentation?: "page" | "drawer";
|
|
}
|
|
|
|
export function databaseFormTitle(
|
|
mode: DatabaseFormMode,
|
|
presentation: "page" | "drawer" = "page",
|
|
): string {
|
|
if (mode === "configure") return "Configure catalog";
|
|
if (mode === "edit") return "Edit database";
|
|
if (mode === "delete") return presentation === "drawer" ? "Remove configuration" : "Delete database";
|
|
return "Database details";
|
|
}
|
|
|
|
export function DatabaseForm({
|
|
mode,
|
|
row,
|
|
draft,
|
|
dirty,
|
|
canManage,
|
|
canManageSecrets,
|
|
busyAction,
|
|
stale,
|
|
staleBannerOpen,
|
|
partialSecretFailure,
|
|
headingRef,
|
|
onBack,
|
|
onFieldChange,
|
|
onTransportChange,
|
|
onBindingChange,
|
|
onSecretChange,
|
|
onSave,
|
|
onTest,
|
|
onDelete,
|
|
onReloadLatest,
|
|
onKeepEditing,
|
|
onRetrySecrets,
|
|
onOpenTables,
|
|
onOpenRelationships,
|
|
onSync,
|
|
onOpenSync,
|
|
onOpenDescriptionHistory,
|
|
onOpenSensitiveHistory,
|
|
activeSyncRun,
|
|
presentation = "page",
|
|
}: DatabaseFormProps) {
|
|
const title = databaseFormTitle(mode, presentation);
|
|
const readOnly = mode === "view" || mode === "delete";
|
|
const editable = mode === "configure" || mode === "edit";
|
|
const busy = busyAction !== null;
|
|
const testDisabled = !canManage
|
|
|| !row.configured
|
|
|| !row.id
|
|
|| dirty
|
|
|| stale
|
|
|| busy;
|
|
const saveDisabled = !canManage
|
|
|| stale
|
|
|| busy
|
|
|| (mode === "edit" && !dirty);
|
|
const bindingReady = row.connectionStatus === "reachable" && row.testedVersion === row.version;
|
|
|
|
const form = (
|
|
<form
|
|
className="grid gap-5"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (editable) onSave();
|
|
}}
|
|
>
|
|
{mode === "delete" ? (
|
|
<div className="rounded-md border border-destructive/35 bg-destructive/8 p-4 text-sm text-foreground" role="alert">
|
|
<p className="font-semibold text-destructive">This removes the local database configuration.</p>
|
|
<p className="mt-1 leading-5 text-muted-foreground">
|
|
{row.workspaceAvailable
|
|
? "The repository workspace remains available and will return to the list as Not configured. "
|
|
: "This orphaned catalog row will disappear from the list. "}
|
|
Secret references associated with this configuration are removed too. The source database is not modified.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{stale ? (
|
|
staleBannerOpen ? (
|
|
<div className="rounded-md border border-amber-300/70 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="alert">
|
|
<p className="font-semibold">A newer database configuration is available.</p>
|
|
<p className="mt-1 leading-5">Reload the latest values before saving, testing, or deleting. Your draft is still intact.</p>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={onReloadLatest}>
|
|
<RefreshCw /> Reload latest
|
|
</Button>
|
|
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onKeepEditing}>
|
|
Keep editing
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-md border border-amber-300/70 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="status">
|
|
Reload the latest values before this configuration can be changed.
|
|
</div>
|
|
)
|
|
) : null}
|
|
|
|
{partialSecretFailure ? (
|
|
<div className="rounded-md border border-amber-300/70 bg-amber-50 p-4 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100" role="alert">
|
|
<p className="font-semibold">Database configuration saved; secret update could not be confirmed.</p>
|
|
<p className="mt-1 leading-5">{partialSecretFailure} The values you entered are retained in this form.</p>
|
|
<Button type="button" size="sm" variant="outline" className="mt-3" disabled={busy || stale} onClick={onRetrySecrets}>
|
|
<RefreshCw /> {busyAction === "retry-secrets" ? "Retrying…" : "Retry secrets"}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{editable && !canManageSecrets ? (
|
|
<div className="rounded-md border border-border bg-muted/45 px-4 py-3 text-sm text-muted-foreground" role="note">
|
|
Secret status is visible, but replacing secret values requires the workspace.secrets.manage permission.
|
|
</div>
|
|
) : null}
|
|
|
|
{!row.workspaceAvailable ? (
|
|
<div className="rounded-md border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive" role="alert">
|
|
This configuration references a workspace that is no longer present in the repository YAML.
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Field label="Workspace">
|
|
<input className={inputClass} value={row.workspaceName} readOnly />
|
|
</Field>
|
|
<Field label="Workspace ID">
|
|
<input className={`${inputClass} font-mono text-xs`} value={row.workspaceId} readOnly />
|
|
</Field>
|
|
<Field label="Database engine">
|
|
<input className={inputClass} value="PostgreSQL" readOnly />
|
|
</Field>
|
|
<Field label="Transport">
|
|
<select
|
|
className={inputClass}
|
|
aria-label="Transport"
|
|
value={draft.binding.transport}
|
|
disabled={readOnly || busy}
|
|
onChange={(event) => onTransportChange(event.target.value as DatabaseTransport)}
|
|
>
|
|
<option value="postgres_direct">Direct PostgreSQL connection</option>
|
|
<option value="rest_api">REST API</option>
|
|
<option value="ssh_tunnel">SSH tunnel</option>
|
|
</select>
|
|
</Field>
|
|
<Field label="Database name">
|
|
<input
|
|
className={inputClass}
|
|
required={editable}
|
|
value={draft.databaseName}
|
|
readOnly={readOnly}
|
|
disabled={busy}
|
|
onChange={(event) => onFieldChange("databaseName", event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Schema">
|
|
<input
|
|
className={inputClass}
|
|
required={editable}
|
|
value={draft.schema}
|
|
readOnly={readOnly}
|
|
disabled={busy}
|
|
onChange={(event) => onFieldChange("schema", event.target.value)}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
<div className="border-t border-border pt-5">
|
|
<p className="thot-label mb-4">Installation binding</p>
|
|
|
|
{draft.binding.transport === "postgres_direct" ? (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Field label="Host">
|
|
<input className={inputClass} required={editable} value={draft.binding.host ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("host", event.target.value)} />
|
|
</Field>
|
|
<Field label="Port">
|
|
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.port ?? 5432} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("port", Number(event.target.value))} />
|
|
</Field>
|
|
<Field label="Username">
|
|
<input className={inputClass} required={editable} value={draft.binding.username ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("username", event.target.value)} />
|
|
</Field>
|
|
<Field label="TLS server name">
|
|
<input className={inputClass} value={draft.binding.tlsServername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("tlsServername", event.target.value || undefined)} />
|
|
</Field>
|
|
<SecretField label="Password" name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
|
|
<SecretField label="TLS CA certificate" name="tlsCa" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
|
|
</div>
|
|
) : null}
|
|
|
|
{draft.binding.transport === "rest_api" ? (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Field label="Base URL">
|
|
<input className={inputClass} type="url" required={editable} placeholder="https://dwh.example/api" value={draft.binding.baseUrl ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("baseUrl", event.target.value)} />
|
|
</Field>
|
|
<Field label="Diagnostic endpoint" hint="This relative path is fixed by the runtime contract.">
|
|
<input className={inputClass} value={draft.binding.restPath ?? "/health"} readOnly aria-label="Diagnostic endpoint" />
|
|
</Field>
|
|
<Field label="Authentication">
|
|
<select className={inputClass} aria-label="Authentication" value={draft.binding.restAuth ?? "x-api-key"} disabled={readOnly || busy} onChange={(event) => onBindingChange("restAuth", event.target.value as DatabaseBinding["restAuth"])}>
|
|
<option value="x-api-key">X-API-Key</option>
|
|
<option value="bearer">Bearer token</option>
|
|
<option value="none">None</option>
|
|
</select>
|
|
</Field>
|
|
{draft.binding.restAuth !== "none" ? (
|
|
<SecretField label="API key" name="apiKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{draft.binding.transport === "ssh_tunnel" ? (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="rounded-md border border-border bg-muted/45 px-4 py-3 text-sm text-muted-foreground md:col-span-2" role="note">
|
|
SSH host verification is strict. Provide the trusted known_hosts entry before testing this binding.
|
|
</div>
|
|
<Field label="Database username">
|
|
<input className={inputClass} required={editable} value={draft.binding.username ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("username", event.target.value)} />
|
|
</Field>
|
|
<SecretField label="Database password" name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
|
|
<Field label="SSH host">
|
|
<input className={inputClass} required={editable} value={draft.binding.sshHost ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshHost", event.target.value)} />
|
|
</Field>
|
|
<Field label="SSH port">
|
|
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.sshPort ?? 22} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshPort", Number(event.target.value))} />
|
|
</Field>
|
|
<Field label="SSH username">
|
|
<input className={inputClass} required={editable} value={draft.binding.sshUsername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshUsername", event.target.value)} />
|
|
</Field>
|
|
<SecretField label="SSH private key" name="sshPrivateKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
|
|
<SecretField label="Private key passphrase" name="sshPrivateKeyPassphrase" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
|
|
<SecretField label="SSH known hosts" name="sshKnownHosts" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
|
|
<Field label="Target host">
|
|
<input className={inputClass} required={editable} value={draft.binding.sshTargetHost ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshTargetHost", event.target.value)} />
|
|
</Field>
|
|
<Field label="Target port">
|
|
<input className={inputClass} type="number" min={1} max={65535} required={editable} value={draft.binding.sshTargetPort ?? 5432} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("sshTargetPort", Number(event.target.value))} />
|
|
</Field>
|
|
<Field label="TLS server name">
|
|
<input className={inputClass} value={draft.binding.tlsServername ?? ""} readOnly={readOnly} disabled={busy} onChange={(event) => onBindingChange("tlsServername", event.target.value || undefined)} />
|
|
</Field>
|
|
<SecretField label="TLS CA certificate" name="tlsCa" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{row.lastErrorMessage ? (
|
|
<div className="rounded-md border border-destructive/25 bg-destructive/8 px-4 py-3 text-sm text-destructive" role="status">
|
|
Last connection error: {row.lastErrorMessage}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-4">
|
|
{mode === "view" || mode === "edit" ? (
|
|
<Button type="button" variant="outline" disabled={testDisabled} onClick={onTest}>
|
|
<TestTube2 /> {busyAction === "test" ? "Testing…" : "Test connection"}
|
|
</Button>
|
|
) : null}
|
|
{mode === "configure" || mode === "edit" ? (
|
|
<Button type="submit" disabled={saveDisabled}>
|
|
<Save />
|
|
{busyAction === "save"
|
|
? "Saving…"
|
|
: mode === "configure"
|
|
? "Save catalog configuration"
|
|
: "Save changes"}
|
|
</Button>
|
|
) : null}
|
|
{mode === "delete" ? (
|
|
<Button type="button" variant="destructive" disabled={!canManage || !row.configured || !row.id || busy || stale} onClick={onDelete}>
|
|
<Trash2 /> {busyAction === "delete" ? "Removing…" : presentation === "drawer" ? "Remove configuration" : "Delete database"}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</form>
|
|
);
|
|
|
|
if (presentation === "drawer") {
|
|
return (
|
|
<section aria-label={`${title} form`} className="thot-fleet-drawer-form">
|
|
<h2 ref={headingRef} tabIndex={-1} className="sr-only">{title}</h2>
|
|
<div className="thot-fleet-drawer-form__context">
|
|
<div className="min-w-0">
|
|
<p className="thot-label">Workspace</p>
|
|
<p className="mt-1 truncate text-sm font-semibold">{row.workspaceName}</p>
|
|
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">{row.workspaceId}</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
<Database className="size-4 text-muted-foreground" aria-hidden="true" />
|
|
<span className="rounded-md border border-border bg-muted px-2.5 py-1 text-xs font-semibold text-muted-foreground">
|
|
{statusLabel(row)}
|
|
</span>
|
|
{mode === "view" && row.configured ? (
|
|
<Button type="button" variant="outline" size="sm" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
|
|
{activeSyncRun ? <RefreshCw className="animate-spin" /> : <History />} Sync history
|
|
</Button>
|
|
) : null}
|
|
{mode === "view" && row.configured && onOpenDescriptionHistory ? (
|
|
<Button type="button" variant="outline" size="sm" onClick={onOpenDescriptionHistory}>
|
|
<History /> Description history
|
|
</Button>
|
|
) : null}
|
|
{mode === "view" && row.configured && onOpenSensitiveHistory ? (
|
|
<Button type="button" variant="outline" size="sm" onClick={onOpenSensitiveHistory}>
|
|
<History /> Sensitive history
|
|
</Button>
|
|
) : null}
|
|
{mode === "view" ? (
|
|
<DatabaseSyncMenu
|
|
disabled={!canManage || !bindingReady || busy || Boolean(activeSyncRun)}
|
|
disabledReason={!bindingReady ? "Test the current database binding before synchronizing the schema" : undefined}
|
|
onSelect={onSync}
|
|
/>
|
|
) : null}
|
|
{mode === "view" && row.configured && onOpenDescriptionHistory ? <Button type="button" variant="outline" onClick={onOpenDescriptionHistory}><History /> Description history</Button> : null}
|
|
{mode === "view" && row.configured && onOpenSensitiveHistory ? <Button type="button" variant="outline" onClick={onOpenSensitiveHistory}><History /> Sensitive history</Button> : null}
|
|
</div>
|
|
</div>
|
|
{row.lastTestedAt ? (
|
|
<p className="mb-4 text-xs text-muted-foreground">Last tested {new Date(row.lastTestedAt).toLocaleString()}</p>
|
|
) : null}
|
|
{form}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section aria-label={`${title} form`} className="min-h-0 flex-1 overflow-y-auto bg-background">
|
|
<div className="mx-auto w-full max-w-[900px] px-4 py-5 sm:px-6 sm:py-6">
|
|
<Button type="button" variant="ghost" className="-ml-2 mb-5" disabled={busy} onClick={onBack}>
|
|
<ArrowLeft /> Back to list
|
|
</Button>
|
|
|
|
<div className="mb-6 flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
|
|
<div className="min-w-0">
|
|
<p className="thot-label mb-1">Database management</p>
|
|
<h2 ref={headingRef} tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight outline-none">
|
|
{title}
|
|
</h2>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{row.workspaceName} · {row.workspaceId}
|
|
</p>
|
|
{row.lastTestedAt ? (
|
|
<p className="mt-1 text-xs text-muted-foreground">Last tested {new Date(row.lastTestedAt).toLocaleString()}</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
<Database className="size-4 text-muted-foreground" aria-hidden="true" />
|
|
<span className="rounded-full border border-border bg-muted px-2.5 py-1 text-xs font-semibold text-muted-foreground">
|
|
{statusLabel(row)}
|
|
</span>
|
|
{mode === "view" && activeSyncRun ? (
|
|
<Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button>
|
|
) : null}
|
|
{mode === "view" ? (
|
|
<DatabaseSyncMenu
|
|
disabled={!canManage || !bindingReady || busy || Boolean(activeSyncRun)}
|
|
disabledReason={!bindingReady ? "Test the current database binding before synchronizing the schema" : undefined}
|
|
onSelect={onSync}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{mode === "view" ? (
|
|
<nav aria-label="Database sections" className="mb-6 flex gap-1 border-b border-border" role="tablist">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected="true"
|
|
className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground"
|
|
>
|
|
Overview
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected="false"
|
|
disabled={!row.configured || !row.id}
|
|
className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-45"
|
|
onClick={(event) => onOpenTables(event.currentTarget)}
|
|
>
|
|
Tables
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected="false"
|
|
disabled={!row.configured || !row.id}
|
|
className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-45"
|
|
onClick={onOpenRelationships}
|
|
>
|
|
Relationships
|
|
</button>
|
|
</nav>
|
|
) : null}
|
|
|
|
{form}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|