diff --git a/ui/litellm-dashboard/src/components/lens/investigations/live/ConclusionsPanel.tsx b/ui/litellm-dashboard/src/components/lens/investigations/live/ConclusionsPanel.tsx new file mode 100644 index 00000000000..42fda962ffa --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/investigations/live/ConclusionsPanel.tsx @@ -0,0 +1,97 @@ +import { useEffect, useRef, type RefObject } from "react"; + +import { cn } from "@/lib/cva.config"; + +import { type Conclusion } from "../../model/live"; + +export interface Arrival { + key: string; + checkId: string; + text: string; +} + +const FLY_MS = 550; + +function fly(from: DOMRect, to: DOMRect, text: string) { + const node = document.createElement("div"); + node.textContent = text; + node.setAttribute("aria-hidden", "true"); + node.className = + "pointer-events-none fixed z-floating truncate rounded-lg border border-[#e5484d]/30 bg-background px-3 py-2 text-xs font-semibold text-[#e5484d] shadow-lg"; + Object.assign(node.style, { left: `${from.left}px`, top: `${from.top}px`, width: `${from.width}px` }); + document.body.appendChild(node); + const animation = node.animate( + [ + { transform: "translate(0, 0)", width: `${from.width}px`, opacity: 1 }, + { + transform: `translate(${to.left - from.left}px, ${to.top - from.top}px)`, + width: `${Math.max(200, to.width)}px`, + opacity: 0.2, + }, + ], + { duration: FLY_MS, easing: "cubic-bezier(.3,.7,.2,1)" }, + ); + animation.onfinish = () => node.remove(); + return () => node.remove(); +} + +function useFlyIn(arrival: Arrival | null, from: RefObject, list: RefObject) { + useEffect(() => { + const source = from.current; + const box = list.current; + if (!arrival || !source || !box || typeof HTMLElement.prototype.animate !== "function") return; + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; + const target = box.querySelector(`[data-check="${CSS.escape(arrival.checkId)}"]`) ?? box; + return fly(source.getBoundingClientRect(), target.getBoundingClientRect(), arrival.text); + }, [arrival, from, list]); +} + +export function ConclusionsPanel({ + groups, + reviewed, + arrival, + verdictRef, +}: { + groups: readonly Conclusion[]; + reviewed: number; + arrival: Arrival | null; + verdictRef: RefObject; +}) { + const list = useRef(null); + useFlyIn(arrival, verdictRef, list); + return ( +
    + {groups.map((group) => ( +
  1. +

    +

    +

    + + {group.count} {group.count === 1 ? "trace" : "traces"} + + {Math.round((group.count / Math.max(1, reviewed)) * 100)}% of reviewed +

    + {group.latest !== group.label && ( +

    + latest: {group.latest} +

    + )} +
  2. + ))} + {!groups.length &&
  3. Nothing flagged yet.
  4. } +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/lens/investigations/live/LiveRun.tsx b/ui/litellm-dashboard/src/components/lens/investigations/live/LiveRun.tsx new file mode 100644 index 00000000000..caf00f38511 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/investigations/live/LiveRun.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useMemo, useRef, useState, type CSSProperties } from "react"; + +import { Button } from "@/components/ui/button"; +import { useNow } from "@/hooks/useNow"; +import { cn } from "@/lib/cva.config"; + +import { sampleBatch } from "../../__fixtures__/reviews"; +import { durationText, money } from "../../model/format"; +import { modelsUsed } from "../../model/inbox"; +import { + analysisModel, + conclusions, + isCompact, + liveStats, + outcome, + providerAccent, + providerOf, + rateLabel, + reviewKey, + shownCount, + tokenLabel, + verdictLine, +} from "../../model/live"; +import { analysisProgress } from "../../model/progress"; +import type { Job, Review } from "../../model/types"; +import { ConclusionsPanel, type Arrival } from "./ConclusionsPanel"; +import { ProviderBadge } from "./ProviderBadge"; +import { ReadingPanel } from "./ReadingPanel"; +import { ReviewQueue } from "./ReviewQueue"; +import { useReviewPlayback } from "./useReviewPlayback"; + +const NEUTRAL_ACCENT = "var(--foreground)"; +const SAMPLE_POLL_MS = 2000; +const SAMPLE_TOTAL = 60; +const COLUMN_HEAD = + "flex h-9 shrink-0 items-center justify-between gap-2 border-b px-3.5 text-[10px] tracking-[0.08em] text-muted-foreground uppercase"; + +function useArrival(review: Review | null, verdict: boolean, compact: boolean): Arrival | null { + const issue = review && verdict && !compact ? review.verdicts.find((v) => v.kind === "issue") : undefined; + const key = review && issue ? reviewKey(review) : ""; + const checkId = issue?.check_id ?? ""; + const text = review ? verdictLine(review) : ""; + return useMemo(() => (key ? { key, checkId, text } : null), [key, checkId, text]); +} + +function Status({ job }: { job: Job }) { + if (job.status === "queued") return Queued; + if (job.status === "running") + return ( + + Running + + ); + if (job.status === "completed") + return ( + + Done + + ); + return {job.status}; +} + +function useSampleReviews(enabled: boolean): readonly Review[] { + const now = useNow(SAMPLE_POLL_MS); + const [startedAt] = useState(now); + return useMemo(() => { + if (!enabled) return []; + const polls = Math.floor((now - startedAt) / SAMPLE_POLL_MS); + return Array.from({ length: polls + 1 }, (_, poll) => sampleBatch(poll, poll < 3 ? 1 : 3)) + .flat() + .slice(0, SAMPLE_TOTAL); + }, [enabled, now, startedAt]); +} + +export function LiveRun({ job, onCancel }: { job: Job; onCancel?: () => void }) { + const live = job.status === "queued" || job.status === "running"; + const sample = live && job.reviews.length === 0; + const sampled = useSampleReviews(sample); + const reviews = sample ? sampled : job.reviews; + const playback = useReviewPlayback(reviews, live); + const { current, played, pending, phase, duration } = playback; + const now = useNow(1000); + const verdictRef = useRef(null); + const model = analysisModel([current?.model ?? "", ...reviews.map((r) => r.model), ...modelsUsed(job.steps)]); + const accent = providerAccent(providerOf(model)) ?? NEUTRAL_ACCENT; + const decided = phase.verdict && current ? [...played, current] : played; + const groups = conclusions(decided, job.settings.checks); + const arrival = useArrival(current, phase.verdict, isCompact(duration)); + const stats = liveStats(job, now); + const reviewed = sample ? decided.length : shownCount(job.reviewed, playback); + const selected = sample ? SAMPLE_TOTAL : job.coverage.selected; + const percent = live ? Math.min(100, Math.round((reviewed / Math.max(1, selected)) * 100)) : 100; + const issues = decided.filter((r) => outcome(r) === "issue").length; + const progress = analysisProgress(job); + + return ( +
+
+
+ + + {live ? progress.title : `Reviewed ${reviewed} ${reviewed === 1 ? "trace" : "traces"}`} + {sample && " · preview data until the worker reports reviews"} + +
+
+ + {live && onCancel && ( + + )} +
+
+
+
+
+
+
+
+ Traces + + {reviewed} / {selected} + +
+ +
+
+
+ {live ? "Reading trace" : "Last trace"} + {rateLabel(stats.perSecond)} +
+ +
+
+
+ Conclusions + 0 && "text-[#e5484d]")}> + {issues} {issues === 1 ? "problem" : "problems"} + +
+ +
+ {rateLabel(stats.perSecond)} + {tokenLabel(stats.tokens)} + {money(stats.cost)} + {durationText(stats.elapsedSeconds)} +
+
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/lens/investigations/live/ProviderBadge.tsx b/ui/litellm-dashboard/src/components/lens/investigations/live/ProviderBadge.tsx new file mode 100644 index 00000000000..3bc3d1402d9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/investigations/live/ProviderBadge.tsx @@ -0,0 +1,38 @@ +import { getProviderLogoAndName } from "@/components/provider_info_helpers"; +import { cn } from "@/lib/cva.config"; + +import { modelName, providerOf } from "../../model/live"; + +const MARK_CROP: Readonly> = { + cerebras: "absolute top-[-8px] left-[-2.4px] h-[30px] w-[40px] max-w-none", +}; + +export function ProviderLogo({ model, className }: { model: string; className?: string }) { + const provider = providerOf(model); + const { logo, displayName } = getProviderLogoAndName(provider); + if (!provider || !logo) return null; + const crop = MARK_CROP[provider]; + return ( + + {/* eslint-disable-next-line @next/next/no-img-element -- provider logos are static svgs from /assets/logos */} + {displayName} + + ); +} + +export function ProviderBadge({ model }: { model: string }) { + if (!model) return null; + const provider = providerOf(model); + const { displayName } = getProviderLogoAndName(provider); + return ( + + + + {provider ? `Analyzed by ${displayName} · ${modelName(model)}` : `Analyzed by ${model}`} + + + ); +} diff --git a/ui/litellm-dashboard/src/components/lens/investigations/live/ReadingPanel.tsx b/ui/litellm-dashboard/src/components/lens/investigations/live/ReadingPanel.tsx new file mode 100644 index 00000000000..5949e8172ec --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/investigations/live/ReadingPanel.tsx @@ -0,0 +1,115 @@ +import type { Ref } from "react"; + +import { cn } from "@/lib/cva.config"; + +import { modelName, outcome, verdictLine, type Phase } from "../../model/live"; +import type { Review } from "../../model/types"; +import { ProviderLogo } from "./ProviderBadge"; + +const VERDICT_TONE = { + issue: "border-[#e5484d]/30 bg-[#e5484d]/5 text-[#e5484d]", + clear: "border-border bg-muted/60 text-foreground", + unknown: "border-border bg-muted/60 text-muted-foreground", +} as const; +const VERDICT_MARK = { issue: "●", clear: "✓", unknown: "?" } as const; + +function Spans({ review, phase }: { review: Review; phase: Phase }) { + return ( +
+
+ trace {review.trace_id.slice(0, 8)} + {review.agent || review.name} +
+
    + {review.spans.map((span, index) => { + const scanning = phase.span === index; + const cited = phase.verdict && span.cited; + return ( +
  1. + + {span.kind || span.name} + + + {span.preview || span.name} + +
  2. + ); + })} + {!review.spans.length &&
  3. No trace parts shown
  4. } +
+
+ ); +} + +function Reasoning({ review, phase, tokens }: { review: Review; phase: Phase; tokens: string }) { + const typing = phase.typed < review.reasoning.length; + return ( +
+
+ + + {modelName(review.model) || "analysis model"} · reasoning + + {tokens} +
+

+ {review.reasoning ? review.reasoning.slice(0, phase.typed) : !typing && "No reasoning was returned."} + {typing && ( +

+
+ ); +} + +export function ReadingPanel({ + review, + phase, + tokens, + verdictRef, +}: { + review: Review | null; + phase: Phase; + tokens: string; + verdictRef: Ref; +}) { + if (!review) { + return ( +

+ Traces appear here as the worker reviews them. +

+ ); + } + const result = outcome(review); + return ( +
+ + +
+ + {phase.verdict ? verdictLine(review) : ""} +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/lens/investigations/live/ReviewQueue.tsx b/ui/litellm-dashboard/src/components/lens/investigations/live/ReviewQueue.tsx new file mode 100644 index 00000000000..56902d82c07 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/investigations/live/ReviewQueue.tsx @@ -0,0 +1,69 @@ +import { cn } from "@/lib/cva.config"; + +import { outcome, reviewKey } from "../../model/live"; +import type { Review } from "../../model/types"; + +const BEFORE = 6; +const AFTER = 12; +const MARK = { issue: "●", clear: "✓", unknown: "?" } as const; + +type Row = { review: Review; state: "done" | "active" | "next" }; + +function rows(played: readonly Review[], current: Review | null, pending: readonly Review[], live: boolean): Row[] { + const tail = played.slice(live ? -BEFORE : -(BEFORE + AFTER)); + return [ + ...tail.map((review): Row => ({ review, state: "done" })), + ...(current ? [{ review: current, state: live ? "active" : "done" } as Row] : []), + ...pending.slice(0, AFTER).map((review): Row => ({ review, state: "next" })), + ]; +} + +export function ReviewQueue({ + played, + current, + pending, + live, +}: { + played: readonly Review[]; + current: Review | null; + pending: readonly Review[]; + live: boolean; +}) { + return ( +
    + {rows(played, current, pending, live).map(({ review, state }) => { + const result = outcome(review); + return ( +
  1. + {state === "active" && ( + + )} + {state === "done" && ( + + {MARK[result]} + + )} + {state === "next" && ·} + {review.agent || review.name} + {review.trace_id.slice(0, 8)} +
  2. + ); + })} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/lens/investigations/live/useReviewPlayback.ts b/ui/litellm-dashboard/src/components/lens/investigations/live/useReviewPlayback.ts new file mode 100644 index 00000000000..6d8979da777 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/investigations/live/useReviewPlayback.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useReducer, useState } from "react"; + +import { playbackPhase, playbackReducer, startPlayback, type Phase, type Playback } from "../../model/live"; +import type { Review } from "../../model/types"; + +const FRAME_MS = 40; + +function prefersReducedMotion(): boolean { + return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +export function useReviewPlayback(reviews: readonly Review[], live: boolean): Playback & { phase: Phase } { + const [still] = useState(prefersReducedMotion); + const [state, dispatch] = useReducer(playbackReducer, undefined, () => + playbackReducer(startPlayback(reviews, live), still ? { type: "settle" } : { type: "tick", now: Date.now() }), + ); + const [now, setNow] = useState(Date.now); + + useEffect(() => { + dispatch({ type: "enqueue", reviews }); + if (still) dispatch({ type: "settle" }); + }, [reviews, still]); + + const busy = state.pending.length > 0 || now - state.startedAt < state.duration; + useEffect(() => { + if (!busy || still) return; + const timer = window.setInterval(() => { + const at = Date.now(); + setNow(at); + dispatch({ type: "tick", now: at }); + }, FRAME_MS); + return () => window.clearInterval(timer); + }, [busy, still]); + + const current = state.current; + const phase = + still || !current + ? { span: -1, typed: current?.reasoning.length ?? 0, verdict: true } + : playbackPhase(now - state.startedAt, state.duration, current.spans.length, current.reasoning.length); + return { ...state, phase }; +}