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";
|
||||
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
analysisElapsed,
|
||||
analysisFraction,
|
||||
analysisPace,
|
||||
analysisProgress,
|
||||
analysisStages,
|
||||
remainingLabel,
|
||||
stageDurations,
|
||||
} from "../model/progress";
|
||||
import { analysisFraction, analysisPace, analysisProgress, remainingLabel } from "../model/progress";
|
||||
import { useProgressSamples } from "./useProgressSamples";
|
||||
import { durationText } from "../model/format";
|
||||
import { type Job } from "../model/types";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const steps = ["review runs", "find patterns", "check evidence"];
|
||||
const markers = { done: "✓", active: "▸", todo: "·" };
|
||||
const blocks = 32;
|
||||
const STAGES = ["Review runs", "Find patterns", "Check evidence"];
|
||||
|
||||
function stageState(index: number, current: number): keyof typeof markers {
|
||||
function stageState(index: number, current: number) {
|
||||
if (index < current) return "done";
|
||||
return index === current ? "active" : "todo";
|
||||
}
|
||||
|
||||
export function InvestigationProgress({ job, onCancel }: { job: Job; onCancel?: () => void }) {
|
||||
const now = useNow(1000);
|
||||
export function InvestigationProgress({ job, now, onCancel }: { job: Job; now: number; onCancel?: () => void }) {
|
||||
const progress = analysisProgress(job);
|
||||
const fraction = analysisFraction(progress);
|
||||
const percent = Math.round(analysisFraction(progress) * 100);
|
||||
const samples = useProgressSamples(progress);
|
||||
const pace = analysisPace(samples, now);
|
||||
const percent = Math.round(fraction * 100);
|
||||
const { secondsLeft } = analysisPace(samples, now);
|
||||
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 (
|
||||
<section aria-label="Analysis progress" className="space-y-3 rounded-md border bg-muted/40 px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate" role="status">
|
||||
<section aria-label="Analysis progress" className="space-y-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p role="status" className="min-w-0 text-sm">
|
||||
<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>
|
||||
{onCancel && (
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-w-2xl space-y-1.5 pl-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden="true" className="text-muted-foreground">
|
||||
[
|
||||
</span>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="Investigation progress"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
aria-valuetext={queued ? progress.title : `${progress.title}: ${progress.detail}`}
|
||||
className="flex h-3.5 min-w-0 flex-1 gap-px"
|
||||
>
|
||||
{Array.from({ length: blocks }, (_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
data-state={index < filled ? "active" : "inactive"}
|
||||
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) => {
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="Investigation progress"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
aria-valuetext={queued ? progress.title : `${progress.title}: ${progress.detail}`}
|
||||
className="h-1.5 overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
<div
|
||||
data-state={queued ? "queued" : "running"}
|
||||
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"
|
||||
style={queued ? undefined : { width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<ol aria-label="Analysis stages" className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
{STAGES.map((label, index) => {
|
||||
const state = stageState(index, progress.step);
|
||||
const { done, total } = counts[index];
|
||||
const seconds = durations[index];
|
||||
return (
|
||||
<li
|
||||
key={label}
|
||||
data-state={state}
|
||||
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 className="truncate">{label}</span>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{state === "todo" || !total ? "–" : `${Math.min(done, total)}/${total}`}
|
||||
<span
|
||||
data-state={state}
|
||||
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 === "done" ? <Check className="size-3" /> : index + 1}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{seconds === null ? "" : durationText(seconds)}</span>
|
||||
{label}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1 pt-1 tabular-nums">
|
||||
{queued ? (
|
||||
<span className="text-muted-foreground">{progress.detail}</span>
|
||||
) : (
|
||||
stats.map(([key, value]) => (
|
||||
<span key={key}>
|
||||
<span className="text-muted-foreground">{key}</span> {value}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{onCancel && (
|
||||
<Button variant="ghost" size="xs" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
window.history.replaceState({}, "", "/lens/");
|
||||
testQueryClient.clear();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { modelsUsed, stepLine, windowLabel } from "../model/inbox";
|
||||
import { stepLine } from "../model/inbox";
|
||||
import type { Job } from "../model/types";
|
||||
|
||||
const STEP_MARK = { model: "✓", stage: "▸", error: "!" } as const;
|
||||
|
|
@ -16,57 +13,29 @@ const STEP_TONE = {
|
|||
} as const;
|
||||
|
||||
export function StepFeed({ job }: { job: Job }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const steps = job.steps ?? [];
|
||||
const models = modelsUsed(steps);
|
||||
const calls = steps.filter((s) => s.kind === "model").length;
|
||||
return (
|
||||
<section aria-label="Live investigation steps" className="rounded-md border bg-background font-mono text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
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"
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">
|
||||
{job.trigger === "manual" ? "manual run" : "scheduled run"}
|
||||
<ol
|
||||
aria-label="Investigation steps"
|
||||
className="max-h-56 space-y-0.5 overflow-y-auto font-mono text-xs"
|
||||
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="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>{windowLabel(job)}</span>
|
||||
<span>
|
||||
{calls} model {calls === 1 ? "call" : "calls"} · ${job.cost.toFixed(4)}
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{new Date(step.at).toLocaleTimeString(undefined, { hour12: false })}
|
||||
</span>
|
||||
{models.length > 0 && (
|
||||
<span data-testid="step-feed-models">
|
||||
using <span className="text-foreground">{models.join(", ")}</span>
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
<span className={cn("truncate", STEP_TONE[step.kind])}>{stepLine(step)}</span>
|
||||
<span className="truncate text-muted-foreground">{step.model}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,9 @@ import { Button } from "@/components/ui/button";
|
|||
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { RunsTab } from "./RunsTab";
|
||||
import { InvestigationProgress } from "../InvestigationProgress";
|
||||
import { StepFeed } from "../StepFeed";
|
||||
import { InvestigationSummary } from "./InvestigationSummary";
|
||||
import { InvestigationFailure } from "./InvestigationFailure";
|
||||
import { scopeLabel } from "../../model/format";
|
||||
import { RunReport } from "./RunReport";
|
||||
import type { OwnedFinding } from "../../model/inbox";
|
||||
import { activeJob } from "../../model/status";
|
||||
import { type Finding, type Lens } from "../../model/types";
|
||||
import { FindingPanel } from "../FindingDetails";
|
||||
import { useSectionRoute } from "../../route";
|
||||
|
|
@ -44,21 +40,23 @@ export function InvestigationDetail({
|
|||
const { section, setSection } = useSectionRoute();
|
||||
const snapshot = useRunSnapshot(lens);
|
||||
const { job, batchId, batchSettings, batchFindings, missingSnapshot } = snapshot;
|
||||
const active = activeJob(lens.jobs);
|
||||
return (
|
||||
<div>
|
||||
<section className="min-w-0 space-y-5">
|
||||
<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>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{scopeLabel(lens.settings)}</p>
|
||||
<InvestigationSummary lens={lens} />
|
||||
</div>
|
||||
{!readOnly && <InvestigationActions lens={lens} ready={ready} busy={busy} {...intents} />}
|
||||
</div>
|
||||
<InvestigationSummary lens={lens} connected={connected} />
|
||||
{active && <InvestigationProgress key={active.id} job={active} onCancel={readOnly ? undefined : onCancelRun} />}
|
||||
{active && <StepFeed job={active} />}
|
||||
{job?.error && <InvestigationFailure job={job} connected={connected} />}
|
||||
<RunReport
|
||||
job={job}
|
||||
findings={job?.findings}
|
||||
connected={connected}
|
||||
picker={<RunPicker lens={lens} job={job} />}
|
||||
onCancel={readOnly ? undefined : onCancelRun}
|
||||
/>
|
||||
<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">
|
||||
<TabsList variant="line">
|
||||
|
|
@ -67,7 +65,6 @@ export function InvestigationDetail({
|
|||
<TabsTrigger value="runs">Runs</TabsTrigger>
|
||||
<TabsTrigger value="activity">History</TabsTrigger>
|
||||
</TabsList>
|
||||
{section !== "activity" && <RunPicker lens={lens} job={job} />}
|
||||
</div>
|
||||
{snapshot.error && (
|
||||
<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";
|
||||
import { NextCheck } from "./JobMeta";
|
||||
import { lensStatus } from "../../model/status";
|
||||
import { runTime } from "../../model/format";
|
||||
"use client";
|
||||
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
import { durationLabel, money, scopeLabel } from "../../model/format";
|
||||
import { nextCheckStatus } from "../../model/status";
|
||||
import { type Lens } from "../../model/types";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export type InvestigationSummaryProps = ComponentProps<"div"> & {
|
||||
lens: Lens;
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
export function InvestigationSummary({ lens, connected, className, ...props }: InvestigationSummaryProps) {
|
||||
const lastCompleted = lens.jobs.find((job) => job.status === "completed");
|
||||
const lastSuccess = lastCompleted?.finished_at ?? lens.last_scan_at;
|
||||
const spent = lens.budget_month === new Date().toISOString().slice(0, 7) ? lens.spent ?? 0 : 0;
|
||||
export function InvestigationSummary({ lens }: { lens: Lens }) {
|
||||
const now = useNow(15000);
|
||||
const { settings } = lens;
|
||||
const spent = lens.budget_month === new Date(now).toISOString().slice(0, 7) ? lens.spent ?? 0 : 0;
|
||||
const parts = [
|
||||
settings.enabled ? `Every ${durationLabel(settings.interval_minutes)}` : "One-off",
|
||||
nextCheckStatus(lens, now),
|
||||
`${money(spent)} of ${money(settings.monthly_budget ?? 100)} this month`,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
data-slot="investigation-summary"
|
||||
className={cn("flex flex-wrap gap-x-8 gap-y-3 border-y py-3 text-xs text-muted-foreground", className)}
|
||||
>
|
||||
<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>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{scopeLabel(settings)}</span>
|
||||
{parts.map((part) => ` · ${part}`)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
export const shortTime = (iso: string) => new Date(iso).toLocaleString(undefined, WINDOW_FORMAT);
|
||||
|
||||
export function windowLabel(job: Job): string {
|
||||
const fmt = (iso: string) => new Date(iso).toLocaleString(undefined, WINDOW_FORMAT);
|
||||
return `${fmt(job.start)} → ${fmt(job.end)}`;
|
||||
return `${shortTime(job.start)} → ${shortTime(job.end)}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue