Files
ThothII/frontend/src/shell/database-management/DatabaseForm.tsx
T

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>
);
}