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.
478 lines
22 KiB
TypeScript
478 lines
22 KiB
TypeScript
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";
|
|
import { toast } from "sonner";
|
|
import { Button } from "../../components/ui/button";
|
|
import { apiErrorMessage } from "../../api/client";
|
|
import {
|
|
cancelCatalogSync,
|
|
catalogSyncEventsUrl,
|
|
confirmCatalogSync,
|
|
getCatalogSyncRun,
|
|
listCatalogSyncEvents,
|
|
listCatalogSyncRuns,
|
|
retryCatalogSync,
|
|
type CatalogSyncEvent,
|
|
type CatalogSyncPhase,
|
|
type CatalogSyncRun,
|
|
} from "../../api/catalog-databases";
|
|
import { useTailFollowing } from "../activityScroll";
|
|
import { FleetLedgerDrawer } from "./FleetLedgerShell";
|
|
|
|
interface Props {
|
|
databaseId: string | null;
|
|
runId: string | null;
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onRunChange: (run: CatalogSyncRun) => void;
|
|
onRunUpdate: (run: CatalogSyncRun) => void;
|
|
onCatalogChanged: (run: CatalogSyncRun) => void;
|
|
}
|
|
|
|
const phases: CatalogSyncPhase[] = [
|
|
"queued", "connecting", "scanning_tables", "scanning_columns",
|
|
"scanning_relationships", "planning", "awaiting_confirmation", "applying", "memory_cleanup", "completed",
|
|
];
|
|
|
|
const phaseEnteredByEvent: Record<string, CatalogSyncPhase> = {
|
|
queued: "queued",
|
|
started: "connecting",
|
|
connecting: "connecting",
|
|
scanning_tables: "scanning_tables",
|
|
scanning_columns: "scanning_columns",
|
|
scanning_relationships: "scanning_relationships",
|
|
planning: "planning",
|
|
confirmation_required: "awaiting_confirmation",
|
|
confirmation_received: "awaiting_confirmation",
|
|
applying: "applying",
|
|
memory_cleanup: "memory_cleanup",
|
|
succeeded: "completed",
|
|
};
|
|
|
|
const phaseLabels: Record<CatalogSyncPhase, { pending: string; current: string; completed: string }> = {
|
|
queued: { pending: "Queue", current: "Queued", completed: "Queued" },
|
|
connecting: { pending: "Connect", current: "Connecting", completed: "Connected" },
|
|
scanning_tables: { pending: "Read tables", current: "Reading tables", completed: "Tables read" },
|
|
scanning_columns: { pending: "Read columns", current: "Reading columns", completed: "Columns read" },
|
|
scanning_relationships: {
|
|
pending: "Read relationships",
|
|
current: "Reading relationships",
|
|
completed: "Relationships read",
|
|
},
|
|
planning: { pending: "Plan changes", current: "Planning changes", completed: "Changes planned" },
|
|
awaiting_confirmation: {
|
|
pending: "Confirm changes if needed",
|
|
current: "Confirmation required",
|
|
completed: "Changes confirmed",
|
|
},
|
|
applying: { pending: "Apply changes", current: "Applying changes", completed: "Changes applied" },
|
|
memory_cleanup: { pending: "Clean dependent Memory", current: "Cleaning dependent Memory", completed: "Memory updated" },
|
|
completed: { pending: "Complete", current: "Completing", completed: "Completed" },
|
|
};
|
|
|
|
function terminal(run?: CatalogSyncRun): boolean {
|
|
return Boolean(run && ["succeeded", "failed", "cancelled", "interrupted"].includes(run.state));
|
|
}
|
|
|
|
function elapsed(run: CatalogSyncRun, now: number): string {
|
|
const start = new Date(run.startedAt ?? run.createdAt).getTime();
|
|
const end = run.finishedAt ? new Date(run.finishedAt).getTime() : now;
|
|
const seconds = Math.max(0, Math.floor((end - start) / 1000));
|
|
const minutes = Math.floor(seconds / 60);
|
|
return minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
|
|
}
|
|
|
|
function stateLabel(run: CatalogSyncRun): string {
|
|
return translate(run.state.replaceAll("_", " "));
|
|
}
|
|
|
|
export function CatalogSyncDrawer({
|
|
databaseId,
|
|
runId,
|
|
open,
|
|
onClose,
|
|
onRunChange,
|
|
onRunUpdate,
|
|
onCatalogChanged,
|
|
}: Props) {
|
|
const { t } = useI18n();
|
|
const queryClient = useQueryClient();
|
|
const [events, setEvents] = useState<CatalogSyncEvent[]>([]);
|
|
const [now, setNow] = useState(Date.now());
|
|
const [action, setAction] = useState<"confirm" | "cancel" | "retry" | null>(null);
|
|
const lastSequence = useRef(0);
|
|
const notifiedRun = useRef<string | null>(null);
|
|
|
|
const runQuery = useQuery({
|
|
queryKey: ["catalog-sync-run", runId],
|
|
queryFn: () => getCatalogSyncRun(runId!),
|
|
enabled: Boolean(runId),
|
|
retry: false,
|
|
refetchInterval: (query) => terminal(query.state.data) ? false : 1_000,
|
|
});
|
|
const run = runQuery.data;
|
|
const historyQuery = useQuery({
|
|
queryKey: ["catalog-sync-runs", databaseId],
|
|
queryFn: () => listCatalogSyncRuns(databaseId!),
|
|
enabled: Boolean(databaseId && open),
|
|
retry: false,
|
|
refetchInterval: run && !terminal(run) ? 2_000 : false,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const latestRun = historyQuery.data?.[0];
|
|
if (!open || runId || !latestRun) return;
|
|
onRunChange(latestRun);
|
|
}, [historyQuery.data, onRunChange, open, runId]);
|
|
const eventQuery = useQuery({
|
|
queryKey: ["catalog-sync-events", runId],
|
|
queryFn: () => listCatalogSyncEvents(runId!, lastSequence.current),
|
|
enabled: Boolean(runId && open),
|
|
retry: false,
|
|
refetchInterval: run && terminal(run) ? false : 1_500,
|
|
});
|
|
const latestEventSequence = events.at(-1)?.sequence ?? 0;
|
|
const {
|
|
followingTail: followingEventTail,
|
|
handleScroll: handleEventLogScroll,
|
|
jumpToTail: jumpToLatestEvent,
|
|
viewportRef: eventLogRef,
|
|
} = useTailFollowing<HTMLDivElement>({
|
|
active: open && Boolean(run),
|
|
resetKey: runId,
|
|
tail: latestEventSequence,
|
|
});
|
|
|
|
const mergeEvents = (incoming: CatalogSyncEvent[]) => {
|
|
if (incoming.length === 0) return;
|
|
setEvents((current) => {
|
|
const bySequence = new Map(current.map((event) => [event.sequence, event]));
|
|
for (const event of incoming) bySequence.set(event.sequence, event);
|
|
const merged = [...bySequence.values()].sort((a, b) => a.sequence - b.sequence);
|
|
lastSequence.current = merged.at(-1)?.sequence ?? lastSequence.current;
|
|
return merged;
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
setEvents([]);
|
|
lastSequence.current = 0;
|
|
}, [runId]);
|
|
|
|
useEffect(() => { mergeEvents(eventQuery.data ?? []); }, [eventQuery.data]);
|
|
|
|
useEffect(() => {
|
|
if (!runId || !open || typeof EventSource === "undefined") return;
|
|
const source = new EventSource(catalogSyncEventsUrl(runId));
|
|
const log = (event: MessageEvent<string>) => {
|
|
try { mergeEvents([JSON.parse(event.data) as CatalogSyncEvent]); } catch { /* polling remains authoritative */ }
|
|
};
|
|
const update = (event: MessageEvent<string>) => {
|
|
try {
|
|
const next = JSON.parse(event.data) as CatalogSyncRun;
|
|
queryClient.setQueryData(["catalog-sync-run", runId], next);
|
|
} catch { /* polling remains authoritative */ }
|
|
};
|
|
source.addEventListener("log", log as EventListener);
|
|
source.addEventListener("run", update as EventListener);
|
|
source.onerror = () => source.close();
|
|
return () => source.close();
|
|
}, [open, queryClient, runId]);
|
|
|
|
useEffect(() => {
|
|
if (!run || terminal(run)) return;
|
|
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
|
return () => window.clearInterval(timer);
|
|
}, [run]);
|
|
|
|
useEffect(() => {
|
|
if (!run) return;
|
|
onRunUpdate(run);
|
|
queryClient.setQueryData<CatalogSyncRun[]>(["catalog-sync-runs", run.databaseId], (current) => {
|
|
if (!current) return [run];
|
|
const found = current.some((item) => item.id === run.id);
|
|
return found
|
|
? current.map((item) => item.id === run.id ? run : item)
|
|
: [run, ...current];
|
|
});
|
|
}, [onRunUpdate, queryClient, run]);
|
|
|
|
useEffect(() => {
|
|
if (!run || run.state !== "succeeded" || notifiedRun.current === run.id) return;
|
|
notifiedRun.current = run.id;
|
|
onCatalogChanged(run);
|
|
}, [onCatalogChanged, run]);
|
|
|
|
const currentPhase = run ? phases.indexOf(run.phase) : -1;
|
|
const knownEvents = [...events, ...(eventQuery.data ?? [])];
|
|
const confirmationWasRequired = run?.state === "awaiting_confirmation" || knownEvents.some((event) => (
|
|
event.eventType === "confirmation_required" || event.eventType === "confirmation_received"
|
|
));
|
|
const confirmationWasReceived = knownEvents.some((event) => event.eventType === "confirmation_received")
|
|
|| (run?.state === "succeeded" && confirmationWasRequired);
|
|
const furthestEventPhase = knownEvents.reduce((furthest, event) => {
|
|
const phase = phaseEnteredByEvent[event.eventType];
|
|
return phase ? Math.max(furthest, phases.indexOf(phase)) : furthest;
|
|
}, -1);
|
|
const confirmationPhase = phases.indexOf("awaiting_confirmation");
|
|
const passedConfirmation = run?.state === "succeeded"
|
|
|| (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, t]);
|
|
|
|
const perform = async (kind: "confirm" | "cancel" | "retry") => {
|
|
if (!run) return;
|
|
setAction(kind);
|
|
try {
|
|
const next = kind === "confirm"
|
|
? await confirmCatalogSync(run.id, run.confirmationToken!)
|
|
: kind === "cancel"
|
|
? await cancelCatalogSync(run.id)
|
|
: await retryCatalogSync(run.id);
|
|
onRunChange(next);
|
|
queryClient.setQueryData(["catalog-sync-run", next.id], next);
|
|
await historyQuery.refetch();
|
|
} catch (error) {
|
|
toast.error(t(apiErrorMessage(error)));
|
|
} finally {
|
|
setAction(null);
|
|
}
|
|
};
|
|
|
|
const historySection = (
|
|
<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">{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" /> {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">
|
|
{t("No synchronization runs yet.")}</div>
|
|
) : null}
|
|
{(historyQuery.data ?? []).length > 0 ? (
|
|
<div className="divide-y divide-border rounded-md border border-border">
|
|
{(historyQuery.data ?? []).slice(0, 5).map((item) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
aria-current={item.id === run?.id ? "true" : undefined}
|
|
data-status={item.state}
|
|
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>{t(item.scope)}</span>
|
|
<span className="text-xs text-muted-foreground">{t(stateLabel(item))} · {new Date(item.createdAt).toLocaleString(getLocale())}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
|
|
if (!open) return null;
|
|
const footer = run ? (
|
|
<>
|
|
{run.state === "awaiting_confirmation" ? (
|
|
<Button type="button" disabled={action !== null} onClick={() => void perform("confirm")}>
|
|
{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")}>{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" : ""} /> {t("Retry")}</Button>
|
|
) : null}
|
|
</>
|
|
) : undefined;
|
|
return (
|
|
<FleetLedgerDrawer
|
|
open
|
|
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}
|
|
{t(stateLabel(run)[0].toUpperCase())}{t(stateLabel(run).slice(1))}
|
|
</span>
|
|
) : t("Synchronization history")}
|
|
description={run ? `${t(run.scope.replaceAll("_", " "))} · ${elapsed(run, now)}` : undefined}
|
|
onClose={onClose}
|
|
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">{t("Loading synchronization…")}</p> : null}
|
|
{run ? (
|
|
<>
|
|
<section aria-labelledby="synchronization-steps-heading">
|
|
<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
|
|
&& !eventQuery.isFetched
|
|
&& !confirmationWasRequired;
|
|
const confirmationSkipped = phase === "awaiting_confirmation"
|
|
&& passedConfirmation
|
|
&& eventQuery.isFetched
|
|
&& !confirmationWasRequired;
|
|
const terminalOutcome = phase === "completed" && terminal(run) && run.state !== "succeeded";
|
|
const failedOutcome = terminalOutcome && run.state === "failed";
|
|
const interruptedOutcome = terminalOutcome && run.state === "interrupted";
|
|
const priorPhaseCompleted = terminal(run)
|
|
? index < furthestEventPhase
|
|
: index < currentPhase;
|
|
const complete = !confirmationUnknown && !confirmationSkipped && !terminalOutcome
|
|
&& (phase === "awaiting_confirmation"
|
|
? confirmationWasReceived
|
|
: run.state === "succeeded" || priorPhaseCompleted);
|
|
const current = index === currentPhase && !terminal(run);
|
|
const label = phase === "awaiting_confirmation"
|
|
? confirmationSkipped
|
|
? t("Confirmation not required")
|
|
: confirmationUnknown
|
|
? t("Checking confirmation")
|
|
: confirmationWasReceived
|
|
? phaseLabels[phase].completed
|
|
: current
|
|
? phaseLabels[phase].current
|
|
: complete
|
|
? phaseLabels[phase].completed
|
|
: phaseLabels[phase].pending
|
|
: terminalOutcome
|
|
? `${stateLabel(run)[0].toUpperCase()}${stateLabel(run).slice(1)}`
|
|
: complete
|
|
? phaseLabels[phase].completed
|
|
: current
|
|
? phaseLabels[phase].current
|
|
: phaseLabels[phase].pending;
|
|
const status = terminalOutcome
|
|
? run.state
|
|
: confirmationSkipped
|
|
? t("not required")
|
|
: complete
|
|
? "completed"
|
|
: current
|
|
? t("in progress")
|
|
: "pending";
|
|
return (
|
|
<li
|
|
key={phase}
|
|
aria-current={current ? "step" : undefined}
|
|
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 ? (
|
|
<Minus aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
|
) : failedOutcome ? (
|
|
<X aria-hidden="true" className="size-3.5 text-destructive" />
|
|
) : interruptedOutcome ? (
|
|
<AlertTriangle aria-hidden="true" className="size-3.5 text-amber-600" />
|
|
) : terminalOutcome ? (
|
|
<Minus aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
|
) : complete ? (
|
|
<CheckCircle2 aria-hidden="true" className="size-3.5 text-[oklch(var(--success))]" />
|
|
) : current ? (
|
|
<LoaderCircle aria-hidden="true" className="size-3.5 animate-spin text-amber-600" />
|
|
) : (
|
|
<Circle aria-hidden="true" className="size-3.5" />
|
|
)}
|
|
{t(label)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
</section>
|
|
|
|
<div className="mt-5 grid grid-cols-3 gap-2">
|
|
{(["tables", "columns", "relationships"] as const).map((name) => (
|
|
<div key={name} className="rounded-md border border-border bg-muted/25 px-3 py-2">
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{name}</p>
|
|
<p className="mt-1 text-lg font-semibold tabular-nums">{run.counts[name] ?? ""}</p>
|
|
</div>
|
|
))}
|
|
</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={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">{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}`}>{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">
|
|
{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">
|
|
{t(run.errorMessage)}
|
|
</div>
|
|
) : null}
|
|
|
|
<section className="mt-5" aria-label={t("Synchronization log")}>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<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}>
|
|
{t("Jump to latest")}</Button>
|
|
) : null}
|
|
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={eventLogRef}
|
|
role="log"
|
|
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">{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(getLocale())}</span>{t(event.message)}
|
|
</p>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{historySection}
|
|
</>
|
|
) : (
|
|
<>
|
|
<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 ? 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" /> {t("Retry run")}</Button>
|
|
) : null}
|
|
</div>
|
|
{historySection}
|
|
</>
|
|
)}
|
|
</FleetLedgerDrawer>
|
|
);
|
|
}
|