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";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { analysisFraction, analysisPace, analysisProgress, remainingLabel } from "../model/progress";
|
||||
import {
|
||||
analysisElapsed,
|
||||
analysisFraction,
|
||||
analysisPace,
|
||||
analysisProgress,
|
||||
analysisStages,
|
||||
remainingLabel,
|
||||
} from "../model/progress";
|
||||
import { useProgressSamples } from "./useProgressSamples";
|
||||
import { type Job } from "../model/types";
|
||||
|
||||
const STAGES = ["Review runs", "Find patterns", "Check evidence"];
|
||||
|
||||
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 }) {
|
||||
export function InvestigationProgress({ job, now }: { job: Job; now: number }) {
|
||||
const progress = analysisProgress(job);
|
||||
const percent = Math.round(analysisFraction(progress) * 100);
|
||||
const samples = useProgressSamples(progress);
|
||||
const { secondsLeft } = analysisPace(samples, now);
|
||||
const { secondsLeft } = analysisPace(useProgressSamples(progress), now);
|
||||
const queued = progress.step < 0;
|
||||
return (
|
||||
<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>
|
||||
<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`}
|
||||
<section aria-label="Analysis progress" className="space-y-2">
|
||||
<div className="flex items-baseline justify-between gap-3 tabular-nums">
|
||||
<span
|
||||
role="progressbar"
|
||||
aria-label="Investigation progress"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
aria-valuetext={`${progress.title}: ${progress.detail}`}
|
||||
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>
|
||||
<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);
|
||||
return (
|
||||
<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}
|
||||
<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
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-500"
|
||||
style={{ width: `${stage.fill * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2 text-xs text-muted-foreground group-data-[state=active]:text-foreground">
|
||||
<span className="truncate group-data-[state=active]:font-medium">{stage.label}</span>
|
||||
{stage.state !== "todo" && stage.total > 0 && (
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{stage.done.toLocaleString()} / {stage.total.toLocaleString()}
|
||||
</span>
|
||||
{label}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{onCancel && (
|
||||
<Button variant="ghost" size="xs" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,8 +297,12 @@ it("runs with saved settings from Run now without opening setup, then accepts an
|
|||
});
|
||||
proxy.post.mockResolvedValue(lens);
|
||||
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 />);
|
||||
await user.click(await screen.findByRole("button", { name: "Run now" }));
|
||||
await runNow();
|
||||
const choices = await screen.findByRole("dialog", { name: "Run now" });
|
||||
expect(within(choices).getByRole("button", { name: "Since last run" })).toHaveAttribute("aria-pressed", "true");
|
||||
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());
|
||||
|
||||
proxy.post.mockClear();
|
||||
await user.click(screen.getByRole("button", { name: "Run now" }));
|
||||
await runNow();
|
||||
const custom = await screen.findByRole("dialog", { name: "Run now" });
|
||||
fireEvent.change(within(custom).getByRole("combobox", { name: "Agent" }), { target: { value: "billing" } });
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
window.history.replaceState({}, "", "/lens/");
|
||||
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 }]);
|
||||
});
|
||||
|
||||
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();
|
||||
const running = { ...lens.jobs[0], id: "live", status: "running" as const, stage: "Reading executions" };
|
||||
proxy.get.mockImplementation(async (path) => {
|
||||
|
|
@ -806,7 +826,7 @@ it("cancels the running job from the progress banner", async () => {
|
|||
});
|
||||
const user = userEvent.setup();
|
||||
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()));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function InvestigationsView({ readOnly = false }: InvestigationsViewProps
|
|||
const actions = useInvestigationActions();
|
||||
const { dialog, target, openDialog, closeDialog } = useDialogRoute();
|
||||
const { issueKey, setIssueKey } = useIssueRoute();
|
||||
const { lensId, setLensId } = useLensRoute();
|
||||
const { lensId, setLensId, setTab } = useLensRoute();
|
||||
const list = useQuery(lensQueries.list(api));
|
||||
const status = useLensReadiness(true);
|
||||
const { connected } = status;
|
||||
|
|
@ -162,6 +162,7 @@ export function InvestigationsView({ readOnly = false }: InvestigationsViewProps
|
|||
onEnableMonitoring={() => openDialog("monitoring")}
|
||||
onCancelRun={() => void actions.cancelRun(row.lens)}
|
||||
onRunNow={() => openDialog("run_now")}
|
||||
onConnectWorker={() => setTab("settings")}
|
||||
onReviewFinding={(owned, 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 () => {
|
||||
const user = userEvent.setup();
|
||||
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();
|
||||
await user.click(screen.getByRole("button", { name: "Investigation actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Pause monitoring" }));
|
||||
|
|
@ -77,7 +78,7 @@ describe("InvestigationActions", () => {
|
|||
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 = {
|
||||
id: "job",
|
||||
status: "queued" as const,
|
||||
|
|
@ -95,7 +96,9 @@ describe("InvestigationActions", () => {
|
|||
steps: [],
|
||||
findings: [],
|
||||
};
|
||||
const user = userEvent.setup();
|
||||
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 onCancelRun: () => void;
|
||||
readonly onRunNow: () => void;
|
||||
readonly onConnectWorker: () => void;
|
||||
}
|
||||
|
||||
export type InvestigationActionsProps = Omit<InvestigationIntents, "onCancelRun"> & {
|
||||
export type InvestigationActionsProps = Omit<InvestigationIntents, "onCancelRun" | "onConnectWorker"> & {
|
||||
readonly lens: Lens;
|
||||
readonly ready: boolean;
|
||||
readonly busy: boolean;
|
||||
|
|
@ -37,37 +38,35 @@ export function InvestigationActions({
|
|||
onRunNow,
|
||||
}: InvestigationActionsProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" aria-label="Investigation actions" />}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Settings2 />
|
||||
Edit investigation
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" aria-label="Investigation actions" />}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem disabled={busy || hasActiveJob(lens.jobs) || !ready} onClick={onRunNow}>
|
||||
<Play />
|
||||
Run now
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Settings2 />
|
||||
Edit investigation
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!ready} onClick={onDuplicate}>
|
||||
<Copy />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
{lens.settings.enabled ? (
|
||||
<DropdownMenuItem disabled={busy} onClick={onPause}>
|
||||
<Pause />
|
||||
Pause monitoring
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!ready} onClick={onDuplicate}>
|
||||
<Copy />
|
||||
Duplicate
|
||||
) : (
|
||||
<DropdownMenuItem disabled={!ready || busy} onClick={onEnableMonitoring}>
|
||||
<Play />
|
||||
Enable monitoring
|
||||
</DropdownMenuItem>
|
||||
{lens.settings.enabled ? (
|
||||
<DropdownMenuItem disabled={busy} onClick={onPause}>
|
||||
<Pause />
|
||||
Pause monitoring
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem disabled={!ready || busy} onClick={onEnableMonitoring}>
|
||||
<Play />
|
||||
Enable monitoring
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button disabled={busy || hasActiveJob(lens.jobs) || !ready} onClick={onRunNow}>
|
||||
<Play className="size-3" />
|
||||
Run now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ import { Button } from "@/components/ui/button";
|
|||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { RunsTab } from "./RunsTab";
|
||||
import { InvestigationSummary } from "./InvestigationSummary";
|
||||
import { RunReport } from "./RunReport";
|
||||
import { RunReport, type RunActionHandlers } from "./RunReport";
|
||||
import type { OwnedFinding } from "../../model/inbox";
|
||||
import { type Finding, type Lens } from "../../model/types";
|
||||
import { FindingPanel } from "../FindingDetails";
|
||||
import { useSectionRoute } from "../../route";
|
||||
import { mergeFeedback } from "../../model/findings";
|
||||
import { useFindingFilters, useSectionRoute } from "../../route";
|
||||
import { useRunSnapshot } from "../useRunSnapshot";
|
||||
|
||||
import { InvestigationActions, type InvestigationIntents } from "./InvestigationActions";
|
||||
|
|
@ -38,8 +39,22 @@ export function InvestigationDetail({
|
|||
...intents
|
||||
}: InvestigationDetailProps) {
|
||||
const { section, setSection } = useSectionRoute();
|
||||
const { setKind, setStatus } = useFindingFilters();
|
||||
const snapshot = useRunSnapshot(lens);
|
||||
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 (
|
||||
<div>
|
||||
<section className="min-w-0 space-y-5">
|
||||
|
|
@ -51,11 +66,14 @@ export function InvestigationDetail({
|
|||
{!readOnly && <InvestigationActions lens={lens} ready={ready} busy={busy} {...intents} />}
|
||||
</div>
|
||||
<RunReport
|
||||
lens={lens}
|
||||
job={job}
|
||||
findings={job?.findings}
|
||||
findings={job?.findings && mergeFeedback(job.findings, lens.findings ?? [])}
|
||||
connected={connected}
|
||||
ready={ready}
|
||||
busy={busy}
|
||||
picker={<RunPicker lens={lens} job={job} />}
|
||||
onCancel={readOnly ? undefined : onCancelRun}
|
||||
actions={readOnly ? undefined : runActions}
|
||||
/>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -1,56 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
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 { 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 { NEXT_ACTION, openIssues, runSituation, type RunAction, type RunSituation } from "../../model/runSituation";
|
||||
import type { Finding, Job, Lens } from "../../model/types";
|
||||
import { InvestigationProgress } from "../InvestigationProgress";
|
||||
import { StepFeed } from "../StepFeed";
|
||||
|
||||
export type RunActionHandlers = Record<RunAction, () => void>;
|
||||
|
||||
export interface RunReportProps {
|
||||
readonly lens: Lens;
|
||||
readonly job: Job | undefined;
|
||||
/** Null when the run predates saved result snapshots. */
|
||||
readonly findings: readonly Finding[] | null | undefined;
|
||||
readonly connected: boolean;
|
||||
readonly ready: boolean;
|
||||
readonly busy: boolean;
|
||||
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> = {
|
||||
queued: "Queued",
|
||||
running: "Running",
|
||||
completed: "Completed",
|
||||
failed: "Failed",
|
||||
cancelled: "Cancelled",
|
||||
interface Facts {
|
||||
readonly runs: string;
|
||||
readonly found: string;
|
||||
readonly openIssues: number;
|
||||
}
|
||||
|
||||
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 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 facts(job: Job | undefined, findings: readonly Finding[] | null | undefined): Facts {
|
||||
const count = (kind: Finding["kind"], noun: string) => {
|
||||
const total = findings?.filter((f) => f.kind === kind).length ?? 0;
|
||||
return total ? plural(total, noun) : "";
|
||||
};
|
||||
return {
|
||||
runs: plural(job?.coverage?.screened ?? 0, "run"),
|
||||
found: [count("issue", "issue"), count("pattern", "pattern")].filter(Boolean).join(" and "),
|
||||
openIssues: openIssues(findings),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function issueNote(job: Job, high: number): string | undefined {
|
||||
if (isActive(job)) return "When the run finishes";
|
||||
return high ? `${high} high priority` : undefined;
|
||||
const isActive = (job: Job) => job.status === "queued" || job.status === "running";
|
||||
|
||||
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 }) {
|
||||
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) : "–"}
|
||||
value={isActive(job) || job.finished_at ? analysisElapsed(job.created_at, end) : "–"}
|
||||
note={`Started ${shortTime(job.created_at)}`}
|
||||
/>
|
||||
<Stat
|
||||
|
|
@ -99,7 +183,7 @@ function RunStats({ job, findings, now }: { job: Job; findings: readonly Finding
|
|||
value={`${screened.toLocaleString()} / ${selected.toLocaleString()}`}
|
||||
note={coverageNote(job)}
|
||||
/>
|
||||
<Stat label="Issues" value={known ? issues.length.toLocaleString() : "–"} note={issueNote(job, high)} />
|
||||
<Stat label="Issues" {...issueStat(job, findings)} />
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
|
@ -147,35 +231,53 @@ function RunLog({ job }: { job: Job }) {
|
|||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
function NextAction({
|
||||
action,
|
||||
facts: known,
|
||||
ready,
|
||||
busy,
|
||||
onClick,
|
||||
}: {
|
||||
action: RunAction;
|
||||
facts: Facts;
|
||||
ready: boolean;
|
||||
busy: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { label, icon: Icon, variant, needsReady } = ACTIONS[action];
|
||||
return (
|
||||
<Button variant={variant} disabled={busy || (needsReady && !ready)} onClick={onClick}>
|
||||
<Icon className="size-3.5" />
|
||||
{label(known)}
|
||||
</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 (
|
||||
<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>
|
||||
<p className={cn("text-xs font-medium", TONE_CLASS[view.tone])}>{view.status}</p>
|
||||
<h3 className="mt-0.5 text-base font-semibold">{view.headline(known)}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{job && picker}
|
||||
{action && actions && (
|
||||
<NextAction action={action} facts={known} ready={ready} busy={busy} onClick={actions[action]} />
|
||||
)}
|
||||
</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} />
|
||||
{job && view.body === "progress" && <InvestigationProgress key={job.id} job={job} now={now} />}
|
||||
{job?.error && view.body === "error" && <RunFailure job={job} connected={connected} />}
|
||||
{job && <RunStats job={job} findings={findings} now={now} />}
|
||||
{job && <RunLog job={job} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import {
|
|||
analysisElapsed,
|
||||
analysisProgress,
|
||||
analysisFraction,
|
||||
analysisStages,
|
||||
analysisPace,
|
||||
remainingLabel,
|
||||
stageDurations,
|
||||
} from "./progress";
|
||||
import { type Job } from "./types";
|
||||
|
||||
|
|
@ -207,20 +207,27 @@ describe("Analysis pace", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Stage durations", () => {
|
||||
const start = Date.parse("2026-09-30T12:00:00Z");
|
||||
const createdAt = "2026-09-30T12:00:00Z";
|
||||
|
||||
it("times finished stages from the transitions it saw and the active stage up to now", () => {
|
||||
const samples = [
|
||||
{ at: start + 5000, step: 0, done: 10, fraction: 0.1 },
|
||||
{ at: start + 124000, step: 1, done: 0, fraction: 0.6 },
|
||||
];
|
||||
expect(stageDurations(samples, createdAt, start + 145000)).toEqual([124, 21, null]);
|
||||
describe("Analysis stages", () => {
|
||||
it("fills finished stages, fills the active one by its own count, and leaves later ones empty", () => {
|
||||
const grouping = {
|
||||
...job,
|
||||
stage: "Grouping observations",
|
||||
coverage: { ...coverage, selected: 40, screened: 40, grouping_batches: 4, grouped_batches: 1 },
|
||||
};
|
||||
expect(analysisStages(grouping).map(({ state, fill, done, total }) => ({ state, fill, done, total }))).toEqual([
|
||||
{ state: "done", fill: 1, done: 40, total: 40 },
|
||||
{ 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", () => {
|
||||
const samples = [{ at: start + 90000, step: 1, done: 2, fraction: 0.7 }];
|
||||
expect(stageDurations(samples, createdAt, start + 100000)).toEqual([null, null, null]);
|
||||
it("weights the stages so the bar agrees with the overall percentage", () => {
|
||||
const grouping = {
|
||||
...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 }[] {
|
||||
const {
|
||||
screened = 0,
|
||||
selected = 0,
|
||||
grouped_batches = 0,
|
||||
grouping_batches = 0,
|
||||
investigated = 0,
|
||||
candidates = 0,
|
||||
} = job.coverage ?? {};
|
||||
return [
|
||||
{ done: screened, total: selected },
|
||||
{ done: grouped_batches, total: grouping_batches },
|
||||
{ done: investigated, total: candidates },
|
||||
];
|
||||
type Coverage = Job["coverage"];
|
||||
|
||||
/** The three analysis stages in order, with their share of the overall progress and how each one counts its work. */
|
||||
export const ANALYSIS_STAGES = [
|
||||
{ label: "Review runs", weight: 0.6, done: (c: Coverage) => c.screened, total: (c: Coverage) => c.selected },
|
||||
{
|
||||
label: "Find patterns",
|
||||
weight: 0.2,
|
||||
done: (c: Coverage) => c.grouped_batches,
|
||||
total: (c: Coverage) => c.grouping_batches,
|
||||
},
|
||||
{ label: "Check evidence", weight: 0.2, done: (c: Coverage) => c.investigated, total: (c: Coverage) => c.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 {
|
||||
|
|
@ -82,16 +110,14 @@ export interface ProgressSample {
|
|||
fraction: number;
|
||||
}
|
||||
|
||||
const stageWeights = [0.6, 0.2, 0.2];
|
||||
|
||||
export function analysisFraction({
|
||||
step,
|
||||
done,
|
||||
total,
|
||||
}: Pick<ReturnType<typeof analysisProgress>, "step" | "done" | "total">): number {
|
||||
if (step < 0) return 0;
|
||||
const before = stageWeights.slice(0, step).reduce((sum, weight) => sum + weight, 0);
|
||||
return before + stageWeights[step] * (total ? Math.min(1, done / total) : 0);
|
||||
const before = ANALYSIS_STAGES.slice(0, step).reduce((sum, stage) => sum + stage.weight, 0);
|
||||
return before + ANALYSIS_STAGES[step].weight * (total ? Math.min(1, done / total) : 0);
|
||||
}
|
||||
|
||||
function windowStart(samples: readonly ProgressSample[], now: number): ProgressSample | undefined {
|
||||
|
|
@ -115,22 +141,6 @@ export function analysisPace(samples: readonly ProgressSample[], now: number) {
|
|||
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 {
|
||||
if (seconds === null) return "estimating";
|
||||
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 { budgetReached } from "./runSituation";
|
||||
import type { Job, Lens, LensList } from "./types";
|
||||
|
||||
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 {
|
||||
const active = lens.jobs?.find((job) => ["queued", "running"].includes(job.status ?? ""));
|
||||
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 (spent >= (lens.settings.monthly_budget ?? 100)) return "Budget reached";
|
||||
if (budgetReached(lens)) return "Budget reached";
|
||||
const latest = lens.jobs?.[0];
|
||||
if (latest?.status === "failed") return "Failed";
|
||||
if (latest?.status === "cancelled") return "Cancelled";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue