feat: complete catalog-driven preprocessing
Publish documentation / publish (push) Successful in 2m12s
Publish documentation / publish (push) Successful in 2m12s
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
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 gap-1">
|
||||
<div className="flex items-center justify-between gap-2 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 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 grid-cols-[0.78fr_1.22fr] gap-1.5">
|
||||
<Button type="button" variant="outline" size="xs" className="h-7 text-[9px]" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" size="xs" className="h-7 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 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 grid-cols-[0.78fr_1.22fr] gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 px-2 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 px-2 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user