328 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|