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:
Yujong Lee 2026-10-04 20:57:27 -07:00
parent 8d2511419f
commit a43f111abe
8 changed files with 296 additions and 248 deletions

View file

@ -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>
);

View file

@ -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();

View file

@ -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>
);
}

View file

@ -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">

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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)}`;
}