Files
ThothII/frontend/src/shell/WorkspacePreprocessingControl.tsx
T

328 lines
14 KiB
TypeScript

import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Check, CircleAlert, Clock3, LoaderCircle, ShieldCheck, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../components/ui/button";
import { apiErrorMessage } from "../api/client";
import {
clearWorkspacePreprocessing,
runWorkspacePreprocessing,
type WorkspacePreprocessingState,
type WorkspacePreprocessingStatus,
} from "../api/workspace-preprocessing";
const TITLES: Record<WorkspacePreprocessingState, string> = {
ready: "Complete",
required: "Required",
running: "Running",
blocked: "Blocked",
failed: "Failed",
};
type PreprocessingProgress = NonNullable<WorkspacePreprocessingStatus["progress"]>;
const PROGRESS_LABELS: Record<PreprocessingProgress["stage"], string> = {
catalog_snapshot: "Preparing Catalog snapshot",
schema_index: "Building schema and LSH",
evidence: "Indexing Evidence",
finalizing: "Finalizing",
};
const INITIAL_PROGRESS: PreprocessingProgress = {
stage: "catalog_snapshot",
step: 1,
totalSteps: 4,
};
function stateClasses(state: WorkspacePreprocessingState | undefined): string {
switch (state) {
case "ready": return "text-emerald-700";
case "failed": return "text-destructive";
case "required":
case "running":
case "blocked": return "text-amber-700";
default: return "text-muted-foreground";
}
}
function StatusIcon({ state }: { state?: WorkspacePreprocessingState }) {
if (state === "running") {
return <LoaderCircle aria-hidden="true" className="size-3.5 animate-spin motion-reduce:animate-none" />;
}
if (state === "ready") return <Check aria-hidden="true" className="size-3.5" />;
if (state === "required") return <Clock3 aria-hidden="true" className="size-3.5" />;
return <CircleAlert aria-hidden="true" className="size-3.5" />;
}
function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
const step = Math.max(1, Math.min(progress.step, progress.totalSteps));
return (
<div className="col-span-2 mt-1 grid min-w-0 max-w-full gap-1">
<div className="flex min-w-0 max-w-full items-center justify-between gap-[0.5rem] text-[9px] leading-none">
<span className="min-w-0 truncate font-medium text-foreground">
{PROGRESS_LABELS[progress.stage]}
</span>
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
{step} / {progress.totalSteps}
</span>
</div>
<div
role="progressbar"
aria-label="Preprocessing progress"
aria-valuemin={1}
aria-valuemax={progress.totalSteps}
aria-valuenow={step}
aria-valuetext={`${PROGRESS_LABELS[progress.stage]}, stage ${step} of ${progress.totalSteps}`}
className="grid grid-cols-4 gap-1"
>
{Array.from({ length: progress.totalSteps }, (_, index) => (
<span
key={index}
aria-hidden="true"
className={[
"h-1 rounded-full",
index < step - 1
? "bg-emerald-600/70"
: index === step - 1
? "bg-amber-500"
: "bg-muted",
].join(" ")}
/>
))}
</div>
</div>
);
}
function formatFinishedAt(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "Unavailable";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(parsed);
}
function Diagnostic({ status }: { status: WorkspacePreprocessingStatus }) {
if (status.state !== "blocked" && status.state !== "failed") return null;
return (
<div
className={[
"mt-2 grid gap-2 rounded-md border p-2.5 text-[10px] leading-[1.45]",
status.state === "failed"
? "border-destructive/25 bg-destructive/[0.055]"
: "border-amber-500/35 bg-amber-500/[0.07]",
].join(" ")}
>
<div className="grid gap-0.5">
<strong className="font-semibold text-foreground">Why this happened</strong>
<span className="text-muted-foreground">{status.reason}</span>
</div>
<div className="grid gap-0.5">
<strong className="font-semibold text-foreground">Next step</strong>
<span className="text-muted-foreground">{status.nextStep}</span>
</div>
{status.state === "failed" && status.lastFailure ? (
<details className="overflow-hidden rounded-md border border-border bg-card">
<summary className="cursor-pointer px-2 py-1.5 font-semibold text-foreground">
Last run details
</summary>
<dl className="grid gap-1.5 border-t border-border px-2 py-2">
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-1.5">
<dt className="text-muted-foreground">Failed stage</dt>
<dd className="min-w-0 break-all font-mono font-medium text-foreground">
{status.lastFailure.stage}
</dd>
</div>
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-1.5">
<dt className="text-muted-foreground">Error code</dt>
<dd className="min-w-0 break-all font-mono font-medium text-foreground">
{status.lastFailure.errorCode}
</dd>
</div>
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-1.5">
<dt className="text-muted-foreground">Finished</dt>
<dd className="font-medium text-foreground">
{formatFinishedAt(status.lastFailure.finishedAt)}
</dd>
</div>
</dl>
<p className="border-t border-border px-2 py-1.5 text-muted-foreground">
Only the latest diagnostic is retained. Service log: <code>docker compose logs core</code>.
</p>
</details>
) : (
<p className="border-t border-border pt-2 text-muted-foreground">
No preprocessing log is available because a run did not start.
</p>
)}
</div>
);
}
export function WorkspacePreprocessingControl({
workspaceId,
status,
loading,
unavailable,
canManage,
}: {
workspaceId?: string;
status?: WorkspacePreprocessingStatus;
loading: boolean;
unavailable: boolean;
canManage: boolean;
}) {
const queryClient = useQueryClient();
const [confirmingClear, setConfirmingClear] = useState(false);
const mutation = useMutation({
mutationFn: async () => {
if (!workspaceId) throw new Error("workspace unavailable");
return await runWorkspacePreprocessing(workspaceId);
},
onMutate: () => {
setConfirmingClear(false);
if (!workspaceId || !status) return;
queryClient.setQueryData<WorkspacePreprocessingStatus>(
["workspace-preprocessing", workspaceId],
{
...status,
state: "running",
actionable: false,
detail: "Preparing the PostgreSQL Catalog snapshot.",
progress: INITIAL_PROGRESS,
},
);
},
onError: (error) => toast.error(apiErrorMessage(error)),
onSettled: async () => {
if (!workspaceId) return;
await queryClient.invalidateQueries({ queryKey: ["workspace-preprocessing", workspaceId] });
},
});
const clearMutation = useMutation({
mutationFn: async () => {
if (!workspaceId) throw new Error("workspace unavailable");
return await clearWorkspacePreprocessing(workspaceId);
},
onSuccess: () => {
setConfirmingClear(false);
toast.success("Reference vectors and LSH cleared. Memory preserved.");
},
onError: (error) => toast.error(apiErrorMessage(error)),
onSettled: async () => {
if (!workspaceId) return;
await queryClient.invalidateQueries({ queryKey: ["workspace-preprocessing", workspaceId] });
},
});
const state = mutation.isPending ? "running" : status?.state;
const progress = state === "running" ? status?.progress ?? INITIAL_PROGRESS : undefined;
const title = state ? TITLES[state] : unavailable ? "Status unavailable" : "Checking preprocessing";
const detail = status?.detail
?? (workspaceId ? "Reading the current workspace state." : "Select a workspace first.");
const actionable = Boolean(
workspaceId
&& status?.actionable
&& canManage
&& !mutation.isPending,
);
const clearable = Boolean(
workspaceId
&& status?.clearable
&& canManage
&& !mutation.isPending
&& !clearMutation.isPending,
);
const actionLabel = state === "ready" ? "Run again" : state === "failed" ? "Retry" : "Run";
return (
<section
aria-label="Workspace preprocessing"
aria-busy={state === "running" || loading || clearMutation.isPending}
className="mt-0.5 min-w-0 max-w-full border-t border-border/90 px-0.5 pt-2"
>
<p className="mb-2 px-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.08em] text-primary">
Preprocessing
</p>
{confirmingClear ? (
<div className="rounded-lg border border-destructive/25 bg-destructive/[0.045] p-2.5">
<div className="grid grid-cols-[1.5rem_minmax(0,1fr)] items-center gap-1.5">
<span className="grid size-6 place-items-center rounded-md bg-destructive/10 text-destructive">
<AlertTriangle aria-hidden="true" className="size-3.5" />
</span>
<span className="grid gap-0.5">
<strong className="text-[10px] font-semibold text-foreground">Clear derived data?</strong>
<small className="text-[8px] leading-tight text-muted-foreground">
The workspace will require preprocessing.
</small>
</span>
</div>
<div className="my-2 grid gap-1.5 border-y border-destructive/15 py-2 text-[8px]">
<div className="grid grid-cols-[1.2rem_minmax(0,1fr)] items-center gap-1.5">
<span className="grid size-[18px] place-items-center rounded bg-destructive/10 text-destructive">
<Trash2 aria-hidden="true" className="size-3" />
</span>
<span><strong className="block text-foreground">Clear</strong><small className="text-muted-foreground">Schema/Evidence vectors, LSH and derived checkpoints</small></span>
</div>
<div className="grid grid-cols-[1.2rem_minmax(0,1fr)] items-center gap-1.5">
<span className="grid size-[18px] place-items-center rounded bg-emerald-600/10 text-emerald-700">
<ShieldCheck aria-hidden="true" className="size-3" />
</span>
<span><strong className="block text-foreground">Keep</strong><small className="text-muted-foreground">Memory and solved questions</small></span>
</div>
</div>
<div className="grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
<Button type="button" variant="outline" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
Cancel
</Button>
<Button type="button" variant="destructive" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => clearMutation.mutate()}>
{clearMutation.isPending ? <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <Trash2 aria-hidden="true" />}
{clearMutation.isPending ? "Clearing" : "Clear derived data"}
</Button>
</div>
</div>
) : (
<>
<div className="grid min-w-0 max-w-full grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-1.5">
<span className={`grid size-5 place-items-center rounded-full bg-muted ${stateClasses(state)}`}>
<StatusIcon state={state} />
</span>
<div className="grid min-w-0 gap-0.5" role="status" aria-live="polite">
<strong className="text-[11px] font-semibold leading-tight text-foreground">{title}</strong>
{state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={detail}>{detail}</span>}
</div>
{progress && <PhaseProgress progress={progress} />}
<div className="col-span-2 mt-0.5 grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
<Button
type="button"
variant="outline"
size="xs"
className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px] hover:border-destructive/35 hover:bg-destructive/5 hover:text-destructive"
disabled={!clearable}
title={!canManage ? "Database management permission is required" : undefined}
onClick={() => setConfirmingClear(true)}
>
<Trash2 aria-hidden="true" /> Clear
</Button>
<Button
type="button"
variant="outline"
size="xs"
className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px]"
disabled={!actionable}
title={!canManage ? "Database management permission is required" : undefined}
onClick={() => mutation.mutate()}
>
{state === "running" && <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" />}
{state === "running" ? "Running" : actionLabel}
</Button>
</div>
</div>
{status && <Diagnostic status={status} />}
</>
)}
</section>
);
}