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:
@@ -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; },
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user