Add full shell, replaceable Omics adapter and bilingual interaction

Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
This commit is contained in:
Codex
2026-09-13 14:26:39 +02:00
parent 2d1b714ebe
commit d8a29bfbdd
207 changed files with 8570 additions and 2164 deletions
@@ -1,3 +1,4 @@
import { getLocale, translate, useI18n } from "../../i18n";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Check, CheckCircle2, Circle, LoaderCircle, Minus, RotateCcw, X } from "lucide-react";
@@ -83,7 +84,7 @@ function elapsed(run: CatalogSyncRun, now: number): string {
}
function stateLabel(run: CatalogSyncRun): string {
return run.state.replaceAll("_", " ");
return translate(run.state.replaceAll("_", " "));
}
export function CatalogSyncDrawer({
@@ -95,6 +96,7 @@ export function CatalogSyncDrawer({
onRunUpdate,
onCatalogChanged,
}: Props) {
const { t } = useI18n();
const queryClient = useQueryClient();
const [events, setEvents] = useState<CatalogSyncEvent[]>([]);
const [now, setNow] = useState(Date.now());
@@ -218,7 +220,7 @@ export function CatalogSyncDrawer({
|| (run && !terminal(run) ? currentPhase > confirmationPhase : furthestEventPhase > confirmationPhase);
const deletionCount = useMemo(() => run?.plannedDiff
? run.plannedDiff.deletedTables.length + run.plannedDiff.deletedColumns.length + run.plannedDiff.deletedRelationships.length
: 0, [run]);
: 0, [run, t]);
const perform = async (kind: "confirm" | "cancel" | "retry") => {
if (!run) return;
@@ -233,28 +235,26 @@ export function CatalogSyncDrawer({
queryClient.setQueryData(["catalog-sync-run", next.id], next);
await historyQuery.refetch();
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setAction(null);
}
};
const historySection = (
<section className="thot-catalog-drawer__history mt-5" aria-label="Synchronization history">
<h3 className="thot-label mb-2">Recent runs</h3>
{historyQuery.isLoading ? <p className="text-sm text-muted-foreground">Loading recent runs…</p> : null}
<section className="thot-catalog-drawer__history mt-5" aria-label={t("Synchronization history")}>
<h3 className="thot-label mb-2">{t("Recent runs")}</h3>
{historyQuery.isLoading ? <p className="text-sm text-muted-foreground">{t("Loading recent runs…")}</p> : null}
{historyQuery.isError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm" role="alert">
<p className="font-semibold text-destructive">Synchronization history could not be loaded.</p>
<p className="font-semibold text-destructive">{t("Synchronization history could not be loaded.")}</p>
<Button type="button" variant="outline" size="sm" className="mt-3" disabled={historyQuery.isFetching} onClick={() => void historyQuery.refetch()}>
<RotateCcw className={historyQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry history
</Button>
<RotateCcw className={historyQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Retry history")}</Button>
</div>
) : null}
{!historyQuery.isLoading && !historyQuery.isError && (historyQuery.data ?? []).length === 0 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-4 text-sm text-muted-foreground">
No synchronization runs yet.
</div>
{t("No synchronization runs yet.")}</div>
) : null}
{(historyQuery.data ?? []).length > 0 ? (
<div className="divide-y divide-border rounded-md border border-border">
@@ -267,8 +267,8 @@ export function CatalogSyncDrawer({
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 aria-[current=true]:bg-primary/8"
onClick={() => onRunChange(item)}
>
<span>{item.scope}</span>
<span className="text-xs text-muted-foreground">{stateLabel(item)} · {new Date(item.createdAt).toLocaleString()}</span>
<span>{t(item.scope)}</span>
<span className="text-xs text-muted-foreground">{t(stateLabel(item))} · {new Date(item.createdAt).toLocaleString(getLocale())}</span>
</button>
))}
</div>
@@ -281,43 +281,41 @@ export function CatalogSyncDrawer({
<>
{run.state === "awaiting_confirmation" ? (
<Button type="button" disabled={action !== null} onClick={() => void perform("confirm")}>
{action === "confirm" ? <LoaderCircle className="animate-spin" /> : <Check />} Confirm removals
</Button>
{action === "confirm" ? <LoaderCircle className="animate-spin" /> : <Check />} {t("Confirm removals")}</Button>
) : null}
{["queued", "running", "awaiting_confirmation"].includes(run.state) ? (
<Button type="button" variant="outline" disabled={action !== null} onClick={() => void perform("cancel")}>Cancel</Button>
<Button type="button" variant="outline" disabled={action !== null} onClick={() => void perform("cancel")}>{t("Cancel")}</Button>
) : null}
{["failed", "cancelled", "interrupted"].includes(run.state) ? (
<Button type="button" disabled={action !== null} onClick={() => void perform("retry")}>
<RotateCcw className={action === "retry" ? "animate-spin" : ""} /> Retry
</Button>
<RotateCcw className={action === "retry" ? "animate-spin" : ""} /> {t("Retry")}</Button>
) : null}
</>
) : undefined;
return (
<FleetLedgerDrawer
open
ariaLabel="Schema synchronization"
eyebrow="Schema synchronization"
ariaLabel={t("Schema synchronization")}
eyebrow={t("Schema synchronization")}
title={run ? (
<span className="inline-flex items-center gap-2">
{!terminal(run) ? <LoaderCircle className="size-5 animate-spin" aria-hidden="true" /> : null}
{stateLabel(run)[0].toUpperCase()}{stateLabel(run).slice(1)}
{t(stateLabel(run)[0].toUpperCase())}{t(stateLabel(run).slice(1))}
</span>
) : "Synchronization history"}
description={run ? `${run.scope} · ${elapsed(run, now)}` : undefined}
) : t("Synchronization history")}
description={run ? `${t(run.scope.replaceAll("_", " "))} · ${elapsed(run, now)}` : undefined}
onClose={onClose}
closeLabel="Close synchronization drawer"
closeLabel={t("Close synchronization drawer")}
busy={action !== null}
bodyClassName="thot-catalog-drawer__history-layout"
footer={footer}
>
{runQuery.isLoading ? <p className="text-sm text-muted-foreground">Loading synchronization…</p> : null}
{runQuery.isLoading ? <p className="text-sm text-muted-foreground">{t("Loading synchronization…")}</p> : null}
{run ? (
<>
<section aria-labelledby="synchronization-steps-heading">
<h3 id="synchronization-steps-heading" className="thot-label mb-3">Synchronization steps</h3>
<ol className="grid grid-cols-2 gap-x-4 gap-y-2.5 sm:grid-cols-3" aria-label="Synchronization steps">
<h3 id="synchronization-steps-heading" className="thot-label mb-3">{t("Synchronization steps")}</h3>
<ol className="grid grid-cols-2 gap-x-4 gap-y-2.5 sm:grid-cols-3" aria-label={t("Synchronization steps")}>
{phases.map((phase, index) => {
const confirmationUnknown = phase === "awaiting_confirmation"
&& passedConfirmation
@@ -340,9 +338,9 @@ export function CatalogSyncDrawer({
const current = index === currentPhase && !terminal(run);
const label = phase === "awaiting_confirmation"
? confirmationSkipped
? "Confirmation not required"
? t("Confirmation not required")
: confirmationUnknown
? "Checking confirmation"
? t("Checking confirmation")
: confirmationWasReceived
? phaseLabels[phase].completed
: current
@@ -360,17 +358,17 @@ export function CatalogSyncDrawer({
const status = terminalOutcome
? run.state
: confirmationSkipped
? "not required"
? t("not required")
: complete
? "completed"
: current
? "in progress"
? t("in progress")
: "pending";
return (
<li
key={phase}
aria-current={current ? "step" : undefined}
aria-label={`${label}, ${status}`}
aria-label={`${t(label)}, ${t(status)}`}
className={`flex items-center gap-2 text-xs ${failedOutcome ? "font-semibold text-destructive" : interruptedOutcome ? "font-semibold text-amber-600" : terminalOutcome || current ? "font-semibold text-foreground" : "text-muted-foreground"}`}
>
{confirmationSkipped ? (
@@ -388,7 +386,7 @@ export function CatalogSyncDrawer({
) : (
<Circle aria-hidden="true" className="size-3.5" />
)}
{label}
{t(label)}
</li>
);
})}
@@ -405,40 +403,39 @@ export function CatalogSyncDrawer({
</div>
{run.state === "awaiting_confirmation" && run.plannedDiff ? (
<section className="mt-5 rounded-md border border-amber-500/50 bg-amber-500/8 p-4" aria-label="Destructive changes">
<section className="mt-5 rounded-md border border-amber-500/50 bg-amber-500/8 p-4" aria-label={t("Destructive changes")}>
<div className="flex gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600" />
<div>
<h3 className="font-semibold">Confirm {deletionCount} removals</h3>
<p className="mt-1 text-sm text-muted-foreground">The database will be scanned again before anything is removed.</p>
<p className="mt-1 text-sm text-muted-foreground">Memory cards that depend on removed tables or columns will also be deleted, including their links and search entries.</p>
<h3 className="font-semibold">{t("Confirm")} {deletionCount} {t("removals")}</h3>
<p className="mt-1 text-sm text-muted-foreground">{t("The database will be scanned again before anything is removed.")}</p>
<p className="mt-1 text-sm text-muted-foreground">{t("Memory cards that depend on removed tables or columns will also be deleted, including their links and search entries.")}</p>
</div>
</div>
<ul className="mt-3 max-h-40 space-y-1 overflow-y-auto font-mono text-xs">
{run.plannedDiff.deletedTables.map((name) => <li key={`t-${name}`}>table · {name}</li>)}
{run.plannedDiff.deletedColumns.map((item) => <li key={`c-${item.tableName}-${item.columnName}`}>column · {item.tableName}.{item.columnName}</li>)}
{run.plannedDiff.deletedRelationships.map((item) => <li key={`r-${item.sourceTableName}-${item.constraintName}`}>relationship · {item.sourceTableName}.{item.constraintName}</li>)}
{run.plannedDiff.deletedTables.map((name) => <li key={`t-${name}`}>{t("table ·")} {name}</li>)}
{run.plannedDiff.deletedColumns.map((item) => <li key={`c-${item.tableName}-${item.columnName}`}>{t("column ·")} {item.tableName}.{item.columnName}</li>)}
{run.plannedDiff.deletedRelationships.map((item) => <li key={`r-${item.sourceTableName}-${item.constraintName}`}>{t("relationship ·")} {item.sourceTableName}.{item.constraintName}</li>)}
</ul>
</section>
) : null}
{run.counts.memoryDeleted !== undefined && <p className="mt-4 text-sm text-muted-foreground">
Dependent Memory cards removed: {run.counts.memoryDeleted}
{t("Dependent Memory cards removed:")} {run.counts.memoryDeleted}
</p>}
{run.errorMessage ? (
<div className="mt-5 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{run.errorMessage}
{t(run.errorMessage)}
</div>
) : null}
<section className="mt-5" aria-label="Synchronization log">
<section className="mt-5" aria-label={t("Synchronization log")}>
<div className="mb-2 flex items-center justify-between">
<h3 className="thot-label">Live log</h3>
<h3 className="thot-label">{t("Live log")}</h3>
<div className="flex items-center gap-2">
{!followingEventTail && events.length > 0 ? (
<Button type="button" variant="outline" size="xs" onClick={jumpToLatestEvent}>
Jump to latest
</Button>
{t("Jump to latest")}</Button>
) : null}
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
</div>
@@ -446,15 +443,15 @@ export function CatalogSyncDrawer({
<div
ref={eventLogRef}
role="log"
aria-label="Synchronization events"
aria-label={t("Synchronization events")}
aria-relevant="additions"
tabIndex={0}
onScroll={handleEventLogScroll}
className="thot-catalog-drawer__event-log max-h-64 overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
{events.length === 0 ? <p className="text-zinc-500">Waiting for events…</p> : events.map((event) => (
{events.length === 0 ? <p className="text-zinc-500">{t("Waiting for events…")}</p> : events.map((event) => (
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString()}</span>{event.message}
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString(getLocale())}</span>{t(event.message)}
</p>
))}
</div>
@@ -465,12 +462,11 @@ export function CatalogSyncDrawer({
) : (
<>
<div className="rounded-lg border border-dashed border-primary/30 bg-primary/5 px-5 py-6 text-sm">
<p className="font-semibold">{runQuery.isError ? "This synchronization run could not be loaded." : historyQuery.isLoading ? "Loading synchronization history…" : "No run selected."}</p>
<p className="mt-1 text-muted-foreground">Choose a recent run to inspect its steps and event log.</p>
<p className="font-semibold">{runQuery.isError ? t("This synchronization run could not be loaded.") : historyQuery.isLoading ? t("Loading synchronization history…") : t("No run selected.")}</p>
<p className="mt-1 text-muted-foreground">{t("Choose a recent run to inspect its steps and event log.")}</p>
{runQuery.isError ? (
<Button type="button" variant="outline" size="sm" className="mt-3" disabled={runQuery.isFetching} onClick={() => void runQuery.refetch()}>
<RotateCcw className={runQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry run
</Button>
<RotateCcw className={runQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Retry run")}</Button>
) : null}
</div>
{historySection}
@@ -1,3 +1,6 @@
import { useAdminGridLocale } from "./useAdminGridLocale";
import { adminMessage, type AdminMessage } from "./messages";
import { useI18n } from "../../i18n";
import { useEffect, useMemo, useRef, useState } from "react";
import { Menu } from "@base-ui/react/menu";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -35,7 +38,7 @@ interface Props {
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
onCatalogSyncRunStarted?: (run: CatalogSyncRun) => void;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onSuggestSensitive: (selection: SensitivityAnalysisRequest, scopeLabel: string) => Promise<void>;
onSuggestSensitive: (selection: SensitivityAnalysisRequest, scopeLabel: AdminMessage) => Promise<void>;
catalogOperationActive?: boolean;
onCatalogMetricsChanged?: () => void | Promise<void>;
presentation?: "legacy" | "fleet";
@@ -50,6 +53,7 @@ interface GridContext {
}
function KeyCell({ data }: ICellRendererParams<CatalogColumn>) {
const { t } = useI18n();
if (!data) return null;
return (
<div className="flex h-full items-center gap-1.5">
@@ -60,11 +64,12 @@ function KeyCell({ data }: ICellRendererParams<CatalogColumn>) {
}
function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknown, GridContext>) {
const { t } = useI18n();
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Edit metadata" : undefined}>
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={`Edit metadata for ${data.name}`} title={context.presentation === "legacy" ? `Edit metadata for ${data.name}` : undefined} onClick={(event) => context.onEdit(data, event.currentTarget)}>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? t("Edit metadata") : undefined}>
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={t("Edit metadata for {name}", { name: data.name })} title={context.presentation === "legacy" ? t("Edit metadata for {name}", { name: data.name }) : undefined} onClick={(event) => context.onEdit(data, event.currentTarget)}>
<Pencil aria-hidden="true" />
</Button>
</span>
@@ -73,6 +78,7 @@ function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknow
}
function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unknown, GridContext>) {
const { t } = useI18n();
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-center" onClick={(event) => event.stopPropagation()}>
@@ -81,8 +87,8 @@ function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unk
className="size-4 accent-primary outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
checked={data.sensitive}
disabled={!context.canManage || context.busy}
aria-label={`Sensitive data for ${data.name}`}
title="Mark this column as sensitive; this does not select it for actions."
aria-label={t("Sensitive data for {name}", { name: data.name })}
title={t("Mark this column as sensitive; this does not select it for actions.")}
onChange={(event) => context.onSensitiveChange(data, event.target.checked)}
onClick={(event) => event.stopPropagation()}
/>
@@ -107,6 +113,7 @@ function ColumnMetadataFieldMatrix({
onDescriptionChange: (value: string) => void;
onGeneratedDescriptionChange: (value: string) => void;
}) {
const { t } = useI18n();
const fieldClass = [
"thot-column-metadata-field flex min-h-0 flex-col gap-1.5",
"text-sm font-semibold",
@@ -122,16 +129,14 @@ function ColumnMetadataFieldMatrix({
return (
<div
role="group"
aria-label="Column metadata fields"
aria-label={t("Column metadata fields")}
className="thot-column-metadata-matrix mt-5 grid min-h-0 gap-4 sm:grid-cols-2"
>
<label className={fieldClass}>
Source comment
<textarea className={readOnlyClass} value={column.sourceComment ?? ""} readOnly />
{t("Source comment")}<textarea className={readOnlyClass} value={column.sourceComment ?? ""} readOnly />
</label>
<label className={fieldClass}>
Description
<textarea
{t("Description")}<textarea
className={editableClass}
value={description}
disabled={!canManage || busy}
@@ -139,8 +144,7 @@ function ColumnMetadataFieldMatrix({
/>
</label>
<label className={fieldClass}>
Generated description
<textarea
{t("Generated description")}<textarea
className={editableClass}
value={generatedDescription}
disabled={!canManage || busy}
@@ -148,11 +152,10 @@ function ColumnMetadataFieldMatrix({
/>
</label>
<label className={fieldClass}>
Sensitive motivation
<textarea
{t("Sensitive motivation")}<textarea
className={readOnlyClass}
value={column.sensitivityReason ?? ""}
placeholder="No sensitivity motivation recorded."
placeholder={t("No sensitivity motivation recorded.")}
readOnly
/>
</label>
@@ -175,6 +178,8 @@ export function DatabaseColumns({
onCatalogMetricsChanged,
presentation = "legacy",
}: Props) {
const { t } = useI18n();
const { locale, gridLocaleProps } = useAdminGridLocale();
const queryClient = useQueryClient();
const queryKey = ["catalog-columns", databaseId, table.id] as const;
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
@@ -208,7 +213,7 @@ export function DatabaseColumns({
Object.hasOwn(sensitiveDrafts, column.id)
? { ...column, sensitive: sensitiveDrafts[column.id]! }
: column
)), [data, sensitiveDrafts]);
)), [data, sensitiveDrafts, t]);
useEffect(() => { onNavigationStateChange({ dirty, busy }); }, [busy, dirty, onNavigationStateChange]);
useEffect(() => {
@@ -229,7 +234,7 @@ export function DatabaseColumns({
};
const closeEditor = () => {
if (busy) return;
if (editorDirty && !window.confirm("Discard unsaved column metadata?")) return;
if (editorDirty && !window.confirm(t("Discard unsaved column metadata?"))) return;
setEditingId(null);
window.setTimeout(() => originRef.current?.focus(), 0);
};
@@ -248,13 +253,13 @@ export function DatabaseColumns({
setVersion(updated.version);
setStale(false);
void onCatalogMetricsChanged?.();
toast.success("Column metadata saved");
toast.success(t("Column metadata saved"));
} catch (error) {
if (error instanceof ApiError && error.code === "column_stale") {
await refetch();
setStale(true);
setStaleBannerOpen(true);
} else toast.error(apiErrorMessage(error));
} else toast.error(t(apiErrorMessage(error)));
} finally { setBusy(false); }
};
const reloadColumn = async () => {
@@ -270,7 +275,7 @@ export function DatabaseColumns({
setVersion(latest.version);
setStale(false);
setStaleBannerOpen(true);
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
} catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(false); }
};
const consolidateDescriptions = async () => {
setBusy(true);
@@ -280,9 +285,9 @@ export function DatabaseColumns({
gridRef.current?.api.deselectAll();
setSelectedIds([]);
void onCatalogMetricsChanged?.();
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
toast.success((counts.copied === 1 ? t("Copied {copied} description; skipped {skipped}", { copied: counts.copied, skipped: counts.skipped }) : t("Copied {copied} descriptions; skipped {skipped}", { copied: counts.copied, skipped: counts.skipped })));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally { setBusy(false); }
};
const generateDescriptions = async () => {
@@ -296,9 +301,9 @@ export function DatabaseColumns({
selectedIds,
);
onDescriptionGenerationRunStarted(run);
toast.success(`Description generation started for ${selectedIds.length} column${selectedIds.length === 1 ? "" : "s"}`);
toast.success((selectedIds.length === 1 ? t("Description generation started for {count} column", { count: selectedIds.length }) : t("Description generation started for {count} columns", { count: selectedIds.length })));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally { setBusy(false); }
};
@@ -320,7 +325,7 @@ export function DatabaseColumns({
try {
await onSuggestSensitive(
{ scope: "selected_columns", targetIds: selectedIds },
`${selectedIds.length} selected column${selectedIds.length === 1 ? "" : "s"} in ${table.name}`,
(selectedIds.length === 1 ? adminMessage("{count} selected column in {name}", { count: selectedIds.length, name: table.name }) : adminMessage("{count} selected columns in {name}", { count: selectedIds.length, name: table.name })),
);
} catch {
// The page-level request reports the safe, specific reason and preserves the selection.
@@ -363,9 +368,9 @@ export function DatabaseColumns({
void onCatalogMetricsChanged?.();
}
if (failed.length > 0) {
toast.error(`${failed.length} sensitive flag${failed.length === 1 ? "" : "s"} could not be saved: ${apiErrorMessage(failed[0])}`);
toast.error((failed.length === 1 ? t("{count} sensitive flag could not be saved: {error}", { count: failed.length, error: apiErrorMessage(failed[0]) }) : t("{count} sensitive flags could not be saved: {error}", { count: failed.length, error: apiErrorMessage(failed[0]) })));
} else {
toast.success("Sensitive fields saved");
toast.success(t("Sensitive fields saved"));
}
} finally {
setSensitiveAction(null);
@@ -382,69 +387,69 @@ export function DatabaseColumns({
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
{
id: "sync-columns",
label: "Synchronize columns for this table",
group: "Synchronization",
scopeLabel: "Selected table",
runLabel: "Synchronize",
label: t("Synchronize columns for this table"),
group: t("Synchronization"),
scopeLabel: t("Selected table"),
runLabel: t("Synchronize"),
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
? t("You do not have permission to synchronize the catalog.")
: selectedIds.length === 0
? "Select at least one column."
? t("Select at least one column.")
: catalogOperationActive
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy
? "Another action is running."
? t("Another action is running.")
: undefined,
},
{
id: "generate-descriptions",
label: "Generate column descriptions",
group: "Descriptions",
runLabel: "Generate",
label: t("Generate column descriptions"),
group: t("Descriptions"),
runLabel: t("Generate"),
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to generate descriptions."
? t("You do not have permission to generate descriptions.")
: selectedIds.length === 0
? "Select at least one column."
? t("Select at least one column.")
: !selectedMetadataModel
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy
? "Another action is running."
? t("Another action is running.")
: undefined,
},
{
id: "consolidate-descriptions",
label: "Copy generated descriptions to Description",
group: "Descriptions",
runLabel: "Move",
label: t("Copy generated descriptions to Description"),
group: t("Descriptions"),
runLabel: t("Move"),
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to update metadata."
? t("You do not have permission to update metadata.")
: selectedIds.length === 0
? "Select at least one column."
? t("Select at least one column.")
: catalogOperationActive
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy
? "Another action is running."
? t("Another action is running.")
: undefined,
},
{
id: "suggest-sensitive",
label: "Analyze sensitive fields",
group: "Sensitive data",
runLabel: "Analyze",
label: t("Analyze sensitive fields"),
group: t("Sensitive data"),
runLabel: t("Analyze"),
disabled: !canManage || selectedIds.length === 0 || descriptionGenerationActive || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to review sensitive data."
? t("You do not have permission to review sensitive data.")
: selectedIds.length === 0
? "Select at least one column."
? t("Select at least one column.")
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy
? "Another action is running."
? t("Another action is running.")
: undefined,
},
];
@@ -469,9 +474,9 @@ export function DatabaseColumns({
onCatalogSyncRunStarted?.(run);
gridRef.current?.api.deselectAll();
setSelectedIds([]);
toast.success("Column synchronization started");
toast.success(t("Column synchronization started"));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setBusy(false);
}
@@ -479,31 +484,30 @@ export function DatabaseColumns({
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
{ field: "sensitive", headerName: "Sensitive", headerTooltip: "Mark this column as sensitive; this does not select it for actions.", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
{ field: "name", headerName: "Name", minWidth: 190, flex: 1, cellClass: "font-mono text-xs" },
{ field: "dataType", headerName: "Type", minWidth: 150, flex: 0.8, cellClass: "font-mono text-xs" },
{ headerName: "Keys", minWidth: 125, width: 125, sortable: false, filter: false, cellRenderer: KeyCell },
{ headerName: "Nullable", minWidth: 100, width: 100, valueGetter: ({ data: row }) => row ? (row.isNullable ? "Yes" : "No") : "" },
{ field: "sourceComment", headerName: "Source comment", minWidth: 220, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "generatedDescription", headerName: "Generated description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "description", headerName: "Description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: "Actions", width: 64, maxWidth: 64, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: ActionCell },
], []);
{ field: "sensitive", headerName: t("Sensitive"), headerTooltip: t("Mark this column as sensitive; this does not select it for actions."), minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
{ field: "name", headerName: t("Name"), minWidth: 190, flex: 1, cellClass: "font-mono text-xs" },
{ field: "dataType", headerName: t("Type"), minWidth: 150, flex: 0.8, cellClass: "font-mono text-xs" },
{ headerName: t("Keys"), minWidth: 125, width: 125, sortable: false, filter: false, cellRenderer: KeyCell },
{ headerName: t("Nullable"), minWidth: 100, width: 100, valueGetter: ({ data: row }) => row ? (row.isNullable ? t("Yes") : t("No")) : "" },
{ field: "sourceComment", headerName: t("Source comment"), minWidth: 220, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "generatedDescription", headerName: t("Generated description"), minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "description", headerName: t("Description"), minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: t("Actions"), width: 64, maxWidth: 64, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: ActionCell },
], [t]);
const context = useMemo<GridContext>(() => ({
canManage,
busy: busy || catalogOperationActive,
presentation,
onEdit: edit,
onSensitiveChange: changeSensitive,
}), [busy, canManage, catalogOperationActive, data, presentation]);
}), [busy, canManage, catalogOperationActive, data, presentation, t]);
const fleetQueryError = presentation === "fleet" && isError ? (
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
<p className="font-semibold text-destructive">Catalog columns could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<p className="font-semibold text-destructive">{t("Catalog columns could not be loaded.")}</p>
<p className="mt-1 leading-5 text-muted-foreground">{t(apiErrorMessage(error))}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry loading columns
</Button>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Retry loading columns")}</Button>
</div>
</div>
) : null;
@@ -511,13 +515,13 @@ export function DatabaseColumns({
if (editingId && active && presentation === "legacy") {
return (
<div className="mx-auto w-full max-w-4xl px-4 py-6 sm:px-5">
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={closeEditor}>← Back to columns</Button>
<p className="thot-label">{table.name} · column</p>
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={closeEditor}>{t("← Back to columns")}</Button>
<p className="thot-label">{table.name} {t("· column")}</p>
<h3 className="mt-1 font-heading text-2xl font-semibold">{active.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">Physical schema fields are read-only. Review fields can be edited.</p>
<p className="mt-1 text-sm text-muted-foreground">{t("Physical schema fields are read-only. Review fields can be edited.")}</p>
{stale ? (
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">{t("Newer column metadata is available.")}</p><p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload.")}</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this metadata can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button></div>
) : null}
<ColumnMetadataFieldMatrix
column={active}
@@ -529,8 +533,8 @@ export function DatabaseColumns({
onGeneratedDescriptionChange={setGeneratedDescription}
/>
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save />{busy ? "Saving…" : "Save metadata"}</Button>
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>{t("Cancel")}</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save />{busy ? t("Saving…") : t("Save metadata")}</Button>
</div>
</div>
);
@@ -546,31 +550,31 @@ export function DatabaseColumns({
actions={fleetActions}
selectedCount={selectedIds.length}
busy={busy}
busyLabel={sensitiveAction === "suggest" ? "Suggesting…" : sensitiveAction === "save" ? "Saving…" : "Running…"}
busyLabel={sensitiveAction === "suggest" ? t("Suggesting…") : sensitiveAction === "save" ? t("Saving…") : t("Running…")}
onRun={runFleetAction}
onClear={clearSelection}
label="Column action"
label={t("Column action")}
/>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? t("Saving…") : t("Save sensitive fields")}</Button> : null}
</>
) : (
<>
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
<span className="text-sm font-semibold">{selectedIds.length} {t("selected")}</span>
<Menu.Root>
<Menu.Trigger className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Trigger className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy}>{t("Actions")} <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedIds.length || catalogOperationActive || busy} onClick={() => void synchronizeColumns()}>Synchronize columns for this table</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "column description" : "column descriptions"}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>Copy generated descriptions to Description</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedIds.length || catalogOperationActive || busy} onClick={() => void synchronizeColumns()}>{t("Synchronize columns for this table")}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>{t("Generate")} {selectedIds.length === 1 ? t("column description") : t("column descriptions")}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>{t("Copy generated descriptions to Description")}</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{sensitiveAction === "suggest" ? "Analyzing…" : "Analyze sensitive fields"}</Button>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<Button type="button" variant="ghost" onClick={clearSelection}><X />Clear</Button>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy} title={descriptionGenerationActive ? t("Wait for the active description generation to finish") : undefined} onClick={() => void suggestSensitive()}><Sparkles />{sensitiveAction === "suggest" ? t("Analyzing…") : t("Analyze sensitive fields")}</Button>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? t("Saving…") : t("Save sensitive fields")}</Button> : null}
<Button type="button" variant="ghost" onClick={clearSelection}><X />{t("Clear")}</Button>
</>
)
) : (
@@ -580,30 +584,30 @@ export function DatabaseColumns({
actions={fleetActions}
selectedCount={0}
busy={busy}
busyLabel={sensitiveAction === "save" ? "Saving…" : "Running…"}
busyLabel={sensitiveAction === "save" ? t("Saving…") : t("Running…")}
onRun={runFleetAction}
onClear={clearSelection}
label="Column action"
label={t("Column action")}
/>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? t("Saving…") : t("Save sensitive fields")}</Button> : null}
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label={t("Search columns")} placeholder={t("Search")} value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />{t("Refresh")}</Button>
</>
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog columns</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="thot-label whitespace-nowrap">{t("Catalog columns")}</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label={t("Search columns")} placeholder={t("Search")} value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? t("Saving…") : t("Save sensitive fields")}</Button> : null}
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />{t("Refresh")}</Button>
</>
)
)}
</div>
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogColumn>
<AgGridReact<CatalogColumn> key={locale} {...gridLocaleProps}
ref={gridRef}
rowData={displayedColumns}
columnDefs={columns}
@@ -614,8 +618,8 @@ export function DatabaseColumns({
getRowId={({ data: row }) => row.id}
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
selectionColumnDef={{
headerName: "Select",
headerTooltip: "Select columns for actions",
headerName: t("Select"),
headerTooltip: t("Select columns for actions"),
width: 72,
minWidth: 72,
maxWidth: 72,
@@ -628,32 +632,32 @@ export function DatabaseColumns({
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No columns synchronized for this table."
overlayNoRowsTemplate={t("No columns synchronized for this table.")}
/>
</div>
</div>}
{presentation === "fleet" && active ? (
<FleetLedgerDrawer
open
eyebrow="Catalog metadata"
title="Review column description"
description="Physical schema fields are read-only. Review fields can be edited."
eyebrow={t("Catalog metadata")}
title={t("Review column description")}
description={t("Physical schema fields are read-only. Review fields can be edited.")}
onClose={closeEditor}
closeLabel="Close column metadata"
closeLabel={t("Close column metadata")}
busy={busy}
bodyClassName="thot-column-metadata-drawer-body"
footer={(
<>
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save aria-hidden="true" />{busy ? "Saving…" : "Save metadata"}</Button>
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>{t("Cancel")}</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save aria-hidden="true" />{busy ? t("Saving…") : t("Save metadata")}</Button>
</>
)}
>
<p className="thot-label">{table.name} · column</p>
<p className="thot-label">{table.name} {t("· column")}</p>
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{active.name}</h3>
{stale ? (
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">{t("Newer column metadata is available.")}</p><p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload.")}</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this metadata can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button></div>
) : null}
<ColumnMetadataFieldMatrix
column={active}
@@ -1,3 +1,4 @@
import { getLocale, useI18n, translate } from "../../i18n";
import type { RefObject, ReactNode } from "react";
import {
ArrowLeft,
@@ -34,11 +35,12 @@ interface FieldProps {
}
function Field({ label, hint, children }: FieldProps) {
const { t } = useI18n();
return (
<label className={labelClass}>
<span>{label}</span>
<span>{t(label)}</span>
{children}
{hint ? <span className="font-normal leading-4 text-muted-foreground">{hint}</span> : null}
{hint ? <span className="font-normal leading-4 text-muted-foreground">{t(hint)}</span> : null}
</label>
);
}
@@ -64,14 +66,15 @@ function SecretField({
onChange,
multiline = false,
}: SecretFieldProps) {
const { t } = useI18n();
if (readOnly) {
return (
<Field label={label} hint="Secret values are never displayed.">
<Field label={t(label)} hint={t("Secret values are never displayed.")}>
<input
className={inputClass}
value={row.secrets[name] ? "Configured" : "Not configured"}
readOnly
aria-label={label}
aria-label={t(label)}
/>
</Field>
);
@@ -79,29 +82,29 @@ function SecretField({
const permissionHint = canManageSecrets
? undefined
: "You do not have permission to replace secret values.";
: t("You do not have permission to replace secret values.");
return (
<Field label={label} hint={permissionHint}>
<Field label={t(label)} hint={permissionHint}>
{multiline ? (
<textarea
className={`${inputClass} min-h-24 resize-y py-2 font-mono text-xs leading-5`}
aria-label={label}
aria-label={t(label)}
autoComplete="off"
value={draft.secrets[name] ?? ""}
disabled={!canManageSecrets}
placeholder={row.secrets[name] ? "Configured; leave blank to keep" : "Paste value"}
placeholder={row.secrets[name] ? t("Configured; leave blank to keep") : t("Paste value")}
onChange={(event) => onChange(name, event.target.value)}
/>
) : (
<input
className={inputClass}
type="password"
aria-label={label}
aria-label={t(label)}
autoComplete="new-password"
value={draft.secrets[name] ?? ""}
disabled={!canManageSecrets}
placeholder={row.secrets[name] ? "Configured; leave blank to keep" : "Not configured"}
placeholder={row.secrets[name] ? t("Configured; leave blank to keep") : t("Not configured")}
onChange={(event) => onChange(name, event.target.value)}
/>
)}
@@ -146,10 +149,10 @@ 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";
if (mode === "configure") return translate("Configure catalog");
if (mode === "edit") return translate("Edit database");
if (mode === "delete") return presentation === "drawer" ? translate("Remove configuration") : translate("Delete database");
return translate("Database details");
}
export function DatabaseForm({
@@ -184,6 +187,7 @@ export function DatabaseForm({
activeSyncRun,
presentation = "page",
}: DatabaseFormProps) {
const { t } = useI18n();
const title = databaseFormTitle(mode, presentation);
const readOnly = mode === "view" || mode === "delete";
const editable = mode === "configure" || mode === "edit";
@@ -208,83 +212,77 @@ export function DatabaseForm({
>
{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="font-semibold text-destructive">{t("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>
? t("The repository workspace remains available and will return to the list as Not configured. ")
: t("This orphaned catalog row will disappear from the list. ")}
{t("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>
<p className="font-semibold">{t("A newer database configuration is available.")}</p>
<p className="mt-1 leading-5">{t("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>
<RefreshCw /> {t("Reload latest")}</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onKeepEditing}>
Keep editing
</Button>
{t("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>
{t("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>
<p className="font-semibold">{t("Database configuration saved; secret update could not be confirmed.")}</p>
<p className="mt-1 leading-5">{t(partialSecretFailure)} {t("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"}
<RefreshCw /> {busyAction === "retry-secrets" ? t("Retrying…") : t("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>
{t("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>
{t("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">
<Field label={t("Workspace")}>
<input className={inputClass} value={row.workspaceName} readOnly />
</Field>
<Field label="Workspace ID">
<Field label={t("Workspace ID")}>
<input className={`${inputClass} font-mono text-xs`} value={row.workspaceId} readOnly />
</Field>
<Field label="Database engine">
<Field label={t("Database engine")}>
<input className={inputClass} value="PostgreSQL" readOnly />
</Field>
<Field label="Transport">
<Field label={t("Transport")}>
<select
className={inputClass}
aria-label="Transport"
aria-label={t("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>
<option value="postgres_direct">{t("Direct PostgreSQL connection")}</option>
<option value="rest_api">{t("REST API")}</option>
<option value="ssh_tunnel">{t("SSH tunnel")}</option>
</select>
</Field>
<Field label="Database name">
<Field label={t("Database name")}>
<input
className={inputClass}
required={editable}
@@ -294,7 +292,7 @@ export function DatabaseForm({
onChange={(event) => onFieldChange("databaseName", event.target.value)}
/>
</Field>
<Field label="Schema">
<Field label={t("Schema")}>
<input
className={inputClass}
required={editable}
@@ -307,44 +305,44 @@ export function DatabaseForm({
</div>
<div className="border-t border-border pt-5">
<p className="thot-label mb-4">Installation binding</p>
<p className="thot-label mb-4">{t("Installation binding")}</p>
{draft.binding.transport === "postgres_direct" ? (
<div className="grid gap-4 md:grid-cols-2">
<Field label="Host">
<Field label={t("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">
<Field label={t("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">
<Field label={t("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">
<Field label={t("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} />
<SecretField label={t("Password")} name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<SecretField label={t("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">
<Field label={t("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 label={t("Diagnostic endpoint")} hint={t("This relative path is fixed by the runtime contract.")}>
<input className={inputClass} value={draft.binding.restPath ?? "/health"} readOnly aria-label={t("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"])}>
<Field label={t("Authentication")}>
<select className={inputClass} aria-label={t("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>
<option value="bearer">{t("Bearer token")}</option>
<option value="none">{t("None")}</option>
</select>
</Field>
{draft.binding.restAuth !== "none" ? (
<SecretField label="API key" name="apiKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<SecretField label={t("API key")} name="apiKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
) : null}
</div>
) : null}
@@ -352,63 +350,62 @@ export function DatabaseForm({
{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">
{t("SSH host verification is strict. Provide the trusted known_hosts entry before testing this binding.")}</div>
<Field label={t("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">
<SecretField label={t("Database password")} name="password" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<Field label={t("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">
<Field label={t("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">
<Field label={t("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">
<SecretField label={t("SSH private key")} name="sshPrivateKey" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
<SecretField label={t("Private key passphrase")} name="sshPrivateKeyPassphrase" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} />
<SecretField label={t("SSH known hosts")} name="sshKnownHosts" row={row} draft={draft} readOnly={readOnly} canManageSecrets={canManageSecrets} onChange={onSecretChange} multiline />
<Field label={t("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">
<Field label={t("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">
<Field label={t("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 />
<SecretField label={t("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}
{t("Last connection error:")} {t(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"}
<TestTube2 /> {busyAction === "test" ? t("Testing…") : t("Test connection")}
</Button>
) : null}
{mode === "configure" || mode === "edit" ? (
<Button type="submit" disabled={saveDisabled}>
<Save />
{busyAction === "save"
? "Saving…"
? t("Saving…")
: mode === "configure"
? "Save catalog configuration"
: "Save changes"}
? t("Save catalog configuration")
: t("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"}
<Trash2 /> {busyAction === "delete" ? t("Removing…") : presentation === "drawer" ? t("Remove configuration") : t("Delete database")}
</Button>
) : null}
</div>
@@ -417,33 +414,30 @@ export function DatabaseForm({
if (presentation === "drawer") {
return (
<section aria-label={`${title} form`} className="thot-fleet-drawer-form">
<section aria-label={t("{title} form", { title })} 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="thot-label">{t("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)}
{t(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>
<Button type="button" variant="outline" size="sm" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip={t("Synchronization history")} onClick={onOpenSync}>
{activeSyncRun ? <RefreshCw className="animate-spin" /> : <History />} {t("Sync history")}</Button>
) : null}
{mode === "view" && row.configured && onOpenDescriptionHistory ? (
<Button type="button" variant="outline" size="sm" onClick={onOpenDescriptionHistory}>
<History /> Description history
</Button>
<History /> {t("Description history")}</Button>
) : null}
{mode === "view" && row.configured && onOpenSensitiveHistory ? (
<Button type="button" variant="outline" size="sm" onClick={onOpenSensitiveHistory}>
<History /> Sensitive history
</Button>
<History /> {t("Sensitive history")}</Button>
) : null}
{mode === "view" ? (
<DatabaseSyncMenu
@@ -451,12 +445,12 @@ export function DatabaseForm({
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}
{mode === "view" && row.configured && onOpenDescriptionHistory ? <Button type="button" variant="outline" onClick={onOpenDescriptionHistory}><History /> {t("Description history")}</Button> : null}
{mode === "view" && row.configured && onOpenSensitiveHistory ? <Button type="button" variant="outline" onClick={onOpenSensitiveHistory}><History /> {t("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>
<p className="mb-4 text-xs text-muted-foreground">{t("Last tested")} {new Date(row.lastTestedAt).toLocaleString(getLocale())}</p>
) : null}
{form}
</section>
@@ -464,15 +458,14 @@ export function DatabaseForm({
}
return (
<section aria-label={`${title} form`} className="min-h-0 flex-1 overflow-y-auto bg-background">
<section aria-label={t("{title} form", { title })} 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>
<ArrowLeft /> {t("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>
<p className="thot-label mb-1">{t("Database management")}</p>
<h2 ref={headingRef} tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight outline-none">
{title}
</h2>
@@ -480,16 +473,16 @@ export function DatabaseForm({
{row.workspaceName} · {row.workspaceId}
</p>
{row.lastTestedAt ? (
<p className="mt-1 text-xs text-muted-foreground">Last tested {new Date(row.lastTestedAt).toLocaleString()}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("Last tested")} {new Date(row.lastTestedAt).toLocaleString(getLocale())}</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)}
{t(statusLabel(row))}
</span>
{mode === "view" && activeSyncRun ? (
<Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button>
<Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />{t("View sync")}</Button>
) : null}
{mode === "view" ? (
<DatabaseSyncMenu
@@ -501,15 +494,14 @@ export function DatabaseForm({
</div>
{mode === "view" ? (
<nav aria-label="Database sections" className="mb-6 flex gap-1 border-b border-border" role="tablist">
<nav aria-label={t("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>
{t("Overview")}</button>
<button
type="button"
role="tab"
@@ -518,8 +510,7 @@ export function DatabaseForm({
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>
{t("Tables")}</button>
<button
type="button"
role="tab"
@@ -528,8 +519,7 @@ export function DatabaseForm({
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>
{t("Relationships")}</button>
</nav>
) : null}
@@ -1,3 +1,5 @@
import { useAdminGridLocale } from "./useAdminGridLocale";
import { useI18n, translate } from "../../i18n";
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useQueries } from "@tanstack/react-query";
import { Menu } from "@base-ui/react/menu";
@@ -91,8 +93,8 @@ function useCompactViewport(): boolean {
const query = window.matchMedia("(max-width: 767px)");
const update = () => setCompact(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
query.addEventListener(translate("change"), update);
return () => query.removeEventListener(translate("change"), update);
}, []);
return compact;
@@ -119,19 +121,20 @@ function StateCell({
tone: StateTone;
detailClassName?: string;
}) {
const { t } = useI18n();
return (
<div className="flex h-full min-w-0 flex-col justify-center gap-0.5 py-1 leading-tight">
<span className="flex min-w-0 items-center gap-1.5 text-xs font-semibold text-foreground">
<span className={`size-1.5 shrink-0 rounded-full ${stateToneClass[tone]}`} aria-hidden="true" />
<span className="truncate">{label}</span>
<span className="truncate">{t(label)}</span>
</span>
<span className={`truncate pl-3 text-[11px] ${detailClassName}`}>{detail}</span>
<span className={`truncate pl-3 text-[11px] ${detailClassName}`}>{t(detail)}</span>
</div>
);
}
function endpoint(host: string | undefined, port: number | undefined): string {
if (!host) return "Endpoint not configured";
if (!host) return translate("Endpoint not configured");
return port ? `${host}:${port}` : host;
}
@@ -140,7 +143,7 @@ function accessSummary(row: CatalogDatabase): { label: string; detail: string }
? row.binding.transport
: row.runtimeBinding?.transport ?? row.binding.transport;
if (transport === "rest_api") {
if (!row.binding.baseUrl) return { label: "REST", detail: "Endpoint not configured" };
if (!row.binding.baseUrl) return { label: "REST", detail: translate("Endpoint not configured") };
try {
return { label: "REST", detail: new URL(row.binding.baseUrl).host };
} catch {
@@ -151,41 +154,42 @@ function accessSummary(row: CatalogDatabase): { label: string; detail: string }
return { label: "SSH", detail: endpoint(row.binding.sshHost, row.binding.sshPort) };
}
return {
label: "Direct PostgreSQL connection",
label: translate("Direct PostgreSQL connection"),
detail: endpoint(row.binding.host, row.binding.port),
};
}
function coverageStatus(total: number, complete: number): { label: string; detail: string; tone: StateTone } {
if (total === 0) return { label: "Not started", detail: "No catalog objects", tone: "danger" };
if (complete >= total) return { label: "Complete", detail: `${complete}/${total}`, tone: "success" };
if (complete > 0) return { label: "Partial", detail: `${complete}/${total}`, tone: "warning" };
return { label: "Not started", detail: `0/${total}`, tone: "danger" };
if (total === 0) return { label: translate("Not started"), detail: translate("No catalog objects"), tone: "danger" };
if (complete >= total) return { label: translate("Complete"), detail: `${complete}/${total}`, tone: "success" };
if (complete > 0) return { label: translate("Partial"), detail: `${complete}/${total}`, tone: "warning" };
return { label: translate("Not started"), detail: `0/${total}`, tone: "danger" };
}
function CatalogStatusCells({ row, metrics, sensitiveRun }: { row: CatalogDatabase; metrics?: CatalogMetrics; sensitiveRun?: SensitivityAnalysisRun }) {
const { t } = useI18n();
const access = accessSummary(row);
const synchronized = row.schemaSyncedVersion === row.version;
const syncStatus = !row.schemaSyncedVersion
? { label: "Not synchronized", detail: "Schema not synchronized", tone: "danger" as const }
? { label: t("Not synchronized"), detail: t("Schema not synchronized"), tone: "danger" as const }
: synchronized
? { label: "Synchronized", detail: "Current schema", tone: "success" as const }
: { label: "Partial", detail: "Schema revision changed", tone: "warning" as const };
? { label: t("Synchronized"), detail: t("Current schema"), tone: "success" as const }
: { label: t("Partial"), detail: t("Schema revision changed"), tone: "warning" as const };
const descriptions = coverageStatus(metrics?.descriptionTargets ?? 0, metrics?.describedTargets ?? 0);
const sensitiveProcessed = sensitiveRun ? sensitiveRun.suggestedSensitive + sensitiveRun.suggestedNonSensitive : 0;
const sensitive = !sensitiveRun
? { label: "Not started", detail: "No analysis has been run", tone: "danger" as const }
? { label: t("Not started"), detail: t("No analysis has been run"), tone: "danger" as const }
: sensitiveRun.status === "completed" && sensitiveProcessed >= sensitiveRun.total
? { label: "Complete", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "success" as const }
? { label: t("Complete"), detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "success" as const }
: ["failed", "interrupted"].includes(sensitiveRun.status)
? { label: "Incomplete", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "danger" as const }
: { label: "Partial", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "warning" as const };
? { label: t("Incomplete"), detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "danger" as const }
: { label: t("Partial"), detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "warning" as const };
return (
<div className="grid h-full min-w-[540px] grid-cols-4 gap-3 py-1">
<StateCell label={access.label} detail={access.detail} tone="info" />
<StateCell label={syncStatus.label} detail={syncStatus.detail} tone={syncStatus.tone} />
<StateCell label={descriptions.label} detail={`Descriptions · ${descriptions.detail}`} tone={descriptions.tone} />
<StateCell label={sensitive.label} detail={`Sensitive · ${sensitive.detail}`} tone={sensitive.tone} />
<StateCell label={descriptions.label} detail={t("Descriptions · {detail}", { detail: descriptions.detail })} tone={descriptions.tone} />
<StateCell label={sensitive.label} detail={t("Sensitive · {detail}", { detail: sensitive.detail })} tone={sensitive.tone} />
</div>
);
}
@@ -194,6 +198,7 @@ function DatabaseActionsCell({
data,
context,
}: ICellRendererParams<CatalogDatabase, unknown, DatabaseGridContext>) {
const { t } = useI18n();
if (!data || !context) return null;
if (data.workspaceAvailable && !data.configured) {
@@ -205,9 +210,9 @@ function DatabaseActionsCell({
variant="ghost"
size="icon-lg"
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Details" : undefined}
title={context.presentation === "legacy" ? `View ${data.workspaceName}` : undefined}
aria-label={`View ${data.workspaceName}`}
data-tooltip={context.presentation === "fleet" ? t("Details") : undefined}
title={context.presentation === "legacy" ? t("View {workspaceName}", { workspaceName: data.workspaceName }) : undefined}
aria-label={t("View {workspaceName}", { workspaceName: data.workspaceName })}
onClick={(event) => context.onView(data, event.currentTarget)}
>
{context.presentation === "fleet" ? <Info aria-hidden="true" /> : <Eye aria-hidden="true" />}
@@ -216,17 +221,15 @@ function DatabaseActionsCell({
type="button"
size="sm"
className="whitespace-nowrap"
aria-label={`Configure catalog for ${data.workspaceName}`}
aria-label={t("Configure catalog for {workspaceName}", { workspaceName: data.workspaceName })}
aria-describedby={!context.canManage ? configureReasonId : undefined}
disabled={!context.canManage}
onClick={(event) => context.onEdit(data, event.currentTarget)}
>
Configure catalog
</Button>
{t("Configure catalog")}</Button>
{!context.canManage ? (
<span id={configureReasonId} className="sr-only">
Database management permission is required.
</span>
{t("Database management permission is required.")}</span>
) : null}
</div>
);
@@ -234,7 +237,7 @@ function DatabaseActionsCell({
const editDisabled = !context.canManage || !data.workspaceAvailable;
const deleteDisabled = !context.canManage || !data.configured;
const editLabel = `Edit ${data.workspaceName}`;
const editLabel = t("Edit {workspaceName}", { workspaceName: data.workspaceName });
const editReasonId = `database-edit-reason-${data.workspaceId}`;
const deleteReasonId = `database-delete-reason-${data.workspaceId}`;
const relationshipsReasonId = `database-relationships-reason-${data.workspaceId}`;
@@ -242,12 +245,12 @@ function DatabaseActionsCell({
return (
<div className="flex h-full items-center justify-end gap-0.5" onClick={(event) => event.stopPropagation()}>
{context.presentation === "fleet" && context.onOpenTables ? (
<span className="thot-fleet-icon-action inline-flex" data-tooltip="Tables">
<span className="thot-fleet-icon-action inline-flex" data-tooltip={t("Tables")}>
<Button
type="button"
variant="ghost"
size="icon-lg"
aria-label={`View tables for ${data.workspaceName}`}
aria-label={t("View tables for {workspaceName}", { workspaceName: data.workspaceName })}
disabled={!data.configured || !data.id}
onClick={(event) => context.onOpenTables?.(data, event.currentTarget)}
>
@@ -256,12 +259,12 @@ function DatabaseActionsCell({
</span>
) : null}
{context.presentation === "fleet" && context.onOpenRelationships ? (
<span className="thot-fleet-icon-action inline-flex" data-tooltip="Relationships">
<span className="thot-fleet-icon-action inline-flex" data-tooltip={t("Relationships")}>
<Button
type="button"
variant="ghost"
size="icon-lg"
aria-label={`View relationships for ${data.workspaceName}`}
aria-label={t("View relationships for {workspaceName}", { workspaceName: data.workspaceName })}
aria-describedby={!data.configured || !data.id ? relationshipsReasonId : undefined}
disabled={!data.configured || !data.id}
onClick={(event) => context.onOpenRelationships?.(data, event.currentTarget)}
@@ -270,8 +273,7 @@ function DatabaseActionsCell({
</Button>
{!data.configured || !data.id ? (
<span id={relationshipsReasonId} className="sr-only">
Save this database configuration before managing relationships.
</span>
{t("Save this database configuration before managing relationships.")}</span>
) : null}
</span>
) : null}
@@ -280,41 +282,41 @@ function DatabaseActionsCell({
variant="ghost"
size="icon-lg"
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Details" : undefined}
title={context.presentation === "legacy" ? `View ${data.workspaceName}` : undefined}
aria-label={`View ${data.workspaceName}`}
data-tooltip={context.presentation === "fleet" ? t("Details") : undefined}
title={context.presentation === "legacy" ? t("View {workspaceName}", { workspaceName: data.workspaceName }) : undefined}
aria-label={t("View {workspaceName}", { workspaceName: data.workspaceName })}
onClick={(event) => context.onView(data, event.currentTarget)}
>
{context.presentation === "fleet" ? <Info aria-hidden="true" /> : <Eye aria-hidden="true" />}
</Button>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Edit configuration" : undefined} title={context.presentation === "legacy" ? (editDisabled ? "This database configuration cannot be changed" : editLabel) : undefined}>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? t("Edit configuration") : undefined} title={context.presentation === "legacy" ? (editDisabled ? t("This database configuration cannot be changed") : editLabel) : undefined}>
<Button
type="button"
variant="ghost"
size="icon-lg"
aria-label={editLabel}
aria-label={t(editLabel)}
aria-describedby={editDisabled ? editReasonId : undefined}
disabled={editDisabled}
onClick={(event) => context.onEdit(data, event.currentTarget)}
>
<Pencil aria-hidden="true" />
</Button>
{editDisabled ? <span id={editReasonId} className="sr-only">This database configuration cannot be changed.</span> : null}
{editDisabled ? <span id={editReasonId} className="sr-only">{t("This database configuration cannot be changed.")}</span> : null}
</span>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Remove configuration" : undefined} title={context.presentation === "legacy" ? (deleteDisabled ? "Save this database configuration before deleting it" : `Delete ${data.workspaceName}`) : undefined}>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? t("Remove configuration") : undefined} title={context.presentation === "legacy" ? (deleteDisabled ? t("Save this database configuration before deleting it") : t("Delete {workspaceName}", { workspaceName: data.workspaceName })) : undefined}>
<Button
type="button"
variant="ghost"
size="icon-lg"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label={`Delete ${data.workspaceName}`}
aria-label={t("Delete {workspaceName}", { workspaceName: data.workspaceName })}
aria-describedby={deleteDisabled ? deleteReasonId : undefined}
disabled={deleteDisabled}
onClick={(event) => context.onDelete(data, event.currentTarget)}
>
<Trash2 aria-hidden="true" />
</Button>
{deleteDisabled ? <span id={deleteReasonId} className="sr-only">Save this database configuration before deleting it.</span> : null}
{deleteDisabled ? <span id={deleteReasonId} className="sr-only">{t("Save this database configuration before deleting it.")}</span> : null}
</span>
</div>
);
@@ -347,6 +349,8 @@ export function DatabaseGrid({
onRefresh,
presentation = "legacy",
}: DatabaseGridProps) {
const { t } = useI18n();
const { locale, gridLocaleProps } = useAdminGridLocale();
const metricQueries = useQueries({
queries: rows.filter((row) => row.id).map((row) => ({
queryKey: ["catalog-metrics", row.id],
@@ -362,10 +366,10 @@ export function DatabaseGrid({
if (metrics && row.id) result[row.id] = metrics;
});
return result;
}, [metricQueries, rows]);
}, [metricQueries, rows, t]);
const sensitiveRunsByDatabase = useMemo(() => new Map(
rows.filter((row) => row.id).map((row) => [row.id!, sensitivityAnalysisRuns.find((run) => run.databaseId === row.id)]),
), [rows, sensitivityAnalysisRuns]);
), [rows, sensitivityAnalysisRuns, t]);
const compact = useCompactViewport();
const gridRef = useRef<AgGridReact<CatalogDatabase>>(null);
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
@@ -379,19 +383,19 @@ export function DatabaseGrid({
const [pendingConsolidation, setPendingConsolidation] = useState(false);
const context = useMemo<DatabaseGridContext>(
() => ({ canManage, presentation, onView, onOpenTables, onOpenRelationships, onEdit, onDelete, onOpenSync }),
[canManage, presentation, onView, onOpenTables, onOpenRelationships, onEdit, onDelete, onOpenSync],
[canManage, presentation, onView, onOpenTables, onOpenRelationships, onEdit, onDelete, onOpenSync, t],
);
const columnDefs = useMemo<ColDef<CatalogDatabase>[]>(() => {
const workspace: ColDef<CatalogDatabase> = {
field: "workspaceName",
headerName: "Workspace",
headerName: t("Workspace"),
minWidth: compact ? 112 : 190,
flex: compact ? undefined : 1.35,
width: compact ? 112 : undefined,
};
const status: ColDef<CatalogDatabase> = {
headerName: "Catalog status",
headerName: t("Catalog status"),
minWidth: 580,
flex: 2.5,
cellRenderer: ({ data }: ICellRendererParams<CatalogDatabase>) => {
@@ -404,7 +408,7 @@ export function DatabaseGrid({
const actionsWidth = presentation === "fleet" ? 232 : 210;
const actions: ColDef<CatalogDatabase> = {
colId: "actions",
headerName: "Actions",
headerName: t("Actions"),
width: actionsWidth,
minWidth: actionsWidth,
maxWidth: actionsWidth,
@@ -420,7 +424,7 @@ export function DatabaseGrid({
};
return [workspace, status, actions];
}, [compact, metricsByDatabase, presentation, sensitiveRunsByDatabase]);
}, [compact, metricsByDatabase, presentation, sensitiveRunsByDatabase, t]);
const configuredCount = rows.filter((row) => row.configured).length;
const hiddenSelected = selectedRows.filter((row) => {
@@ -446,54 +450,54 @@ export function DatabaseGrid({
const canDeleteMetadataSelection = canManage && selectedRows.length > 0
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const fleetActions = useMemo<readonly FleetActionOption<DatabaseFleetAction>[]>(() => {
const permissionReason = !canManage ? "Database management permission is required" : undefined;
const permissionReason = !canManage ? t("Database management permission is required") : undefined;
const testReason = permissionReason
?? (selectedRows.some((row) => !row.configured || !row.id)
? "Every selected workspace must be configured"
? t("Every selected workspace must be configured")
: selectedRows.some((row) => row.activeSyncRun)
? "Wait for active synchronization"
? t("Wait for active synchronization")
: undefined);
const syncReason = permissionReason
?? (selectedRows.some((row) => !row.configured || !row.id)
? "Every selected workspace must be configured"
? t("Every selected workspace must be configured")
: selectedRows.some((row) => row.activeSyncRun)
? "Wait for active synchronization"
? t("Wait for active synchronization")
: undefined);
const generationReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (selectedRows.length !== 1 ? t("Select exactly one database") : undefined)
?? (!selectedMetadataModel ? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE : undefined)
?? (descriptionGenerationActive ? "Wait for active description generation" : undefined)
?? (descriptionGenerationActive ? t("Wait for active description generation") : undefined)
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
? t("The selected database must be configured and idle")
: undefined);
const consolidationReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (descriptionGenerationActive ? "Wait for active description generation" : undefined)
?? (selectedRows.length !== 1 ? t("Select exactly one database") : undefined)
?? (descriptionGenerationActive ? t("Wait for active description generation") : undefined)
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
? t("The selected database must be configured and idle")
: undefined);
const sensitiveReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (descriptionGenerationActive ? "Wait for the active metadata operation" : undefined)
?? (selectedRows.length !== 1 ? t("Select exactly one database") : undefined)
?? (descriptionGenerationActive ? t("Wait for the active metadata operation") : undefined)
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
? t("The selected database must be configured and idle")
: undefined);
const cleanupReason = permissionReason
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "Every selected database must be configured and idle"
? t("Every selected database must be configured and idle")
: undefined);
return [
{ id: "test", label: "Test connections", group: "Connection", disabled: !canTestSelection, disabledReason: testReason },
{ id: "sync-tables", label: "Synchronize tables", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-relationships", label: "Synchronize relationships", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-all", label: "Synchronize all", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "generate-missing", label: "Generate missing descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason },
{ id: "generate-all", label: "Generate all descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason, runLabel: "Review generation" },
{ id: "consolidate-descriptions", label: "Copy generated description to descriptions", group: "Descriptions", disabled: !canConsolidateDescriptions, disabledReason: consolidationReason, runLabel: "Review copy" },
{ id: "suggest-sensitive", label: "Analyze sensitive fields", group: "Sensitive data", disabled: !canSuggestSensitive, disabledReason: sensitiveReason, runLabel: "Open review" },
{ id: "clear-tables", label: "Clear catalog tables and relationships", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
{ id: "clear-relationships", label: "Clear catalog relationships", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
{ id: "test", label: t("Test connections"), group: t("Connection"), disabled: !canTestSelection, disabledReason: testReason },
{ id: "sync-tables", label: t("Synchronize tables"), group: t("Synchronization"), disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-relationships", label: t("Synchronize relationships"), group: t("Synchronization"), disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-all", label: t("Synchronize all"), group: t("Synchronization"), disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "generate-missing", label: t("Generate missing descriptions"), group: t("Descriptions"), disabled: !canGenerateDescriptions, disabledReason: generationReason },
{ id: "generate-all", label: t("Generate all descriptions"), group: t("Descriptions"), disabled: !canGenerateDescriptions, disabledReason: generationReason, runLabel: t("Review generation") },
{ id: "consolidate-descriptions", label: t("Copy generated description to descriptions"), group: t("Descriptions"), disabled: !canConsolidateDescriptions, disabledReason: consolidationReason, runLabel: t("Review copy") },
{ id: "suggest-sensitive", label: t("Analyze sensitive fields"), group: t("Sensitive data"), disabled: !canSuggestSensitive, disabledReason: sensitiveReason, runLabel: t("Open review") },
{ id: "clear-tables", label: t("Clear catalog tables and relationships"), group: t("Cleanup"), disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: t("Review cleanup") },
{ id: "clear-relationships", label: t("Clear catalog relationships"), group: t("Cleanup"), disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: t("Review cleanup") },
];
}, [
canConsolidateDescriptions,
@@ -506,7 +510,7 @@ export function DatabaseGrid({
descriptionGenerationActive,
selectedMetadataModel,
selectedRows,
]);
, t]);
useEffect(() => {
if (!pendingDelete) return;
const timer = window.setTimeout(() => document.getElementById("database-cleanup-confirm-button")?.focus(), 0);
@@ -587,7 +591,7 @@ export function DatabaseGrid({
return (
<section
aria-label="Workspace databases"
aria-label={t("Workspace databases")}
data-presentation={presentation}
className={presentation === "fleet"
? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card"
@@ -610,24 +614,21 @@ export function DatabaseGrid({
<div className="mr-auto min-w-56">
<p id="database-generation-confirmation" className="text-sm font-semibold">
{pendingGenerationScope === "all"
? `Replace generated descriptions for ${selectedRows[0]?.workspaceName}?`
: `Generate missing descriptions for ${selectedRows[0]?.workspaceName}?`}
? t("Replace generated descriptions for {workspaceName}?", { workspaceName: selectedRows[0]?.workspaceName })
: t("Generate missing descriptions for {workspaceName}?", { workspaceName: selectedRows[0]?.workspaceName })}
</p>
{pendingGenerationScope === "all" ? (
<p className="text-xs text-muted-foreground">
Existing generated descriptions for eligible tables and columns will be replaced.
</p>
{t("Existing generated descriptions for eligible tables and columns will be replaced.")}</p>
) : null}
<p
role="note"
aria-label="Metadata generation source data disclosure"
aria-label={t("Metadata generation source data disclosure")}
className="mt-1 text-xs font-medium text-amber-800 dark:text-amber-300"
>
When available, up to five real source rows and five representative values from
columns not marked sensitive are sent to the selected provider.
</p>
{t("When available, up to five real source rows and five representative values from columns not marked sensitive are sent to the selected provider.")}</p>
</div>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeGenerationConfirmation}>Cancel</Button>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeGenerationConfirmation}>{t("Cancel")}</Button>
<Button
id="database-generation-confirm-button"
type="button"
@@ -638,7 +639,7 @@ export function DatabaseGrid({
() => onGenerateDescriptions(selectedRows, pendingGenerationScope),
)}
>
{pendingGenerationScope === "all" ? "Generate all descriptions" : "Generate missing descriptions"}
{pendingGenerationScope === "all" ? t("Generate all descriptions") : t("Generate missing descriptions")}
</Button>
</div>
) : pendingConsolidation ? (
@@ -652,13 +653,12 @@ export function DatabaseGrid({
>
<div className="mr-auto min-w-56">
<p id="database-consolidation-confirmation" className="text-sm font-semibold">
Copy generated descriptions to Description fields in {selectedRows[0]?.workspaceName}?
{t("Copy generated descriptions to Description fields in")} {selectedRows[0]?.workspaceName}?
</p>
<p className="text-xs text-muted-foreground">
Every non-empty AI-generated table and column description will replace its current Description value. Generated descriptions are retained.
</p>
{t("Every non-empty AI-generated table and column description will replace its current Description value. Generated descriptions are retained.")}</p>
</div>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeConsolidationConfirmation}>Cancel</Button>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeConsolidationConfirmation}>{t("Cancel")}</Button>
<Button
id="database-consolidation-confirm-button"
type="button"
@@ -669,8 +669,7 @@ export function DatabaseGrid({
() => onConsolidateDescriptions(selectedRows),
)}
>
Copy to descriptions
</Button>
{t("Copy to descriptions")}</Button>
</div>
) : pendingDelete ? (
<div
@@ -684,16 +683,16 @@ export function DatabaseGrid({
<div className="mr-auto min-w-56">
<p id="database-cleanup-confirmation" className="text-sm font-semibold">
{pendingDelete === "tables"
? `${presentation === "fleet" ? "Clear" : "Delete all"} catalog tables for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`
: `${presentation === "fleet" ? "Clear catalog" : "Delete all"} relationships for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`}
? (selectedRows.length === 1 ? t("{value} catalog tables for {count} database?", { value: presentation === "fleet" ? t("Clear") : t("Delete all"), count: selectedRows.length }) : t("{value} catalog tables for {count} databases?", { value: presentation === "fleet" ? t("Clear") : t("Delete all"), count: selectedRows.length }))
: (selectedRows.length === 1 ? t("{value} relationships for {count} database?", { value: presentation === "fleet" ? t("Clear catalog") : t("Delete all"), count: selectedRows.length }) : t("{value} relationships for {count} databases?", { value: presentation === "fleet" ? t("Clear catalog") : t("Delete all"), count: selectedRows.length }))}
</p>
<p className="text-xs text-muted-foreground">
{pendingDelete === "tables"
? "Columns and relationships will also be removed. Database configurations and source databases are unchanged."
: "Catalog tables, columns, database configurations, and source databases are unchanged."}
? t("Columns and relationships will also be removed. Database configurations and source databases are unchanged.")
: t("Catalog tables, columns, database configurations, and source databases are unchanged.")}
</p>
</div>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeDeleteConfirmation}>{t("Cancel")}</Button>
<Button
id="database-cleanup-confirm-button"
type="button"
@@ -702,8 +701,8 @@ export function DatabaseGrid({
onClick={() => void perform("delete", () => onDeleteMetadataSelected(selectedRows, pendingDelete))}
>
<Trash2 />{pendingDelete === "tables"
? presentation === "fleet" ? "Clear catalog tables" : "Delete catalog tables"
: presentation === "fleet" ? "Clear catalog relationships" : "Delete relationships"}
? presentation === "fleet" ? t("Clear catalog tables") : t("Delete catalog tables")
: presentation === "fleet" ? t("Clear catalog relationships") : t("Delete relationships")}
</Button>
</div>
) : presentation === "fleet" ? (
@@ -713,7 +712,7 @@ export function DatabaseGrid({
selectedCount={selectedRows.length}
hiddenSelectedCount={hiddenSelected}
busy={action !== null}
busyLabel={action === "suggest" ? "Suggesting…" : "Running…"}
busyLabel={action === "suggest" ? t("Suggesting…") : t("Running…")}
selectRef={actionSelectRef}
onRun={runFleetAction}
onClear={() => {
@@ -724,14 +723,14 @@ export function DatabaseGrid({
</>
) : (
<>
<span className="text-sm font-semibold">{selectedRows.length} selected</span>
{hiddenSelected ? <span className="text-xs text-muted-foreground">{hiddenSelected} hidden by filter</span> : null}
<span className="text-sm font-semibold">{selectedRows.length} {t("selected")}</span>
{hiddenSelected ? <span className="text-xs text-muted-foreground">{hiddenSelected} {t("hidden by filter")}</span> : null}
<Menu.Root>
<Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={action !== null}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={action !== null}>{t("Actions")} <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canTestSelection} onClick={() => void perform("test", () => onTestSelected(selectedRows))}>Test connections</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canTestSelection} onClick={() => void perform("test", () => onTestSelected(selectedRows))}>{t("Test connections")}</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
{databaseSyncScopes.map(({ scope, label }) => (
<Menu.Item
@@ -740,7 +739,7 @@ export function DatabaseGrid({
disabled={!canSyncSelection}
onClick={() => void perform("sync", () => onSyncSelected(selectedRows, scope))}
>
{label}
{t(label)}
</Menu.Item>
))}
<Menu.Separator className="my-1 h-px bg-border" />
@@ -749,37 +748,32 @@ export function DatabaseGrid({
disabled={!canGenerateDescriptions}
onClick={() => setPendingGenerationScope("all")}
>
Generate all descriptions
</Menu.Item>
{t("Generate all descriptions")}</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canGenerateDescriptions}
onClick={() => setPendingGenerationScope("missing")}
>
Generate missing descriptions
</Menu.Item>
{t("Generate missing descriptions")}</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canConsolidateDescriptions}
onClick={() => setPendingConsolidation(true)}
>
Copy generated description to descriptions
</Menu.Item>
{t("Copy generated description to descriptions")}</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
disabled={!canDeleteMetadataSelection}
onClick={() => setPendingDelete("tables")}
>
Delete all tables
</Menu.Item>
{t("Delete all tables")}</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
disabled={!canDeleteMetadataSelection}
onClick={() => setPendingDelete("relationships")}
>
Delete all relationships
</Menu.Item>
{t("Delete all relationships")}</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
@@ -788,37 +782,35 @@ export function DatabaseGrid({
type="button"
variant="outline"
disabled={!canManage || action !== null || descriptionGenerationActive}
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
title={descriptionGenerationActive ? t("Wait for the active description generation to finish") : undefined}
onClick={() => void perform("suggest", () => onSuggestSensitive(selectedRows))}
>
<Sparkles />{action === "suggest" ? "Analyzing…" : "Analyze sensitive fields"}
<Sparkles />{action === "suggest" ? t("Analyzing…") : t("Analyze sensitive fields")}
</Button>
<Button type="button" variant="ghost" disabled={action !== null} onClick={() => { gridRef.current?.api.deselectAll(); setSelectedRows([]); }}><X />Clear</Button>
<Button type="button" variant="ghost" disabled={action !== null} onClick={() => { gridRef.current?.api.deselectAll(); setSelectedRows([]); }}><X />{t("Clear")}</Button>
</>
)
) : <><span className="thot-label whitespace-nowrap">Workspace databases</span><input
) : <><span className="thot-label whitespace-nowrap">{t("Workspace databases")}</span><input
ref={searchInputRef}
className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Search databases"
placeholder="Search"
aria-label={t("Search databases")}
placeholder={t("Search")}
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
{presentation === "fleet" && onRefresh ? (
<Button type="button" variant="outline" size="sm" disabled={isFetching} onClick={() => void onRefresh()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Refresh
</Button>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Refresh")}</Button>
) : null}</>}
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
{configuredCount}/{rows.length} catalogs configured
</span>
{configuredCount}/{rows.length} {t("catalogs configured")}</span>
{isFetching && !isLoading ? (
<span className="text-xs text-muted-foreground" role="status">Refreshing</span>
<span className="text-xs text-muted-foreground" role="status">{t("Refreshing")}</span>
) : null}
</div>
<div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full${presentation === "fleet" ? " thot-fleet-ledger-grid" : ""}`}>
<AgGridReact<CatalogDatabase>
<AgGridReact<CatalogDatabase> key={locale} {...gridLocaleProps}
ref={gridRef}
rowData={rows}
columnDefs={columnDefs}
@@ -849,12 +841,10 @@ export function DatabaseGrid({
</div>
{isLoading ? (
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-card/80 text-sm text-muted-foreground" role="status">
Loading database configurations…
</div>
{t("Loading database configurations…")}</div>
) : rows.length === 0 ? (
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-card px-6 text-center text-sm text-muted-foreground" role="status">
No repository workspaces are available for database configuration.
</div>
{t("No repository workspaces are available for database configuration.")}</div>
) : null}
</div>
</section>
@@ -1,3 +1,5 @@
import { useAdminGridLocale } from "./useAdminGridLocale";
import { useI18n, translate } from "../../i18n";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
@@ -52,10 +54,11 @@ function relationshipLabel(relationship: CatalogRelationship): string {
const pair = relationship.columns[0];
const source = `${relationship.sourceTableName}${pair ? `.${pair.sourceColumnName}` : ""}`;
const target = `${relationship.targetTableName}${pair ? `.${pair.targetColumnName}` : ""}`;
return `${source} to ${target}`;
return translate("{source} to {target}", { source, target });
}
function RelationshipActionsCell({ data, context }: ICellRendererParams<CatalogRelationship, unknown, RelationshipGridContext>) {
const { t } = useI18n();
if (!data || !context || context.presentation !== "fleet") return null;
return (
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
@@ -64,8 +67,8 @@ function RelationshipActionsCell({ data, context }: ICellRendererParams<CatalogR
variant="ghost"
size="icon-lg"
className="thot-fleet-icon-action"
data-tooltip="Details"
aria-label={`View relationship ${relationshipLabel(data)}`}
data-tooltip={t("Details")}
aria-label={t("View relationship {relationship}", { relationship: relationshipLabel(data) })}
onClick={(event) => context.onView(data, event.currentTarget)}
>
<Info aria-hidden="true" />
@@ -88,6 +91,8 @@ export function DatabaseRelationships({
onNavigationStateChange,
presentation = "legacy",
}: Props) {
const { t } = useI18n();
const { locale, gridLocaleProps } = useAdminGridLocale();
const databaseId = database.id!;
const queryClient = useQueryClient();
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
@@ -115,7 +120,7 @@ export function DatabaseRelationships({
: undefined;
const visibleRelationships = useMemo(() => statusFilter === "all"
? data
: data.filter((relationship) => relationship.status === statusFilter), [data, statusFilter]);
: data.filter((relationship) => relationship.status === statusFilter), [data, statusFilter, t]);
const tablesQuery = useQuery({
queryKey: ["catalog-tables", databaseId],
queryFn: () => listCatalogTables(databaseId),
@@ -148,16 +153,16 @@ export function DatabaseRelationships({
const selectedActionUnavailable = !selectedAction
? null
: !canManage
? "You do not have permission to manage catalog relationships."
? t("You do not have permission to manage catalog relationships.")
: database.activeSyncRun
? "Wait for the active synchronization to finish."
? t("Wait for the active synchronization to finish.")
: null;
const addUnavailable = !canManage
? "You do not have permission to add catalog relationships."
? t("You do not have permission to add catalog relationships.")
: database.activeSyncRun
? "Wait for the active synchronization to finish."
? t("Wait for the active synchronization to finish.")
: busy
? "Wait for the current relationship operation to finish."
? t("Wait for the current relationship operation to finish.")
: null;
const synchronize = async (scope: "relationships") => {
@@ -165,8 +170,8 @@ export function DatabaseRelationships({
try {
const run = await startCatalogSync(databaseId, database.version, scope);
onRunStarted(run);
toast.success("Relationship synchronization started");
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
toast.success(t("Relationship synchronization started"));
} catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(false); }
};
const openRelationship = (relationship: CatalogRelationship, origin: HTMLButtonElement) => {
@@ -200,10 +205,10 @@ export function DatabaseRelationships({
else statusFilterRef.current?.focus();
}, 0);
toast.success(status === "excluded"
? "Relationship excluded. Future builds will leave it excluded."
: "Relationship restored.");
? t("Relationship excluded. Future builds will leave it excluded.")
: t("Relationship restored."));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setBusy(false);
}
@@ -222,9 +227,9 @@ export function DatabaseRelationships({
if (originRef.current?.isConnected) originRef.current.focus();
else statusFilterRef.current?.focus();
}, 0);
toast.success("Relationship deleted permanently. A future build may infer it again.");
toast.success(t("Relationship deleted permanently. A future build may infer it again."));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setBusy(false);
}
@@ -256,7 +261,7 @@ export function DatabaseRelationships({
await onCatalogMetricsChanged?.();
setAddOpen(false);
resetAddForm();
toast.success("Relationship added.");
toast.success(t("Relationship added."));
} catch (error) {
if (error instanceof ApiError && error.code === "relationship_target_not_unique") {
setTargetColumnError("The target column must be the only primary-key column of its table.");
@@ -267,7 +272,7 @@ export function DatabaseRelationships({
} else {
setAddError(apiErrorMessage(error));
}
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setBusy(false);
}
@@ -286,10 +291,10 @@ export function DatabaseRelationships({
&& result.excluded === 0
&& result.ambiguous === 0;
toast.success(noFindings
? "Build complete: no new relationships found."
: `Build complete: ${result.added} added, ${result.alreadyPresent} already present, ${result.excluded} excluded, ${result.ambiguous} ambiguous.`);
? t("Build complete: no new relationships found.")
: t("Build complete: {added} added, {alreadyPresent} already present, {excluded} excluded, {ambiguous} ambiguous.", { added: result.added, alreadyPresent: result.alreadyPresent, excluded: result.excluded, ambiguous: result.ambiguous }));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setBusy(false);
}
@@ -300,79 +305,78 @@ export function DatabaseRelationships({
};
const columns = useMemo<ColDef<CatalogRelationship>[]>(() => [
...(presentation === "legacy" ? [{ field: "constraintName", headerName: "Constraint", minWidth: 220, flex: 1, cellClass: "font-mono text-xs" } satisfies ColDef<CatalogRelationship>] : []),
{ field: "sourceTableName", headerName: "Source table", minWidth: 200, flex: 1 },
{ headerName: "Source column", minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.sourceColumnName).join(", ") ?? "" },
{ field: "targetTableName", headerName: "Target table", minWidth: 200, flex: 1 },
{ headerName: "Target column", minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.targetColumnName).join(", ") ?? "" },
{ field: "origin", headerName: "Origin", minWidth: 120, width: 120, valueFormatter: ({ value }) => titleCase(String(value ?? "")) },
{ field: "status", headerName: "Status", minWidth: 110, width: 110, valueFormatter: ({ value }) => titleCase(String(value ?? "")) },
...(presentation === "legacy" ? [{ field: "constraintName", headerName: t("Constraint"), minWidth: 220, flex: 1, cellClass: "font-mono text-xs" } satisfies ColDef<CatalogRelationship>] : []),
{ field: "sourceTableName", headerName: t("Source table"), minWidth: 200, flex: 1 },
{ headerName: t("Source column"), minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.sourceColumnName).join(", ") ?? "" },
{ field: "targetTableName", headerName: t("Target table"), minWidth: 200, flex: 1 },
{ headerName: t("Target column"), minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.targetColumnName).join(", ") ?? "" },
{ field: "origin", headerName: t("Origin"), minWidth: 120, width: 120, valueFormatter: ({ value }) => t(titleCase(String(value ?? ""))) },
{ field: "status", headerName: t("Status"), minWidth: 110, width: 110, valueFormatter: ({ value }) => t(titleCase(String(value ?? ""))) },
...(presentation === "legacy" ? [
{ field: "updateRule", headerName: "On update", minWidth: 125, width: 125 },
{ field: "deleteRule", headerName: "On delete", minWidth: 125, width: 125 },
{ field: "updateRule", headerName: t("On update"), minWidth: 125, width: 125 },
{ field: "deleteRule", headerName: t("On delete"), minWidth: 125, width: 125 },
] satisfies ColDef<CatalogRelationship>[] : []),
...(presentation === "fleet" ? [{ colId: "actions", headerName: "Actions", width: 64, minWidth: 64, maxWidth: 64, pinned: "right" as const, lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: RelationshipActionsCell }] : []),
], [presentation]);
const context = useMemo<RelationshipGridContext>(() => ({ presentation, onView: openRelationship }), [data, presentation]);
...(presentation === "fleet" ? [{ colId: "actions", headerName: t("Actions"), width: 64, minWidth: 64, maxWidth: 64, pinned: "right" as const, lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: RelationshipActionsCell }] : []),
], [presentation, t]);
const context = useMemo<RelationshipGridContext>(() => ({ presentation, onView: openRelationship }), [data, presentation, t]);
const fleetQueryError = presentation === "fleet" && isError ? (
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
<p className="font-semibold text-destructive">Catalog relationships could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<p className="font-semibold text-destructive">{t("Catalog relationships could not be loaded.")}</p>
<p className="mt-1 leading-5 text-muted-foreground">{t(apiErrorMessage(error))}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry relationships
</Button>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Retry relationships")}</Button>
</div>
</div>
) : null;
return (
<section aria-label={`Relationships for ${database.workspaceName}`} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<section aria-label={t("Relationships for {workspaceName}", { workspaceName: database.workspaceName })} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
{presentation === "legacy" ? <div className="px-4 pt-5 sm:px-5">
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={onBackToDatabases}><ArrowLeft />Back to databases</Button>
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={onBackToDatabases}><ArrowLeft />{t("Back to databases")}</Button>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
<div>
<p className="thot-label mb-1">Database management</p>
<p className="thot-label mb-1">{t("Database management")}</p>
<h2 className="font-heading text-2xl font-semibold tracking-tight">{database.workspaceName}</h2>
<p className="mt-1 text-sm text-muted-foreground">{database.databaseName} · {database.schema}</p>
</div>
<div className="flex gap-2">
{database.activeSyncRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button> : null}
<Button type="button" disabled={!canManage || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("relationships")}><RefreshCw />Synchronize relationships</Button>
{database.activeSyncRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />{t("View sync")}</Button> : null}
<Button type="button" disabled={!canManage || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("relationships")}><RefreshCw />{t("Synchronize relationships")}</Button>
</div>
</div>
<nav aria-label="Database sections" className="flex gap-1 border-b border-border" role="tablist">
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenOverview}>Overview</button>
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenTables}>Tables</button>
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">Relationships</button>
<nav aria-label={t("Database sections")} className="flex gap-1 border-b border-border" role="tablist">
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenOverview}>{t("Overview")}</button>
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenTables}>{t("Tables")}</button>
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">{t("Relationships")}</button>
</nav>
</div> : null}
<div className={presentation === "fleet" ? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card" : "mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5"}>
<div className={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2`}>
<span className="thot-label whitespace-nowrap">Relationship map</span>
<label className="sr-only" htmlFor="relationship-status-filter">Relationship status</label>
<span className="thot-label whitespace-nowrap">{t("Relationship map")}</span>
<label className="sr-only" htmlFor="relationship-status-filter">{t("Relationship status")}</label>
<select
ref={statusFilterRef}
id="relationship-status-filter"
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Relationship status"
aria-label={t("Relationship status")}
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as RelationshipStatusFilter)}
>
<option value="active">Active</option>
<option value="excluded">Excluded</option>
<option value="all">All</option>
<option value="active">{t("Active")}</option>
<option value="excluded">{t("Excluded")}</option>
<option value="all">{t("All")}</option>
</select>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search relationships" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label={t("Search relationships")} placeholder={t("Search")} value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{visibleRelationships.length}</span>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />{t("Refresh")}</Button>
{presentation === "fleet" ? (
<div className="flex flex-wrap items-center gap-2">
<div className="thot-fleet-action-selector__scope" aria-live="polite">
<strong>{selectedAction === "relationships" ? "Physical relationships in this database" : "This database"}</strong>
<span>Action scope</span>
<strong>{selectedAction === "relationships" ? t("Physical relationships in this database") : t("This database")}</strong>
<span>{t("Action scope")}</span>
</div>
<label className="sr-only" htmlFor="relationship-action">Relationship action</label>
<label className="sr-only" htmlFor="relationship-action">{t("Relationship action")}</label>
<select
id="relationship-action"
className="thot-fleet-action-selector__select"
@@ -380,20 +384,19 @@ export function DatabaseRelationships({
disabled={busy}
onChange={(event) => setSelectedAction(event.target.value as "" | "rebuild-generated" | "relationships")}
>
<option value="">Choose an action…</option>
<optgroup label="Logical relationships">
<option value="rebuild-generated">Build generated relationships</option>
<option value="">{t("Choose an action…")}</option>
<optgroup label={t("Logical relationships")}>
<option value="rebuild-generated">{t("Build generated relationships")}</option>
</optgroup>
<optgroup label="Synchronization">
<option value="relationships">Synchronize physical relationships</option>
<optgroup label={t("Synchronization")}>
<option value="relationships">{t("Synchronize physical relationships")}</option>
</optgroup>
</select>
<Button type="button" size="sm" disabled={!selectedAction || Boolean(selectedActionUnavailable) || busy} aria-describedby={selectedActionUnavailable ? "relationship-action-reason" : undefined} onClick={() => void runSelectedAction()}><Play aria-hidden="true" />{busy ? selectedAction === "rebuild-generated" ? "Building…" : "Running…" : "Run action"}</Button>
<Button type="button" size="sm" variant="outline" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
{database.activeSyncRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />} Sync history
</Button>
{onOpenDescriptionHistory ? <Button type="button" size="sm" variant="outline" onClick={onOpenDescriptionHistory}><History aria-hidden="true" /> Description history</Button> : null}
{onOpenSensitiveHistory ? <Button type="button" size="sm" variant="outline" onClick={onOpenSensitiveHistory}><History aria-hidden="true" /> Sensitive history</Button> : null}
<Button type="button" size="sm" disabled={!selectedAction || Boolean(selectedActionUnavailable) || busy} aria-describedby={selectedActionUnavailable ? "relationship-action-reason" : undefined} onClick={() => void runSelectedAction()}><Play aria-hidden="true" />{busy ? selectedAction === "rebuild-generated" ? t("Building…") : t("Running…") : t("Run action")}</Button>
<Button type="button" size="sm" variant="outline" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip={t("Synchronization history")} onClick={onOpenSync}>
{database.activeSyncRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />} {t("Sync history")}</Button>
{onOpenDescriptionHistory ? <Button type="button" size="sm" variant="outline" onClick={onOpenDescriptionHistory}><History aria-hidden="true" /> {t("Description history")}</Button> : null}
{onOpenSensitiveHistory ? <Button type="button" size="sm" variant="outline" onClick={onOpenSensitiveHistory}><History aria-hidden="true" /> {t("Sensitive history")}</Button> : null}
<Button
type="button"
size="sm"
@@ -405,47 +408,46 @@ export function DatabaseRelationships({
setAddOpen(true);
}}
>
<Plus aria-hidden="true" /> Add relationship
</Button>
{addUnavailable ? <span id="relationship-add-reason" className="sr-only">{addUnavailable}</span> : null}
{selectedActionUnavailable ? <span id="relationship-action-reason" className="basis-full text-xs text-muted-foreground" role="status">{selectedActionUnavailable}</span> : null}
<Plus aria-hidden="true" /> {t("Add relationship")}</Button>
{addUnavailable ? <span id="relationship-add-reason" className="sr-only">{t(addUnavailable)}</span> : null}
{selectedActionUnavailable ? <span id="relationship-action-reason" className="basis-full text-xs text-muted-foreground" role="status">{t(selectedActionUnavailable)}</span> : null}
</div>
) : <Button type="button" disabled={!canManage || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("relationships")}><RefreshCw />Synchronize relationships</Button>}
) : <Button type="button" disabled={!canManage || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("relationships")}><RefreshCw />{t("Synchronize relationships")}</Button>}
</div>
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogRelationship> rowData={visibleRelationships} columnDefs={columns} context={context} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate={statusFilter === "active" ? "No relationships yet. Build from column names or add one manually." : statusFilter === "excluded" ? "No excluded relationships." : "No relationships for this database."} />
<AgGridReact<CatalogRelationship> key={locale} {...gridLocaleProps} rowData={visibleRelationships} columnDefs={columns} context={context} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate={statusFilter === "active" ? t("No relationships yet. Build from column names or add one manually.") : statusFilter === "excluded" ? t("No excluded relationships.") : t("No relationships for this database.")} />
</div>
</div>}
</div>
{presentation === "fleet" && addOpen ? (
<FleetLedgerDrawer
open
eyebrow="Logical relationship"
title="Add relationship"
description="Connect one source column to one primary-key target column."
eyebrow={t("Logical relationship")}
title={t("Add relationship")}
description={t("Connect one source column to one primary-key target column.")}
onClose={closeAdd}
closeLabel="Close relationship form"
closeLabel={t("Close relationship form")}
busy={busy}
footer={(
<>
<Button type="button" variant="outline" disabled={busy} onClick={closeAdd}>Cancel</Button>
<Button type="button" variant="outline" disabled={busy} onClick={closeAdd}>{t("Cancel")}</Button>
<Button
type="button"
disabled={busy || !sourceTableId || !sourceColumnId || !targetTableId || !targetColumnId}
onClick={() => void addRelationship()}
>
{busy ? "Adding…" : "Add relationship"}
{busy ? t("Adding…") : t("Add relationship")}
</Button>
</>
)}
>
<div className="grid gap-4 text-sm sm:grid-cols-2">
<label className="grid gap-1.5">
<span className="font-semibold">Source table</span>
<span className="font-semibold">{t("Source table")}</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Source table"
aria-label={t("Source table")}
value={sourceTableId}
disabled={busy || tablesQuery.isLoading}
onChange={(event) => {
@@ -454,15 +456,15 @@ export function DatabaseRelationships({
setAddError(null);
}}
>
<option value="">Select source table…</option>
<option value="">{t("Select source table…")}</option>
{(tablesQuery.data ?? []).map((table) => <option key={table.id} value={table.id}>{table.name}</option>)}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-semibold">Source column</span>
<span className="font-semibold">{t("Source column")}</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Source column"
aria-label={t("Source column")}
value={sourceColumnId}
disabled={busy || !sourceTableId || sourceColumnsQuery.isLoading}
onChange={(event) => {
@@ -471,15 +473,15 @@ export function DatabaseRelationships({
setTargetColumnError(null);
}}
>
<option value="">Select source column…</option>
<option value="">{t("Select source column…")}</option>
{(sourceColumnsQuery.data ?? []).map((column) => <option key={column.id} value={column.id}>{column.name}</option>)}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-semibold">Target table</span>
<span className="font-semibold">{t("Target table")}</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Target table"
aria-label={t("Target table")}
value={targetTableId}
disabled={busy || tablesQuery.isLoading}
onChange={(event) => {
@@ -489,15 +491,15 @@ export function DatabaseRelationships({
setTargetColumnError(null);
}}
>
<option value="">Select target table…</option>
<option value="">{t("Select target table…")}</option>
{(tablesQuery.data ?? []).map((table) => <option key={table.id} value={table.id}>{table.name}</option>)}
</select>
</label>
<label className="grid gap-1.5">
<span className="font-semibold">Target column</span>
<span className="font-semibold">{t("Target column")}</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Target column"
aria-label={t("Target column")}
aria-invalid={Boolean(targetColumnError)}
aria-describedby={targetColumnError ? "target-column-error" : undefined}
value={targetColumnId}
@@ -508,30 +510,30 @@ export function DatabaseRelationships({
setTargetColumnError(null);
}}
>
<option value="">Select target column…</option>
<option value="">{t("Select target column…")}</option>
{targetColumns.map((column) => <option key={column.id} value={column.id}>{column.name}</option>)}
</select>
{targetColumnError ? <span id="target-column-error" className="text-xs text-destructive" role="alert">{targetColumnError}</span> : null}
{targetColumnError ? <span id="target-column-error" className="text-xs text-destructive" role="alert">{t(targetColumnError)}</span> : null}
</label>
{tablesQuery.isError || sourceColumnsQuery.isError || targetColumnsQuery.isError ? (
<p className="sm:col-span-2 text-sm text-destructive" role="alert">Catalog tables or columns could not be loaded.</p>
<p className="sm:col-span-2 text-sm text-destructive" role="alert">{t("Catalog tables or columns could not be loaded.")}</p>
) : null}
{addError ? <p className="sm:col-span-2 text-sm text-destructive" role="alert">{addError}</p> : null}
{addError ? <p className="sm:col-span-2 text-sm text-destructive" role="alert">{t(addError)}</p> : null}
</div>
</FleetLedgerDrawer>
) : null}
{presentation === "fleet" && activeRelationship ? (
<FleetLedgerDrawer
open
eyebrow={`${titleCase(activeRelationship.origin)} relationship`}
title="Relationship details"
eyebrow={t("{origin} relationship", { origin: t(titleCase(activeRelationship.origin)) })}
title={t("Relationship details")}
description={activeRelationship.origin === "physical"
? "This information is synchronized from the source schema and is read-only."
? t("This information is synchronized from the source schema and is read-only.")
: activeRelationship.origin === "generated"
? "This relationship was inferred from catalog column names."
: "This relationship was added manually."}
? t("This relationship was inferred from catalog column names.")
: t("This relationship was added manually.")}
onClose={closeRelationship}
closeLabel="Close relationship details"
closeLabel={t("Close relationship details")}
busy={busy}
footer={pendingMutation ? (
<div
@@ -544,10 +546,10 @@ export function DatabaseRelationships({
>
<p id="relationship-mutation-confirmation" className="mr-auto max-w-xl text-sm text-muted-foreground">
{pendingMutation === "exclude"
? "The relationship will move to Excluded. Builds will not recreate it, and you can restore it later."
: "The relationship record will be erased. A future build may infer it again."}
? t("The relationship will move to Excluded. Builds will not recreate it, and you can restore it later.")
: t("The relationship record will be erased. A future build may infer it again.")}
</p>
<Button type="button" variant="ghost" disabled={busy} onClick={() => setPendingMutation(null)}>Cancel</Button>
<Button type="button" variant="ghost" disabled={busy} onClick={() => setPendingMutation(null)}>{t("Cancel")}</Button>
<Button
id="relationship-mutation-confirm-button"
type="button"
@@ -557,36 +559,36 @@ export function DatabaseRelationships({
? changeRelationshipStatus("excluded")
: permanentlyDeleteRelationship())}
>
{pendingMutation === "exclude" ? "Exclude relationship" : "Delete permanently"}
{pendingMutation === "exclude" ? t("Exclude relationship") : t("Delete permanently")}
</Button>
</div>
) : (
<>
<Button type="button" variant="outline" disabled={busy} onClick={closeRelationship}>Close</Button>
<Button type="button" variant="outline" disabled={busy} onClick={closeRelationship}>{t("Close")}</Button>
{activeRelationship.origin !== "physical" && activeRelationship.status === "active" ? (
<Button type="button" variant="outline" disabled={!canManage || busy} onClick={() => setPendingMutation("exclude")}>Exclude</Button>
<Button type="button" variant="outline" disabled={!canManage || busy} onClick={() => setPendingMutation("exclude")}>{t("Exclude")}</Button>
) : null}
{activeRelationship.origin !== "physical" && activeRelationship.status === "excluded" ? (
<Button type="button" disabled={!canManage || busy} onClick={() => void changeRelationshipStatus("active")}>Restore</Button>
<Button type="button" disabled={!canManage || busy} onClick={() => void changeRelationshipStatus("active")}>{t("Restore")}</Button>
) : null}
{activeRelationship.origin !== "physical" ? (
<Button type="button" variant="destructive" disabled={!canManage || busy} onClick={() => setPendingMutation("delete")}>Delete permanently</Button>
<Button type="button" variant="destructive" disabled={!canManage || busy} onClick={() => setPendingMutation("delete")}>{t("Delete permanently")}</Button>
) : null}
</>
)}
>
<dl className="grid gap-4 text-sm sm:grid-cols-2">
<div><dt className="thot-label">Origin</dt><dd className="mt-1">{titleCase(activeRelationship.origin)}</dd></div>
<div><dt className="thot-label">Status</dt><dd className="mt-1">{titleCase(activeRelationship.status)}</dd></div>
{activeRelationship.constraintName ? <div className="sm:col-span-2"><dt className="thot-label">Constraint</dt><dd className="mt-1 break-words font-mono text-xs">{activeRelationship.constraintName}</dd></div> : null}
<div><dt className="thot-label">Source table</dt><dd className="mt-1 break-words">{activeRelationship.sourceTableName}</dd></div>
<div><dt className="thot-label">Target table</dt><dd className="mt-1 break-words">{activeRelationship.targetTableName}</dd></div>
{activeRelationship.origin === "physical" ? <><div><dt className="thot-label">On update</dt><dd className="mt-1">{activeRelationship.updateRule ?? "Not specified"}</dd></div><div><dt className="thot-label">On delete</dt><dd className="mt-1">{activeRelationship.deleteRule ?? "Not specified"}</dd></div></> : null}
<div><dt className="thot-label">{t("Origin")}</dt><dd className="mt-1">{t(titleCase(activeRelationship.origin))}</dd></div>
<div><dt className="thot-label">{t("Status")}</dt><dd className="mt-1">{t(titleCase(activeRelationship.status))}</dd></div>
{activeRelationship.constraintName ? <div className="sm:col-span-2"><dt className="thot-label">{t("Constraint")}</dt><dd className="mt-1 break-words font-mono text-xs">{activeRelationship.constraintName}</dd></div> : null}
<div><dt className="thot-label">{t("Source table")}</dt><dd className="mt-1 break-words">{activeRelationship.sourceTableName}</dd></div>
<div><dt className="thot-label">{t("Target table")}</dt><dd className="mt-1 break-words">{activeRelationship.targetTableName}</dd></div>
{activeRelationship.origin === "physical" ? <><div><dt className="thot-label">{t("On update")}</dt><dd className="mt-1">{activeRelationship.updateRule ?? t("Not specified")}</dd></div><div><dt className="thot-label">{t("On delete")}</dt><dd className="mt-1">{activeRelationship.deleteRule ?? t("Not specified")}</dd></div></> : null}
<div className="sm:col-span-2">
<dt className="thot-label">Column mapping</dt>
<dt className="thot-label">{t("Column mapping")}</dt>
<dd className="mt-2 overflow-hidden rounded-md border border-border">
<table className="w-full text-left text-xs">
<thead className="bg-muted/60 font-mono uppercase tracking-wide text-muted-foreground"><tr><th className="px-3 py-2">Source</th><th className="px-3 py-2">Target</th></tr></thead>
<thead className="bg-muted/60 font-mono uppercase tracking-wide text-muted-foreground"><tr><th className="px-3 py-2">{t("Source")}</th><th className="px-3 py-2">{t("Target")}</th></tr></thead>
<tbody>{activeRelationship.columns.map((pair) => <tr key={`${pair.sourceColumnName}:${pair.targetColumnName}`} className="border-t border-border"><td className="px-3 py-2 font-mono">{pair.sourceColumnName}</td><td className="px-3 py-2 font-mono">{pair.targetColumnName}</td></tr>)}</tbody>
</table>
</dd>
@@ -1,3 +1,4 @@
import { useI18n } from "../../i18n";
import { Menu } from "@base-ui/react/menu";
import { ChevronDown, RefreshCw } from "lucide-react";
import { buttonVariants } from "../../components/ui/button";
@@ -23,16 +24,17 @@ interface DatabaseSyncMenuProps {
}
export function DatabaseSyncMenu({ disabled, disabledReason, onSelect }: DatabaseSyncMenuProps) {
const { t } = useI18n();
return (
<Menu.Root>
<Menu.Trigger
type="button"
className={buttonVariants()}
disabled={disabled}
title={disabledReason}
aria-label="Synchronize database schema"
title={disabledReason ? t(disabledReason) : undefined}
aria-label={t("Synchronize database schema")}
>
<RefreshCw />Synchronize<ChevronDown />
<RefreshCw />{t("Synchronize")}<ChevronDown />
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="end" sideOffset={4}>
@@ -44,7 +46,7 @@ export function DatabaseSyncMenu({ disabled, disabledReason, onSelect }: Databas
disabled={disabled}
onClick={() => onSelect(scope)}
>
{label}
{t(label)}
</Menu.Item>
))}
</Menu.Popup>
@@ -1,3 +1,6 @@
import { useAdminGridLocale } from "./useAdminGridLocale";
import { adminMessage, type AdminMessage } from "./messages";
import { useI18n } from "../../i18n";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Menu } from "@base-ui/react/menu";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -43,7 +46,7 @@ interface Props {
onOpenSync: () => void;
onClearCatalogTables?: () => Promise<void>;
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
onSuggestSensitive: (selection: SensitivityAnalysisRequest, scopeLabel: string) => Promise<void>;
onSuggestSensitive: (selection: SensitivityAnalysisRequest, scopeLabel: AdminMessage) => Promise<void>;
onCatalogMetricsChanged?: () => void | Promise<void>;
presentation?: "legacy" | "fleet";
}
@@ -55,6 +58,7 @@ interface TableGridContext {
}
function TableActionsCell({ data, context }: ICellRendererParams<CatalogTable, unknown, TableGridContext>) {
const { t } = useI18n();
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-end gap-0.5" onClick={(event) => event.stopPropagation()}>
@@ -63,18 +67,18 @@ function TableActionsCell({ data, context }: ICellRendererParams<CatalogTable, u
variant="ghost"
size="icon-lg"
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Columns" : undefined}
aria-label={`View columns for ${data.name}`}
title={context.presentation === "legacy" ? `View columns for ${data.name}` : undefined}
data-tooltip={context.presentation === "fleet" ? t("Columns") : undefined}
aria-label={t("View columns for {name}", { name: data.name })}
title={context.presentation === "legacy" ? t("View columns for {name}", { name: data.name }) : undefined}
onClick={(event) => context.onOpen(data, "columns", event.currentTarget)}
>
<Columns3 aria-hidden="true" />
</Button>
<span
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Edit metadata" : undefined}
data-tooltip={context.presentation === "fleet" ? t("Edit metadata") : undefined}
>
<Button type="button" variant="ghost" size="icon-lg" aria-label={`Edit description for ${data.name}`} title={context.presentation === "legacy" ? `Edit metadata for ${data.name}` : undefined} disabled={!context.canManage} onClick={(event) => context.onOpen(data, "overview", event.currentTarget)}>
<Button type="button" variant="ghost" size="icon-lg" aria-label={t("Edit description for {name}", { name: data.name })} title={context.presentation === "legacy" ? t("Edit metadata for {name}", { name: data.name }) : undefined} disabled={!context.canManage} onClick={(event) => context.onOpen(data, "overview", event.currentTarget)}>
<Pencil aria-hidden="true" />
</Button>
</span>
@@ -101,6 +105,8 @@ export function DatabaseTables({
onCatalogMetricsChanged,
presentation = "legacy",
}: Props) {
const { t } = useI18n();
const { locale, gridLocaleProps } = useAdminGridLocale();
const databaseId = database.id!;
const queryClient = useQueryClient();
const queryKey = ["catalog-tables", databaseId] as const;
@@ -133,16 +139,16 @@ export function DatabaseTables({
const nestedNavigationActive = presentation === "fleet" && Boolean(activeTable) && tableSection === "columns";
const currentRun = activeRun ?? database.activeSyncRun;
const clearAllCatalogTables = async () => {
if (!onClearCatalogTables || !window.confirm("Clear all catalog tables, columns, and relationships for this database? The source database will not be changed.")) return;
if (!onClearCatalogTables || !window.confirm(t("Clear all catalog tables, columns, and relationships for this database? The source database will not be changed."))) return;
await onClearCatalogTables();
};
const leaveTable = useCallback(() => {
if (navigationBusy) return;
if (dirty && !window.confirm("Discard unsaved metadata?")) return;
if (dirty && !window.confirm(t("Discard unsaved metadata?"))) return;
setActiveTableId(null);
setTableSection("overview");
window.setTimeout(() => originRef.current?.focus(), 0);
}, [dirty, navigationBusy]);
}, [dirty, navigationBusy, t]);
useEffect(() => { onNavigationStateChange({ dirty, busy: navigationBusy }); }, [dirty, navigationBusy, onNavigationStateChange]);
useEffect(() => {
@@ -181,7 +187,7 @@ export function DatabaseTables({
};
const navigateDatabase = (target: "databases" | "overview" | "relationships") => {
if (navigationBusy) return;
if (dirty && !window.confirm("Discard unsaved metadata?")) return;
if (dirty && !window.confirm(t("Discard unsaved metadata?"))) return;
if (target === "databases") onBackToDatabases();
else if (target === "overview") onOpenOverview();
else onOpenRelationships();
@@ -201,13 +207,13 @@ export function DatabaseTables({
setEditorVersion(updated.version);
setStale(false);
void onCatalogMetricsChanged?.();
toast.success("Table metadata saved");
toast.success(t("Table metadata saved"));
} catch (error) {
if (error instanceof ApiError && error.code === "table_stale") {
await refetch();
setStale(true);
setStaleBannerOpen(true);
} else toast.error(apiErrorMessage(error));
} else toast.error(t(apiErrorMessage(error)));
} finally { setBusy(null); }
};
const reloadTable = async () => {
@@ -223,7 +229,7 @@ export function DatabaseTables({
setEditorVersion(latest.version);
setStale(false);
setStaleBannerOpen(true);
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(null); }
} catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(null); }
};
const synchronize = async (scope: "tables" | "columns" | "all", tableIds: string[] = []) => {
setBusy("sync");
@@ -232,8 +238,8 @@ export function DatabaseTables({
onRunStarted(run);
gridRef.current?.api.deselectAll();
setSelectedIds([]);
toast.success(scope === "all" ? "Full schema synchronization started" : scope === "columns" ? "Column synchronization started" : "Table synchronization started");
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(null); }
toast.success(scope === "all" ? t("Full schema synchronization started") : scope === "columns" ? t("Column synchronization started") : t("Table synchronization started"));
} catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setBusy(null); }
};
const deleteMetadata = async (target: CatalogTableMetadataDeleteTarget) => {
setBusy("delete");
@@ -250,10 +256,10 @@ export function DatabaseTables({
window.setTimeout(() => searchInputRef.current?.focus(), 0);
void onCatalogMetricsChanged?.();
toast.success(target === "columns"
? `${presentation === "fleet" ? "Cleared" : "Deleted"} ${counts.columns} catalog columns`
: `${presentation === "fleet" ? "Cleared" : "Deleted"} ${counts.relationships} catalog relationships`);
? t("{value} {columns} catalog columns", { value: presentation === "fleet" ? t("Cleared") : t("Deleted"), columns: counts.columns })
: t("{value} {relationships} catalog relationships", { value: presentation === "fleet" ? t("Cleared") : t("Deleted"), relationships: counts.relationships }));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally { setBusy(null); }
};
const consolidateDescriptions = async () => {
@@ -264,9 +270,9 @@ export function DatabaseTables({
gridRef.current?.api.deselectAll();
setSelectedIds([]);
void onCatalogMetricsChanged?.();
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
toast.success((counts.copied === 1 ? t("Copied {copied} description; skipped {skipped}", { copied: counts.copied, skipped: counts.skipped }) : t("Copied {copied} descriptions; skipped {skipped}", { copied: counts.copied, skipped: counts.skipped })));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally { setBusy(null); }
};
const generateDescriptions = async () => {
@@ -280,9 +286,9 @@ export function DatabaseTables({
selectedIds,
);
onDescriptionGenerationRunStarted(run);
toast.success(`Description generation started for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}`);
toast.success((selectedIds.length === 1 ? t("Description generation started for {count} table", { count: selectedIds.length }) : t("Description generation started for {count} tables", { count: selectedIds.length })));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally { setBusy(null); }
};
const generateColumnDescriptions = async () => {
@@ -292,7 +298,7 @@ export function DatabaseTables({
const columns = (await Promise.all(selectedIds.map((tableId) => listCatalogColumns(databaseId, tableId)))).flat();
const columnIds = columns.map((column) => column.id);
if (columnIds.length === 0) {
toast.error("The selected tables have no synchronized catalog columns.");
toast.error(t("The selected tables have no synchronized catalog columns."));
return;
}
const run = await startDescriptionGenerationRun(
@@ -302,9 +308,9 @@ export function DatabaseTables({
columnIds,
);
onDescriptionGenerationRunStarted(run);
toast.success(`Column description generation started for ${columnIds.length} column${columnIds.length === 1 ? "" : "s"}`);
toast.success((columnIds.length === 1 ? t("Column description generation started for {count} column", { count: columnIds.length }) : t("Column description generation started for {count} columns", { count: columnIds.length })));
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally { setBusy(null); }
};
const suggestSensitive = async () => {
@@ -313,7 +319,7 @@ export function DatabaseTables({
try {
await onSuggestSensitive(
{ scope: "selected_tables", targetIds: selectedIds },
`${selectedIds.length} selected table${selectedIds.length === 1 ? "" : "s"}`,
(selectedIds.length === 1 ? adminMessage("{count} selected table", { count: selectedIds.length }) : adminMessage("{count} selected tables", { count: selectedIds.length })),
);
} catch {
// The page-level request reports the safe, specific reason and preserves the selection.
@@ -334,112 +340,112 @@ export function DatabaseTables({
const fleetActions: readonly FleetActionOption<FleetTableAction>[] = [
{
id: "generate-descriptions",
label: "Generate table description",
group: "Descriptions",
runLabel: "Generate",
label: t("Generate table description"),
group: t("Descriptions"),
runLabel: t("Generate"),
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to generate descriptions."
? t("You do not have permission to generate descriptions.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: !selectedMetadataModel
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: descriptionGenerationActive || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy !== null
? "Another action is running."
? t("Another action is running.")
: undefined,
},
{
id: "generate-column-descriptions",
label: "Generate column descriptions",
group: "Descriptions",
runLabel: "Generate",
label: t("Generate column descriptions"),
group: t("Descriptions"),
runLabel: t("Generate"),
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to generate descriptions."
? t("You do not have permission to generate descriptions.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: !selectedMetadataModel
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: descriptionGenerationActive || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy !== null
? "Another action is running."
? t("Another action is running.")
: undefined,
},
{
id: "consolidate-descriptions",
label: "Copy generated descriptions to Description",
group: "Descriptions",
runLabel: "Move",
label: t("Copy generated descriptions to Description"),
group: t("Descriptions"),
runLabel: t("Move"),
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to update metadata."
? t("You do not have permission to update metadata.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: undefined,
},
{
id: "sync-tables",
label: "Synchronize database tables",
group: "Synchronization",
scopeLabel: "Selected database",
runLabel: "Synchronize",
label: t("Synchronize database tables"),
group: t("Synchronization"),
scopeLabel: t("Selected database"),
runLabel: t("Synchronize"),
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
? t("You do not have permission to synchronize the catalog.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: undefined,
},
{
id: "sync-columns",
label: "Synchronize columns for selected tables",
group: "Synchronization",
runLabel: "Synchronize",
label: t("Synchronize columns for selected tables"),
group: t("Synchronization"),
runLabel: t("Synchronize"),
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
? t("You do not have permission to synchronize the catalog.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: undefined,
},
{
id: "suggest-sensitive",
label: "Analyze sensitive fields",
group: "Sensitive data",
runLabel: "Analyze",
label: t("Analyze sensitive fields"),
group: t("Sensitive data"),
runLabel: t("Analyze"),
disabled: !canManage || selectedIds.length === 0 || descriptionGenerationActive || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to review sensitive data."
? t("You do not have permission to review sensitive data.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: descriptionGenerationActive || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: busy !== null
? "Another action is running."
? t("Another action is running.")
: undefined,
},
{
id: "clear-columns",
label: "Remove synchronized columns",
group: "Catalog cleanup",
label: t("Remove synchronized columns"),
group: t("Catalog cleanup"),
tone: "destructive",
runLabel: "Review cleanup",
runLabel: t("Review cleanup"),
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to clear catalog metadata."
? t("You do not have permission to clear catalog metadata.")
: selectedIds.length === 0
? "Select at least one table."
? t("Select at least one table.")
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
? t("Wait for the active catalog operation to finish.")
: undefined,
},
];
@@ -460,51 +466,50 @@ export function DatabaseTables({
};
const columns = useMemo<ColDef<CatalogTable>[]>(() => [
{ field: "name", headerName: "Name", minWidth: 250, flex: 1, cellClass: "font-mono text-xs" },
{ field: "sourceComment", headerName: "Source comment", minWidth: 260, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ field: "generatedDescription", headerName: "Generated description", minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ field: "description", headerName: "Description", minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: "Actions", width: 104, minWidth: 104, maxWidth: 104, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: TableActionsCell },
], []);
const context = useMemo<TableGridContext>(() => ({ canManage, presentation, onOpen: openTable }), [canManage, data, presentation]);
{ field: "name", headerName: t("Name"), minWidth: 250, flex: 1, cellClass: "font-mono text-xs" },
{ field: "sourceComment", headerName: t("Source comment"), minWidth: 260, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ field: "generatedDescription", headerName: t("Generated description"), minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ field: "description", headerName: t("Description"), minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: t("Actions"), width: 104, minWidth: 104, maxWidth: 104, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: TableActionsCell },
], [t]);
const context = useMemo<TableGridContext>(() => ({ canManage, presentation, onOpen: openTable }), [canManage, data, presentation, t]);
const visibleSelected = useMemo(() => {
const term = search.trim().toLocaleLowerCase();
if (!term) return selectedIds.length;
const selected = new Set(selectedIds);
return data.filter((table) => selected.has(table.id) && [table.name, table.sourceComment, table.generatedDescription, table.description]
.some((value) => value?.toLocaleLowerCase().includes(term))).length;
}, [data, search, selectedIds]);
}, [data, search, selectedIds, t]);
const fleetQueryError = presentation === "fleet" && isError ? (
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
<p className="font-semibold text-destructive">Catalog tables could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<p className="font-semibold text-destructive">{t("Catalog tables could not be loaded.")}</p>
<p className="mt-1 leading-5 text-muted-foreground">{t(apiErrorMessage(error))}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry tables
</Button>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> {t("Retry tables")}</Button>
</div>
</div>
) : null;
const databaseHeader = (
<div className="px-4 pt-5 sm:px-5">
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={navigationBusy} onClick={() => navigateDatabase("databases")}><ArrowLeft />Back to databases</Button>
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={navigationBusy} onClick={() => navigateDatabase("databases")}><ArrowLeft />{t("Back to databases")}</Button>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
<div>
<p className="thot-label mb-1">Database management</p>
<p className="thot-label mb-1">{t("Database management")}</p>
<h2 ref={headingRef} tabIndex={-1} className="font-heading text-2xl font-semibold tracking-tight outline-none">{database.workspaceName}</h2>
<p className="mt-1 text-sm text-muted-foreground">{database.databaseName} · {database.schema}</p>
</div>
<div className="flex gap-2">
{currentRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button> : null}
{onClearCatalogTables ? <Button type="button" variant="outline" className="text-destructive" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void clearAllCatalogTables()}>Clear catalog tables</Button> : null}
<Button type="button" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("all")}><RefreshCw />Sync all</Button>
{currentRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />{t("View sync")}</Button> : null}
{onClearCatalogTables ? <Button type="button" variant="outline" className="text-destructive" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void clearAllCatalogTables()}>{t("Clear catalog tables")}</Button> : null}
<Button type="button" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("all")}><RefreshCw />{t("Sync all")}</Button>
</div>
</div>
<nav aria-label="Database sections" className="flex gap-1 border-b border-border" role="tablist">
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={() => navigateDatabase("overview")}>Overview</button>
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">Tables</button>
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={() => navigateDatabase("relationships")}>Relationships</button>
<nav aria-label={t("Database sections")} className="flex gap-1 border-b border-border" role="tablist">
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={() => navigateDatabase("overview")}>{t("Overview")}</button>
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">{t("Tables")}</button>
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={() => navigateDatabase("relationships")}>{t("Relationships")}</button>
</nav>
</div>
);
@@ -512,10 +517,10 @@ export function DatabaseTables({
if (activeTable && presentation === "fleet") {
if (tableSection === "columns") {
return (
<section aria-label={`Columns for ${activeTable.name}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<section aria-label={t("Columns for {name}", { name: activeTable.name })} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border bg-muted/25 px-3 py-2">
<div className="min-w-0">
<p className="thot-label">Physical table</p>
<p className="thot-label">{t("Physical table")}</p>
<h3 className="truncate font-heading text-lg font-semibold">{activeTable.name}</h3>
</div>
</div>
@@ -539,27 +544,26 @@ export function DatabaseTables({
}
return (
<section aria-label={`Tables for ${database.workspaceName}`} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<section aria-label={t("Tables for {workspaceName}", { workspaceName: database.workspaceName })} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<div className="thot-fleet-grid-toolbar flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
<span className="thot-label whitespace-nowrap">Catalog tables</span>
<span className="thot-label whitespace-nowrap">{t("Catalog tables")}</span>
<input
ref={searchInputRef}
className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Search tables"
placeholder="Search"
aria-label={t("Search tables")}
placeholder={t("Search")}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}
{search.trim() ? t("{count} of {total}", { count: displayedCount, total: data.length }) : displayedCount}
</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> Refresh
</Button>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> {t("Refresh")}</Button>
</div>
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid thot-fleet-ledger-grid ag-theme-alpine absolute inset-0 h-full w-full">
<AgGridReact<CatalogTable>
<AgGridReact<CatalogTable> key={locale} {...gridLocaleProps}
ref={gridRef}
rowData={data}
columnDefs={columns}
@@ -574,43 +578,43 @@ export function DatabaseTables({
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No catalog tables. Synchronize the schema from the database view."
overlayNoRowsTemplate={t("No catalog tables. Synchronize the schema from the database view.")}
/>
</div>
</div>}
<FleetLedgerDrawer
open
eyebrow="Catalog metadata"
title="Review table description"
description="Physical schema fields are read-only. Review fields can be edited."
eyebrow={t("Catalog metadata")}
title={t("Review table description")}
description={t("Physical schema fields are read-only. Review fields can be edited.")}
onClose={leaveTable}
closeLabel="Close table metadata"
closeLabel={t("Close table metadata")}
busy={busy !== null}
footer={(
<>
<Button type="button" variant="outline" disabled={busy !== null} onClick={leaveTable}>Cancel</Button>
<Button type="button" variant="outline" disabled={busy !== null} onClick={leaveTable}>{t("Cancel")}</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy !== null || stale} onClick={() => void save()}>
<Save aria-hidden="true" />{busy === "save" ? "Saving…" : "Save metadata"}
<Save aria-hidden="true" />{busy === "save" ? t("Saving…") : t("Save metadata")}
</Button>
</>
)}
>
<p className="thot-label">Physical table</p>
<p className="thot-label">{t("Physical table")}</p>
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{activeTable.name}</h3>
{stale ? (
staleBannerOpen ? (
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm">
<p className="font-semibold">A newer table description is available.</p>
<p className="mt-1 text-muted-foreground">Your draft is preserved until you reload the current metadata.</p>
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div>
<p className="font-semibold">{t("A newer table description is available.")}</p>
<p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload the current metadata.")}</p>
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div>
</div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this description can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button></div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
) : null}
<div className="mt-5 grid gap-4">
<label className="grid gap-1.5 text-sm font-semibold">Physical table name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={activeTable.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Generated description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy !== null} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold">Description<textarea className="min-h-36 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy !== null} onChange={(event) => setDescription(event.target.value)} /><span className="font-normal text-muted-foreground">Leave blank to keep the curated description empty.</span></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Physical table name")}<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Source comment")}<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={activeTable.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Generated description")}<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy !== null} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Description")}<textarea className="min-h-36 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy !== null} onChange={(event) => setDescription(event.target.value)} /><span className="font-normal text-muted-foreground">{t("Leave blank to keep the curated description empty.")}</span></label>
</div>
</FleetLedgerDrawer>
</section>
@@ -619,16 +623,16 @@ export function DatabaseTables({
if (activeTable) {
return (
<section aria-label={`Table ${activeTable.name}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<section aria-label={t("Table {name}", { name: activeTable.name })} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
{databaseHeader}
<div className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5">
<div className="border-b border-border px-4 pt-4">
<Button type="button" variant="ghost" className="-ml-2 mb-2" onClick={leaveTable}><ArrowLeft />Back to tables</Button>
<p className="thot-label">Physical table</p>
<Button type="button" variant="ghost" className="-ml-2 mb-2" onClick={leaveTable}><ArrowLeft />{t("Back to tables")}</Button>
<p className="thot-label">{t("Physical table")}</p>
<h3 className="mt-1 font-heading text-xl font-semibold">{activeTable.name}</h3>
<nav aria-label="Table sections" className="mt-3 flex gap-1" role="tablist">
<button type="button" role="tab" aria-selected={tableSection === "overview"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "overview" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("overview")}>Overview</button>
<button type="button" role="tab" aria-selected={tableSection === "columns"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "columns" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("columns")}>Columns</button>
<nav aria-label={t("Table sections")} className="mt-3 flex gap-1" role="tablist">
<button type="button" role="tab" aria-selected={tableSection === "overview"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "overview" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("overview")}>{t("Overview")}</button>
<button type="button" role="tab" aria-selected={tableSection === "columns"} className={`border-b-2 px-3 py-2 text-sm font-semibold ${tableSection === "columns" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`} onClick={() => setTableSection("columns")}>{t("Columns")}</button>
</nav>
</div>
{tableSection === "columns" ? (
@@ -647,25 +651,25 @@ export function DatabaseTables({
/>
) : (
<div className="min-h-0 overflow-y-auto px-4 py-5">
<p className="text-sm text-muted-foreground">Only review metadata can be changed.</p>
<p className="text-sm text-muted-foreground">{t("Only review metadata can be changed.")}</p>
{stale ? (
staleBannerOpen ? (
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm">
<p className="font-semibold">A newer table description is available.</p>
<p className="mt-1 text-muted-foreground">Your draft is preserved until you reload the current metadata.</p>
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div>
<p className="font-semibold">{t("A newer table description is available.")}</p>
<p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload the current metadata.")}</p>
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div>
</div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this description can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button></div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
) : null}
<div className="mt-5 grid gap-4">
<label className="grid gap-1.5 text-sm font-semibold">Physical table name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={activeTable.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Generated description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy !== null} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold">Description<textarea className="min-h-36 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy !== null} onChange={(event) => setDescription(event.target.value)} /><span className="font-normal text-muted-foreground">Leave blank to keep the curated description empty.</span></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Physical table name")}<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Source comment")}<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={activeTable.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Generated description")}<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy !== null} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold">{t("Description")}<textarea className="min-h-36 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy !== null} onChange={(event) => setDescription(event.target.value)} /><span className="font-normal text-muted-foreground">{t("Leave blank to keep the curated description empty.")}</span></label>
</div>
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" disabled={busy !== null} onClick={leaveTable}>Cancel</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy !== null || stale} onClick={() => void save()}><Save />{busy === "save" ? "Saving…" : "Save metadata"}</Button>
<Button type="button" variant="outline" disabled={busy !== null} onClick={leaveTable}>{t("Cancel")}</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy !== null || stale} onClick={() => void save()}><Save />{busy === "save" ? t("Saving…") : t("Save metadata")}</Button>
</div>
</div>
)}
@@ -675,7 +679,7 @@ export function DatabaseTables({
}
return (
<section aria-label={`Tables for ${database.workspaceName}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<section aria-label={t("Tables for {workspaceName}", { workspaceName: database.workspaceName })} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
{presentation === "legacy" ? databaseHeader : null}
<div className={presentation === "fleet" ? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card" : "mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5"}>
<div className={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2`}>
@@ -692,14 +696,13 @@ export function DatabaseTables({
<div className="mr-auto min-w-56">
<p id="table-cleanup-confirmation" className="text-sm font-semibold">
{presentation === "fleet"
? `Remove synchronized columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
: `Remove synchronized columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`}
? (selectedIds.length === 1 ? t("Remove synchronized columns for {count} table?", { count: selectedIds.length }) : t("Remove synchronized columns for {count} tables?", { count: selectedIds.length }))
: (selectedIds.length === 1 ? t("Remove synchronized columns for {count} table?", { count: selectedIds.length }) : t("Remove synchronized columns for {count} tables?", { count: selectedIds.length }))}
</p>
<p className="text-xs text-muted-foreground">
Only synchronized catalog columns for these tables will be removed. The source database is not modified.
</p>
{t("Only synchronized catalog columns for these tables will be removed. The source database is not modified.")}</p>
</div>
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>{t("Cancel")}</Button>
<Button
id="table-cleanup-confirm-button"
type="button"
@@ -707,8 +710,7 @@ export function DatabaseTables({
disabled={busy !== null}
onClick={() => void deleteMetadata(pendingDelete)}
>
<Trash2 />Remove synchronized columns
</Button>
<Trash2 />{t("Remove synchronized columns")}</Button>
</div>
) : (
presentation === "fleet" ? (
@@ -718,35 +720,35 @@ export function DatabaseTables({
selectedCount={selectedIds.length}
hiddenSelectedCount={selectedIds.length - visibleSelected}
busy={busy !== null}
busyLabel={busy === "generate" ? "Generating…" : busy === "suggest" ? "Suggesting…" : "Running…"}
busyLabel={busy === "generate" ? t("Generating…") : busy === "suggest" ? t("Suggesting…") : t("Running…")}
selectRef={fleetActionSelectRef}
onRun={runFleetAction}
onClear={clearSelection}
label="Table action"
label={t("Table action")}
/>
</>
) : (
<>
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
{visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} hidden by filter</span> : null}
<span className="text-sm font-semibold">{selectedIds.length} {t("selected")}</span>
{visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} {t("hidden by filter")}</span> : null}
<Menu.Root>
<Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy !== null}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy !== null}>{t("Actions")} <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>Generate table description</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateColumnDescriptions()}>Generate descriptions for all columns</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("tables")}>Synchronize database tables</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>Synchronize columns for selected tables</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>Copy generated descriptions to Description</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>{t("Generate table description")}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateColumnDescriptions()}>{t("Generate descriptions for all columns")}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("tables")}>{t("Synchronize database tables")}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>{t("Synchronize columns for selected tables")}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>{t("Copy generated descriptions to Description")}</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>Remove synchronized columns</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>{t("Remove synchronized columns")}</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy !== null} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{busy === "suggest" ? "Analyzing…" : "Analyze sensitive fields"}</Button>
<Button type="button" variant="ghost" onClick={clearSelection}><X />Clear</Button>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy !== null} title={descriptionGenerationActive ? t("Wait for the active description generation to finish") : undefined} onClick={() => void suggestSensitive()}><Sparkles />{busy === "suggest" ? t("Analyzing…") : t("Analyze sensitive fields")}</Button>
<Button type="button" variant="ghost" onClick={clearSelection}><X />{t("Clear")}</Button>
</>
)
)
@@ -757,29 +759,29 @@ export function DatabaseTables({
actions={fleetActions}
selectedCount={0}
busy={busy !== null}
busyLabel={busy === "generate" ? "Generating…" : busy === "suggest" ? "Suggesting…" : "Running…"}
busyLabel={busy === "generate" ? t("Generating…") : busy === "suggest" ? t("Suggesting…") : t("Running…")}
selectRef={fleetActionSelectRef}
onRun={runFleetAction}
onClear={clearSelection}
label="Table action"
label={t("Table action")}
/>
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label={t("Search tables")} placeholder={t("Search")} value={search} onChange={(event) => setSearch(event.target.value)} />
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? t("{count} of {total}", { count: displayedCount, total: data.length }) : displayedCount}</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />{t("Refresh")}</Button>
</>
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog tables</span>
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<span className="thot-label whitespace-nowrap">{t("Catalog tables")}</span>
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label={t("Search tables")} placeholder={t("Search")} value={search} onChange={(event) => setSearch(event.target.value)} />
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? t("{count} of {total}", { count: displayedCount, total: data.length }) : displayedCount}</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />{t("Refresh")}</Button>
</>
)
)}
</div>
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogTable>
<AgGridReact<CatalogTable> key={locale} {...gridLocaleProps}
ref={gridRef}
rowData={data}
columnDefs={columns}
@@ -797,7 +799,7 @@ export function DatabaseTables({
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No catalog tables. Synchronize the schema from the database view."
overlayNoRowsTemplate={t("No catalog tables. Synchronize the schema from the database view.")}
/>
</div>
</div>}
@@ -1,3 +1,4 @@
import { getLocale, useI18n, translate } from "../../i18n";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { LoaderCircle, LockOpen, Square } from "lucide-react";
@@ -32,7 +33,7 @@ const SAFE_FINAL_ERROR = "The run ended before all targets were processed. Revie
function statusLabel(run: DescriptionGenerationRun): string {
const label = run.status.replaceAll("_", " ");
return `${label[0].toUpperCase()}${label.slice(1)}`;
return translate(`${label[0].toUpperCase()}${label.slice(1)}`);
}
function isTerminal(run?: DescriptionGenerationRun): boolean {
@@ -59,7 +60,7 @@ function safeFinalError(summary: string | null): string | null {
}
function timestamp(value: string | null): string {
return value ? new Date(value).toLocaleString() : "Not available";
return value ? new Date(value).toLocaleString(getLocale()) : translate("Not available");
}
export function DescriptionGenerationDrawer({
@@ -71,6 +72,7 @@ export function DescriptionGenerationDrawer({
onRunUpdate,
onTerminal,
}: Props) {
const { t } = useI18n();
const queryClient = useQueryClient();
const runId = initialRun?.id ?? null;
const [events, setEvents] = useState<DescriptionGenerationEvent[]>([]);
@@ -105,7 +107,7 @@ export function DescriptionGenerationDrawer({
});
const visibleHistory = useMemo(
() => (historyQuery.data ?? []).filter((item) => !databaseId || item.databaseId === databaseId),
[databaseId, historyQuery.data],
[databaseId, historyQuery.data, t],
);
const latestEventSequence = events.at(-1)?.sequence ?? 0;
const {
@@ -133,7 +135,7 @@ export function DescriptionGenerationDrawer({
lastSequence.current = merged.at(-1)?.sequence ?? lastSequence.current;
return merged;
});
}, []);
}, [t]);
useEffect(() => {
setEvents([]);
@@ -257,7 +259,7 @@ export function DescriptionGenerationDrawer({
onRunUpdate(next);
await historyQuery.refetch();
} catch (error) {
toast.error(apiErrorMessage(error));
toast.error(t(apiErrorMessage(error)));
} finally {
setAction(null);
}
@@ -273,7 +275,7 @@ export function DescriptionGenerationDrawer({
await historyQuery.refetch();
} catch (error) {
toast.error(error instanceof ApiError && error.status === 409
? "Unlock was refused because a live description worker is still running."
? t("Unlock was refused because a live description worker is still running.")
: apiErrorMessage(error));
} finally {
setAction(null);
@@ -285,26 +287,25 @@ export function DescriptionGenerationDrawer({
return (
<FleetLedgerDrawer
open
ariaLabel="Description generation"
eyebrow="Description generation"
title="Run history"
ariaLabel={t("Description generation")}
eyebrow={t("Description generation")}
title={t("Run history")}
onClose={onClose}
closeLabel="Close description generation"
closeLabel={t("Close description generation")}
busy={action !== null}
bodyClassName="thot-catalog-drawer__body--empty"
>
{historyQuery.isError ? (
<div role="alert" className="w-full rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(historyQuery.error)}
{t(apiErrorMessage(historyQuery.error))}
</div>
) : historyQuery.isLoading || (historyQuery.data?.length ?? 0) > 0 ? (
<p className="text-sm text-muted-foreground">Loading run history…</p>
<p className="text-sm text-muted-foreground">{t("Loading run history…")}</p>
) : (
<div className="w-full rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
<p className="font-semibold">No description generation runs yet.</p>
<p className="font-semibold">{t("No description generation runs yet.")}</p>
<p className="mt-1 text-muted-foreground">
Start a description generation to create the first history entry.
</p>
{t("Start a description generation to create the first history entry.")}</p>
</div>
)}
</FleetLedgerDrawer>
@@ -316,11 +317,11 @@ export function DescriptionGenerationDrawer({
? `${modelLabel} (${run.modelId})`
: run.modelId;
const progressCounters = [
["total", "Total"],
["processed", "Processed"],
["generated", "Generated"],
["nonGeneratable", "Not generated"],
["failed", "Failed"],
["total", t("Total")],
["processed", t("Processed")],
["generated", t("Generated")],
["nonGeneratable", t("Not generated")],
["failed", t("Failed")],
] as const;
const footer = ["queued", "running"].includes(run.status) ? (
<div className="w-full">
@@ -329,21 +330,20 @@ export function DescriptionGenerationDrawer({
id="description-generation-unlock-help"
className="mb-3 rounded-md border border-amber-500/35 bg-amber-500/8 px-3 py-2 text-xs leading-5 text-muted-foreground"
>
Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.
</p>
{t("Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.")}</p>
) : null}
<div className="flex flex-wrap justify-end gap-2">
{apparentlyStale ? (
<Button
type="button"
variant="outline"
aria-label="Unlock stale run"
aria-label={t("Unlock stale run")}
aria-describedby="description-generation-unlock-help"
disabled={action !== null}
onClick={() => void unlock()}
>
{action === "unlock" ? <LoaderCircle className="animate-spin" /> : <LockOpen />}
{action === "unlock" ? "Unlocking…" : "Unlock"}
{action === "unlock" ? t("Unlocking…") : t("Unlock")}
</Button>
) : null}
<Button
@@ -353,7 +353,7 @@ export function DescriptionGenerationDrawer({
onClick={() => void stop()}
>
{action === "stop" ? <LoaderCircle className="animate-spin" /> : <Square />}
{action === "stop" ? "Stopping…" : "Stop"}
{action === "stop" ? t("Stopping…") : t("Stop")}
</Button>
</div>
</div>
@@ -361,17 +361,17 @@ export function DescriptionGenerationDrawer({
return (
<FleetLedgerDrawer
open
ariaLabel="Description generation"
eyebrow="Description generation"
ariaLabel={t("Description generation")}
eyebrow={t("Description generation")}
title={(
<span className="inline-flex items-center gap-2">
{!isTerminal(run) ? <LoaderCircle className="size-5 animate-spin" aria-hidden="true" /> : null}
{statusLabel(run)}
{t(statusLabel(run))}
</span>
)}
description={run.scope.replaceAll("_", " ")}
description={t(run.scope.replaceAll("_", " "))}
onClose={onClose}
closeLabel="Close description generation"
closeLabel={t("Close description generation")}
busy={action !== null}
bodyClassName="thot-catalog-drawer__history-layout thot-catalog-drawer__run-layout"
footer={footer}
@@ -379,40 +379,40 @@ export function DescriptionGenerationDrawer({
<div className="thot-catalog-drawer__run-main">
{runQuery.isError ? (
<div role="alert" className="rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(runQuery.error)}
{t(apiErrorMessage(runQuery.error))}
</div>
) : null}
<div className={`grid gap-4 sm:grid-cols-2${runQuery.isError ? " mt-4" : ""}`}>
<section aria-label="AI usage">
<h3 className="thot-label mb-2">AI usage</h3>
<section aria-label={t("AI usage")}>
<h3 className="thot-label mb-2">{t("AI usage")}</h3>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt>Model</dt><dd className="truncate text-right" title={modelDisplay}>{modelDisplay}</dd>
<dt>Input tokens</dt><dd className="text-right tabular-nums">{(run.inputTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>Cache tokens</dt><dd className="text-right tabular-nums">{(run.cacheReadTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>Output tokens</dt><dd className="text-right tabular-nums">{(run.outputTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>{t("Model")}</dt><dd className="truncate text-right" title={modelDisplay}>{modelDisplay}</dd>
<dt>{t("Input tokens")}</dt><dd className="text-right tabular-nums">{(run.inputTokens ?? 0).toLocaleString(getLocale())}</dd>
<dt>{t("Cache tokens")}</dt><dd className="text-right tabular-nums">{(run.cacheReadTokens ?? 0).toLocaleString(getLocale())}</dd>
<dt>{t("Output tokens")}</dt><dd className="text-right tabular-nums">{(run.outputTokens ?? 0).toLocaleString(getLocale())}</dd>
</dl>
</section>
<section aria-label="Description generation timestamps">
<h3 className="thot-label mb-2">Timestamps</h3>
<section aria-label={t("Description generation timestamps")}>
<h3 className="thot-label mb-2">{t("Timestamps")}</h3>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt className="text-muted-foreground">Created</dt><dd className="text-right tabular-nums">{timestamp(run.createdAt)}</dd>
<dt className="text-muted-foreground">Started</dt><dd className="text-right tabular-nums">{timestamp(run.startedAt)}</dd>
<dt className="text-muted-foreground">Updated</dt><dd className="text-right tabular-nums">{timestamp(run.updatedAt)}</dd>
<dt className="text-muted-foreground">Finished</dt><dd className="text-right tabular-nums">{timestamp(run.finishedAt)}</dd>
<dt className="text-muted-foreground">{t("Created")}</dt><dd className="text-right tabular-nums">{timestamp(run.createdAt)}</dd>
<dt className="text-muted-foreground">{t("Started")}</dt><dd className="text-right tabular-nums">{timestamp(run.startedAt)}</dd>
<dt className="text-muted-foreground">{t("Updated")}</dt><dd className="text-right tabular-nums">{timestamp(run.updatedAt)}</dd>
<dt className="text-muted-foreground">{t("Finished")}</dt><dd className="text-right tabular-nums">{timestamp(run.finishedAt)}</dd>
</dl>
</section>
</div>
<section className="mt-4" aria-label="Description generation counters">
<h3 className="thot-label mb-2">Progress</h3>
<section className="mt-4" aria-label={t("Description generation counters")}>
<h3 className="thot-label mb-2">{t("Progress")}</h3>
<div className="overflow-x-auto rounded-md border border-border bg-muted/20">
<table className="w-full min-w-[28rem] table-fixed" aria-label="Description generation progress">
<table className="w-full min-w-[28rem] table-fixed" aria-label={t("Description generation progress")}>
<thead>
<tr className="border-b border-border bg-muted/45">
{progressCounters.map(([name, label]) => (
<th key={name} scope="col" className="border-l border-border px-2 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-muted-foreground first:border-l-0">
{label}
{t(label)}
</th>
))}
</tr>
@@ -432,18 +432,17 @@ export function DescriptionGenerationDrawer({
{finalError ? (
<div className="mt-3 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{finalError}
{t(finalError)}
</div>
) : null}
<section className="thot-catalog-drawer__events mt-3" aria-label="Description generation log">
<section className="thot-catalog-drawer__events mt-3" aria-label={t("Description generation log")}>
<div className="mb-2 flex items-center justify-between">
<h3 className="thot-label">Events</h3>
<h3 className="thot-label">{t("Events")}</h3>
<div className="flex items-center gap-2">
{!followingEventTail && events.length > 0 ? (
<Button type="button" variant="outline" size="xs" onClick={jumpToLatestEvent}>
Jump to latest
</Button>
{t("Jump to latest")}</Button>
) : null}
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
</div>
@@ -451,52 +450,52 @@ export function DescriptionGenerationDrawer({
<div
ref={eventLogRef}
role="log"
aria-label="Description generation events"
aria-label={t("Description generation events")}
aria-relevant="additions"
tabIndex={0}
onScroll={handleEventLogScroll}
className="thot-catalog-drawer__event-log overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
{events.length === 0 ? <p className="text-zinc-500">Waiting for events…</p> : events.map((event) => (
{events.length === 0 ? <p className="text-zinc-500">{t("Waiting for events…")}</p> : events.map((event) => (
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString()}</span>{event.message}
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString(getLocale())}</span>{t(event.message)}
</p>
))}
</div>
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{apiErrorMessage(eventQuery.error)}</p> : null}
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{t(apiErrorMessage(eventQuery.error))}</p> : null}
</section>
</div>
<section className="thot-catalog-drawer__history" aria-label="Description generation history">
<section className="thot-catalog-drawer__history" aria-label={t("Description generation history")}>
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="thot-label">Recent runs</h3>
<h3 className="thot-label">{t("Recent runs")}</h3>
<span className="text-xs tabular-nums text-muted-foreground">
{visibleHistory.length}
</span>
</div>
{historyQuery.isError ? (
<p role="alert" className="text-sm text-destructive">
{apiErrorMessage(historyQuery.error)}
{t(apiErrorMessage(historyQuery.error))}
</p>
) : historyQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading run history…</p>
<p className="text-sm text-muted-foreground">{t("Loading run history…")}</p>
) : visibleHistory.length === 0 ? (
<p className="text-sm text-muted-foreground">No description generation runs yet.</p>
<p className="text-sm text-muted-foreground">{t("No description generation runs yet.")}</p>
) : (
<div className="thot-catalog-drawer__history-list divide-y divide-border overflow-y-auto rounded-md border border-border">
{visibleHistory.map((item) => (
<button
key={item.id}
type="button"
aria-label={`${statusLabel(item)} · ${item.scope.replaceAll("_", " ")}`}
aria-label={`${statusLabel(item)} · ${t(item.scope.replaceAll("_", " "))}`}
aria-current={item.id === run.id ? "true" : undefined}
data-status={item.status}
className={`flex w-full items-start justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/25${item.status === "completed" ? " bg-[oklch(var(--success)/0.1)]" : ""}`}
onClick={() => onRunUpdate(item)}
>
<span className="font-medium">{statusLabel(item)}</span>
<span className="font-medium">{t(statusLabel(item))}</span>
<span className="shrink-0 text-right text-xs text-muted-foreground">
{item.processed}/{item.total}<br />{new Date(item.createdAt).toLocaleString()}
{item.processed}/{item.total}<br />{new Date(item.createdAt).toLocaleString(getLocale())}
</span>
</button>
))}
@@ -1,3 +1,4 @@
import { useI18n } from "../../i18n";
import {
useEffect,
useId,
@@ -50,6 +51,7 @@ export function FleetActionSelector<ActionId extends string>({
renderContext,
label = "Batch action",
}: FleetActionSelectorProps<ActionId>) {
const { t } = useI18n();
const [selectedActionId, setSelectedActionId] = useState<ActionId | "">("");
const hintId = useId();
const groups = useMemo(() => {
@@ -63,10 +65,10 @@ export function FleetActionSelector<ActionId extends string>({
grouped.get(action.group)!.push(action);
}
return order.map((group) => ({ group, actions: grouped.get(group)! }));
}, [actions]);
}, [actions, t]);
const selectedAction = actions.find((action) => action.id === selectedActionId);
const unavailableReason = selectedAction?.disabled
? selectedAction.disabledReason ?? "This action is not available for the current selection."
? selectedAction.disabledReason ?? t("This action is not available for the current selection.")
: null;
const actionScope = selectedAction?.scopeLabel;
@@ -85,33 +87,33 @@ export function FleetActionSelector<ActionId extends string>({
return (
<div className="thot-fleet-action-selector">
<div className="thot-fleet-action-selector__scope" aria-live="polite">
<strong>{actionScope ?? `${selectedCount} selected`}</strong>
<strong>{actionScope ? t(actionScope) : t("{count} selected", { count: selectedCount })}</strong>
{actionScope ? (
<span>Action scope</span>
<span>{t("Action scope")}</span>
) : hiddenSelectedCount > 0 ? (
<span>{hiddenSelectedCount} hidden by filter</span>
<span>{hiddenSelectedCount} {t("hidden by filter")}</span>
) : (
<span>Selected rows</span>
<span>{t("Selected rows")}</span>
)}
</div>
<label className="sr-only" htmlFor={hintId}>{label}</label>
<label className="sr-only" htmlFor={hintId}>{t(label)}</label>
<select
ref={selectRef}
id={hintId}
className="thot-fleet-action-selector__select"
aria-label={label}
aria-label={t(label)}
value={selectedActionId}
disabled={busy || selectedCount === 0}
onChange={(event) => setSelectedActionId(event.target.value as ActionId | "")}
>
<option value="">Choose an action…</option>
<option value="">{t("Choose an action…")}</option>
{groups.map(({ group, actions: groupActions }) => (
<optgroup key={group} label={group}>
<optgroup key={group} label={t(group)}>
{groupActions.map((action) => (
<option key={action.id} value={action.id}>
{action.label}
{t(action.label)}
{action.disabled && action.disabledReason
? `, unavailable: ${action.disabledReason}`
? t(", unavailable: {reason}", { reason: t(action.disabledReason) })
: ""}
</option>
))}
@@ -129,14 +131,12 @@ export function FleetActionSelector<ActionId extends string>({
onClick={() => void run()}
>
<Play aria-hidden="true" />
Run action
</Button>
{t("Run action")}</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onClear}>
<X aria-hidden="true" /> Clear
</Button>
<X aria-hidden="true" /> {t("Clear")}</Button>
{unavailableReason ? (
<span id={`${hintId}-reason`} className="thot-fleet-action-selector__reason" role="status">
{unavailableReason}
{t(unavailableReason)}
</span>
) : null}
</div>
@@ -11,7 +11,7 @@ test("uses operational body text for every available-command list", () => {
.flatMap((fileName) => {
const source = readFileSync(`${sourceDirectory}/${fileName}`, "utf8");
const matches = source.match(
/<select\b[\s\S]*?<option value="">Choose an action…<\/option>[\s\S]*?<\/select>/g,
/<select\b[\s\S]*?<option value="">\{t\("Choose an action…"\)\}<\/option>[\s\S]*?<\/select>/g,
);
return (matches ?? []).map((markup) => ({ fileName, markup }));
});
@@ -1,3 +1,4 @@
import { useI18n } from "../../i18n";
import { forwardRef, useId, useState, type ReactNode } from "react"
import { ArrowLeft, ChevronRight } from "lucide-react"
@@ -80,6 +81,7 @@ export function FleetLedgerShell({
drawer,
className,
}: FleetLedgerShellProps) {
const { t } = useI18n();
const [panelHost, setPanelHost] = useState<HTMLElement | null>(null)
return (
@@ -87,7 +89,7 @@ export function FleetLedgerShell({
<section
ref={setPanelHost}
className={classes("thot-fleet-ledger", className)}
aria-label={ariaLabel}
aria-label={t(ariaLabel)}
>
<div className="thot-fleet-ledger__workspace">
{header}
@@ -117,11 +119,12 @@ export function FleetLedgerHeader({
actionsLabel = "Database management actions",
className,
}: FleetLedgerHeaderProps) {
const { t } = useI18n();
return (
<div className={className}>
<AdministrationHeader title={title} description="Configure database connections, synchronize schema and curate the catalog." />
<AdministrationHeader title={title} description={t("Configure database connections, synchronize schema and curate the catalog.")} />
<div className="thot-fleet-ledger__header-status">{status}</div>
{actions && <div className="thot-administration-toolbar" role="group" aria-label={actionsLabel}>
{actions && <div className="thot-administration-toolbar" role="group" aria-label={t(actionsLabel)}>
{actions}
</div>}
</div>
@@ -135,11 +138,12 @@ export interface FleetLedgerRealStatusProps extends WithClassName {
}
export function FleetLedgerRealStatus({
title = "Live catalog status",
title,
items,
live = "polite",
className,
}: FleetLedgerRealStatusProps) {
const { t } = useI18n();
const titleId = useId()
return (
@@ -150,7 +154,7 @@ export function FleetLedgerRealStatus({
aria-atomic="false"
>
<h2 id={titleId} className="thot-fleet-ledger__section-label">
{title}
{title ?? t("Live catalog status")}
</h2>
<ul className="thot-fleet-ledger__status-list">
{items.map((item) => (
@@ -180,10 +184,11 @@ export interface FleetLedgerKpiStripProps extends WithClassName {
}
export function FleetLedgerKpiStrip({
title = "Fleet summary",
title,
items,
className,
}: FleetLedgerKpiStripProps) {
const { t } = useI18n();
const titleId = useId()
return (
@@ -192,7 +197,7 @@ export function FleetLedgerKpiStrip({
aria-labelledby={titleId}
>
<h2 id={titleId} className="thot-fleet-ledger__section-label">
{title}
{title ?? t("Fleet summary")}
</h2>
<dl className="thot-fleet-ledger__kpi-strip">
{items.map((item) => (
@@ -223,6 +228,7 @@ export function FleetLedgerBreadcrumb({
ariaLabel = "Database hierarchy",
className,
}: FleetLedgerBreadcrumbProps) {
const { t } = useI18n();
return (
<div className={classes("thot-fleet-ledger__navigation", className)}>
{back ? (
@@ -237,11 +243,11 @@ export function FleetLedgerBreadcrumb({
data-icon="inline-start"
>
<ArrowLeft aria-hidden="true" />
{back.label}
{t(back.label)}
</Button>
) : null}
<nav className="thot-fleet-ledger__breadcrumb" aria-label={ariaLabel}>
<nav className="thot-fleet-ledger__breadcrumb" aria-label={t(ariaLabel)}>
<ol className="thot-fleet-ledger__breadcrumb-list">
{items.map((item, index) => {
const isCurrent = item.current ?? index === items.length - 1
@@ -296,11 +302,12 @@ export function FleetLedgerCommandBar({
ariaLabel = "Fleet commands",
className,
}: FleetLedgerCommandBarProps) {
const { t } = useI18n();
return (
<div
className={classes("thot-fleet-ledger__command-bar", className)}
role="group"
aria-label={ariaLabel}
aria-label={t(ariaLabel)}
>
<div className="thot-fleet-ledger__command-picker">{picker}</div>
{selectionSummary ? (
@@ -324,10 +331,11 @@ export function FleetLedgerContent({
busy = false,
className,
}: FleetLedgerContentProps) {
const { t } = useI18n();
return (
<section
className={classes("thot-fleet-ledger__content", className)}
aria-label={ariaLabel}
aria-label={t(ariaLabel)}
aria-busy={busy}
>
{children}
@@ -1,3 +1,4 @@
import { useI18n } from "../../i18n";
import { useId } from "react";
import { useWorkingContextScope } from "../../workspaces/WorkingContext";
import type { MetadataGenerationModels } from "../../api/catalog-databases";
@@ -24,6 +25,7 @@ export function MetadataGenerationModelSelector({
onSelectedModelChange,
variant = "default",
}: Props) {
const { t } = useI18n();
const helpId = useId();
const workingContext = useWorkingContextScope();
if (workingContext) return null;
@@ -34,10 +36,10 @@ export function MetadataGenerationModelSelector({
const noUsableModel = !isLoading && !isError && Boolean(data) && !hasUsableDefault;
const staleSelection = Boolean(selectedModel) && !models.some((model) => model.id === selectedModel);
const unavailableMessage = !isLoading && isError
? "Global LLM model choices are unavailable."
? t("Global LLM model choices are unavailable.")
: noUsableModel
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
: staleSelection ? "The global LLM model is unavailable. Choose a configured model; no fallback is applied." : null;
: staleSelection ? t("The global LLM model is unavailable. Choose a configured model; no fallback is applied.") : null;
const compact = variant === "compact";
return (
@@ -49,30 +51,30 @@ export function MetadataGenerationModelSelector({
? "flex min-w-0 items-center gap-2 text-xs font-medium text-foreground"
: "grid gap-1.5 text-xs font-medium text-foreground"}
>
<span className={compact ? "whitespace-nowrap" : undefined}>Global LLM model</span>
<span className={compact ? "whitespace-nowrap" : undefined}>{t("Global LLM model")}</span>
<select
className={`${compact ? "h-8 min-w-52" : "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 disabled:bg-muted disabled:text-muted-foreground`}
aria-label="Global LLM model"
aria-label={t("Global LLM model")}
aria-describedby={unavailableMessage ? helpId : undefined}
value={selectedModel}
disabled={!canManage || isLoading || isError || !hasUsableDefault}
onChange={(event) => onSelectedModelChange(event.target.value)}
>
{staleSelection && <option value={selectedModel}>{selectedModel} (unavailable)</option>}
{isLoading ? <option value="">Loading models…</option> : null}
{!isLoading && isError ? <option value="">Model choices unavailable</option> : null}
{staleSelection && <option value={selectedModel}>{selectedModel} {t("(unavailable)")}</option>}
{isLoading ? <option value="">{t("Loading models…")}</option> : null}
{!isLoading && isError ? <option value="">{t("Model choices unavailable")}</option> : null}
{noUsableModel ? (
<option value="">{models.length === 0 ? "No model configured" : "No usable model configured"}</option>
<option value="">{models.length === 0 ? t("No model configured") : t("No usable model configured")}</option>
) : null}
{models.map((model) => (
<option key={model.id} value={model.id}>{model.label}</option>
))}
</select>
</label>
<span className="text-xs text-muted-foreground">Shared with Core and all workspaces.</span>
<span className="text-xs text-muted-foreground">{t("Shared with Core and all workspaces.")}</span>
{unavailableMessage ? (
<p id={helpId} className="max-w-72 text-xs font-normal leading-4 text-muted-foreground">
{unavailableMessage}
{t(unavailableMessage)}
</p>
) : null}
</div>
@@ -1,3 +1,5 @@
import { renderAdminMessage, type AdminMessage } from "./messages";
import { useI18n, translate } from "../../i18n";
import { useEffect, useMemo, useRef, useState } from "react";
import { Save } from "lucide-react";
import { toast } from "sonner";
@@ -13,7 +15,7 @@ import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
open: boolean;
databaseId: string | null;
scopeLabel: string;
scopeLabel: AdminMessage;
suggestions: SensitivityReviewItem[];
canManage: boolean;
onClose: () => void;
@@ -49,6 +51,7 @@ export function SensitiveDataReviewDrawer({
onClose,
onSaved,
}: Props) {
const { t } = useI18n();
const [drafts, setDrafts] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState("");
const [showAll, setShowAll] = useState(false);
@@ -56,7 +59,7 @@ export function SensitiveDataReviewDrawer({
const initializedReview = useRef<string | null>(null);
const reviewKey = useMemo(
() => suggestions.map((suggestion) => suggestion.columnId).join(":"),
[suggestions],
[suggestions, t],
);
useEffect(() => {
@@ -77,18 +80,18 @@ export function SensitiveDataReviewDrawer({
const changed = useMemo(() => suggestions.filter((suggestion) => (
drafts[suggestion.columnId] !== undefined
&& drafts[suggestion.columnId] !== suggestion.currentSensitive
)), [drafts, suggestions]);
)), [drafts, suggestions, t]);
const visible = useMemo(() => {
const term = search.trim().toLocaleLowerCase();
return suggestions.filter((suggestion) => (
(showAll || drafts[suggestion.columnId] !== suggestion.currentSensitive)
&& (!term || `${suggestion.tableName}.${suggestion.columnName}`.toLocaleLowerCase().includes(term))
));
}, [drafts, search, showAll, suggestions]);
}, [drafts, search, showAll, suggestions, t]);
const close = () => {
if (saving) return;
if (changed.length > 0 && !window.confirm("Discard the sensitive-field review?")) return;
if (changed.length > 0 && !window.confirm(t("Discard the sensitive-field review?"))) return;
onClose();
};
@@ -113,9 +116,9 @@ export function SensitiveDataReviewDrawer({
}
if (saved.length > 0) onSaved(saved);
if (failed.length > 0) {
toast.error(`${failed.length} sensitive flag${failed.length === 1 ? "" : "s"} could not be saved: ${apiErrorMessage(failed[0])}`);
toast.error((failed.length === 1 ? t("{count} sensitive flag could not be saved: {error}", { count: failed.length, error: apiErrorMessage(failed[0]) }) : t("{count} sensitive flags could not be saved: {error}", { count: failed.length, error: apiErrorMessage(failed[0]) })));
} else {
toast.success(`Saved ${saved.length} sensitive flag${saved.length === 1 ? "" : "s"}`);
toast.success((saved.length === 1 ? t("Saved {count} sensitive flag", { count: saved.length }) : t("Saved {count} sensitive flags", { count: saved.length })));
onClose();
}
setSaving(false);
@@ -125,22 +128,22 @@ export function SensitiveDataReviewDrawer({
return (
<FleetLedgerDrawer
open
ariaLabel="Sensitive field review"
eyebrow="Sensitive data"
title="Review local assessments"
description={`${scopeLabel}. Local rules proposed values, but only your save changes the catalog.`}
ariaLabel={t("Sensitive field review")}
eyebrow={t("Sensitive data")}
title={t("Review local assessments")}
description={t("{scopeLabel}. Local rules proposed values, but only your save changes the catalog.", { scopeLabel: renderAdminMessage(t, scopeLabel) })}
onClose={close}
closeLabel="Close sensitive field review"
closeLabel={t("Close sensitive field review")}
busy={saving}
bodyClassName="thot-catalog-drawer__body--flush"
footerClassName="thot-catalog-drawer__footer--split"
footer={(
<>
<p className="text-xs text-muted-foreground">Unsaved assessments never change the catalog.</p>
<p className="text-xs text-muted-foreground">{t("Unsaved assessments never change the catalog.")}</p>
<div className="flex gap-2">
<Button type="button" variant="outline" disabled={saving} onClick={close}>Cancel</Button>
<Button type="button" variant="outline" disabled={saving} onClick={close}>{t("Cancel")}</Button>
<Button type="button" disabled={!canManage || saving || changed.length === 0} onClick={() => void save()}>
<Save />{saving ? "Saving…" : `Save all ${changed.length} change${changed.length === 1 ? "" : "s"}`}
<Save />{saving ? t("Saving…") : (changed.length === 1 ? t("Save all {count} change", { count: changed.length }) : t("Save all {count} changes", { count: changed.length }))}
</Button>
</div>
</>
@@ -150,13 +153,13 @@ export function SensitiveDataReviewDrawer({
<div className="flex items-center gap-3">
<input
className="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Search sensitivity assessments"
placeholder="Search table or column"
aria-label={t("Search sensitivity assessments")}
placeholder={t("Search table or column")}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
{changed.length} change{changed.length === 1 ? "" : "s"}
{changed.length === 1 ? t("{count} change", { count: changed.length }) : t("{count} changes", { count: changed.length })}
</span>
</div>
<label className="mt-3 inline-flex items-center gap-2 text-sm text-muted-foreground">
@@ -166,20 +169,18 @@ export function SensitiveDataReviewDrawer({
checked={showAll}
onChange={(event) => setShowAll(event.target.checked)}
/>
Show all {suggestions.length} assessed columns
</label>
{t("Show all")} {suggestions.length} {t("assessed columns")}</label>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{visible.length === 0 ? (
<div className="rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
<p className="font-semibold">No proposed changes in this view.</p>
<p className="font-semibold">{t("No proposed changes in this view.")}</p>
<p className="mt-1 text-muted-foreground">
Show all classified columns to inspect unchanged flags, or close this review.
</p>
{t("Show all classified columns to inspect unchanged flags, or close this review.")}</p>
</div>
) : (
<ul className="divide-y divide-border" aria-label="Sensitivity assessments">
<ul className="divide-y divide-border" aria-label={t("Sensitivity assessments")}>
{visible.map((suggestion) => {
const proposed = drafts[suggestion.columnId] ?? suggestion.sensitive;
const changedFromCurrent = proposed !== suggestion.currentSensitive;
@@ -188,7 +189,7 @@ export function SensitiveDataReviewDrawer({
<input
type="checkbox"
className="mt-1 size-4 shrink-0 accent-primary outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
aria-label={`Protect ${suggestion.tableName}.${suggestion.columnName}`}
aria-label={t("Protect {tableName}.{columnName}", { tableName: suggestion.tableName, columnName: suggestion.columnName })}
checked={proposed}
disabled={!canManage || saving}
onChange={(event) => setDrafts((current) => ({
@@ -201,18 +202,18 @@ export function SensitiveDataReviewDrawer({
{suggestion.tableName}.{suggestion.columnName}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Current: {suggestion.currentSensitive ? "protected" : "allowed"}. Proposed: {proposed ? "protected" : "allowed"}.
{t("Current:")} {suggestion.currentSensitive ? t("protected") : t("allowed")}{t(". Proposed:")} {proposed ? t("protected") : t("allowed")}.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Assessment: {suggestion.assessment.replace("_", " ")}. Evidence: {suggestion.evidence.length > 0
{t("Assessment:")} {t(suggestion.assessment.replace("_", " "))}{t(". Evidence:")} {suggestion.evidence.length > 0
? suggestion.evidence.map((item) => item.label
? `${item.ruleId} (${item.label}${item.confidence === undefined ? "" : ` ${Math.round(item.confidence * 100)}%`})`
: item.ruleId).join(", ")
: "no sensitive match"}. Coverage: {suggestion.coverage.replace("_", " ")}. Observed values: {suggestion.observedValues}.
: t("no sensitive match")}{t(". Coverage:")} {t(suggestion.coverage.replace("_", " "))}{t(". Observed values:")} {suggestion.observedValues}.
</p>
</div>
<span className={`rounded px-2 py-0.5 text-[11px] font-semibold ${changedFromCurrent ? "bg-amber-500/12 text-amber-800 dark:text-amber-300" : "bg-muted text-muted-foreground"}`}>
{changedFromCurrent ? "Change" : "No change"}
{changedFromCurrent ? t("Change") : t("No change")}
</span>
</li>
);
@@ -1,3 +1,4 @@
import { getLocale, useI18n, translate } from "../../i18n";
import { useEffect, useMemo } from "react";
import { LoaderCircle } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -26,7 +27,7 @@ const SAFE_FINAL_ERROR = "The run ended before all columns were classified. Revi
function statusLabel(run: SensitivityAnalysisRun): string {
const label = run.status.replaceAll("_", " ");
return `${label[0].toUpperCase()}${label.slice(1)}`;
return translate(`${label[0].toUpperCase()}${label.slice(1)}`);
}
function isTerminal(run?: SensitivityAnalysisRun): boolean {
@@ -51,7 +52,7 @@ function safeFinalError(summary: string | null): string | null {
}
function timestamp(value: string | null): string {
return value ? new Date(value).toLocaleString() : "Not available";
return value ? new Date(value).toLocaleString(getLocale()) : translate("Not available");
}
export function SensitivityAnalysisHistoryDrawer({
@@ -62,6 +63,7 @@ export function SensitivityAnalysisHistoryDrawer({
onClose,
onRunUpdate,
}: Props) {
const { t } = useI18n();
const queryClient = useQueryClient();
const runId = initialRun?.id ?? null;
const runQuery = useQuery({
@@ -91,7 +93,7 @@ export function SensitivityAnalysisHistoryDrawer({
});
const visibleHistory = useMemo(
() => (historyQuery.data ?? []).filter((item) => !databaseId || item.databaseId === databaseId),
[databaseId, historyQuery.data],
[databaseId, historyQuery.data, t],
);
const events = eventQuery.data ?? [];
const latestEventSequence = events.at(-1)?.sequence ?? 0;
@@ -131,42 +133,41 @@ export function SensitivityAnalysisHistoryDrawer({
return (
<FleetLedgerDrawer
open
ariaLabel="Sensitivity analysis history"
eyebrow="Sensitivity analysis"
ariaLabel={t("Sensitivity analysis history")}
eyebrow={t("Sensitivity analysis")}
title={(
<span className="inline-flex items-center gap-2">
<LoaderCircle className="size-5 animate-spin" aria-hidden="true" />
Starting sensitivity analysis
</span>
{t("Starting sensitivity analysis")}</span>
)}
description="Preparing the local policy and source scan."
description={t("Preparing the local policy and source scan.")}
onClose={onClose}
closeLabel="Close sensitivity analysis history"
closeLabel={t("Close sensitivity analysis history")}
bodyClassName="thot-catalog-drawer__history-layout thot-catalog-drawer__run-layout"
>
<div className="thot-catalog-drawer__run-main">
<section aria-label="Sensitivity analysis startup progress">
<h3 className="thot-label mb-2">Progress</h3>
<section aria-label={t("Sensitivity analysis startup progress")}>
<h3 className="thot-label mb-2">{t("Progress")}</h3>
<div
role="progressbar"
aria-label="Sensitivity analysis is starting"
aria-valuetext="Preparing analysis run"
aria-label={t("Sensitivity analysis is starting")}
aria-valuetext={t("Preparing analysis run")}
className="h-2 overflow-hidden rounded-full bg-muted"
>
<div className="h-full w-1/3 animate-pulse rounded-full bg-primary" />
</div>
</section>
<section className="thot-catalog-drawer__events mt-4" aria-label="Sensitivity analysis log">
<section className="thot-catalog-drawer__events mt-4" aria-label={t("Sensitivity analysis log")}>
<div className="mb-2 flex items-center justify-between">
<h3 className="thot-label">Events</h3>
<h3 className="thot-label">{t("Events")}</h3>
<span className="text-xs tabular-nums text-muted-foreground">1</span>
</div>
<div
role="log"
aria-label="Sensitivity analysis events"
aria-label={t("Sensitivity analysis events")}
className="thot-catalog-drawer__event-log overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
<p>Preparing sensitivity analysis…</p>
<p>{t("Preparing sensitivity analysis…")}</p>
</div>
</section>
</div>
@@ -177,27 +178,26 @@ export function SensitivityAnalysisHistoryDrawer({
return (
<FleetLedgerDrawer
open
ariaLabel="Sensitivity analysis history"
eyebrow="Sensitive data"
title="Analysis run history"
ariaLabel={t("Sensitivity analysis history")}
eyebrow={t("Sensitive data")}
title={t("Analysis run history")}
onClose={onClose}
closeLabel="Close sensitivity analysis history"
closeLabel={t("Close sensitivity analysis history")}
bodyClassName="thot-catalog-drawer__body--empty"
>
{historyQuery.isError ? (
<div role="alert" className="w-full rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(historyQuery.error)}
{t(apiErrorMessage(historyQuery.error))}
</div>
) : historyQuery.isLoading || awaitingRun || (historyQuery.data?.length ?? 0) > 0 ? (
<p className="text-sm text-muted-foreground">
{awaitingRun ? "Starting sensitivity analysis…" : "Loading analysis history…"}
{awaitingRun ? t("Starting sensitivity analysis…") : t("Loading analysis history…")}
</p>
) : (
<div className="w-full rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
<p className="font-semibold">No sensitivity analysis runs yet.</p>
<p className="font-semibold">{t("No sensitivity analysis runs yet.")}</p>
<p className="mt-1 text-muted-foreground">
Analyze sensitive fields to create the first history entry.
</p>
{t("Analyze sensitive fields to create the first history entry.")}</p>
</div>
)}
</FleetLedgerDrawer>
@@ -206,70 +206,70 @@ export function SensitivityAnalysisHistoryDrawer({
const finalError = safeFinalError(run.errorSummary);
const engineDisplay = run.engine === "local"
? `Local rules (${run.policyVersion ?? "unknown policy"})`
: `Legacy LLM (${run.modelId ?? "unknown model"})`;
? t("Local rules ({policy})", { policy: run.policyVersion ?? t("unknown policy") })
: t("Legacy LLM ({model})", { model: run.modelId ?? t("unknown model") });
const progressCounters = [
["total", "Total columns"],
["suggestedSensitive", "Sensitive"],
["suggestedNonSensitive", "Not sensitive"],
["unknown", "Unprocessed"],
["total", t("Total columns")],
["suggestedSensitive", t("Sensitive")],
["suggestedNonSensitive", t("Not sensitive")],
["unknown", t("Unprocessed")],
] as const;
return (
<FleetLedgerDrawer
open
className={outcomeClass(run)}
ariaLabel="Sensitivity analysis history"
eyebrow="Sensitivity analysis"
ariaLabel={t("Sensitivity analysis history")}
eyebrow={t("Sensitivity analysis")}
title={(
<span className="inline-flex items-center gap-2">
{!isTerminal(run) ? <LoaderCircle className="size-5 animate-spin" aria-hidden="true" /> : null}
{statusLabel(run)}
{t(statusLabel(run))}
</span>
)}
description={run.scope.replaceAll("_", " ")}
description={t(run.scope.replaceAll("_", " "))}
onClose={onClose}
closeLabel="Close sensitivity analysis history"
closeLabel={t("Close sensitivity analysis history")}
bodyClassName="thot-catalog-drawer__history-layout thot-catalog-drawer__run-layout"
>
<div className="thot-catalog-drawer__run-main">
{runQuery.isError ? (
<div role="alert" className="rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(runQuery.error)}
{t(apiErrorMessage(runQuery.error))}
</div>
) : null}
<div className={`grid gap-4 sm:grid-cols-2${runQuery.isError ? " mt-4" : ""}`}>
<section aria-label="Analysis engine">
<h3 className="thot-label mb-2">Analysis engine</h3>
<section aria-label={t("Analysis engine")}>
<h3 className="thot-label mb-2">{t("Analysis engine")}</h3>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt>Engine</dt><dd className="truncate text-right" title={engineDisplay}>{engineDisplay}</dd>
<dt>{t("Engine")}</dt><dd className="truncate text-right" title={engineDisplay}>{engineDisplay}</dd>
{run.engine === "llm" ? <>
<dt>Input tokens</dt><dd className="text-right tabular-nums">{(run.inputTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>Cache tokens</dt><dd className="text-right tabular-nums">{(run.cacheReadTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>Output tokens</dt><dd className="text-right tabular-nums">{(run.outputTokens ?? 0).toLocaleString("en-US")}</dd>
<dt>{t("Input tokens")}</dt><dd className="text-right tabular-nums">{(run.inputTokens ?? 0).toLocaleString(getLocale())}</dd>
<dt>{t("Cache tokens")}</dt><dd className="text-right tabular-nums">{(run.cacheReadTokens ?? 0).toLocaleString(getLocale())}</dd>
<dt>{t("Output tokens")}</dt><dd className="text-right tabular-nums">{(run.outputTokens ?? 0).toLocaleString(getLocale())}</dd>
</> : null}
</dl>
</section>
<section aria-label="Sensitivity analysis timestamps">
<h3 className="thot-label mb-2">Timestamps</h3>
<section aria-label={t("Sensitivity analysis timestamps")}>
<h3 className="thot-label mb-2">{t("Timestamps")}</h3>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt className="text-muted-foreground">Created</dt><dd className="text-right tabular-nums">{timestamp(run.createdAt)}</dd>
<dt className="text-muted-foreground">Started</dt><dd className="text-right tabular-nums">{timestamp(run.startedAt)}</dd>
<dt className="text-muted-foreground">Updated</dt><dd className="text-right tabular-nums">{timestamp(run.updatedAt)}</dd>
<dt className="text-muted-foreground">Finished</dt><dd className="text-right tabular-nums">{timestamp(run.finishedAt)}</dd>
<dt className="text-muted-foreground">{t("Created")}</dt><dd className="text-right tabular-nums">{timestamp(run.createdAt)}</dd>
<dt className="text-muted-foreground">{t("Started")}</dt><dd className="text-right tabular-nums">{timestamp(run.startedAt)}</dd>
<dt className="text-muted-foreground">{t("Updated")}</dt><dd className="text-right tabular-nums">{timestamp(run.updatedAt)}</dd>
<dt className="text-muted-foreground">{t("Finished")}</dt><dd className="text-right tabular-nums">{timestamp(run.finishedAt)}</dd>
</dl>
</section>
</div>
<section className="mt-4" aria-label="Sensitivity analysis counters">
<h3 className="thot-label mb-2">Results</h3>
<section className="mt-4" aria-label={t("Sensitivity analysis counters")}>
<h3 className="thot-label mb-2">{t("Results")}</h3>
<div className="overflow-x-auto rounded-md border border-border bg-muted/20">
<table className="w-full min-w-[20rem] table-fixed" aria-label="Sensitivity analysis progress">
<table className="w-full min-w-[20rem] table-fixed" aria-label={t("Sensitivity analysis progress")}>
<thead>
<tr className="border-b border-border bg-muted/45">
{progressCounters.map(([name, label]) => (
<th key={name} scope="col" className="border-l border-border px-2 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-muted-foreground first:border-l-0">
{label}
{t(label)}
</th>
))}
</tr>
@@ -289,13 +289,13 @@ export function SensitivityAnalysisHistoryDrawer({
{finalError ? (
<div className="mt-3 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{finalError}
{t(finalError)}
</div>
) : null}
<section className="thot-catalog-drawer__events mt-3" aria-label="Sensitivity analysis log">
<section className="thot-catalog-drawer__events mt-3" aria-label={t("Sensitivity analysis log")}>
<div className="mb-2 flex items-center justify-between">
<h3 className="thot-label">Events</h3>
<h3 className="thot-label">{t("Events")}</h3>
<div className="flex items-center gap-2">
{!followingEventTail && events.length > 0 ? (
<Button
@@ -304,8 +304,7 @@ export function SensitivityAnalysisHistoryDrawer({
size="xs"
onClick={jumpToLatestEvent}
>
Jump to latest
</Button>
{t("Jump to latest")}</Button>
) : null}
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
</div>
@@ -313,51 +312,51 @@ export function SensitivityAnalysisHistoryDrawer({
<div
ref={eventLogRef}
role="log"
aria-label="Sensitivity analysis events"
aria-label={t("Sensitivity analysis events")}
aria-relevant="additions"
tabIndex={0}
onScroll={handleEventLogScroll}
className="thot-catalog-drawer__event-log overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
{events.length === 0 ? <p className="text-zinc-500">Waiting for events…</p> : events.map((event) => (
{events.length === 0 ? <p className="text-zinc-500">{t("Waiting for events…")}</p> : events.map((event) => (
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString()}</span>{event.message}
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString(getLocale())}</span>{t(event.message)}
</p>
))}
</div>
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{apiErrorMessage(eventQuery.error)}</p> : null}
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{t(apiErrorMessage(eventQuery.error))}</p> : null}
</section>
</div>
<section className="thot-catalog-drawer__history" aria-label="Sensitivity analysis run history">
<section className="thot-catalog-drawer__history" aria-label={t("Sensitivity analysis run history")}>
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="thot-label">Recent runs</h3>
<h3 className="thot-label">{t("Recent runs")}</h3>
<span className="text-xs tabular-nums text-muted-foreground">
{visibleHistory.length}
</span>
</div>
{historyQuery.isError ? (
<p role="alert" className="text-sm text-destructive">{apiErrorMessage(historyQuery.error)}</p>
<p role="alert" className="text-sm text-destructive">{t(apiErrorMessage(historyQuery.error))}</p>
) : historyQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading analysis history…</p>
<p className="text-sm text-muted-foreground">{t("Loading analysis history…")}</p>
) : visibleHistory.length === 0 ? (
<p className="text-sm text-muted-foreground">No sensitivity analysis runs yet.</p>
<p className="text-sm text-muted-foreground">{t("No sensitivity analysis runs yet.")}</p>
) : (
<div className="thot-catalog-drawer__history-list divide-y divide-border overflow-y-auto rounded-md border border-border">
{visibleHistory.map((item) => (
<button
key={item.id}
type="button"
aria-label={`${statusLabel(item)} · ${item.scope.replaceAll("_", " ")}`}
aria-label={`${statusLabel(item)} · ${t(item.scope.replaceAll("_", " "))}`}
aria-current={item.id === run.id ? "true" : undefined}
data-status={item.status}
className="flex w-full items-start justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/25"
onClick={() => onRunUpdate(item)}
>
<span className="font-medium">{statusLabel(item)}</span>
<span className="font-medium">{t(statusLabel(item))}</span>
<span className="shrink-0 text-right text-xs text-muted-foreground">
{item.suggestedSensitive + item.suggestedNonSensitive + item.unknown}/{item.total}<br />
{new Date(item.createdAt).toLocaleString()}
{new Date(item.createdAt).toLocaleString(getLocale())}
</span>
</button>
))}
@@ -0,0 +1,28 @@
import type { TranslationParams } from "../../i18n";
/** Keep IDs and parameters in state so open results follow later locale changes. */
export type AdminMessage = string | {
message: string;
params?: Readonly<Record<string, string | number | { message: string }>>;
};
export function adminMessage(message: string, params?: Exclude<AdminMessage, string>["params"]): AdminMessage {
return { message, params };
}
/** Local API diagnostics retain their code; translate only the known message body. */
export function renderAdminDiagnostic(t: (message: string) => string, diagnostic: string): string {
const match = /^([a-z][a-z_]+): (.+)$/.exec(diagnostic);
return match ? `${match[1]}: ${t(match[2])}` : t(diagnostic);
}
export function renderAdminMessage(
t: (message: string, params?: TranslationParams) => string,
value: AdminMessage,
): string {
if (typeof value === "string") return t(value);
const params = value.params && Object.fromEntries(Object.entries(value.params).map(([key, param]) =>
[key, typeof param === "object" ? t(param.message) : param],
));
return t(value.message, params);
}
@@ -1,3 +1,4 @@
import { translate } from "../../i18n";
import type {
CatalogDatabase,
CatalogSecretName,
@@ -111,9 +112,9 @@ export function secretReplacements(draft: DatabaseFormDraft): SecretDraft {
}
export function statusLabel(row: CatalogDatabase): string {
if (!row.workspaceAvailable) return "Workspace missing";
if (!row.configured) return "Not configured";
if (row.connectionStatus === "reachable") return "Reachable";
if (row.connectionStatus === "failed") return "Failed";
return "Untested";
if (!row.workspaceAvailable) return translate("Workspace missing");
if (!row.configured) return translate("Not configured");
if (row.connectionStatus === "reachable") return translate("Reachable");
if (row.connectionStatus === "failed") return translate("Failed");
return translate("Untested");
}
@@ -0,0 +1,19 @@
import { useRef } from "react";
import type { GridState, StateUpdatedEvent } from "ag-grid-community";
import { AG_GRID_LOCALE_EN, AG_GRID_LOCALE_IT } from "@ag-grid-community/locale";
import { useI18n } from "../../i18n";
/** AG Grid reads locale text at creation. Keep selection, filters and sort on recreation. */
export function useAdminGridLocale() {
const { locale } = useI18n();
const state = useRef<GridState>();
return {
locale,
gridLocaleProps: {
theme: "legacy" as const,
localeText: locale === "it" ? AG_GRID_LOCALE_IT : AG_GRID_LOCALE_EN,
initialState: state.current,
onStateUpdated: (event: StateUpdatedEvent) => { state.current = event.state; },
},
};
}