diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensProgress.tsx b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensProgress.tsx index 8249f284ada..3e01671a832 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensProgress.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensProgress.tsx @@ -1,11 +1,31 @@ "use client"; import { useEffect, useState } from "react"; -import { Check, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { analysisElapsed, analysisProgress, nextCheckStatus, type Lens, type Job } from "./lensData"; +import { + analysisElapsed, + analysisFraction, + analysisPace, + analysisProgress, + analysisStages, + durationText, + nextCheckStatus, + remainingLabel, + stageDurations, + type Lens, + type Job, + type ProgressSample, +} from "./lensData"; -const steps = ["Review runs", "Find patterns", "Check evidence"]; +const steps = ["review runs", "find patterns", "check evidence"]; +const markers = { done: "✓", active: "▸", todo: "·" }; +const rowText = { done: "text-foreground", active: "font-medium text-foreground", todo: "text-muted-foreground" }; +const blocks = 32; + +function stageState(index: number, current: number): keyof typeof markers { + if (index < current) return "done"; + return index === current ? "active" : "todo"; +} export function LensProgress({ job, onCancel }: { job: Job; onCancel?: () => void }) { const [now, setNow] = useState(Date.now); @@ -14,57 +34,98 @@ export function LensProgress({ job, onCancel }: { job: Job; onCancel?: () => voi return () => window.clearInterval(timer); }, []); const progress = analysisProgress(job); - const percent = progress.total ? Math.min(100, (progress.done / progress.total) * 100) : undefined; + const fraction = analysisFraction(progress); + const [samples, setSamples] = useState([]); + const latest = samples.at(-1); + if (!latest || latest.step !== progress.step || latest.done !== progress.done) { + setSamples([...samples, { at: now, step: progress.step, done: progress.done, fraction }].slice(-120)); + } + const pace = analysisPace(samples, now); + const percent = Math.round(fraction * 100); + 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 ( -
-
-
-
- - {analysisElapsed(job.created_at, now)} elapsed +
+
+ + {progress.title} + {!queued && · {progress.detail}} -
-
    - {steps.map((label, index) => ( -
  1. -
    - - {index < progress.step && } - {label} - -
  2. - ))} -
-
-

{progress.detail}

-
-
-
-
-
- {job.status === "running" && You can leave this page while the investigation runs.} {onCancel && ( - )}
+
+
+ +
+ {Array.from({ length: blocks }, (_, index) => ( + + ))} +
+ + {percent}% +
+
    + {steps.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}`} + + {seconds === null ? "" : durationText(seconds)} +
  2. + ); + })} +
+
+ {queued ? ( + {progress.detail} + ) : ( + stats.map(([key, value]) => ( + + {key} {value} + + )) + )} +
+
); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/lensData.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/lensData.test.ts index 3be6164fce2..25276fe5a44 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/lensData.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/lensData.test.ts @@ -4,6 +4,10 @@ import { workerConnected, type LensList, analysisProgress, + analysisFraction, + analysisPace, + remainingLabel, + stageDurations, normalizeFilters, sortedFindings, type Finding, @@ -136,6 +140,96 @@ describe("Analysis progress", () => { }); }); +describe("Analysis pace", () => { + it("fills the bar left to right across stages without jumping backwards at a stage boundary", () => { + const endOfReview = analysisFraction({ step: 0, done: 20, total: 20 }); + const startOfGrouping = analysisFraction({ step: 1, done: 0, total: 4 }); + expect(analysisFraction({ step: -1, done: 0, total: 0 })).toBe(0); + expect(analysisFraction({ step: 0, done: 10, total: 20 })).toBeLessThan(endOfReview); + expect(startOfGrouping).toBeCloseTo(endOfReview); + expect(analysisFraction({ step: 2, done: 5, total: 5 })).toBeCloseTo(1); + }); + + it("measures speed within the current stage and projects time left from overall progress", () => { + const start = Date.parse("2026-09-30T12:00:00Z"); + const pace = analysisPace( + [ + { at: start, step: 0, done: 0, fraction: 0 }, + { at: start + 30000, step: 0, done: 30, fraction: 0.25 }, + ], + start + 30000, + ); + expect(pace.perMinute).toBe(60); + expect(pace.secondsLeft).toBe(90); + }); + + it("waits for enough samples instead of showing a wild first estimate", () => { + const start = Date.parse("2026-09-30T12:00:00Z"); + expect(analysisPace([{ at: start, step: 0, done: 1, fraction: 0.01 }], start + 2000)).toEqual({ + perMinute: null, + secondsLeft: null, + }); + expect(remainingLabel(null)).toBe("estimating"); + }); + + it("lengthens the estimate while progress stalls", () => { + const start = Date.parse("2026-09-30T12:00:00Z"); + const samples = [ + { at: start, step: 0, done: 0, fraction: 0 }, + { at: start + 10000, step: 0, done: 10, fraction: 0.1 }, + ]; + const moving = analysisPace(samples, start + 10000).secondsLeft ?? 0; + const stalled = analysisPace(samples, start + 40000).secondsLeft ?? 0; + expect(stalled).toBeGreaterThan(moving); + }); + + it("still estimates when the worker reports progress less than once a minute", () => { + const start = Date.parse("2026-09-30T12:00:00Z"); + const pace = analysisPace( + [ + { at: start, step: 2, done: 0, fraction: 0.8 }, + { at: start + 90000, step: 2, done: 1, fraction: 0.85 }, + ], + start + 90000, + ); + expect(pace.secondsLeft).toBeCloseTo(270); + expect(pace.perMinute).toBeCloseTo(2 / 3); + }); + + it("measures from the last minute rather than the whole run once updates are frequent", () => { + const start = Date.parse("2026-09-30T12:00:00Z"); + const samples = [ + { at: start, step: 0, done: 0, fraction: 0 }, + { at: start + 120000, step: 0, done: 12, fraction: 0.06 }, + { at: start + 180000, step: 0, done: 72, fraction: 0.36 }, + ]; + expect(analysisPace(samples, start + 180000).perMinute).toBe(60); + }); + + it("rounds remaining time up so the label never promises less than the estimate", () => { + expect(remainingLabel(61)).toBe("~2m"); + expect(remainingLabel(30)).toBe("<1m"); + }); +}); + +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]); + }); + + 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]); + }); +}); + describe("Lens selection and findings", () => { it("preserves literal equals signs in a metadata value", () => { expect(normalizeFilters([{ key: " swarm ", value: " research=v2 " }])).toEqual([ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/lensData.ts b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/lensData.ts index 1e30dc75a38..7aa2df9edbe 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/lensData.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/lensData.ts @@ -126,8 +126,90 @@ 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 }, + ]; +} + +export interface ProgressSample { + at: number; + step: number; + done: number; + fraction: number; +} + +export const stageWeights = [0.6, 0.2, 0.2]; + +export function analysisFraction({ + step, + done, + total, +}: Pick, "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); +} + +function windowStart(samples: readonly ProgressSample[], now: number): ProgressSample | undefined { + return samples.findLast((sample) => now - sample.at >= 60000) ?? samples[0]; +} + +export function analysisPace(samples: readonly ProgressSample[], now: number) { + const latest = samples.at(-1); + if (!latest) return { perMinute: null, secondsLeft: null }; + const first = windowStart( + samples.filter((sample) => sample.step === latest.step), + now, + ); + const anchor = windowStart(samples, now); + if (!first || !anchor) return { perMinute: null, secondsLeft: null }; + const stepMinutes = (now - first.at) / 60000; + const perMinute = stepMinutes >= 1 / 6 ? (latest.done - first.done) / stepMinutes : null; + const spanSeconds = (now - anchor.at) / 1000; + const gained = latest.fraction - anchor.fraction; + const secondsLeft = spanSeconds >= 10 && gained > 0 ? ((1 - latest.fraction) * spanSeconds) / gained : null; + 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"; + if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`; + return `~${Math.floor(seconds / 3600)}h ${Math.ceil((seconds % 3600) / 60)}m`; +} + export function analysisElapsed(createdAt: string, now: number): string { - const seconds = Math.max(0, Math.floor((now - Date.parse(createdAt)) / 1000)); + return durationText(Math.max(0, Math.floor((now - Date.parse(createdAt)) / 1000))); +} + +export function durationText(seconds: number): string { if (!Number.isFinite(seconds)) return "0s"; if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;