mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
feat(ui): lead the Lens investigation detail with a run report
Status, headline, progress or failure, then cost, duration, coverage and issues for the selected run. The step log folds into a collapsed activity log and budget and schedule move under the title. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8d2511419f
commit
a43f111abe
8 changed files with 296 additions and 248 deletions
|
|
@ -1,125 +1,78 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useNow } from "@/hooks/useNow";
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { analysisFraction, analysisPace, analysisProgress, remainingLabel } from "../model/progress";
|
||||||
analysisElapsed,
|
|
||||||
analysisFraction,
|
|
||||||
analysisPace,
|
|
||||||
analysisProgress,
|
|
||||||
analysisStages,
|
|
||||||
remainingLabel,
|
|
||||||
stageDurations,
|
|
||||||
} from "../model/progress";
|
|
||||||
import { useProgressSamples } from "./useProgressSamples";
|
import { useProgressSamples } from "./useProgressSamples";
|
||||||
import { durationText } from "../model/format";
|
|
||||||
import { type Job } from "../model/types";
|
import { type Job } from "../model/types";
|
||||||
import { cn } from "@/lib/cva.config";
|
|
||||||
|
|
||||||
const steps = ["review runs", "find patterns", "check evidence"];
|
const STAGES = ["Review runs", "Find patterns", "Check evidence"];
|
||||||
const markers = { done: "✓", active: "▸", todo: "·" };
|
|
||||||
const blocks = 32;
|
|
||||||
|
|
||||||
function stageState(index: number, current: number): keyof typeof markers {
|
function stageState(index: number, current: number) {
|
||||||
if (index < current) return "done";
|
if (index < current) return "done";
|
||||||
return index === current ? "active" : "todo";
|
return index === current ? "active" : "todo";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvestigationProgress({ job, onCancel }: { job: Job; onCancel?: () => void }) {
|
export function InvestigationProgress({ job, now, onCancel }: { job: Job; now: number; onCancel?: () => void }) {
|
||||||
const now = useNow(1000);
|
|
||||||
const progress = analysisProgress(job);
|
const progress = analysisProgress(job);
|
||||||
const fraction = analysisFraction(progress);
|
const percent = Math.round(analysisFraction(progress) * 100);
|
||||||
const samples = useProgressSamples(progress);
|
const samples = useProgressSamples(progress);
|
||||||
const pace = analysisPace(samples, now);
|
const { secondsLeft } = analysisPace(samples, now);
|
||||||
const percent = Math.round(fraction * 100);
|
|
||||||
const queued = progress.step < 0;
|
const queued = progress.step < 0;
|
||||||
const counts = analysisStages(job);
|
|
||||||
const durations = stageDurations(samples, job.created_at, now);
|
|
||||||
const filled = Math.round(fraction * blocks);
|
|
||||||
const stats = [
|
|
||||||
["eta", remainingLabel(pace.secondsLeft)],
|
|
||||||
["rate", pace.perMinute === null ? "–" : `${Math.round(pace.perMinute)}/min`],
|
|
||||||
["elapsed", analysisElapsed(job.created_at, now)],
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Analysis progress" className="space-y-3 rounded-md border bg-muted/40 px-4 py-3 text-sm">
|
<section aria-label="Analysis progress" className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<span className="truncate" role="status">
|
<p role="status" className="min-w-0 text-sm">
|
||||||
<span className="font-medium">{progress.title}</span>
|
<span className="font-medium">{progress.title}</span>
|
||||||
{!queued && <span className="text-muted-foreground"> · {progress.detail}</span>}
|
<span className="text-muted-foreground"> · {progress.detail}</span>
|
||||||
|
</p>
|
||||||
|
<span className="shrink-0 text-sm tabular-nums text-muted-foreground">
|
||||||
|
{queued ? "" : `${percent}% · ${remainingLabel(secondsLeft)} left`}
|
||||||
</span>
|
</span>
|
||||||
{onCancel && (
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={onCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-2xl space-y-1.5 pl-3">
|
<div
|
||||||
<div className="flex items-center gap-2">
|
role="progressbar"
|
||||||
<span aria-hidden="true" className="text-muted-foreground">
|
aria-label="Investigation progress"
|
||||||
[
|
aria-valuemin={0}
|
||||||
</span>
|
aria-valuemax={100}
|
||||||
<div
|
aria-valuenow={percent}
|
||||||
role="progressbar"
|
aria-valuetext={queued ? progress.title : `${progress.title}: ${progress.detail}`}
|
||||||
aria-label="Investigation progress"
|
className="h-1.5 overflow-hidden rounded-full bg-muted"
|
||||||
aria-valuemin={0}
|
>
|
||||||
aria-valuemax={100}
|
<div
|
||||||
aria-valuenow={percent}
|
data-state={queued ? "queued" : "running"}
|
||||||
aria-valuetext={queued ? progress.title : `${progress.title}: ${progress.detail}`}
|
className="h-full rounded-full bg-primary transition-[width] duration-500 data-[state=queued]:w-1/4 data-[state=queued]:animate-pulse data-[state=queued]:bg-muted-foreground/40"
|
||||||
className="flex h-3.5 min-w-0 flex-1 gap-px"
|
style={queued ? undefined : { width: `${percent}%` }}
|
||||||
>
|
/>
|
||||||
{Array.from({ length: blocks }, (_, index) => (
|
</div>
|
||||||
<span
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
key={index}
|
<ol aria-label="Analysis stages" className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
data-state={index < filled ? "active" : "inactive"}
|
{STAGES.map((label, index) => {
|
||||||
className={cn(
|
|
||||||
"flex-1",
|
|
||||||
index < filled ? "bg-foreground" : "bg-foreground/10",
|
|
||||||
queued && index < blocks / 3 && "motion-safe:animate-pulse bg-foreground/30",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span aria-hidden="true" className="text-muted-foreground">
|
|
||||||
]
|
|
||||||
</span>
|
|
||||||
<span className="w-10 text-right tabular-nums">{percent}%</span>
|
|
||||||
</div>
|
|
||||||
<ol aria-label="Analysis stages" className="space-y-0.5">
|
|
||||||
{steps.map((label, index) => {
|
|
||||||
const state = stageState(index, progress.step);
|
const state = stageState(index, progress.step);
|
||||||
const { done, total } = counts[index];
|
|
||||||
const seconds = durations[index];
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={label}
|
key={label}
|
||||||
data-state={state}
|
data-state={state}
|
||||||
aria-current={state === "active" ? "step" : undefined}
|
aria-current={state === "active" ? "step" : undefined}
|
||||||
className="grid grid-cols-[1rem_minmax(0,9rem)_6rem_auto] items-baseline gap-2 tabular-nums data-[state=done]:text-foreground data-[state=active]:font-medium data-[state=active]:text-foreground data-[state=todo]:text-muted-foreground"
|
className="flex items-center gap-1.5 text-muted-foreground data-[state=active]:font-medium data-[state=active]:text-foreground"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">{markers[state]}</span>
|
<span
|
||||||
<span className="truncate">{label}</span>
|
data-state={state}
|
||||||
<span className="text-right text-muted-foreground">
|
className="flex size-5 items-center justify-center rounded-full border tabular-nums data-[state=active]:border-primary data-[state=active]:text-primary data-[state=done]:border-transparent data-[state=done]:bg-primary data-[state=done]:text-primary-foreground"
|
||||||
{state === "todo" || !total ? "–" : `${Math.min(done, total)}/${total}`}
|
>
|
||||||
|
{state === "done" ? <Check className="size-3" /> : index + 1}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">{seconds === null ? "" : durationText(seconds)}</span>
|
{label}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
<div className="flex flex-wrap gap-x-5 gap-y-1 pt-1 tabular-nums">
|
{onCancel && (
|
||||||
{queued ? (
|
<Button variant="ghost" size="xs" onClick={onCancel}>
|
||||||
<span className="text-muted-foreground">{progress.detail}</span>
|
Cancel
|
||||||
) : (
|
</Button>
|
||||||
stats.map(([key, value]) => (
|
)}
|
||||||
<span key={key}>
|
|
||||||
<span className="text-muted-foreground">{key}</span> {value}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -736,6 +736,25 @@ it("shows the actual saved failure and run context without opening backend logs"
|
||||||
expect(failure.queryByText(/find the error in proxy and worker logs/)).not.toBeInTheDocument();
|
expect(failure.queryByText(/find the error in proxy and worker logs/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports what the selected run found, cost, and covered", async () => {
|
||||||
|
testQueryClient.clear();
|
||||||
|
const coverage = { ...lens.jobs[0].coverage, selected: 40, screened: 38, partial: 2 };
|
||||||
|
const job = { ...lens.jobs[0], cost: 1.25, coverage, finished_at: "2026-09-30T10:03:05Z" };
|
||||||
|
proxy.get.mockImplementation(async (path) => {
|
||||||
|
if (path === "/lens") return { lenses: [{ ...lens, jobs: [job] }], workers: [], tracing_enabled: true };
|
||||||
|
if (path === "/lens/lens/runs") return [job];
|
||||||
|
return { data: [] };
|
||||||
|
});
|
||||||
|
renderWithProviders(<InvestigationsView readOnly />);
|
||||||
|
const report = within(await screen.findByRole("region", { name: "Run report" }));
|
||||||
|
expect(report.getByRole("heading", { name: "Found 1 issue and 1 pattern across 38 runs" })).toBeVisible();
|
||||||
|
const stat = (label: string) => report.getByText(label).parentElement;
|
||||||
|
expect(stat("Cost")).toHaveTextContent("$1.25");
|
||||||
|
expect(stat("Duration")).toHaveTextContent("3m 5s");
|
||||||
|
expect(stat("Runs reviewed")).toHaveTextContent("38 / 402 partial");
|
||||||
|
expect(stat("Issues")).toHaveTextContent("11 high priority");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a finding open to retry when its update fails", async () => {
|
it("keeps a finding open to retry when its update fails", async () => {
|
||||||
window.history.replaceState({}, "", "/lens/");
|
window.history.replaceState({}, "", "/lens/");
|
||||||
testQueryClient.clear();
|
testQueryClient.clear();
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/cva.config";
|
import { cn } from "@/lib/cva.config";
|
||||||
|
|
||||||
import { modelsUsed, stepLine, windowLabel } from "../model/inbox";
|
import { stepLine } from "../model/inbox";
|
||||||
import type { Job } from "../model/types";
|
import type { Job } from "../model/types";
|
||||||
|
|
||||||
const STEP_MARK = { model: "✓", stage: "▸", error: "!" } as const;
|
const STEP_MARK = { model: "✓", stage: "▸", error: "!" } as const;
|
||||||
|
|
@ -16,57 +13,29 @@ const STEP_TONE = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function StepFeed({ job }: { job: Job }) {
|
export function StepFeed({ job }: { job: Job }) {
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
const steps = job.steps ?? [];
|
const steps = job.steps ?? [];
|
||||||
const models = modelsUsed(steps);
|
|
||||||
const calls = steps.filter((s) => s.kind === "model").length;
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Live investigation steps" className="rounded-md border bg-background font-mono text-xs">
|
<ol
|
||||||
<button
|
aria-label="Investigation steps"
|
||||||
type="button"
|
className="max-h-56 space-y-0.5 overflow-y-auto font-mono text-xs"
|
||||||
onClick={() => setOpen(!open)}
|
data-testid="step-feed"
|
||||||
aria-expanded={open}
|
>
|
||||||
className="flex w-full flex-wrap items-center justify-between gap-x-4 gap-y-1 px-3 py-2 text-left hover:bg-muted/40"
|
{steps.length === 0 && <li className="text-muted-foreground">Waiting for the worker to start…</li>}
|
||||||
>
|
{[...steps].reverse().map((step, index) => (
|
||||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
|
<li
|
||||||
<span className="font-semibold text-foreground">
|
key={`${step.at}-${index}`}
|
||||||
{job.trigger === "manual" ? "manual run" : "scheduled run"}
|
className="grid grid-cols-[1rem_4.5rem_minmax(0,1fr)_auto] items-baseline gap-2"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className={STEP_TONE[step.kind]}>
|
||||||
|
{STEP_MARK[step.kind]}
|
||||||
</span>
|
</span>
|
||||||
<span>{windowLabel(job)}</span>
|
<span className="tabular-nums text-muted-foreground">
|
||||||
<span>
|
{new Date(step.at).toLocaleTimeString(undefined, { hour12: false })}
|
||||||
{calls} model {calls === 1 ? "call" : "calls"} · ${job.cost.toFixed(4)}
|
|
||||||
</span>
|
</span>
|
||||||
{models.length > 0 && (
|
<span className={cn("truncate", STEP_TONE[step.kind])}>{stepLine(step)}</span>
|
||||||
<span data-testid="step-feed-models">
|
<span className="truncate text-muted-foreground">{step.model}</span>
|
||||||
using <span className="text-foreground">{models.join(", ")}</span>
|
</li>
|
||||||
</span>
|
))}
|
||||||
)}
|
</ol>
|
||||||
</span>
|
|
||||||
<ChevronDown className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<ol className="max-h-56 space-y-0.5 overflow-y-auto border-t px-3 py-2" data-testid="step-feed">
|
|
||||||
{steps.length === 0 && <li className="text-muted-foreground">Waiting for the worker to start…</li>}
|
|
||||||
{[...steps].reverse().map((step, index) => (
|
|
||||||
<li
|
|
||||||
key={`${step.at}-${index}`}
|
|
||||||
className={cn(
|
|
||||||
"grid grid-cols-[1rem_4.5rem_minmax(0,1fr)_auto] items-baseline gap-2",
|
|
||||||
index === 0 && "motion-safe:animate-in motion-safe:fade-in",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className={STEP_TONE[step.kind]}>
|
|
||||||
{index === 0 && step.kind === "stage" ? "▸" : STEP_MARK[step.kind]}
|
|
||||||
</span>
|
|
||||||
<span className="tabular-nums text-muted-foreground">
|
|
||||||
{new Date(step.at).toLocaleTimeString(undefined, { hour12: false })}
|
|
||||||
</span>
|
|
||||||
<span className={cn("truncate", STEP_TONE[step.kind])}>{stepLine(step)}</span>
|
|
||||||
<span className="truncate text-muted-foreground">{step.model}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,9 @@ import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { RunsTab } from "./RunsTab";
|
import { RunsTab } from "./RunsTab";
|
||||||
import { InvestigationProgress } from "../InvestigationProgress";
|
|
||||||
import { StepFeed } from "../StepFeed";
|
|
||||||
import { InvestigationSummary } from "./InvestigationSummary";
|
import { InvestigationSummary } from "./InvestigationSummary";
|
||||||
import { InvestigationFailure } from "./InvestigationFailure";
|
import { RunReport } from "./RunReport";
|
||||||
import { scopeLabel } from "../../model/format";
|
|
||||||
import type { OwnedFinding } from "../../model/inbox";
|
import type { OwnedFinding } from "../../model/inbox";
|
||||||
import { activeJob } from "../../model/status";
|
|
||||||
import { type Finding, type Lens } from "../../model/types";
|
import { type Finding, type Lens } from "../../model/types";
|
||||||
import { FindingPanel } from "../FindingDetails";
|
import { FindingPanel } from "../FindingDetails";
|
||||||
import { useSectionRoute } from "../../route";
|
import { useSectionRoute } from "../../route";
|
||||||
|
|
@ -44,21 +40,23 @@ export function InvestigationDetail({
|
||||||
const { section, setSection } = useSectionRoute();
|
const { section, setSection } = useSectionRoute();
|
||||||
const snapshot = useRunSnapshot(lens);
|
const snapshot = useRunSnapshot(lens);
|
||||||
const { job, batchId, batchSettings, batchFindings, missingSnapshot } = snapshot;
|
const { job, batchId, batchSettings, batchFindings, missingSnapshot } = snapshot;
|
||||||
const active = activeJob(lens.jobs);
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<section className="min-w-0 space-y-5">
|
<section className="min-w-0 space-y-5">
|
||||||
<div className="flex flex-wrap justify-between gap-3">
|
<div className="flex flex-wrap justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h2 className="text-lg font-semibold">{lens.settings.name}</h2>
|
<h2 className="text-lg font-semibold">{lens.settings.name}</h2>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{scopeLabel(lens.settings)}</p>
|
<InvestigationSummary lens={lens} />
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && <InvestigationActions lens={lens} ready={ready} busy={busy} {...intents} />}
|
{!readOnly && <InvestigationActions lens={lens} ready={ready} busy={busy} {...intents} />}
|
||||||
</div>
|
</div>
|
||||||
<InvestigationSummary lens={lens} connected={connected} />
|
<RunReport
|
||||||
{active && <InvestigationProgress key={active.id} job={active} onCancel={readOnly ? undefined : onCancelRun} />}
|
job={job}
|
||||||
{active && <StepFeed job={active} />}
|
findings={job?.findings}
|
||||||
{job?.error && <InvestigationFailure job={job} connected={connected} />}
|
connected={connected}
|
||||||
|
picker={<RunPicker lens={lens} job={job} />}
|
||||||
|
onCancel={readOnly ? undefined : onCancelRun}
|
||||||
|
/>
|
||||||
<Tabs value={section} onValueChange={setSection} key={lens.id}>
|
<Tabs value={section} onValueChange={setSection} key={lens.id}>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b">
|
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b">
|
||||||
<TabsList variant="line">
|
<TabsList variant="line">
|
||||||
|
|
@ -67,7 +65,6 @@ export function InvestigationDetail({
|
||||||
<TabsTrigger value="runs">Runs</TabsTrigger>
|
<TabsTrigger value="runs">Runs</TabsTrigger>
|
||||||
<TabsTrigger value="activity">History</TabsTrigger>
|
<TabsTrigger value="activity">History</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{section !== "activity" && <RunPicker lens={lens} job={job} />}
|
|
||||||
</div>
|
</div>
|
||||||
{snapshot.error && (
|
{snapshot.error && (
|
||||||
<p role="alert" className="text-sm text-destructive">
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import type { ComponentProps } from "react";
|
|
||||||
import { runTime } from "../../model/format";
|
|
||||||
import { type Job } from "../../model/types";
|
|
||||||
import { cn } from "@/lib/cva.config";
|
|
||||||
|
|
||||||
export type InvestigationFailureProps = ComponentProps<"div"> & {
|
|
||||||
job: Job;
|
|
||||||
connected: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function InvestigationFailure({ job, connected, className, ...props }: InvestigationFailureProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...props}
|
|
||||||
data-slot="investigation-failure"
|
|
||||||
role="alert"
|
|
||||||
className={cn("space-y-2 rounded-md border border-destructive/20 p-3 text-sm", className)}
|
|
||||||
>
|
|
||||||
<p className="font-medium text-destructive">This investigation did not finish</p>
|
|
||||||
<pre className="whitespace-pre-wrap break-words font-mono text-xs" aria-label="Investigation error">
|
|
||||||
{job.error}
|
|
||||||
</pre>
|
|
||||||
<details open>
|
|
||||||
<summary className="cursor-pointer text-xs text-muted-foreground">Run details</summary>
|
|
||||||
<dl className="mt-2 space-y-1 text-xs text-muted-foreground">
|
|
||||||
<div>
|
|
||||||
<dt className="inline">Run: </dt>
|
|
||||||
<dd className="inline">{job.id}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="inline">Model: </dt>
|
|
||||||
<dd className="inline">{job.settings.model}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="inline">Worker: </dt>
|
|
||||||
<dd className="inline">{connected ? "Connected now" : "Not connected"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="inline">Started: </dt>
|
|
||||||
<dd className="inline">{runTime(job.created_at)}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +1,23 @@
|
||||||
import type { ComponentProps } from "react";
|
"use client";
|
||||||
import { NextCheck } from "./JobMeta";
|
|
||||||
import { lensStatus } from "../../model/status";
|
import { useNow } from "@/hooks/useNow";
|
||||||
import { runTime } from "../../model/format";
|
import { durationLabel, money, scopeLabel } from "../../model/format";
|
||||||
|
import { nextCheckStatus } from "../../model/status";
|
||||||
import { type Lens } from "../../model/types";
|
import { type Lens } from "../../model/types";
|
||||||
import { cn } from "@/lib/cva.config";
|
|
||||||
|
|
||||||
export type InvestigationSummaryProps = ComponentProps<"div"> & {
|
export function InvestigationSummary({ lens }: { lens: Lens }) {
|
||||||
lens: Lens;
|
const now = useNow(15000);
|
||||||
connected: boolean;
|
const { settings } = lens;
|
||||||
};
|
const spent = lens.budget_month === new Date(now).toISOString().slice(0, 7) ? lens.spent ?? 0 : 0;
|
||||||
|
const parts = [
|
||||||
export function InvestigationSummary({ lens, connected, className, ...props }: InvestigationSummaryProps) {
|
settings.enabled ? `Every ${durationLabel(settings.interval_minutes)}` : "One-off",
|
||||||
const lastCompleted = lens.jobs.find((job) => job.status === "completed");
|
nextCheckStatus(lens, now),
|
||||||
const lastSuccess = lastCompleted?.finished_at ?? lens.last_scan_at;
|
`${money(spent)} of ${money(settings.monthly_budget ?? 100)} this month`,
|
||||||
const spent = lens.budget_month === new Date().toISOString().slice(0, 7) ? lens.spent ?? 0 : 0;
|
].filter(Boolean);
|
||||||
return (
|
return (
|
||||||
<div
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{...props}
|
<span className="font-mono">{scopeLabel(settings)}</span>
|
||||||
data-slot="investigation-summary"
|
{parts.map((part) => ` · ${part}`)}
|
||||||
className={cn("flex flex-wrap gap-x-8 gap-y-3 border-y py-3 text-xs text-muted-foreground", className)}
|
</p>
|
||||||
>
|
|
||||||
<span>
|
|
||||||
Latest run:{" "}
|
|
||||||
<strong
|
|
||||||
data-state={lens.jobs[0]?.status === "failed" ? "failed" : "ok"}
|
|
||||||
className="font-medium data-[state=failed]:text-destructive data-[state=ok]:text-foreground"
|
|
||||||
>
|
|
||||||
{lensStatus(lens, connected)}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Last success: <span className="text-foreground">{lastSuccess ? runTime(lastSuccess) : "Not yet"}</span>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
This month:{" "}
|
|
||||||
<span className="text-foreground">
|
|
||||||
${spent.toFixed(3)} / ${lens.settings.monthly_budget ?? 100}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{lens.settings.enabled && (
|
|
||||||
<span>
|
|
||||||
Monitoring every {lens.settings.interval_minutes} minutes
|
|
||||||
<NextCheck lens={lens} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { useNow } from "@/hooks/useNow";
|
||||||
|
|
||||||
|
import { analysisElapsed } from "../../model/progress";
|
||||||
|
import { money } from "../../model/format";
|
||||||
|
import { modelsUsed, shortTime, windowLabel } from "../../model/inbox";
|
||||||
|
import type { Finding, Job } from "../../model/types";
|
||||||
|
import { InvestigationProgress } from "../InvestigationProgress";
|
||||||
|
import { StepFeed } from "../StepFeed";
|
||||||
|
|
||||||
|
export interface RunReportProps {
|
||||||
|
readonly job: Job | undefined;
|
||||||
|
/** Null when the run predates saved result snapshots. */
|
||||||
|
readonly findings: readonly Finding[] | null | undefined;
|
||||||
|
readonly connected: boolean;
|
||||||
|
readonly picker: ReactNode;
|
||||||
|
readonly onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<Job["status"], string> = {
|
||||||
|
queued: "Queued",
|
||||||
|
running: "Running",
|
||||||
|
completed: "Completed",
|
||||||
|
failed: "Failed",
|
||||||
|
cancelled: "Cancelled",
|
||||||
|
};
|
||||||
|
|
||||||
|
const plural = (count: number, noun: string) => `${count.toLocaleString()} ${noun}${count === 1 ? "" : "s"}`;
|
||||||
|
const isActive = (job: Job) => job.status === "queued" || job.status === "running";
|
||||||
|
|
||||||
|
function headline(job: Job, findings: readonly Finding[] | null | undefined, connected: boolean): string {
|
||||||
|
const runs = plural(job.coverage?.screened ?? 0, "run");
|
||||||
|
const issues = findings?.filter((f) => f.kind === "issue").length ?? 0;
|
||||||
|
const patterns = findings?.filter((f) => f.kind === "pattern").length ?? 0;
|
||||||
|
switch (job.status) {
|
||||||
|
case "queued":
|
||||||
|
return connected ? "Waiting for the analyzer to pick this up" : "Waiting for an analyzer to connect";
|
||||||
|
case "running":
|
||||||
|
return "Investigating now";
|
||||||
|
case "failed":
|
||||||
|
return "Stopped before it finished";
|
||||||
|
case "cancelled":
|
||||||
|
return `Cancelled after reviewing ${runs}`;
|
||||||
|
case "completed":
|
||||||
|
if (!findings) return `Reviewed ${runs}`;
|
||||||
|
if (issues + patterns === 0) return `Nothing found across ${runs}`;
|
||||||
|
return `Found ${[issues && plural(issues, "issue"), patterns && plural(patterns, "pattern")].filter(Boolean).join(" and ")} across ${runs}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, note }: { label: string; value: string; note?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="mt-1 truncate text-lg font-semibold tabular-nums">{value}</dd>
|
||||||
|
{note && <dd className="truncate text-xs text-muted-foreground">{note}</dd>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverageNote(job: Job): string | undefined {
|
||||||
|
const { partial = 0, unassessable = 0, inconclusive = 0 } = job.coverage ?? {};
|
||||||
|
const notes = [
|
||||||
|
partial && `${partial} partial`,
|
||||||
|
unassessable && `${unassessable} unreadable`,
|
||||||
|
inconclusive && `${inconclusive} inconclusive`,
|
||||||
|
].filter(Boolean);
|
||||||
|
return notes.length ? notes.join(" · ") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueNote(job: Job, high: number): string | undefined {
|
||||||
|
if (isActive(job)) return "When the run finishes";
|
||||||
|
return high ? `${high} high priority` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunStats({ job, findings, now }: { job: Job; findings: readonly Finding[] | null | undefined; now: number }) {
|
||||||
|
const active = isActive(job);
|
||||||
|
const known = job.status === "completed" && findings != null;
|
||||||
|
const calls = (job.steps ?? []).filter((step) => step.kind === "model").length;
|
||||||
|
const { screened = 0, selected = 0 } = job.coverage ?? {};
|
||||||
|
const issues = findings?.filter((f) => f.kind === "issue") ?? [];
|
||||||
|
const high = issues.filter((f) => f.priority === "high").length;
|
||||||
|
const end = job.finished_at ? Date.parse(job.finished_at) : now;
|
||||||
|
return (
|
||||||
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
|
||||||
|
<Stat label="Cost" value={money(job.cost ?? 0)} note={plural(calls, "model call")} />
|
||||||
|
<Stat
|
||||||
|
label="Duration"
|
||||||
|
value={active || job.finished_at ? analysisElapsed(job.created_at, end) : "–"}
|
||||||
|
note={`Started ${shortTime(job.created_at)}`}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Runs reviewed"
|
||||||
|
value={`${screened.toLocaleString()} / ${selected.toLocaleString()}`}
|
||||||
|
note={coverageNote(job)}
|
||||||
|
/>
|
||||||
|
<Stat label="Issues" value={known ? issues.length.toLocaleString() : "–"} note={issueNote(job, high)} />
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunFailure({ job, connected }: { job: Job; connected: boolean }) {
|
||||||
|
return (
|
||||||
|
<div role="alert" className="space-y-2 rounded-md bg-destructive/5 p-3">
|
||||||
|
<pre
|
||||||
|
aria-label="Investigation error"
|
||||||
|
className="whitespace-pre-wrap break-words font-mono text-xs text-destructive"
|
||||||
|
>
|
||||||
|
{job.error}
|
||||||
|
</pre>
|
||||||
|
<p className="flex flex-wrap gap-x-3 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Run <span className="font-mono">{job.id}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Model <span>{job.settings.model}</span>
|
||||||
|
</span>
|
||||||
|
<span>Worker {connected ? "connected now" : "not connected"}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunLog({ job }: { job: Job }) {
|
||||||
|
const models = modelsUsed(job.steps ?? []);
|
||||||
|
return (
|
||||||
|
<Collapsible className="border-t pt-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<CollapsibleTrigger className="group flex items-center gap-1 font-medium text-foreground hover:underline">
|
||||||
|
<ChevronRight className="size-3.5 transition-transform group-data-[panel-open]:rotate-90" />
|
||||||
|
Activity log
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<span className="truncate">
|
||||||
|
{job.trigger === "manual" ? "Manual" : "Scheduled"} · {windowLabel(job)}
|
||||||
|
{models.length > 0 && ` · ${models.join(", ")}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent className="pt-3">
|
||||||
|
<StepFeed job={job} />
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunReport({ job, findings, connected, picker, onCancel }: RunReportProps) {
|
||||||
|
const active = job !== undefined && isActive(job);
|
||||||
|
const now = useNow(active ? 1000 : 60000);
|
||||||
|
if (!job) {
|
||||||
|
return (
|
||||||
|
<section aria-label="Run report" className="flex items-center justify-between gap-3 rounded-lg border p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">No runs yet. Run it now to get the first report.</p>
|
||||||
|
{picker}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section aria-label="Run report" className="space-y-4 rounded-lg border p-4">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p
|
||||||
|
data-state={job.status}
|
||||||
|
className="text-xs font-medium text-muted-foreground data-[state=completed]:text-success data-[state=failed]:text-destructive data-[state=running]:text-info"
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[job.status]}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-0.5 text-base font-semibold">{headline(job, findings, connected)}</h3>
|
||||||
|
</div>
|
||||||
|
{picker}
|
||||||
|
</header>
|
||||||
|
{active && <InvestigationProgress key={job.id} job={job} now={now} onCancel={onCancel} />}
|
||||||
|
{job.error && <RunFailure job={job} connected={connected} />}
|
||||||
|
<RunStats job={job} findings={findings} now={now} />
|
||||||
|
<RunLog job={job} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -89,7 +89,8 @@ const WINDOW_FORMAT: Intl.DateTimeFormatOptions = {
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const shortTime = (iso: string) => new Date(iso).toLocaleString(undefined, WINDOW_FORMAT);
|
||||||
|
|
||||||
export function windowLabel(job: Job): string {
|
export function windowLabel(job: Job): string {
|
||||||
const fmt = (iso: string) => new Date(iso).toLocaleString(undefined, WINDOW_FORMAT);
|
return `${shortTime(job.start)} → ${shortTime(job.end)}`;
|
||||||
return `${fmt(job.start)} → ${fmt(job.end)}`;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue