mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
feat(ui): pick the Lens run report's one next action from a situation table
An ordered rule table reads the latest run into a situation, and lookup tables map each situation to its copy and its single primary action: Stop run, Retry, Raise budget, Connect worker, Review issues, Monitor this, or Run now. Run now moves into the overflow menu. Progress becomes a segmented bar with one weighted segment per analysis stage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b630654146
commit
29a1f3175d
12 changed files with 549 additions and 218 deletions
|
|
@ -1,79 +1,66 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check } from "lucide-react";
|
import {
|
||||||
|
analysisElapsed,
|
||||||
import { Button } from "@/components/ui/button";
|
analysisFraction,
|
||||||
import { analysisFraction, analysisPace, analysisProgress, remainingLabel } from "../model/progress";
|
analysisPace,
|
||||||
|
analysisProgress,
|
||||||
|
analysisStages,
|
||||||
|
remainingLabel,
|
||||||
|
} from "../model/progress";
|
||||||
import { useProgressSamples } from "./useProgressSamples";
|
import { useProgressSamples } from "./useProgressSamples";
|
||||||
import { type Job } from "../model/types";
|
import { type Job } from "../model/types";
|
||||||
|
|
||||||
const STAGES = ["Review runs", "Find patterns", "Check evidence"];
|
export function InvestigationProgress({ job, now }: { job: Job; now: number }) {
|
||||||
|
|
||||||
function stageState(index: number, current: number) {
|
|
||||||
if (index < current) return "done";
|
|
||||||
return index === current ? "active" : "todo";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InvestigationProgress({ job, now, onCancel }: { job: Job; now: number; onCancel?: () => void }) {
|
|
||||||
const progress = analysisProgress(job);
|
const progress = analysisProgress(job);
|
||||||
const percent = Math.round(analysisFraction(progress) * 100);
|
const percent = Math.round(analysisFraction(progress) * 100);
|
||||||
const samples = useProgressSamples(progress);
|
const { secondsLeft } = analysisPace(useProgressSamples(progress), now);
|
||||||
const { secondsLeft } = analysisPace(samples, now);
|
|
||||||
const queued = progress.step < 0;
|
const queued = progress.step < 0;
|
||||||
return (
|
return (
|
||||||
<section aria-label="Analysis progress" className="space-y-3">
|
<section aria-label="Analysis progress" className="space-y-2">
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3 tabular-nums">
|
||||||
<p role="status" className="min-w-0 text-sm">
|
<span
|
||||||
<span className="font-medium">{progress.title}</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>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-label="Investigation progress"
|
aria-label="Investigation progress"
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuemax={100}
|
aria-valuemax={100}
|
||||||
aria-valuenow={percent}
|
aria-valuenow={percent}
|
||||||
aria-valuetext={queued ? progress.title : `${progress.title}: ${progress.detail}`}
|
aria-valuetext={`${progress.title}: ${progress.detail}`}
|
||||||
className="h-1.5 overflow-hidden rounded-full bg-muted"
|
className="text-2xl font-semibold"
|
||||||
>
|
>
|
||||||
|
{queued ? "–" : `${percent}%`}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{analysisElapsed(job.created_at, now)} elapsed
|
||||||
|
{!queued && ` · ${remainingLabel(secondsLeft)} left`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ol aria-label="Analysis stages" className="flex gap-1">
|
||||||
|
{analysisStages(job).map((stage) => (
|
||||||
|
<li
|
||||||
|
key={stage.label}
|
||||||
|
data-state={stage.state}
|
||||||
|
aria-current={stage.state === "active" ? "step" : undefined}
|
||||||
|
className="group min-w-0 space-y-1.5"
|
||||||
|
style={{ flexGrow: stage.weight, flexBasis: 0 }}
|
||||||
|
>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
data-state={queued ? "queued" : "running"}
|
className="h-full rounded-full bg-primary transition-[width] duration-500"
|
||||||
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={{ width: `${stage.fill * 100}%` }}
|
||||||
style={queued ? undefined : { width: `${percent}%` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2 text-xs text-muted-foreground group-data-[state=active]:text-foreground">
|
||||||
<ol aria-label="Analysis stages" className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
<span className="truncate group-data-[state=active]:font-medium">{stage.label}</span>
|
||||||
{STAGES.map((label, index) => {
|
{stage.state !== "todo" && stage.total > 0 && (
|
||||||
const state = stageState(index, progress.step);
|
<span className="shrink-0 tabular-nums">
|
||||||
return (
|
{stage.done.toLocaleString()} / {stage.total.toLocaleString()}
|
||||||
<li
|
|
||||||
key={label}
|
|
||||||
data-state={state}
|
|
||||||
aria-current={state === "active" ? "step" : undefined}
|
|
||||||
className="flex items-center gap-1.5 text-muted-foreground data-[state=active]:font-medium data-[state=active]:text-foreground"
|
|
||||||
>
|
|
||||||
<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>
|
||||||
{label}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
{onCancel && (
|
|
||||||
<Button variant="ghost" size="xs" onClick={onCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -297,8 +297,12 @@ it("runs with saved settings from Run now without opening setup, then accepts an
|
||||||
});
|
});
|
||||||
proxy.post.mockResolvedValue(lens);
|
proxy.post.mockResolvedValue(lens);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
const runNow = async () => {
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Investigation actions" }));
|
||||||
|
await user.click(await screen.findByRole("menuitem", { name: "Run now" }));
|
||||||
|
};
|
||||||
renderWithProviders(<InvestigationsView />);
|
renderWithProviders(<InvestigationsView />);
|
||||||
await user.click(await screen.findByRole("button", { name: "Run now" }));
|
await runNow();
|
||||||
const choices = await screen.findByRole("dialog", { name: "Run now" });
|
const choices = await screen.findByRole("dialog", { name: "Run now" });
|
||||||
expect(within(choices).getByRole("button", { name: "Since last run" })).toHaveAttribute("aria-pressed", "true");
|
expect(within(choices).getByRole("button", { name: "Since last run" })).toHaveAttribute("aria-pressed", "true");
|
||||||
await user.click(within(choices).getByRole("button", { name: "Run now" }));
|
await user.click(within(choices).getByRole("button", { name: "Run now" }));
|
||||||
|
|
@ -306,7 +310,7 @@ it("runs with saved settings from Run now without opening setup, then accepts an
|
||||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||||
|
|
||||||
proxy.post.mockClear();
|
proxy.post.mockClear();
|
||||||
await user.click(screen.getByRole("button", { name: "Run now" }));
|
await runNow();
|
||||||
const custom = await screen.findByRole("dialog", { name: "Run now" });
|
const custom = await screen.findByRole("dialog", { name: "Run now" });
|
||||||
fireEvent.change(within(custom).getByRole("combobox", { name: "Agent" }), { target: { value: "billing" } });
|
fireEvent.change(within(custom).getByRole("combobox", { name: "Agent" }), { target: { value: "billing" } });
|
||||||
await user.click(within(custom).getByRole("button", { name: "Last 24h" }));
|
await user.click(within(custom).getByRole("button", { name: "Last 24h" }));
|
||||||
|
|
@ -755,6 +759,22 @@ it("reports what the selected run found, cost, and covered", async () => {
|
||||||
expect(stat("Issues")).toHaveTextContent("11 high priority");
|
expect(stat("Issues")).toHaveTextContent("11 high priority");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends the report's Review issues action to the open issues of that run", async () => {
|
||||||
|
window.history.replaceState({}, "", "/lens/?lens=lens&kind=pattern&finding_status=resolved");
|
||||||
|
testQueryClient.clear();
|
||||||
|
proxy.get.mockImplementation(async (path) => {
|
||||||
|
if (path === "/lens") return { lenses: [lens], workers: [], tracing_enabled: true };
|
||||||
|
if (path === "/lens/lens/runs") return lens.jobs;
|
||||||
|
return { data: [] };
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(<InvestigationsView />);
|
||||||
|
const report = within(await screen.findByRole("region", { name: "Run report" }));
|
||||||
|
expect(screen.queryByRole("button", { name: issue.title })).not.toBeInTheDocument();
|
||||||
|
await user.click(report.getByRole("button", { name: "Review 1 issue" }));
|
||||||
|
expect(await screen.findByRole("button", { name: new RegExp(issue.title) })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
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();
|
||||||
|
|
@ -795,7 +815,7 @@ it("pauses monitoring from the detail menu by saving the investigation with moni
|
||||||
expect(sentBody(proxy.put, "/lens/lens")).toEqual([{ ...watching.settings, enabled: false }]);
|
expect(sentBody(proxy.put, "/lens/lens")).toEqual([{ ...watching.settings, enabled: false }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cancels the running job from the progress banner", async () => {
|
it("stops the running job from the run report's primary action", async () => {
|
||||||
testQueryClient.clear();
|
testQueryClient.clear();
|
||||||
const running = { ...lens.jobs[0], id: "live", status: "running" as const, stage: "Reading executions" };
|
const running = { ...lens.jobs[0], id: "live", status: "running" as const, stage: "Reading executions" };
|
||||||
proxy.get.mockImplementation(async (path) => {
|
proxy.get.mockImplementation(async (path) => {
|
||||||
|
|
@ -806,7 +826,7 @@ it("cancels the running job from the progress banner", async () => {
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithProviders(<InvestigationsView />);
|
renderWithProviders(<InvestigationsView />);
|
||||||
await user.click(await screen.findByRole("button", { name: "Cancel" }));
|
await user.click(await screen.findByRole("button", { name: "Stop run" }));
|
||||||
await waitFor(() => expect(proxy.post).toHaveBeenCalledWith("/lens/lens/cancel", expect.anything()));
|
await waitFor(() => expect(proxy.post).toHaveBeenCalledWith("/lens/lens/cancel", expect.anything()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export function InvestigationsView({ readOnly = false }: InvestigationsViewProps
|
||||||
const actions = useInvestigationActions();
|
const actions = useInvestigationActions();
|
||||||
const { dialog, target, openDialog, closeDialog } = useDialogRoute();
|
const { dialog, target, openDialog, closeDialog } = useDialogRoute();
|
||||||
const { issueKey, setIssueKey } = useIssueRoute();
|
const { issueKey, setIssueKey } = useIssueRoute();
|
||||||
const { lensId, setLensId } = useLensRoute();
|
const { lensId, setLensId, setTab } = useLensRoute();
|
||||||
const list = useQuery(lensQueries.list(api));
|
const list = useQuery(lensQueries.list(api));
|
||||||
const status = useLensReadiness(true);
|
const status = useLensReadiness(true);
|
||||||
const { connected } = status;
|
const { connected } = status;
|
||||||
|
|
@ -162,6 +162,7 @@ export function InvestigationsView({ readOnly = false }: InvestigationsViewProps
|
||||||
onEnableMonitoring={() => openDialog("monitoring")}
|
onEnableMonitoring={() => openDialog("monitoring")}
|
||||||
onCancelRun={() => void actions.cancelRun(row.lens)}
|
onCancelRun={() => void actions.cancelRun(row.lens)}
|
||||||
onRunNow={() => openDialog("run_now")}
|
onRunNow={() => openDialog("run_now")}
|
||||||
|
onConnectWorker={() => setTab("settings")}
|
||||||
onReviewFinding={(owned, reviewStatus, reason) =>
|
onReviewFinding={(owned, reviewStatus, reason) =>
|
||||||
void actions.review(owned.lens, owned.finding, reviewStatus, reason)
|
void actions.review(owned.lens, owned.finding, reviewStatus, reason)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,8 @@ describe("InvestigationActions", () => {
|
||||||
it("reports each menu choice as an intent without touching the API", async () => {
|
it("reports each menu choice as an intent without touching the API", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const intents = renderActions();
|
const intents = renderActions();
|
||||||
await user.click(screen.getByRole("button", { name: "Run now" }));
|
await user.click(screen.getByRole("button", { name: "Investigation actions" }));
|
||||||
|
await user.click(await screen.findByRole("menuitem", { name: "Run now" }));
|
||||||
expect(intents.onRunNow).toHaveBeenCalledOnce();
|
expect(intents.onRunNow).toHaveBeenCalledOnce();
|
||||||
await user.click(screen.getByRole("button", { name: "Investigation actions" }));
|
await user.click(screen.getByRole("button", { name: "Investigation actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Pause monitoring" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Pause monitoring" }));
|
||||||
|
|
@ -77,7 +78,7 @@ describe("InvestigationActions", () => {
|
||||||
expect(intents.onPause).not.toHaveBeenCalled();
|
expect(intents.onPause).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks a second run while one is queued or running", () => {
|
it("blocks a second run while one is queued or running", async () => {
|
||||||
const job = {
|
const job = {
|
||||||
id: "job",
|
id: "job",
|
||||||
status: "queued" as const,
|
status: "queued" as const,
|
||||||
|
|
@ -95,7 +96,9 @@ describe("InvestigationActions", () => {
|
||||||
steps: [],
|
steps: [],
|
||||||
findings: [],
|
findings: [],
|
||||||
};
|
};
|
||||||
|
const user = userEvent.setup();
|
||||||
renderActions({ lens: { ...lens, jobs: [job] } });
|
renderActions({ lens: { ...lens, jobs: [job] } });
|
||||||
expect(screen.getByRole("button", { name: "Run now" })).toBeDisabled();
|
await user.click(screen.getByRole("button", { name: "Investigation actions" }));
|
||||||
|
expect(await screen.findByRole("menuitem", { name: "Run now" })).toHaveAttribute("aria-disabled", "true");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,10 @@ export interface InvestigationIntents {
|
||||||
readonly onEnableMonitoring: () => void;
|
readonly onEnableMonitoring: () => void;
|
||||||
readonly onCancelRun: () => void;
|
readonly onCancelRun: () => void;
|
||||||
readonly onRunNow: () => void;
|
readonly onRunNow: () => void;
|
||||||
|
readonly onConnectWorker: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InvestigationActionsProps = Omit<InvestigationIntents, "onCancelRun"> & {
|
export type InvestigationActionsProps = Omit<InvestigationIntents, "onCancelRun" | "onConnectWorker"> & {
|
||||||
readonly lens: Lens;
|
readonly lens: Lens;
|
||||||
readonly ready: boolean;
|
readonly ready: boolean;
|
||||||
readonly busy: boolean;
|
readonly busy: boolean;
|
||||||
|
|
@ -37,12 +38,15 @@ export function InvestigationActions({
|
||||||
onRunNow,
|
onRunNow,
|
||||||
}: InvestigationActionsProps) {
|
}: InvestigationActionsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" aria-label="Investigation actions" />}>
|
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" aria-label="Investigation actions" />}>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem disabled={busy || hasActiveJob(lens.jobs) || !ready} onClick={onRunNow}>
|
||||||
|
<Play />
|
||||||
|
Run now
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={onEdit}>
|
<DropdownMenuItem onClick={onEdit}>
|
||||||
<Settings2 />
|
<Settings2 />
|
||||||
Edit investigation
|
Edit investigation
|
||||||
|
|
@ -64,10 +68,5 @@ export function InvestigationActions({
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Button disabled={busy || hasActiveJob(lens.jobs) || !ready} onClick={onRunNow}>
|
|
||||||
<Play className="size-3" />
|
|
||||||
Run now
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,12 @@ 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 { InvestigationSummary } from "./InvestigationSummary";
|
import { InvestigationSummary } from "./InvestigationSummary";
|
||||||
import { RunReport } from "./RunReport";
|
import { RunReport, type RunActionHandlers } from "./RunReport";
|
||||||
import type { OwnedFinding } from "../../model/inbox";
|
import type { OwnedFinding } from "../../model/inbox";
|
||||||
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 { mergeFeedback } from "../../model/findings";
|
||||||
|
import { useFindingFilters, useSectionRoute } from "../../route";
|
||||||
import { useRunSnapshot } from "../useRunSnapshot";
|
import { useRunSnapshot } from "../useRunSnapshot";
|
||||||
|
|
||||||
import { InvestigationActions, type InvestigationIntents } from "./InvestigationActions";
|
import { InvestigationActions, type InvestigationIntents } from "./InvestigationActions";
|
||||||
|
|
@ -38,8 +39,22 @@ export function InvestigationDetail({
|
||||||
...intents
|
...intents
|
||||||
}: InvestigationDetailProps) {
|
}: InvestigationDetailProps) {
|
||||||
const { section, setSection } = useSectionRoute();
|
const { section, setSection } = useSectionRoute();
|
||||||
|
const { setKind, setStatus } = useFindingFilters();
|
||||||
const snapshot = useRunSnapshot(lens);
|
const snapshot = useRunSnapshot(lens);
|
||||||
const { job, batchId, batchSettings, batchFindings, missingSnapshot } = snapshot;
|
const { job, batchId, batchSettings, batchFindings, missingSnapshot } = snapshot;
|
||||||
|
const runActions: RunActionHandlers = {
|
||||||
|
run: intents.onRunNow,
|
||||||
|
retry: intents.onRunNow,
|
||||||
|
stop: onCancelRun,
|
||||||
|
raiseBudget: intents.onEdit,
|
||||||
|
connectWorker: intents.onConnectWorker,
|
||||||
|
monitor: intents.onEnableMonitoring,
|
||||||
|
reviewIssues: () => {
|
||||||
|
setSection("findings");
|
||||||
|
setKind("issue");
|
||||||
|
setStatus("open");
|
||||||
|
},
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<section className="min-w-0 space-y-5">
|
<section className="min-w-0 space-y-5">
|
||||||
|
|
@ -51,11 +66,14 @@ export function InvestigationDetail({
|
||||||
{!readOnly && <InvestigationActions lens={lens} ready={ready} busy={busy} {...intents} />}
|
{!readOnly && <InvestigationActions lens={lens} ready={ready} busy={busy} {...intents} />}
|
||||||
</div>
|
</div>
|
||||||
<RunReport
|
<RunReport
|
||||||
|
lens={lens}
|
||||||
job={job}
|
job={job}
|
||||||
findings={job?.findings}
|
findings={job?.findings && mergeFeedback(job.findings, lens.findings ?? [])}
|
||||||
connected={connected}
|
connected={connected}
|
||||||
|
ready={ready}
|
||||||
|
busy={busy}
|
||||||
picker={<RunPicker lens={lens} job={job} />}
|
picker={<RunPicker lens={lens} job={job} />}
|
||||||
onCancel={readOnly ? undefined : onCancelRun}
|
actions={readOnly ? undefined : runActions}
|
||||||
/>
|
/>
|
||||||
<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">
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,139 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronRight } from "lucide-react";
|
import {
|
||||||
import type { ReactNode } from "react";
|
ChevronRight,
|
||||||
|
CircleStop,
|
||||||
|
ListChecks,
|
||||||
|
Play,
|
||||||
|
Plug,
|
||||||
|
RefreshCw,
|
||||||
|
Repeat,
|
||||||
|
Wallet,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/cva.config";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { useNow } from "@/hooks/useNow";
|
import { useNow } from "@/hooks/useNow";
|
||||||
|
|
||||||
import { analysisElapsed } from "../../model/progress";
|
import { analysisElapsed } from "../../model/progress";
|
||||||
import { money } from "../../model/format";
|
import { money } from "../../model/format";
|
||||||
import { modelsUsed, shortTime, windowLabel } from "../../model/inbox";
|
import { modelsUsed, shortTime, windowLabel } from "../../model/inbox";
|
||||||
import type { Finding, Job } from "../../model/types";
|
import { NEXT_ACTION, openIssues, runSituation, type RunAction, type RunSituation } from "../../model/runSituation";
|
||||||
|
import type { Finding, Job, Lens } from "../../model/types";
|
||||||
import { InvestigationProgress } from "../InvestigationProgress";
|
import { InvestigationProgress } from "../InvestigationProgress";
|
||||||
import { StepFeed } from "../StepFeed";
|
import { StepFeed } from "../StepFeed";
|
||||||
|
|
||||||
|
export type RunActionHandlers = Record<RunAction, () => void>;
|
||||||
|
|
||||||
export interface RunReportProps {
|
export interface RunReportProps {
|
||||||
|
readonly lens: Lens;
|
||||||
readonly job: Job | undefined;
|
readonly job: Job | undefined;
|
||||||
/** Null when the run predates saved result snapshots. */
|
/** Null when the run predates saved result snapshots. */
|
||||||
readonly findings: readonly Finding[] | null | undefined;
|
readonly findings: readonly Finding[] | null | undefined;
|
||||||
readonly connected: boolean;
|
readonly connected: boolean;
|
||||||
|
readonly ready: boolean;
|
||||||
|
readonly busy: boolean;
|
||||||
readonly picker: ReactNode;
|
readonly picker: ReactNode;
|
||||||
readonly onCancel?: () => void;
|
/** Absent for viewers who cannot act on the investigation. */
|
||||||
|
readonly actions?: RunActionHandlers;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABEL: Record<Job["status"], string> = {
|
interface Facts {
|
||||||
queued: "Queued",
|
readonly runs: string;
|
||||||
running: "Running",
|
readonly found: string;
|
||||||
completed: "Completed",
|
readonly openIssues: number;
|
||||||
failed: "Failed",
|
}
|
||||||
cancelled: "Cancelled",
|
|
||||||
|
type Tone = "info" | "success" | "destructive" | "muted";
|
||||||
|
|
||||||
|
interface SituationView {
|
||||||
|
readonly status: string;
|
||||||
|
readonly tone: Tone;
|
||||||
|
readonly headline: (facts: Facts) => string;
|
||||||
|
readonly body: "progress" | "error" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = ({ found, runs }: Facts) =>
|
||||||
|
found ? `Found ${found} across ${runs}` : `Nothing found across ${runs}`;
|
||||||
|
|
||||||
|
const SITUATIONS: Record<RunSituation, SituationView> = {
|
||||||
|
never: { status: "Not run yet", tone: "muted", headline: () => "Run it to get the first report", body: null },
|
||||||
|
queued: {
|
||||||
|
status: "Queued",
|
||||||
|
tone: "info",
|
||||||
|
headline: () => "Waiting for an analyzer to pick this up",
|
||||||
|
body: "progress",
|
||||||
|
},
|
||||||
|
running: { status: "Running", tone: "info", headline: () => "Investigating now", body: "progress" },
|
||||||
|
budget: {
|
||||||
|
status: "Failed",
|
||||||
|
tone: "destructive",
|
||||||
|
headline: () => "Stopped: the monthly budget is used up",
|
||||||
|
body: "error",
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
status: "Failed",
|
||||||
|
tone: "destructive",
|
||||||
|
headline: () => "Stopped: no analyzer is connected",
|
||||||
|
body: "error",
|
||||||
|
},
|
||||||
|
failed: { status: "Failed", tone: "destructive", headline: () => "Stopped before it finished", body: "error" },
|
||||||
|
cancelled: {
|
||||||
|
status: "Cancelled",
|
||||||
|
tone: "muted",
|
||||||
|
headline: ({ runs }) => `Cancelled after reviewing ${runs}`,
|
||||||
|
body: "error",
|
||||||
|
},
|
||||||
|
unknown: { status: "Completed", tone: "success", headline: ({ runs }) => `Reviewed ${runs}`, body: null },
|
||||||
|
issues: { status: "Completed", tone: "success", headline: completed, body: null },
|
||||||
|
watching: { status: "Completed", tone: "success", headline: completed, body: null },
|
||||||
|
clean: { status: "Completed", tone: "success", headline: completed, body: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ActionView {
|
||||||
|
readonly label: (facts: Facts) => string;
|
||||||
|
readonly icon: LucideIcon;
|
||||||
|
readonly variant: ComponentProps<typeof Button>["variant"];
|
||||||
|
readonly needsReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIONS: Record<RunAction, ActionView> = {
|
||||||
|
run: { label: () => "Run now", icon: Play, variant: "default", needsReady: true },
|
||||||
|
stop: { label: () => "Stop run", icon: CircleStop, variant: "outline", needsReady: false },
|
||||||
|
retry: { label: () => "Retry", icon: RefreshCw, variant: "default", needsReady: true },
|
||||||
|
raiseBudget: { label: () => "Raise budget", icon: Wallet, variant: "default", needsReady: false },
|
||||||
|
connectWorker: { label: () => "Connect worker", icon: Plug, variant: "default", needsReady: false },
|
||||||
|
reviewIssues: {
|
||||||
|
label: ({ openIssues: count }) => `Review ${plural(count, "issue")}`,
|
||||||
|
icon: ListChecks,
|
||||||
|
variant: "default",
|
||||||
|
needsReady: false,
|
||||||
|
},
|
||||||
|
monitor: { label: () => "Monitor this", icon: Repeat, variant: "default", needsReady: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TONE_CLASS: Record<Tone, string> = {
|
||||||
|
info: "text-info",
|
||||||
|
success: "text-success",
|
||||||
|
destructive: "text-destructive",
|
||||||
|
muted: "text-muted-foreground",
|
||||||
};
|
};
|
||||||
|
|
||||||
const plural = (count: number, noun: string) => `${count.toLocaleString()} ${noun}${count === 1 ? "" : "s"}`;
|
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 {
|
function facts(job: Job | undefined, findings: readonly Finding[] | null | undefined): Facts {
|
||||||
const runs = plural(job.coverage?.screened ?? 0, "run");
|
const count = (kind: Finding["kind"], noun: string) => {
|
||||||
const issues = findings?.filter((f) => f.kind === "issue").length ?? 0;
|
const total = findings?.filter((f) => f.kind === kind).length ?? 0;
|
||||||
const patterns = findings?.filter((f) => f.kind === "pattern").length ?? 0;
|
return total ? plural(total, noun) : "";
|
||||||
switch (job.status) {
|
};
|
||||||
case "queued":
|
return {
|
||||||
return connected ? "Waiting for the analyzer to pick this up" : "Waiting for an analyzer to connect";
|
runs: plural(job?.coverage?.screened ?? 0, "run"),
|
||||||
case "running":
|
found: [count("issue", "issue"), count("pattern", "pattern")].filter(Boolean).join(" and "),
|
||||||
return "Investigating now";
|
openIssues: openIssues(findings),
|
||||||
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 }) {
|
function Stat({ label, value, note }: { label: string; value: string; note?: string }) {
|
||||||
|
|
@ -73,25 +156,26 @@ function coverageNote(job: Job): string | undefined {
|
||||||
return notes.length ? notes.join(" · ") : undefined;
|
return notes.length ? notes.join(" · ") : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function issueNote(job: Job, high: number): string | undefined {
|
const isActive = (job: Job) => job.status === "queued" || job.status === "running";
|
||||||
if (isActive(job)) return "When the run finishes";
|
|
||||||
return high ? `${high} high priority` : undefined;
|
function issueStat(job: Job, findings: readonly Finding[] | null | undefined) {
|
||||||
|
if (isActive(job)) return { value: "–", note: "When the run finishes" };
|
||||||
|
if (job.status !== "completed" || findings == null) return { value: "–" };
|
||||||
|
const issues = findings.filter((f) => f.kind === "issue");
|
||||||
|
const high = issues.filter((f) => f.priority === "high").length;
|
||||||
|
return { value: issues.length.toLocaleString(), note: high ? `${high} high priority` : undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
function RunStats({ job, findings, now }: { job: Job; findings: readonly Finding[] | null | undefined; now: number }) {
|
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 calls = (job.steps ?? []).filter((step) => step.kind === "model").length;
|
||||||
const { screened = 0, selected = 0 } = job.coverage ?? {};
|
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;
|
const end = job.finished_at ? Date.parse(job.finished_at) : now;
|
||||||
return (
|
return (
|
||||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
|
<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="Cost" value={money(job.cost ?? 0)} note={plural(calls, "model call")} />
|
||||||
<Stat
|
<Stat
|
||||||
label="Duration"
|
label="Duration"
|
||||||
value={active || job.finished_at ? analysisElapsed(job.created_at, end) : "–"}
|
value={isActive(job) || job.finished_at ? analysisElapsed(job.created_at, end) : "–"}
|
||||||
note={`Started ${shortTime(job.created_at)}`}
|
note={`Started ${shortTime(job.created_at)}`}
|
||||||
/>
|
/>
|
||||||
<Stat
|
<Stat
|
||||||
|
|
@ -99,7 +183,7 @@ function RunStats({ job, findings, now }: { job: Job; findings: readonly Finding
|
||||||
value={`${screened.toLocaleString()} / ${selected.toLocaleString()}`}
|
value={`${screened.toLocaleString()} / ${selected.toLocaleString()}`}
|
||||||
note={coverageNote(job)}
|
note={coverageNote(job)}
|
||||||
/>
|
/>
|
||||||
<Stat label="Issues" value={known ? issues.length.toLocaleString() : "–"} note={issueNote(job, high)} />
|
<Stat label="Issues" {...issueStat(job, findings)} />
|
||||||
</dl>
|
</dl>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -147,35 +231,53 @@ function RunLog({ job }: { job: Job }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RunReport({ job, findings, connected, picker, onCancel }: RunReportProps) {
|
function NextAction({
|
||||||
const active = job !== undefined && isActive(job);
|
action,
|
||||||
const now = useNow(active ? 1000 : 60000);
|
facts: known,
|
||||||
if (!job) {
|
ready,
|
||||||
|
busy,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
action: RunAction;
|
||||||
|
facts: Facts;
|
||||||
|
ready: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const { label, icon: Icon, variant, needsReady } = ACTIONS[action];
|
||||||
return (
|
return (
|
||||||
<section aria-label="Run report" className="flex items-center justify-between gap-3 rounded-lg border p-4">
|
<Button variant={variant} disabled={busy || (needsReady && !ready)} onClick={onClick}>
|
||||||
<p className="text-sm text-muted-foreground">No runs yet. Run it now to get the first report.</p>
|
<Icon className="size-3.5" />
|
||||||
{picker}
|
{label(known)}
|
||||||
</section>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RunReport({ lens, job, findings, connected, ready, busy, picker, actions }: RunReportProps) {
|
||||||
|
const now = useNow(job && isActive(job) ? 1000 : 60000);
|
||||||
|
const situation = runSituation({ lens, job, findings, connected });
|
||||||
|
const view = SITUATIONS[situation];
|
||||||
|
const known = facts(job, findings);
|
||||||
|
const latest = job === undefined || job.id === lens.jobs[0]?.id;
|
||||||
|
const action = latest ? NEXT_ACTION[situation] : null;
|
||||||
return (
|
return (
|
||||||
<section aria-label="Run report" className="space-y-4 rounded-lg border p-4">
|
<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">
|
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p
|
<p className={cn("text-xs font-medium", TONE_CLASS[view.tone])}>{view.status}</p>
|
||||||
data-state={job.status}
|
<h3 className="mt-0.5 text-base font-semibold">{view.headline(known)}</h3>
|
||||||
className="text-xs font-medium text-muted-foreground data-[state=completed]:text-success data-[state=failed]:text-destructive data-[state=running]:text-info"
|
</div>
|
||||||
>
|
<div className="flex items-center gap-1">
|
||||||
{STATUS_LABEL[job.status]}
|
{job && picker}
|
||||||
</p>
|
{action && actions && (
|
||||||
<h3 className="mt-0.5 text-base font-semibold">{headline(job, findings, connected)}</h3>
|
<NextAction action={action} facts={known} ready={ready} busy={busy} onClick={actions[action]} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{picker}
|
|
||||||
</header>
|
</header>
|
||||||
{active && <InvestigationProgress key={job.id} job={job} now={now} onCancel={onCancel} />}
|
{job && view.body === "progress" && <InvestigationProgress key={job.id} job={job} now={now} />}
|
||||||
{job.error && <RunFailure job={job} connected={connected} />}
|
{job?.error && view.body === "error" && <RunFailure job={job} connected={connected} />}
|
||||||
<RunStats job={job} findings={findings} now={now} />
|
{job && <RunStats job={job} findings={findings} now={now} />}
|
||||||
<RunLog job={job} />
|
{job && <RunLog job={job} />}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ import {
|
||||||
analysisElapsed,
|
analysisElapsed,
|
||||||
analysisProgress,
|
analysisProgress,
|
||||||
analysisFraction,
|
analysisFraction,
|
||||||
|
analysisStages,
|
||||||
analysisPace,
|
analysisPace,
|
||||||
remainingLabel,
|
remainingLabel,
|
||||||
stageDurations,
|
|
||||||
} from "./progress";
|
} from "./progress";
|
||||||
import { type Job } from "./types";
|
import { type Job } from "./types";
|
||||||
|
|
||||||
|
|
@ -207,20 +207,27 @@ describe("Analysis pace", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Stage durations", () => {
|
describe("Analysis stages", () => {
|
||||||
const start = Date.parse("2026-09-30T12:00:00Z");
|
it("fills finished stages, fills the active one by its own count, and leaves later ones empty", () => {
|
||||||
const createdAt = "2026-09-30T12:00:00Z";
|
const grouping = {
|
||||||
|
...job,
|
||||||
it("times finished stages from the transitions it saw and the active stage up to now", () => {
|
stage: "Grouping observations",
|
||||||
const samples = [
|
coverage: { ...coverage, selected: 40, screened: 40, grouping_batches: 4, grouped_batches: 1 },
|
||||||
{ at: start + 5000, step: 0, done: 10, fraction: 0.1 },
|
};
|
||||||
{ at: start + 124000, step: 1, done: 0, fraction: 0.6 },
|
expect(analysisStages(grouping).map(({ state, fill, done, total }) => ({ state, fill, done, total }))).toEqual([
|
||||||
];
|
{ state: "done", fill: 1, done: 40, total: 40 },
|
||||||
expect(stageDurations(samples, createdAt, start + 145000)).toEqual([124, 21, null]);
|
{ state: "active", fill: 0.25, done: 1, total: 4 },
|
||||||
|
{ state: "todo", fill: 0, done: 0, total: 0 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not guess when a stage started before the page was opened", () => {
|
it("weights the stages so the bar agrees with the overall percentage", () => {
|
||||||
const samples = [{ at: start + 90000, step: 1, done: 2, fraction: 0.7 }];
|
const grouping = {
|
||||||
expect(stageDurations(samples, createdAt, start + 100000)).toEqual([null, null, null]);
|
...job,
|
||||||
|
stage: "Grouping observations",
|
||||||
|
coverage: { ...coverage, selected: 40, screened: 40, grouping_batches: 4, grouped_batches: 1 },
|
||||||
|
};
|
||||||
|
const covered = analysisStages(grouping).reduce((sum, stage) => sum + stage.weight * stage.fill, 0);
|
||||||
|
expect(covered).toBeCloseTo(analysisFraction(analysisProgress(grouping)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -59,20 +59,48 @@ export function analysisProgress(job: Job) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function analysisStages(job: Job): { done: number; total: number }[] {
|
type Coverage = Job["coverage"];
|
||||||
const {
|
|
||||||
screened = 0,
|
/** The three analysis stages in order, with their share of the overall progress and how each one counts its work. */
|
||||||
selected = 0,
|
export const ANALYSIS_STAGES = [
|
||||||
grouped_batches = 0,
|
{ label: "Review runs", weight: 0.6, done: (c: Coverage) => c.screened, total: (c: Coverage) => c.selected },
|
||||||
grouping_batches = 0,
|
{
|
||||||
investigated = 0,
|
label: "Find patterns",
|
||||||
candidates = 0,
|
weight: 0.2,
|
||||||
} = job.coverage ?? {};
|
done: (c: Coverage) => c.grouped_batches,
|
||||||
return [
|
total: (c: Coverage) => c.grouping_batches,
|
||||||
{ done: screened, total: selected },
|
},
|
||||||
{ done: grouped_batches, total: grouping_batches },
|
{ label: "Check evidence", weight: 0.2, done: (c: Coverage) => c.investigated, total: (c: Coverage) => c.candidates },
|
||||||
{ done: investigated, total: candidates },
|
] as const;
|
||||||
];
|
|
||||||
|
export interface StageProgress {
|
||||||
|
readonly label: string;
|
||||||
|
readonly weight: number;
|
||||||
|
readonly state: "done" | "active" | "todo";
|
||||||
|
readonly done: number;
|
||||||
|
readonly total: number;
|
||||||
|
readonly fill: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageState(index: number, current: number): StageProgress["state"] {
|
||||||
|
if (index < current) return "done";
|
||||||
|
return index === current ? "active" : "todo";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageFill(state: StageProgress["state"], done: number, total: number): number {
|
||||||
|
if (state === "done") return 1;
|
||||||
|
if (state === "todo" || !total) return 0;
|
||||||
|
return Math.min(1, done / total);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analysisStages(job: Job): readonly StageProgress[] {
|
||||||
|
const step = analysisProgress(job).step;
|
||||||
|
return ANALYSIS_STAGES.map((stage, index) => {
|
||||||
|
const state = stageState(index, step);
|
||||||
|
const done = job.coverage ? stage.done(job.coverage) : 0;
|
||||||
|
const total = job.coverage ? stage.total(job.coverage) : 0;
|
||||||
|
return { label: stage.label, weight: stage.weight, state, done, total, fill: stageFill(state, done, total) };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProgressSample {
|
export interface ProgressSample {
|
||||||
|
|
@ -82,16 +110,14 @@ export interface ProgressSample {
|
||||||
fraction: number;
|
fraction: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stageWeights = [0.6, 0.2, 0.2];
|
|
||||||
|
|
||||||
export function analysisFraction({
|
export function analysisFraction({
|
||||||
step,
|
step,
|
||||||
done,
|
done,
|
||||||
total,
|
total,
|
||||||
}: Pick<ReturnType<typeof analysisProgress>, "step" | "done" | "total">): number {
|
}: Pick<ReturnType<typeof analysisProgress>, "step" | "done" | "total">): number {
|
||||||
if (step < 0) return 0;
|
if (step < 0) return 0;
|
||||||
const before = stageWeights.slice(0, step).reduce((sum, weight) => sum + weight, 0);
|
const before = ANALYSIS_STAGES.slice(0, step).reduce((sum, stage) => sum + stage.weight, 0);
|
||||||
return before + stageWeights[step] * (total ? Math.min(1, done / total) : 0);
|
return before + ANALYSIS_STAGES[step].weight * (total ? Math.min(1, done / total) : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function windowStart(samples: readonly ProgressSample[], now: number): ProgressSample | undefined {
|
function windowStart(samples: readonly ProgressSample[], now: number): ProgressSample | undefined {
|
||||||
|
|
@ -115,22 +141,6 @@ export function analysisPace(samples: readonly ProgressSample[], now: number) {
|
||||||
return { perMinute, secondsLeft };
|
return { perMinute, secondsLeft };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stageDurations(samples: readonly ProgressSample[], createdAt: string, now: number): (number | null)[] {
|
|
||||||
const current = samples.at(-1)?.step ?? -1;
|
|
||||||
const starts = [0, 1, 2].map((stage) => {
|
|
||||||
if (stage === 0) return Date.parse(createdAt);
|
|
||||||
const entered = samples.findIndex(
|
|
||||||
(sample, index) => index > 0 && sample.step >= stage && samples[index - 1].step < stage,
|
|
||||||
);
|
|
||||||
return entered < 0 ? null : samples[entered].at;
|
|
||||||
});
|
|
||||||
return starts.map((start, stage) => {
|
|
||||||
if (start === null || stage > current) return null;
|
|
||||||
const end = stage === current ? now : starts[stage + 1];
|
|
||||||
return end === null ? null : Math.max(0, Math.floor((end - start) / 1000));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function remainingLabel(seconds: number | null): string {
|
export function remainingLabel(seconds: number | null): string {
|
||||||
if (seconds === null) return "estimating";
|
if (seconds === null) return "estimating";
|
||||||
if (seconds < 60) return "<1m";
|
if (seconds < 60) return "<1m";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { NEXT_ACTION, runSituation, type SituationInput } from "./runSituation";
|
||||||
|
import type { Finding, Job, Lens } from "./types";
|
||||||
|
|
||||||
|
const settings: Lens["settings"] = {
|
||||||
|
context: "",
|
||||||
|
lookback_hours: 24,
|
||||||
|
q: "",
|
||||||
|
interval_minutes: 60,
|
||||||
|
sample_size: 100,
|
||||||
|
sample_percent: 100,
|
||||||
|
concurrency: 8,
|
||||||
|
execution_ids: [],
|
||||||
|
monthly_budget: 20,
|
||||||
|
name: "Release reviews",
|
||||||
|
model: "analysis",
|
||||||
|
enabled: false,
|
||||||
|
checks: [],
|
||||||
|
};
|
||||||
|
const month = new Date().toISOString().slice(0, 7);
|
||||||
|
const lens: Lens = {
|
||||||
|
version: 0,
|
||||||
|
spent: 1,
|
||||||
|
id: "lens",
|
||||||
|
scope: { all_teams: true, api_key_hash: "", team_id: "" },
|
||||||
|
settings,
|
||||||
|
revision: 1,
|
||||||
|
created_at: "2026-09-30T10:00:00Z",
|
||||||
|
next_run_at: "2026-09-30T10:00:00Z",
|
||||||
|
budget_month: month,
|
||||||
|
findings: [],
|
||||||
|
jobs: [],
|
||||||
|
};
|
||||||
|
const job: Job = {
|
||||||
|
id: "run",
|
||||||
|
assessments: [],
|
||||||
|
steps: [],
|
||||||
|
trigger: "manual",
|
||||||
|
attempts: 1,
|
||||||
|
error: "",
|
||||||
|
cost: 0,
|
||||||
|
coverage: {
|
||||||
|
eligible: 10,
|
||||||
|
selected: 10,
|
||||||
|
screened: 10,
|
||||||
|
investigated: 0,
|
||||||
|
inconclusive: 0,
|
||||||
|
grouping_batches: 0,
|
||||||
|
grouped_batches: 0,
|
||||||
|
candidates: 0,
|
||||||
|
partial: 0,
|
||||||
|
unassessable: 0,
|
||||||
|
},
|
||||||
|
status: "completed",
|
||||||
|
stage: "Complete",
|
||||||
|
created_at: "2026-09-30T10:00:00Z",
|
||||||
|
start: "2026-09-29T10:00:00Z",
|
||||||
|
end: "2026-09-30T10:00:00Z",
|
||||||
|
settings,
|
||||||
|
revision: 1,
|
||||||
|
};
|
||||||
|
const finding = (kind: Finding["kind"], status: Finding["status"]): Finding => ({
|
||||||
|
id: `${kind}-${status}`,
|
||||||
|
check_id: "check",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
reason: "",
|
||||||
|
suggestion: "",
|
||||||
|
limitation: "",
|
||||||
|
kind,
|
||||||
|
priority: "high",
|
||||||
|
status,
|
||||||
|
revision: 1,
|
||||||
|
first_seen: job.created_at,
|
||||||
|
last_seen: job.created_at,
|
||||||
|
occurrences: [],
|
||||||
|
evidence: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = (overrides: Partial<SituationInput> & { jobPatch?: Partial<Job> }): SituationInput => {
|
||||||
|
const { jobPatch, ...rest } = overrides;
|
||||||
|
return { lens, job: { ...job, ...jobPatch }, findings: [], connected: true, ...rest };
|
||||||
|
};
|
||||||
|
const failed = { status: "failed", error: "Model request failed" } as const;
|
||||||
|
const watching = { ...lens, settings: { ...settings, enabled: true } };
|
||||||
|
|
||||||
|
describe("runSituation", () => {
|
||||||
|
it.each([
|
||||||
|
["never", input({ job: undefined }), "run"],
|
||||||
|
["queued", input({ jobPatch: { status: "queued" } }), "stop"],
|
||||||
|
["running", input({ jobPatch: { status: "running" } }), "stop"],
|
||||||
|
["budget", input({ jobPatch: failed, lens: { ...lens, spent: 20 } }), "raiseBudget"],
|
||||||
|
["budget", input({ jobPatch: { ...failed, error: "Monthly lens budget reached" } }), "raiseBudget"],
|
||||||
|
["offline", input({ jobPatch: failed, connected: false }), "connectWorker"],
|
||||||
|
["failed", input({ jobPatch: failed }), "retry"],
|
||||||
|
["failed", input({ jobPatch: { ...failed, error: "Could not reserve analysis budget" } }), "retry"],
|
||||||
|
["cancelled", input({ jobPatch: { status: "cancelled" } }), "retry"],
|
||||||
|
["unknown", input({ findings: null }), null],
|
||||||
|
["issues", input({ lens: watching, findings: [finding("issue", "open")] }), "reviewIssues"],
|
||||||
|
["watching", input({ lens: watching, findings: [finding("issue", "resolved")] }), null],
|
||||||
|
["clean", input({ findings: [finding("pattern", "open")] }), "monitor"],
|
||||||
|
] as const)("reads %s and offers %s", (expected, situationInput, action) => {
|
||||||
|
const situation = runSituation(situationInput);
|
||||||
|
expect(situation).toBe(expected);
|
||||||
|
expect(NEXT_ACTION[situation]).toBe(action);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a running job ahead of a spent budget, so Stop stays reachable", () => {
|
||||||
|
expect(runSituation(input({ jobPatch: { status: "running" }, lens: { ...lens, spent: 99 } }))).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores spend from an earlier month", () => {
|
||||||
|
expect(runSituation(input({ jobPatch: failed, lens: { ...lens, spent: 99, budget_month: "1999-01" } }))).toBe(
|
||||||
|
"failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import type { Finding, Job, Lens } from "./types";
|
||||||
|
|
||||||
|
export interface SituationInput {
|
||||||
|
readonly lens: Lens;
|
||||||
|
readonly job: Job | undefined;
|
||||||
|
readonly findings: readonly Finding[] | null | undefined;
|
||||||
|
readonly connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RunSituation =
|
||||||
|
| "never"
|
||||||
|
| "queued"
|
||||||
|
| "running"
|
||||||
|
| "budget"
|
||||||
|
| "offline"
|
||||||
|
| "failed"
|
||||||
|
| "cancelled"
|
||||||
|
| "unknown"
|
||||||
|
| "issues"
|
||||||
|
| "watching"
|
||||||
|
| "clean";
|
||||||
|
|
||||||
|
export type RunAction = "run" | "stop" | "retry" | "raiseBudget" | "connectWorker" | "reviewIssues" | "monitor";
|
||||||
|
|
||||||
|
const failed = (job: Job | undefined) => job?.status === "failed";
|
||||||
|
|
||||||
|
export function budgetReached(lens: Lens, now = new Date()): boolean {
|
||||||
|
const spent = lens.budget_month === now.toISOString().slice(0, 7) ? lens.spent ?? 0 : 0;
|
||||||
|
return spent >= (lens.settings.monthly_budget ?? 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openIssues = (findings: readonly Finding[] | null | undefined) =>
|
||||||
|
findings?.filter((finding) => finding.kind === "issue" && finding.status === "open").length ?? 0;
|
||||||
|
|
||||||
|
/** First match wins, so the order is the precedence between overlapping situations. */
|
||||||
|
const RULES: readonly (readonly [RunSituation, (input: SituationInput) => boolean])[] = [
|
||||||
|
["never", ({ job }) => !job],
|
||||||
|
["queued", ({ job }) => job?.status === "queued"],
|
||||||
|
["running", ({ job }) => job?.status === "running"],
|
||||||
|
["budget", ({ job, lens }) => failed(job) && (budgetReached(lens) || /budget reached/i.test(job?.error ?? ""))],
|
||||||
|
["offline", ({ job, connected }) => failed(job) && !connected],
|
||||||
|
["failed", ({ job }) => failed(job)],
|
||||||
|
["cancelled", ({ job }) => job?.status === "cancelled"],
|
||||||
|
["unknown", ({ findings }) => findings == null],
|
||||||
|
["issues", ({ findings }) => openIssues(findings) > 0],
|
||||||
|
["watching", ({ lens }) => lens.settings.enabled],
|
||||||
|
["clean", () => true],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function runSituation(input: SituationInput): RunSituation {
|
||||||
|
return RULES.find(([, matches]) => matches(input))?.[0] ?? "clean";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one thing worth doing next in each situation; null when nothing needs the user. */
|
||||||
|
export const NEXT_ACTION: Record<RunSituation, RunAction | null> = {
|
||||||
|
never: "run",
|
||||||
|
queued: "stop",
|
||||||
|
running: "stop",
|
||||||
|
budget: "raiseBudget",
|
||||||
|
offline: "connectWorker",
|
||||||
|
failed: "retry",
|
||||||
|
cancelled: "retry",
|
||||||
|
unknown: null,
|
||||||
|
issues: "reviewIssues",
|
||||||
|
watching: null,
|
||||||
|
clean: "monitor",
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
|
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
|
||||||
|
import { budgetReached } from "./runSituation";
|
||||||
import type { Job, Lens, LensList } from "./types";
|
import type { Job, Lens, LensList } from "./types";
|
||||||
|
|
||||||
export function workerConnected(worker: LensList["workers"][number], now = Date.now()): boolean {
|
export function workerConnected(worker: LensList["workers"][number], now = Date.now()): boolean {
|
||||||
|
|
@ -23,8 +24,7 @@ export function listPollInterval(list: LensList | undefined, settingsOpen: boole
|
||||||
export function lensStatus(lens: Lens, connected: boolean): string {
|
export function lensStatus(lens: Lens, connected: boolean): string {
|
||||||
const active = lens.jobs?.find((job) => ["queued", "running"].includes(job.status ?? ""));
|
const active = lens.jobs?.find((job) => ["queued", "running"].includes(job.status ?? ""));
|
||||||
if (active) return connected ? active.stage ?? "Queued" : "Waiting for analyzer";
|
if (active) return connected ? active.stage ?? "Queued" : "Waiting for analyzer";
|
||||||
const spent = lens.budget_month === new Date().toISOString().slice(0, 7) ? lens.spent ?? 0 : 0;
|
if (budgetReached(lens)) return "Budget reached";
|
||||||
if (spent >= (lens.settings.monthly_budget ?? 100)) return "Budget reached";
|
|
||||||
const latest = lens.jobs?.[0];
|
const latest = lens.jobs?.[0];
|
||||||
if (latest?.status === "failed") return "Failed";
|
if (latest?.status === "failed") return "Failed";
|
||||||
if (latest?.status === "cancelled") return "Cancelled";
|
if (latest?.status === "cancelled") return "Cancelled";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue