feat(topology): persist comparison runs + history
Save each comparison and let users reload past ones.
- migration 0008: topology_runs (workspace-scoped; full comparison as JSONB).
- cm-db repo::topology_runs (insert / list_recent / get) + regenerated .sqlx.
- cm-api: compare persists best-effort (never loses the LLM result on a DB
hiccup); GET /api/topology-runs (recent) + GET /api/topology-runs/{id}.
Integration test asserts persist → list → get.
- frontend: "Recent comparisons" list on the Compare tab; click to reload a
saved run. e2e p8 green (39 suite); offline build + clippy clean.
Server self-migrates at boot (cm_db::MIGRATOR), so 0008 applies on deploy.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
654ec0f511
commit
7baf2082d0
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { CatalogEntry, TopologyGraph } from "@/lib/api/topology";
|
||||
|
||||
interface RunSummary {
|
||||
id: string;
|
||||
task: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface CompareResult {
|
||||
kind: string;
|
||||
quality: number;
|
||||
@@ -28,6 +34,33 @@ export function TopologyCompare({ catalog }: { catalog: CatalogEntry[] }) {
|
||||
const [cmp, setCmp] = useState<Comparison | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [runs, setRuns] = useState<RunSummary[]>([]);
|
||||
|
||||
async function loadRuns() {
|
||||
try {
|
||||
const res = await fetch("/api/topology-runs");
|
||||
if (res.ok) setRuns((await res.json()) as RunSummary[]);
|
||||
} catch {
|
||||
/* recent list is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadRuns();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function loadRun(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/topology-runs/${id}`);
|
||||
if (res.ok) {
|
||||
const detail = (await res.json()) as { comparison: Comparison };
|
||||
setCmp(detail.comparison);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(kind: string) {
|
||||
setSelected((s) => {
|
||||
@@ -63,6 +96,7 @@ export function TopologyCompare({ catalog }: { catalog: CatalogEntry[] }) {
|
||||
});
|
||||
if (!res.ok) throw new Error(`Compare failed (${res.status})`);
|
||||
setCmp((await res.json()) as Comparison);
|
||||
void loadRuns();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Compare failed");
|
||||
} finally {
|
||||
@@ -120,6 +154,22 @@ export function TopologyCompare({ catalog }: { catalog: CatalogEntry[] }) {
|
||||
{error && <p className="text-sm text-coral">{error}</p>}
|
||||
|
||||
{cmp && <Results cmp={cmp} />}
|
||||
|
||||
{runs.length > 0 && (
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-4">
|
||||
<p className="text-xs font-medium text-muted-foreground">Recent comparisons</p>
|
||||
{runs.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => loadRun(r.id)}
|
||||
className="rounded-lg px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-surface-warm/50"
|
||||
>
|
||||
{r.task}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user