diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationProgress.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationProgress.tsx index 5ce600c0b64..2200534357b 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationProgress.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationProgress.tsx @@ -1,125 +1,78 @@ "use client"; -import { useNow } from "@/hooks/useNow"; +import { Check } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - analysisElapsed, - analysisFraction, - analysisPace, - analysisProgress, - analysisStages, - remainingLabel, - stageDurations, -} from "../model/progress"; +import { analysisFraction, analysisPace, analysisProgress, remainingLabel } from "../model/progress"; import { useProgressSamples } from "./useProgressSamples"; -import { durationText } from "../model/format"; import { type Job } from "../model/types"; -import { cn } from "@/lib/cva.config"; -const steps = ["review runs", "find patterns", "check evidence"]; -const markers = { done: "✓", active: "▸", todo: "·" }; -const blocks = 32; +const STAGES = ["Review runs", "Find patterns", "Check evidence"]; -function stageState(index: number, current: number): keyof typeof markers { +function stageState(index: number, current: number) { if (index < current) return "done"; return index === current ? "active" : "todo"; } -export function InvestigationProgress({ job, onCancel }: { job: Job; onCancel?: () => void }) { - const now = useNow(1000); +export function InvestigationProgress({ job, now, onCancel }: { job: Job; now: number; onCancel?: () => void }) { const progress = analysisProgress(job); - const fraction = analysisFraction(progress); + const percent = Math.round(analysisFraction(progress) * 100); const samples = useProgressSamples(progress); - const pace = analysisPace(samples, now); - const percent = Math.round(fraction * 100); + const { secondsLeft } = analysisPace(samples, now); const queued = progress.step < 0; - const counts = analysisStages(job); - const durations = stageDurations(samples, job.created_at, now); - const filled = Math.round(fraction * blocks); - const stats = [ - ["eta", remainingLabel(pace.secondsLeft)], - ["rate", pace.perMinute === null ? "–" : `${Math.round(pace.perMinute)}/min`], - ["elapsed", analysisElapsed(job.created_at, now)], - ]; - return ( -
-
- +
+
+

{progress.title} - {!queued && · {progress.detail}} + · {progress.detail} +

+ + {queued ? "" : `${percent}% · ${remainingLabel(secondsLeft)} left`} - {onCancel && ( - - )}
-
-
- -
- {Array.from({ length: blocks }, (_, index) => ( - - ))} -
- - {percent}% -
-
    - {steps.map((label, index) => { +
    +
    +
    +
    +
      + {STAGES.map((label, index) => { const state = stageState(index, progress.step); - const { done, total } = counts[index]; - const seconds = durations[index]; return (
    1. - - {label} - - {state === "todo" || !total ? "–" : `${Math.min(done, total)}/${total}`} + + {state === "done" ? : index + 1} - {seconds === null ? "" : durationText(seconds)} + {label}
    2. ); })}
    -
    - {queued ? ( - {progress.detail} - ) : ( - stats.map(([key, value]) => ( - - {key} {value} - - )) - )} -
    + {onCancel && ( + + )}
); diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx index da2aa5a2c67..953da89f920 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx @@ -736,6 +736,25 @@ it("shows the actual saved failure and run context without opening backend logs" expect(failure.queryByText(/find the error in proxy and worker logs/)).not.toBeInTheDocument(); }); +it("reports what the selected run found, cost, and covered", async () => { + testQueryClient.clear(); + const coverage = { ...lens.jobs[0].coverage, selected: 40, screened: 38, partial: 2 }; + const job = { ...lens.jobs[0], cost: 1.25, coverage, finished_at: "2026-09-30T10:03:05Z" }; + proxy.get.mockImplementation(async (path) => { + if (path === "/lens") return { lenses: [{ ...lens, jobs: [job] }], workers: [], tracing_enabled: true }; + if (path === "/lens/lens/runs") return [job]; + return { data: [] }; + }); + renderWithProviders(); + const report = within(await screen.findByRole("region", { name: "Run report" })); + expect(report.getByRole("heading", { name: "Found 1 issue and 1 pattern across 38 runs" })).toBeVisible(); + const stat = (label: string) => report.getByText(label).parentElement; + expect(stat("Cost")).toHaveTextContent("$1.25"); + expect(stat("Duration")).toHaveTextContent("3m 5s"); + expect(stat("Runs reviewed")).toHaveTextContent("38 / 402 partial"); + expect(stat("Issues")).toHaveTextContent("11 high priority"); +}); + it("keeps a finding open to retry when its update fails", async () => { window.history.replaceState({}, "", "/lens/"); testQueryClient.clear(); diff --git a/ui/litellm-dashboard/src/components/lens/investigations/StepFeed.tsx b/ui/litellm-dashboard/src/components/lens/investigations/StepFeed.tsx index 6bd0956c2ca..a085f5b22b3 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/StepFeed.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/StepFeed.tsx @@ -1,11 +1,8 @@ "use client"; -import { ChevronDown } from "lucide-react"; -import { useState } from "react"; - import { cn } from "@/lib/cva.config"; -import { modelsUsed, stepLine, windowLabel } from "../model/inbox"; +import { stepLine } from "../model/inbox"; import type { Job } from "../model/types"; const STEP_MARK = { model: "✓", stage: "▸", error: "!" } as const; @@ -16,57 +13,29 @@ const STEP_TONE = { } as const; export function StepFeed({ job }: { job: Job }) { - const [open, setOpen] = useState(true); const steps = job.steps ?? []; - const models = modelsUsed(steps); - const calls = steps.filter((s) => s.kind === "model").length; return ( -
- - {open && ( -
    - {steps.length === 0 &&
  1. Waiting for the worker to start…
  2. } - {[...steps].reverse().map((step, index) => ( -
  3. - - - {new Date(step.at).toLocaleTimeString(undefined, { hour12: false })} - - {stepLine(step)} - {step.model} -
  4. - ))} -
- )} -
+ {stepLine(step)} + {step.model} + + ))} + ); } diff --git a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationDetail.tsx b/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationDetail.tsx index b9eb6730573..45a11aecac1 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationDetail.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationDetail.tsx @@ -4,13 +4,9 @@ import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { RunsTab } from "./RunsTab"; -import { InvestigationProgress } from "../InvestigationProgress"; -import { StepFeed } from "../StepFeed"; import { InvestigationSummary } from "./InvestigationSummary"; -import { InvestigationFailure } from "./InvestigationFailure"; -import { scopeLabel } from "../../model/format"; +import { RunReport } from "./RunReport"; import type { OwnedFinding } from "../../model/inbox"; -import { activeJob } from "../../model/status"; import { type Finding, type Lens } from "../../model/types"; import { FindingPanel } from "../FindingDetails"; import { useSectionRoute } from "../../route"; @@ -44,21 +40,23 @@ export function InvestigationDetail({ const { section, setSection } = useSectionRoute(); const snapshot = useRunSnapshot(lens); const { job, batchId, batchSettings, batchFindings, missingSnapshot } = snapshot; - const active = activeJob(lens.jobs); return (
-
+

{lens.settings.name}

-

{scopeLabel(lens.settings)}

+
{!readOnly && }
- - {active && } - {active && } - {job?.error && } + } + onCancel={readOnly ? undefined : onCancelRun} + />
@@ -67,7 +65,6 @@ export function InvestigationDetail({ Runs History - {section !== "activity" && }
{snapshot.error && (

diff --git a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationFailure.tsx b/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationFailure.tsx deleted file mode 100644 index ac6dc65babb..00000000000 --- a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationFailure.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { ComponentProps } from "react"; -import { runTime } from "../../model/format"; -import { type Job } from "../../model/types"; -import { cn } from "@/lib/cva.config"; - -export type InvestigationFailureProps = ComponentProps<"div"> & { - job: Job; - connected: boolean; -}; - -export function InvestigationFailure({ job, connected, className, ...props }: InvestigationFailureProps) { - return ( -

-

This investigation did not finish

-
-        {job.error}
-      
-
- Run details -
-
-
Run:
-
{job.id}
-
-
-
Model:
-
{job.settings.model}
-
-
-
Worker:
-
{connected ? "Connected now" : "Not connected"}
-
-
-
Started:
-
{runTime(job.created_at)}
-
-
-
-
- ); -} diff --git a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationSummary.tsx b/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationSummary.tsx index 5f2101139f9..c72a80e9a14 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationSummary.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/detail/InvestigationSummary.tsx @@ -1,49 +1,23 @@ -import type { ComponentProps } from "react"; -import { NextCheck } from "./JobMeta"; -import { lensStatus } from "../../model/status"; -import { runTime } from "../../model/format"; +"use client"; + +import { useNow } from "@/hooks/useNow"; +import { durationLabel, money, scopeLabel } from "../../model/format"; +import { nextCheckStatus } from "../../model/status"; import { type Lens } from "../../model/types"; -import { cn } from "@/lib/cva.config"; -export type InvestigationSummaryProps = ComponentProps<"div"> & { - lens: Lens; - connected: boolean; -}; - -export function InvestigationSummary({ lens, connected, className, ...props }: InvestigationSummaryProps) { - const lastCompleted = lens.jobs.find((job) => job.status === "completed"); - const lastSuccess = lastCompleted?.finished_at ?? lens.last_scan_at; - const spent = lens.budget_month === new Date().toISOString().slice(0, 7) ? lens.spent ?? 0 : 0; +export function InvestigationSummary({ lens }: { lens: Lens }) { + const now = useNow(15000); + const { settings } = lens; + const spent = lens.budget_month === new Date(now).toISOString().slice(0, 7) ? lens.spent ?? 0 : 0; + const parts = [ + settings.enabled ? `Every ${durationLabel(settings.interval_minutes)}` : "One-off", + nextCheckStatus(lens, now), + `${money(spent)} of ${money(settings.monthly_budget ?? 100)} this month`, + ].filter(Boolean); return ( -
- - Latest run:{" "} - - {lensStatus(lens, connected)} - - - - Last success: {lastSuccess ? runTime(lastSuccess) : "Not yet"} - - - This month:{" "} - - ${spent.toFixed(3)} / ${lens.settings.monthly_budget ?? 100} - - - {lens.settings.enabled && ( - - Monitoring every {lens.settings.interval_minutes} minutes - - - )} -
+

+ {scopeLabel(settings)} + {parts.map((part) => ` · ${part}`)} +

); } diff --git a/ui/litellm-dashboard/src/components/lens/investigations/detail/RunReport.tsx b/ui/litellm-dashboard/src/components/lens/investigations/detail/RunReport.tsx new file mode 100644 index 00000000000..180dab791c2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/investigations/detail/RunReport.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import type { ReactNode } from "react"; + +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { useNow } from "@/hooks/useNow"; + +import { analysisElapsed } from "../../model/progress"; +import { money } from "../../model/format"; +import { modelsUsed, shortTime, windowLabel } from "../../model/inbox"; +import type { Finding, Job } from "../../model/types"; +import { InvestigationProgress } from "../InvestigationProgress"; +import { StepFeed } from "../StepFeed"; + +export interface RunReportProps { + readonly job: Job | undefined; + /** Null when the run predates saved result snapshots. */ + readonly findings: readonly Finding[] | null | undefined; + readonly connected: boolean; + readonly picker: ReactNode; + readonly onCancel?: () => void; +} + +const STATUS_LABEL: Record = { + queued: "Queued", + running: "Running", + completed: "Completed", + failed: "Failed", + cancelled: "Cancelled", +}; + +const plural = (count: number, noun: string) => `${count.toLocaleString()} ${noun}${count === 1 ? "" : "s"}`; +const isActive = (job: Job) => job.status === "queued" || job.status === "running"; + +function headline(job: Job, findings: readonly Finding[] | null | undefined, connected: boolean): string { + const runs = plural(job.coverage?.screened ?? 0, "run"); + const issues = findings?.filter((f) => f.kind === "issue").length ?? 0; + const patterns = findings?.filter((f) => f.kind === "pattern").length ?? 0; + switch (job.status) { + case "queued": + return connected ? "Waiting for the analyzer to pick this up" : "Waiting for an analyzer to connect"; + case "running": + return "Investigating now"; + case "failed": + return "Stopped before it finished"; + case "cancelled": + return `Cancelled after reviewing ${runs}`; + case "completed": + if (!findings) return `Reviewed ${runs}`; + if (issues + patterns === 0) return `Nothing found across ${runs}`; + return `Found ${[issues && plural(issues, "issue"), patterns && plural(patterns, "pattern")].filter(Boolean).join(" and ")} across ${runs}`; + } +} + +function Stat({ label, value, note }: { label: string; value: string; note?: string }) { + return ( +
+
{label}
+
{value}
+ {note &&
{note}
} +
+ ); +} + +function coverageNote(job: Job): string | undefined { + const { partial = 0, unassessable = 0, inconclusive = 0 } = job.coverage ?? {}; + const notes = [ + partial && `${partial} partial`, + unassessable && `${unassessable} unreadable`, + inconclusive && `${inconclusive} inconclusive`, + ].filter(Boolean); + return notes.length ? notes.join(" · ") : undefined; +} + +function issueNote(job: Job, high: number): string | undefined { + if (isActive(job)) return "When the run finishes"; + return high ? `${high} high priority` : undefined; +} + +function RunStats({ job, findings, now }: { job: Job; findings: readonly Finding[] | null | undefined; now: number }) { + const active = isActive(job); + const known = job.status === "completed" && findings != null; + const calls = (job.steps ?? []).filter((step) => step.kind === "model").length; + const { screened = 0, selected = 0 } = job.coverage ?? {}; + const issues = findings?.filter((f) => f.kind === "issue") ?? []; + const high = issues.filter((f) => f.priority === "high").length; + const end = job.finished_at ? Date.parse(job.finished_at) : now; + return ( +
+ + + + +
+ ); +} + +function RunFailure({ job, connected }: { job: Job; connected: boolean }) { + return ( +
+
+        {job.error}
+      
+

+ + Run {job.id} + + + Model {job.settings.model} + + Worker {connected ? "connected now" : "not connected"} +

+
+ ); +} + +function RunLog({ job }: { job: Job }) { + const models = modelsUsed(job.steps ?? []); + return ( + +
+ + + Activity log + + + {job.trigger === "manual" ? "Manual" : "Scheduled"} · {windowLabel(job)} + {models.length > 0 && ` · ${models.join(", ")}`} + +
+ + + +
+ ); +} + +export function RunReport({ job, findings, connected, picker, onCancel }: RunReportProps) { + const active = job !== undefined && isActive(job); + const now = useNow(active ? 1000 : 60000); + if (!job) { + return ( +
+

No runs yet. Run it now to get the first report.

+ {picker} +
+ ); + } + return ( +
+
+
+

+ {STATUS_LABEL[job.status]} +

+

{headline(job, findings, connected)}

+
+ {picker} +
+ {active && } + {job.error && } + + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/lens/model/inbox.ts b/ui/litellm-dashboard/src/components/lens/model/inbox.ts index 80c2a2df32a..078f639901f 100644 --- a/ui/litellm-dashboard/src/components/lens/model/inbox.ts +++ b/ui/litellm-dashboard/src/components/lens/model/inbox.ts @@ -89,7 +89,8 @@ const WINDOW_FORMAT: Intl.DateTimeFormatOptions = { minute: "2-digit", }; +export const shortTime = (iso: string) => new Date(iso).toLocaleString(undefined, WINDOW_FORMAT); + export function windowLabel(job: Job): string { - const fmt = (iso: string) => new Date(iso).toLocaleString(undefined, WINDOW_FORMAT); - return `${fmt(job.start)} → ${fmt(job.end)}`; + return `${shortTime(job.start)} → ${shortTime(job.end)}`; }