mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
perf(lens): reduce rendering on live investigations (#44935)
* perf(lens): avoid redundant investigation rendering * fix(lens): keep history polling after run fetch failures
This commit is contained in:
parent
acd2a7ccc5
commit
7f5499a751
10 changed files with 176 additions and 37 deletions
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
import {
|
||||
analysisElapsed,
|
||||
analysisFraction,
|
||||
|
|
@ -13,9 +14,11 @@ import { type Job } from "../model/types";
|
|||
import { QueueReasonText } from "./QueueReasonText";
|
||||
import { useQueueReason, type QueueContext } from "./useQueueReason";
|
||||
|
||||
export function InvestigationProgress({ job, now, queue }: { job: Job; now: number; queue?: QueueContext }) {
|
||||
export function InvestigationProgress({ job, queue }: { job: Job; queue?: QueueContext }) {
|
||||
const now = useNow(1000);
|
||||
const reason = useQueueReason(job, queue);
|
||||
const progress = analysisProgress(job);
|
||||
const stages = analysisStages(job);
|
||||
const percent = Math.round(analysisFraction(progress) * 100);
|
||||
const { secondsLeft } = analysisPace(useProgressSamples(progress), now);
|
||||
const queued = progress.step < 0;
|
||||
|
|
@ -28,10 +31,12 @@ export function InvestigationProgress({ job, now, queue }: { job: Job; now: numb
|
|||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
aria-valuetext={`${progress.title}: ${progress.detail}`}
|
||||
aria-valuetext={`${percent}% overall. ${progress.title}: ${progress.detail}`}
|
||||
title={`Overall progress: ${stages.map((stage) => `${stage.label} ${stage.weight * 100}%`).join(", ")}`}
|
||||
className="text-2xl font-semibold"
|
||||
>
|
||||
{queued ? "–" : `${percent}%`}
|
||||
{!queued && <span className="ml-1.5 text-xs font-normal text-muted-foreground">overall</span>}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{analysisElapsed(job.created_at, now)} elapsed
|
||||
|
|
@ -39,7 +44,7 @@ export function InvestigationProgress({ job, now, queue }: { job: Job; now: numb
|
|||
</span>
|
||||
</div>
|
||||
<ol aria-label="Analysis stages" className="flex gap-1">
|
||||
{analysisStages(job).map((stage) => (
|
||||
{stages.map((stage) => (
|
||||
<li
|
||||
key={stage.label}
|
||||
data-state={stage.state}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { renderWithLens, stubGateway } from "@/../tests/lens-test-utils";
|
|||
import { ApiError } from "@/lib/http/client";
|
||||
import { lensKeys } from "../data/queries";
|
||||
import { InvestigationsView } from "./InvestigationsView";
|
||||
import { RunReport } from "./detail/RunReport";
|
||||
import { briefMarkdown } from "../model/findings";
|
||||
import { findingKey } from "../model/inbox";
|
||||
import { runTime } from "../model/format";
|
||||
|
|
@ -469,6 +470,40 @@ it("opens the saved results of an older batch", async () => {
|
|||
expect(within(screen.getByRole("tabpanel", { name: "History" })).getByText(/Took 2m 13s/)).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps history status and cost updating when an older run fails to load", async () => {
|
||||
testQueryClient.clear();
|
||||
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
|
||||
const running = { ...lens.jobs[0], status: "running" as const, cost: 0.25 };
|
||||
const older = { ...lens.jobs[0], id: "older", created_at: "2026-09-29T10:00:00Z" };
|
||||
const runs = vi.fn().mockResolvedValue([running, older]);
|
||||
proxy.get.mockImplementation(async (path) => {
|
||||
if (path.endsWith("/reviews")) return { reviews: [], reviewed: 0 };
|
||||
if (path === "/lens") return { lenses: [{ ...lens, jobs: [running] }], workers: [], tracing_enabled: true };
|
||||
if (path === "/lens/lens/runs") return runs();
|
||||
if (path === "/lens/lens/runs/older") throw new Error("Run unavailable");
|
||||
return { data: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderWithProviders(<InvestigationsView readOnly />);
|
||||
try {
|
||||
await user.click(await screen.findByRole("tab", { name: "History" }));
|
||||
const history = within(screen.getByRole("tabpanel", { name: "History" }));
|
||||
expect(await history.findByText("Running")).toBeVisible();
|
||||
expect(history.getByText("$0.25")).toBeVisible();
|
||||
await user.selectOptions(screen.getByRole("combobox", { name: "Investigation run" }), "older");
|
||||
expect(await screen.findByText(/Could not load this run/)).toBeVisible();
|
||||
|
||||
runs.mockResolvedValue([{ ...running, status: "completed", cost: 1.75 }, older]);
|
||||
await act(async () => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(await history.findByText("$1.75")).toBeVisible();
|
||||
expect(history.queryByText("Running")).not.toBeInTheDocument();
|
||||
expect(history.getAllByText("Completed")).toHaveLength(2);
|
||||
} finally {
|
||||
unmount();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads request content from the beginning after its abbreviated preview", async () => {
|
||||
testQueryClient.clear();
|
||||
const requestId = btoa(JSON.stringify(["requests", "", "request-1"]));
|
||||
|
|
@ -932,6 +967,59 @@ 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("updates elapsed time without reformatting the activity log and still shows new model calls", async () => {
|
||||
vi.useFakeTimers();
|
||||
const formatTime = vi.spyOn(Date.prototype, "toLocaleTimeString");
|
||||
const created_at = new Date(Date.now() - 60_000).toISOString();
|
||||
const running = {
|
||||
...lens.jobs[0],
|
||||
status: "running" as const,
|
||||
stage: "Reading executions",
|
||||
created_at,
|
||||
coverage: { ...lens.jobs[0].coverage, selected: 62, screened: 13 },
|
||||
steps: Array.from({ length: 200 }, (_, index) => ({
|
||||
at: created_at,
|
||||
kind: "model" as const,
|
||||
label: `Reviewed run ${index}`,
|
||||
model: "analysis",
|
||||
purpose: "extract" as const,
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
cost: 0.01,
|
||||
})),
|
||||
};
|
||||
const view = (job: typeof running) => (
|
||||
<RunReport lens={lens} job={job} findings={[]} connected ready busy={false} picker={null} />
|
||||
);
|
||||
const { rerender, unmount } = renderWithProviders(view(running));
|
||||
try {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Activity log" }));
|
||||
expect(screen.getByRole("progressbar", { name: "Investigation progress" })).toHaveAttribute("aria-valuenow", "13");
|
||||
expect(screen.getByRole("progressbar", { name: "Investigation progress" })).toHaveAttribute(
|
||||
"aria-valuetext",
|
||||
"13% overall. Reviewing activity: 13 of 62 selected runs reviewed",
|
||||
);
|
||||
expect(within(screen.getByRole("list", { name: "Investigation steps" })).getAllByRole("listitem")).toHaveLength(
|
||||
200,
|
||||
);
|
||||
formatTime.mockClear();
|
||||
await act(async () => vi.advanceTimersByTime(3000));
|
||||
expect(screen.getByText("1m 3s")).toBeVisible();
|
||||
expect(formatTime.mock.calls.length).toBe(0);
|
||||
|
||||
rerender(
|
||||
view({ ...running, cost: 2.01, steps: [...running.steps, { ...running.steps[0], label: "New model call" }] }),
|
||||
);
|
||||
expect(screen.getByText("201 model calls")).toBeVisible();
|
||||
expect(screen.getByText("$2.01")).toBeVisible();
|
||||
expect(screen.getByText(/New model call/)).toBeVisible();
|
||||
} finally {
|
||||
unmount();
|
||||
formatTime.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
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" };
|
||||
|
|
|
|||
|
|
@ -23,11 +23,23 @@ export interface HistoryTabProps {
|
|||
|
||||
export function HistoryTab({ lens }: HistoryTabProps) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const history = useRunHistory(lens, offset);
|
||||
return (
|
||||
<TabsContent value="activity" className="pt-4 space-y-4">
|
||||
<HistoryContent lens={lens} offset={offset} setOffset={setOffset} />
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryContent({
|
||||
lens,
|
||||
offset,
|
||||
setOffset,
|
||||
}: HistoryTabProps & { offset: number; setOffset: (offset: number) => void }) {
|
||||
const history = useRunHistory(lens, offset, offset > 0);
|
||||
const { openRun } = useRunRoute();
|
||||
const rows = history.data ?? lens.jobs;
|
||||
return (
|
||||
<TabsContent value="activity" className="pt-4 space-y-4">
|
||||
<>
|
||||
{history.error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Could not load run history.{" "}
|
||||
|
|
@ -85,6 +97,6 @@ export function HistoryTab({ lens }: HistoryTabProps) {
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@ export interface RunPickerProps {
|
|||
export function RunPicker({ lens, job }: RunPickerProps) {
|
||||
const { batchId, selectRun } = useRunRoute();
|
||||
const history = useRunHistory(lens, 0);
|
||||
if (!job) return null;
|
||||
const options = history.data ?? lens.jobs;
|
||||
const reused = job?.coverage?.reused ?? 0;
|
||||
const newlyReviewed = Math.max(0, (job?.coverage?.screened ?? 0) - reused);
|
||||
const reused = job.coverage?.reused ?? 0;
|
||||
const newlyReviewed = Math.max(0, (job.coverage?.screened ?? 0) - reused);
|
||||
const aggregate = batchId === "latest" || batchId === "all";
|
||||
const outsideHistory = !aggregate && !options.some((j) => j.id === batchId);
|
||||
return (
|
||||
|
|
@ -34,7 +35,7 @@ export function RunPicker({ lens, job }: RunPickerProps) {
|
|||
onChange={(e) => selectRun(e.target.value)}
|
||||
>
|
||||
<option value="latest">Latest run</option>
|
||||
{job && outsideHistory && (
|
||||
{outsideHistory && (
|
||||
<option value={batchId}>
|
||||
{when(job.created_at)} · {runStatus(job)}
|
||||
</option>
|
||||
|
|
@ -46,7 +47,7 @@ export function RunPicker({ lens, job }: RunPickerProps) {
|
|||
))}
|
||||
<option value="all">All accumulated findings</option>
|
||||
</select>
|
||||
{job && batchId !== "all" && (
|
||||
{batchId !== "all" && (
|
||||
<Popover key={job.id}>
|
||||
<PopoverTrigger
|
||||
aria-label="Run details"
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ function facts(job: Job | undefined, findings: readonly Finding[] | null | undef
|
|||
};
|
||||
}
|
||||
|
||||
function Stat({ label, value, note }: { label: string; value: string; note?: string }) {
|
||||
function Stat({ label, value, note }: { label: string; value: ReactNode; note?: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
|
|
@ -187,18 +187,19 @@ function issueStat(job: Job, findings: readonly Finding[] | null | undefined) {
|
|||
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 RunDuration({ job }: { job: Job }) {
|
||||
const now = useNow(isActive(job) ? 1000 : 60000);
|
||||
const end = job.finished_at ? Date.parse(job.finished_at) : now;
|
||||
return isActive(job) || job.finished_at ? analysisElapsed(job.created_at, end) : "–";
|
||||
}
|
||||
|
||||
function RunStats({ job, findings }: { job: Job; findings: readonly Finding[] | null | undefined }) {
|
||||
const calls = (job.steps ?? []).filter((step) => step.kind === "model").length;
|
||||
const { screened = 0, selected = 0 } = job.coverage ?? {};
|
||||
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={isActive(job) || job.finished_at ? analysisElapsed(job.created_at, end) : "–"}
|
||||
note={`Started ${shortTime(job.created_at)}`}
|
||||
/>
|
||||
<Stat label="Duration" value={<RunDuration job={job} />} note={`Started ${shortTime(job.created_at)}`} />
|
||||
<Stat
|
||||
label="Runs reviewed"
|
||||
value={`${screened.toLocaleString()} / ${selected.toLocaleString()}`}
|
||||
|
|
@ -302,7 +303,6 @@ function nextAction(lens: Lens, job: Job | undefined, situation: RunSituation):
|
|||
}
|
||||
|
||||
export function RunReport({ lens, job, findings, connected, ready, busy, picker, actions, queue }: RunReportProps) {
|
||||
const now = useNow(job && isActive(job) ? 1000 : 60000);
|
||||
const input: SituationInput = { lens, job, findings, connected };
|
||||
const situation = runSituation(input);
|
||||
const view = SITUATIONS[situation];
|
||||
|
|
@ -316,16 +316,16 @@ export function RunReport({ lens, job, findings, connected, ready, busy, picker,
|
|||
<h3 className="mt-0.5 text-base font-semibold">{view.headline(known)}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{job && picker}
|
||||
{picker}
|
||||
{action && actions && (
|
||||
<NextAction action={action} facts={known} ready={ready} busy={busy} onClick={actions[action]} />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{job && view.body === "progress" && <InvestigationProgress key={job.id} job={job} now={now} queue={queue} />}
|
||||
{job && view.body === "progress" && <InvestigationProgress key={job.id} job={job} queue={queue} />}
|
||||
{job?.error && view.body === "partial" && <RunPartial job={job} connected={connected} />}
|
||||
{job?.error && view.body === "error" && <RunFailure job={job} connected={connected} />}
|
||||
{job && <RunStats job={job} findings={findings} now={now} />}
|
||||
{job && <RunStats job={job} findings={findings} />}
|
||||
{job && <RunLog job={job} />}
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,13 +8,14 @@ import { activeJob } from "../../model/status";
|
|||
import type { Lens } from "../../model/types";
|
||||
|
||||
/** Run history polls off its own rows, so a job the list poll discovers has to push it to refetch. */
|
||||
export function useRunHistory(lens: Lens, historyOffset: number) {
|
||||
export function useRunHistory(lens: Lens, historyOffset: number, poll = true) {
|
||||
const api = useLensApi();
|
||||
const client = useQueryClient();
|
||||
const activeId = activeJob(lens.jobs)?.id;
|
||||
useEffect(() => {
|
||||
if (!activeId) return;
|
||||
if (!poll || !activeId) return;
|
||||
void client.invalidateQueries({ queryKey: lensKeys.histories() }, { cancelRefetch: false });
|
||||
}, [activeId, client]);
|
||||
return useQuery(lensQueries.history(api, { lensId: lens.id, historyOffset }));
|
||||
}, [activeId, client, poll]);
|
||||
const options = lensQueries.history(api, { lensId: lens.id, historyOffset });
|
||||
return useQuery({ ...options, refetchInterval: poll ? options.refetchInterval : false });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ function useFlashing(groups: readonly Conclusion[]): ReadonlySet<string> {
|
|||
return flashing;
|
||||
}
|
||||
|
||||
export function ConclusionsPanel({
|
||||
export const ConclusionsPanel = memo(function ConclusionsPanel({
|
||||
groups,
|
||||
total,
|
||||
scope,
|
||||
|
|
@ -102,4 +102,4 @@ export function ConclusionsPanel({
|
|||
{scope && <p className="text-xs text-muted-foreground">{scope}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
|
@ -48,7 +48,11 @@ function Stage({
|
|||
}) {
|
||||
const legacyReading = running && !activities.length && stageName === "Reading executions";
|
||||
const { stage, now, charMs } = useStage(reviews, reading, legacyReading, slots);
|
||||
const listed = legacyReading ? releasedReviews(reviews, stage) : reviews;
|
||||
const listed = useMemo(
|
||||
() => (legacyReading ? releasedReviews(reviews, stage) : reviews),
|
||||
[legacyReading, reviews, stage],
|
||||
);
|
||||
const groups = useMemo(() => conclusions(listed, checks), [listed, checks]);
|
||||
function currentWork() {
|
||||
if (!running) return null;
|
||||
if (activities.length) return <ActiveWork model={model} activities={activities} />;
|
||||
|
|
@ -61,7 +65,7 @@ function Stage({
|
|||
</p>
|
||||
);
|
||||
}
|
||||
return <>{children(listed, conclusions(listed, checks), currentWork())}</>;
|
||||
return <>{children(listed, groups, currentWork())}</>;
|
||||
}
|
||||
|
||||
export function LiveDrawer({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { fireEvent, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, expect, it } from "vitest";
|
||||
import { act, fireEvent, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithLens, stubGateway } from "@/../tests/lens-test-utils";
|
||||
import { testQueryClient } from "@/../tests/test-utils";
|
||||
|
|
@ -62,6 +62,28 @@ function activity(overrides: Partial<Activity> = {}): Activity {
|
|||
};
|
||||
}
|
||||
|
||||
it("keeps completed trace rows idle while the reading clock advances", async () => {
|
||||
vi.useFakeTimers();
|
||||
const parseTime = vi.spyOn(Date, "parse");
|
||||
const reviewed = review();
|
||||
const active = job({ stage: "Reading executions", activities: [], reading: [] });
|
||||
const { unmount } = renderWithLens(<LiveRun job={active} reviews={[reviewed]} name="Tool quality" />);
|
||||
try {
|
||||
fireEvent.click(screen.getByRole("button", { name: "View run" }));
|
||||
const traces = within(screen.getByRole("list", { name: "Reviewed traces" }));
|
||||
expect(traces.getByRole("button", { name: /research-agent/ })).toBeVisible();
|
||||
parseTime.mockClear();
|
||||
await act(async () => vi.advanceTimersByTime(500));
|
||||
expect(parseTime.mock.calls.filter(([value]) => value === reviewed.at)).toHaveLength(0);
|
||||
fireEvent.click(traces.getByRole("button", { name: /research-agent/ }));
|
||||
expect(traces.getByText("Tool calls: Read × 2 · Python × 1")).toBeVisible();
|
||||
} finally {
|
||||
unmount();
|
||||
parseTime.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows real candidate and grouping activity, durable tool counts, and preliminary review scope", async () => {
|
||||
const reviewed = review();
|
||||
const grouping: Partial<Activity> = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { memo, useState } from "react";
|
||||
|
||||
import { agoLabel } from "../../model/format";
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
|
|
@ -95,7 +95,13 @@ function DoneRow({
|
|||
);
|
||||
}
|
||||
|
||||
export function TraceList({ reviews, group }: { reviews: readonly Review[]; group: string | null }) {
|
||||
export const TraceList = memo(function TraceList({
|
||||
reviews,
|
||||
group,
|
||||
}: {
|
||||
reviews: readonly Review[];
|
||||
group: string | null;
|
||||
}) {
|
||||
const now = useNow(5000);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const rows = newestFirst(reviews, LIMIT).filter((review) => inGroup(review, group));
|
||||
|
|
@ -117,4 +123,4 @@ export function TraceList({ reviews, group }: { reviews: readonly Review[]; grou
|
|||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue