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