mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
feat(lens-ui): show findings ranked by priority with frequency and highlighted evidence (#45143)
* feat(lens-ui): compute how often a finding hits sampled traces per day Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(lens-ui): copy a finding for an agent as markdown Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(lens-ui): add affected, unaffected and quote highlight color tokens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(lens-ui): add a frequency card with stacked affected traces per day Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(lens-ui): keep the issue brief title out of the page heading outline Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(lens-ui): lay out a finding as summary, fix, frequency and highlighted examples Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(lens-ui): show findings as a dated list with percent affected beside the open finding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(lens-ui): cover frequency and highlighted quotes on a finding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(lens-ui): follow findings into the split list and example cards Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style(lens-ui): take finding chart and quote colors from the dashboard theme Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(lens-ui): add a shared priority dot and pill for findings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style(lens-ui): soften the frequency card and show its date range Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(lens-ui): rank findings under high, medium and low priority headings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style(lens-ui): show finding priority, label quotes by content and collapse extra examples Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(lens-ui): prove findings are grouped and ordered by priority Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(lens-ui): cover finding priority, quote labels and example collapsing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a9b9700790
commit
7921716f39
15 changed files with 845 additions and 249 deletions
|
|
@ -211,6 +211,10 @@
|
|||
--trace-row-hover: oklch(0.975 0.008 215);
|
||||
--trace-row-selected: oklch(0.95 0.035 200);
|
||||
--trace-brand: oklch(0.6 0.13 195);
|
||||
--finding-affected: var(--info);
|
||||
--finding-unaffected: oklch(0.551 0.027 264.364 / 0.45);
|
||||
--finding-quote: color-mix(in oklab, var(--warning) 16%, transparent);
|
||||
--finding-ring: 0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px -1px oklch(0 0 0 / 0.06), 0 2px 4px 0 oklch(0 0 0 / 0.04);
|
||||
--trace-border: oklch(0.92 0.01 230);
|
||||
--trace-line: oklch(0.88 0.03 205);
|
||||
--trace-card-border: oklch(0.93 0.01 230);
|
||||
|
|
@ -288,6 +292,10 @@
|
|||
--trace-row-hover: oklch(0.23 0.018 230);
|
||||
--trace-row-selected: oklch(0.29 0.05 210);
|
||||
--trace-brand: oklch(0.78 0.13 190);
|
||||
--finding-affected: var(--info);
|
||||
--finding-unaffected: oklch(0.707 0.022 261.325 / 0.35);
|
||||
--finding-quote: color-mix(in oklab, var(--warning) 24%, transparent);
|
||||
--finding-ring: 0 0 0 1px oklch(1 0 0 / 0.08);
|
||||
--trace-border: oklch(0.3 0.02 235);
|
||||
--trace-line: oklch(0.36 0.04 210);
|
||||
--trace-card-border: oklch(0.27 0.02 235);
|
||||
|
|
@ -326,6 +334,10 @@
|
|||
--color-trace-row-hover: var(--trace-row-hover);
|
||||
--color-trace-row-selected: var(--trace-row-selected);
|
||||
--color-trace-brand: var(--trace-brand);
|
||||
--color-finding-affected: var(--finding-affected);
|
||||
--color-finding-unaffected: var(--finding-unaffected);
|
||||
--color-finding-quote: var(--finding-quote);
|
||||
--shadow-finding-ring: var(--finding-ring);
|
||||
--color-trace-border: var(--trace-border);
|
||||
--color-trace-line: var(--trace-line);
|
||||
--color-trace-card-border: var(--trace-card-border);
|
||||
|
|
|
|||
|
|
@ -141,9 +141,7 @@ describe("Lens interactive demo", () => {
|
|||
await user.click(await screen.findByRole("row", { name: /Repeated lookups leave customers without an answer/ }));
|
||||
const finding = screen.getByRole("complementary", { name: "Finding details" });
|
||||
expect(within(finding).getByText(/The support agent retries/)).toBeVisible();
|
||||
const summaries = within(finding).getAllByText("support_agent", { exact: true });
|
||||
await user.click(summaries[0]);
|
||||
await user.click(within(finding).getAllByRole("button", { name: /Open original step/ })[0]);
|
||||
await user.click(within(finding).getAllByRole("button", { name: "View span" })[0]);
|
||||
expect(await screen.findByRole("complementary", { name: "Span details" })).toHaveTextContent(
|
||||
"I will check that for you.",
|
||||
);
|
||||
|
|
@ -157,7 +155,8 @@ describe("Lens interactive demo", () => {
|
|||
await user.click(within(finding).getByRole("button", { name: "Back to finding" }));
|
||||
expect(within(finding).getByText(/The support agent retries/)).toBeVisible();
|
||||
await user.click(within(finding).getByRole("button", { name: "Close finding (Esc)" }));
|
||||
expect(await screen.findByRole("table", { name: "Findings" })).toBeVisible();
|
||||
expect(await screen.findByRole("grid", { name: "Findings" })).toBeVisible();
|
||||
expect(screen.queryByRole("complementary", { name: "Finding details" })).not.toBeInTheDocument();
|
||||
expect(network).not.toHaveBeenCalled();
|
||||
await expectUrl(onUrlUpdate, (url) => expect(url.get("demo")).toBe("true"));
|
||||
await expectUrl(onUrlUpdate, (url) => expect(url.has("span")).toBe(false));
|
||||
|
|
|
|||
|
|
@ -116,8 +116,7 @@ it("stacks a quote's original step over the finding and keeps the feedback draft
|
|||
const panel = screen.getByRole("complementary", { name: "Finding details" });
|
||||
const reason = () => within(panel).getByRole("textbox", { name: "What should Lens remember?", hidden: true });
|
||||
fireEvent.change(reason(), { target: { value: "Draft feedback" } });
|
||||
for (const summary of within(panel).getAllByText(/quote$/)) await user.click(summary);
|
||||
await user.click(within(panel).getAllByRole("button", { name: "Open original step" })[0]);
|
||||
await user.click(within(panel).getAllByRole("button", { name: "View span" })[0]);
|
||||
expect(await within(panel).findByTestId("run-view")).toHaveTextContent("trace-1 at step-a");
|
||||
expect(reason()).not.toBeVisible();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
|
@ -127,13 +126,51 @@ it("stacks a quote's original step over the finding and keeps the feedback draft
|
|||
expect(reason()).toBeVisible();
|
||||
expect(reason()).toHaveValue("Draft feedback");
|
||||
|
||||
await user.click(within(panel).getAllByRole("button", { name: "Open original step" })[1]);
|
||||
await user.click(within(panel).getAllByRole("button", { name: "View span" })[1]);
|
||||
expect(await within(panel).findByTestId("run-view")).toHaveTextContent("trace-2 at step-b");
|
||||
const url = new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
|
||||
expect(url.get("evidence")).toBe(traceOf("trace-2"));
|
||||
expect(url.get("evidence_span")).toBe("step-b");
|
||||
});
|
||||
|
||||
it("reports how many sampled traces the finding affected and highlights each quoted line", () => {
|
||||
const traceOf = (id: string) => btoa(JSON.stringify(["traces", "", id]));
|
||||
const sampled = ["a", "b", "c", "d"].map((id) => ({
|
||||
id: traceOf(id),
|
||||
name: `run ${id}`,
|
||||
start_time: "2026-10-01T10:00:00Z",
|
||||
metadata: [],
|
||||
root_seen: true,
|
||||
service: "support_agent",
|
||||
source: "traces" as const,
|
||||
span_count: 1,
|
||||
team_id: "",
|
||||
trace_id: id,
|
||||
trace_ref: "",
|
||||
}));
|
||||
const current: Finding = {
|
||||
...finding,
|
||||
occurrences: [traceOf("a")],
|
||||
evidence: [{ execution_id: traceOf("a"), span_id: "s", quote: "files:read is missing", role: "support" }],
|
||||
};
|
||||
renderWithLens(
|
||||
<Inspector.Root
|
||||
items={[]}
|
||||
itemKey={ownedFindingKey}
|
||||
selected={owned(current)}
|
||||
onSelectedChange={vi.fn()}
|
||||
noun="finding"
|
||||
storageKey="test.finding"
|
||||
>
|
||||
<FindingPanel readOnly busy={false} sampledRuns={sampled} onReview={vi.fn()} />
|
||||
</Inspector.Root>,
|
||||
);
|
||||
expect(screen.getByRole("region", { name: "Frequency" })).toHaveTextContent(/25%\s*1 of 4 traces affected/);
|
||||
const example = screen.getByRole("article", { name: "run a" });
|
||||
expect(within(example).getByText("files:read is missing").tagName).toBe("MARK");
|
||||
expect(screen.queryByRole("article", { name: "run b" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows contributing investigation runs and every affected trace, including older traces without retained quotes", async () => {
|
||||
const traceId = btoa(JSON.stringify(["traces", "", "older-trace", ""]));
|
||||
const current: Finding = {
|
||||
|
|
@ -142,8 +179,33 @@ it("shows contributing investigation runs and every affected trace, including ol
|
|||
investigation_runs: ["first-investigation-run", "second-investigation-run"],
|
||||
};
|
||||
renderWithLens(<Harness current={current} onReview={vi.fn()} />);
|
||||
expect(screen.getByText("Found across 2 investigation runs")).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 affected trace/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("older-trace"));
|
||||
expect(screen.getByRole("button", { name: "Open original trace" })).toBeInTheDocument();
|
||||
expect(screen.getByText("1 affected trace")).toBeVisible();
|
||||
expect(screen.getByText("Found across 2 investigation runs")).toBeVisible();
|
||||
const example = screen.getByRole("article", { name: "Trace older-tr" });
|
||||
expect(within(example).getByText("No quote was retained for this trace.")).toBeVisible();
|
||||
expect(within(example).getByRole("button", { name: "View trace" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows the finding's priority and keeps the first three examples, revealing the rest on request", async () => {
|
||||
const user = userEvent.setup();
|
||||
const traceOf = (id: string) => btoa(JSON.stringify(["traces", "", id]));
|
||||
const ids = ["t1", "t2", "t3", "t4", "t5"];
|
||||
const current: Finding = {
|
||||
...finding,
|
||||
occurrences: ids.map(traceOf),
|
||||
evidence: ids.map((id) => ({
|
||||
execution_id: traceOf(id),
|
||||
span_id: id,
|
||||
quote: `Input: ${id}\nOutput: done`,
|
||||
role: "support" as const,
|
||||
})),
|
||||
};
|
||||
renderWithLens(<Harness current={current} onReview={vi.fn()} />);
|
||||
const panel = screen.getByRole("complementary", { name: "Finding details" });
|
||||
expect(within(panel).getByText("High priority")).toBeVisible();
|
||||
expect(within(panel).getAllByRole("article")).toHaveLength(3);
|
||||
expect(within(panel).getAllByText("Call and result")).toHaveLength(3);
|
||||
await user.click(within(panel).getByRole("button", { name: "Show 2 more examples" }));
|
||||
expect(within(panel).getAllByRole("article")).toHaveLength(5);
|
||||
expect(within(panel).queryByRole("button", { name: /Show \d+ more/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,23 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { ChevronRight, ClipboardCopy, X } from "lucide-react";
|
||||
|
||||
import { Inspector } from "@/components/shared/Inspector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
import { AddToDatasetButton } from "../datasets/AddToDatasetDialog";
|
||||
import { evidenceTarget } from "../model/findings";
|
||||
import { runTime } from "../model/format";
|
||||
import { evidenceTarget, findingMarkdown } from "../model/findings";
|
||||
import { findingFrequency } from "../model/frequency";
|
||||
import { agoLabel, runTime } from "../model/format";
|
||||
import { findingAgents, findingKey, type OwnedFinding, sampledExecutions } from "../model/inbox";
|
||||
import type { Finding, Sample } from "../model/types";
|
||||
import { EvidenceView } from "./Evidence";
|
||||
import { FrequencyCard } from "./FrequencyCard";
|
||||
import { IssueBrief } from "./IssueBrief";
|
||||
import { PriorityPill } from "./PriorityMark";
|
||||
import { type EvidenceRef, useEvidenceRoute } from "../route";
|
||||
|
||||
export const ownedFindingKey = (owned: OwnedFinding): string => findingKey(owned.lens, owned.finding);
|
||||
|
||||
type Quote = Finding["evidence"][number];
|
||||
|
||||
const SECTION_LABEL = "text-xs font-medium text-muted-foreground";
|
||||
|
||||
export interface FindingDetailsProps {
|
||||
readonly finding: Finding;
|
||||
readonly lensId?: string;
|
||||
|
|
@ -27,6 +36,240 @@ export interface FindingDetailsProps {
|
|||
readonly busy: boolean;
|
||||
readonly onOpenEvidence: (evidence: EvidenceRef) => void;
|
||||
readonly onReview: (status: Finding["status"], reason: string) => void;
|
||||
readonly onClose?: () => void;
|
||||
}
|
||||
|
||||
function TopBar({ finding, onClose }: Pick<FindingDetailsProps, "finding" | "onClose">) {
|
||||
const now = useNow(30000);
|
||||
return (
|
||||
<div className="sticky top-0 z-raised flex h-11 items-center justify-between gap-2 bg-background/95 px-4 backdrop-blur">
|
||||
<p className="flex min-w-0 items-center gap-2 font-mono text-xs text-muted-foreground">
|
||||
<span className="truncate" title={finding.id}>
|
||||
{finding.id.slice(0, 8)}
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="whitespace-nowrap tabular-nums" title={runTime(finding.last_seen)}>
|
||||
{agoLabel(Date.parse(finding.last_seen), now)}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="enabled:active:scale-[0.96]"
|
||||
onClick={() => void copyToClipboard(findingMarkdown(finding), "Copied for agent")}
|
||||
>
|
||||
<ClipboardCopy />
|
||||
Copy for agent
|
||||
</Button>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="enabled:active:scale-[0.96]"
|
||||
aria-label="Close finding (Esc)"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X />
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Disclosure({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<details className="group rounded-lg">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-sm font-semibold [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className="size-4 text-muted-foreground transition-[rotate] duration-150 group-open:rotate-90 motion-reduce:transition-none"
|
||||
/>
|
||||
{title}
|
||||
</summary>
|
||||
<div className="mt-3">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function ProseSection({ title, children }: { title: string; children: string }) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-1.5 text-sm font-semibold">{title}</h2>
|
||||
<p className="max-w-[70ch] text-sm leading-relaxed text-pretty whitespace-pre-wrap text-foreground/85">
|
||||
{children}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const FIELD = /^(Input|Output|Status|Error)\s*:/gm;
|
||||
const FIELD_LABEL: Readonly<Record<string, string>> = {
|
||||
"Input,Output": "Call and result",
|
||||
Input: "Call input",
|
||||
Output: "Returned output",
|
||||
Status: "Span status",
|
||||
Error: "Error",
|
||||
};
|
||||
|
||||
function quoteLabel(quote: Quote, isTrace: boolean): string {
|
||||
if (quote.role === "counterexample") return "Counterexample";
|
||||
const fields = [...new Set(Array.from(quote.quote.matchAll(FIELD), (m) => m[1]))].join(",");
|
||||
return FIELD_LABEL[fields] ?? (isTrace ? "Trace step" : "Logged request");
|
||||
}
|
||||
|
||||
const MARK = {
|
||||
support: "rounded-sm bg-finding-quote px-0.5 text-inherit",
|
||||
counterexample: "rounded-sm bg-success/20 px-0.5 text-inherit",
|
||||
} as const;
|
||||
|
||||
function QuoteCard({ quote, onOpen }: { quote: Quote; onOpen: () => void }) {
|
||||
const isTrace = evidenceTarget(quote.execution_id)?.source === "traces";
|
||||
return (
|
||||
<div className="-mx-1 rounded-lg border border-transparent bg-background p-2.5 shadow-finding-ring">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-medium">{quoteLabel(quote, isTrace)}</span>
|
||||
<Button variant="outline" size="xs" className="enabled:active:scale-[0.96]" onClick={onOpen}>
|
||||
{isTrace ? "View span" : "View request"}
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
title="Open this evidence"
|
||||
onClick={onOpen}
|
||||
className="block w-full overflow-x-auto rounded-md bg-muted/60 py-1.5 text-left font-mono text-xs leading-5 transition-[background-color] duration-150 hover:bg-muted"
|
||||
>
|
||||
<code className="block border-l-2 border-warning/40 px-2.5 break-words whitespace-pre-wrap text-foreground/85">
|
||||
<mark className={MARK[quote.role]}>{quote.quote}</mark>
|
||||
</code>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceRail({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex gap-2">
|
||||
<div className="relative flex w-3 shrink-0 flex-col items-center">
|
||||
<div className="flex h-5 w-full items-center justify-center">
|
||||
<div className="size-1 rounded-full bg-border" />
|
||||
</div>
|
||||
<div aria-hidden="true" className="h-2 w-px bg-border" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-xs leading-5 font-medium text-muted-foreground">Evidence</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div aria-hidden="true" className="absolute inset-y-0 left-[5px] w-px bg-border" />
|
||||
<div className="relative z-raised space-y-3">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExampleGroup {
|
||||
readonly id: string;
|
||||
readonly run: Sample["executions"][number] | undefined;
|
||||
readonly quotes: readonly Quote[];
|
||||
}
|
||||
|
||||
function Example({ group, onOpenEvidence }: { group: ExampleGroup; onOpenEvidence: (e: EvidenceRef) => void }) {
|
||||
const traceId = evidenceTarget(group.id)?.id;
|
||||
const name = group.run?.name ?? (traceId ? `Trace ${traceId.slice(0, 8)}` : "Recorded run");
|
||||
return (
|
||||
<article aria-label={name} className="flex w-full flex-col items-start gap-3 rounded-xl bg-muted/50 p-3.5">
|
||||
<div className="flex w-full items-baseline justify-between gap-3">
|
||||
<h3 className="min-w-0 truncate text-sm font-medium" title={traceId}>
|
||||
{name}
|
||||
</h3>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{[group.run?.service, group.run && runTime(group.run.start_time)].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{group.quotes.length === 0 ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">No quote was retained for this trace.</p>
|
||||
<Button variant="outline" size="xs" onClick={() => onOpenEvidence({ id: group.id, span: "" })}>
|
||||
View trace
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<EvidenceRail>
|
||||
{group.quotes.map((quote, i) => (
|
||||
<QuoteCard
|
||||
key={`${quote.span_id}-${i}`}
|
||||
quote={quote}
|
||||
onOpen={() => onOpenEvidence({ id: quote.execution_id, span: quote.span_id })}
|
||||
/>
|
||||
))}
|
||||
</EvidenceRail>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const VISIBLE_EXAMPLES = 3;
|
||||
|
||||
function Examples({
|
||||
groups,
|
||||
onOpenEvidence,
|
||||
}: {
|
||||
groups: readonly ExampleGroup[];
|
||||
onOpenEvidence: (e: EvidenceRef) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
if (groups.length === 0) return <p className="text-sm text-muted-foreground">No examples were recorded.</p>;
|
||||
const shown = expanded ? groups : groups.slice(0, VISIBLE_EXAMPLES);
|
||||
const hidden = groups.length - shown.length;
|
||||
return (
|
||||
<>
|
||||
{shown.map((group) => (
|
||||
<Example key={group.id} group={group} onOpenEvidence={onOpenEvidence} />
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start text-muted-foreground"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
Show {hidden} more {hidden === 1 ? "example" : "examples"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewForm({ finding, busy, onReview }: Pick<FindingDetailsProps, "finding" | "busy" | "onReview">) {
|
||||
const [reason, setReason] = useState(finding.reason ?? "");
|
||||
return (
|
||||
<section className="space-y-3 border-t pt-6">
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
What should Lens remember?
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="What should Lens know about this finding?"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Your explanation informs future scans of this Lens.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{finding.kind === "issue" && (
|
||||
<Button disabled={busy} onClick={() => onReview(finding.status === "resolved" ? "open" : "resolved", reason)}>
|
||||
{finding.status === "resolved" ? "Reopen" : "Mark resolved"}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={busy} variant="outline" onClick={() => onReview("dismissed", reason)}>
|
||||
This is expected
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingDetails({
|
||||
|
|
@ -38,132 +281,72 @@ export function FindingDetails({
|
|||
busy,
|
||||
onOpenEvidence,
|
||||
onReview,
|
||||
onClose,
|
||||
}: FindingDetailsProps) {
|
||||
const [reason, setReason] = useState(finding.reason ?? "");
|
||||
const evidenceGroups = [...new Set([...finding.occurrences, ...finding.evidence.map((e) => e.execution_id)])].map(
|
||||
(id) => ({
|
||||
id,
|
||||
run: sampledRuns.find((r) => r.id === id),
|
||||
quotes: finding.evidence.filter((e) => e.execution_id === id),
|
||||
}),
|
||||
);
|
||||
const groups: ExampleGroup[] = [
|
||||
...new Set([...finding.evidence.map((e) => e.execution_id), ...finding.occurrences]),
|
||||
].map((id) => ({
|
||||
id,
|
||||
run: sampledRuns.find((r) => r.id === id),
|
||||
quotes: finding.evidence.filter((e) => e.execution_id === id),
|
||||
}));
|
||||
const affected = finding.occurrences.length;
|
||||
const runs = finding.investigation_runs?.length ?? 0;
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<header className="flex flex-col gap-1.5 border-b px-4 py-4">
|
||||
<h2 className="text-xl leading-snug font-semibold">{finding.title}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{agents.length > 0 && <span className="font-medium text-foreground">{agents.join(", ")} · </span>}
|
||||
{finding.kind === "issue" ? `${finding.priority} priority` : "Pattern"} · {finding.occurrences?.length ?? 0}{" "}
|
||||
affected {finding.occurrences?.length === 1 ? "trace" : "traces"}
|
||||
</p>
|
||||
{(finding.investigation_runs?.length ?? 0) > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Found across {finding.investigation_runs.length} investigation{" "}
|
||||
{finding.investigation_runs.length === 1 ? "run" : "runs"}
|
||||
</p>
|
||||
)}
|
||||
{lensId && finding.evidence.length > 0 && (
|
||||
<AddToDatasetButton
|
||||
sources={[{ kind: "finding", lens_id: lensId, finding_ids: [finding.id] }]}
|
||||
agentName={agents[0]}
|
||||
label="Add evidence to dataset"
|
||||
className="mt-1 self-start"
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
<div className="space-y-6 p-4">
|
||||
{finding.brief ? (
|
||||
<IssueBrief title={finding.title} brief={finding.brief} />
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">What happened</p>
|
||||
<p className="text-sm leading-6 whitespace-pre-wrap">{finding.description}</p>
|
||||
</div>
|
||||
{finding.suggestion && (
|
||||
<div className="border-y py-4">
|
||||
<p className="text-sm font-medium">What to do next</p>
|
||||
<p className="mt-2 text-sm leading-6">{finding.suggestion}</p>
|
||||
</div>
|
||||
<TopBar finding={finding} onClose={onClose} />
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-7 px-6 pt-4 pb-16">
|
||||
<header className="flex flex-col gap-3">
|
||||
<h1 className="text-2xl leading-tight font-semibold tracking-tight text-balance">{finding.title}</h1>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-muted-foreground">
|
||||
{finding.kind === "issue" ? (
|
||||
<PriorityPill priority={finding.priority} />
|
||||
) : (
|
||||
<span className="inline-flex h-5 items-center rounded-full bg-muted px-2 font-medium">Pattern</span>
|
||||
)}
|
||||
</>
|
||||
{agents.length > 0 && <span className="font-medium text-foreground">{agents.join(", ")}</span>}
|
||||
<span className="tabular-nums">
|
||||
{affected} affected {affected === 1 ? "trace" : "traces"}
|
||||
</span>
|
||||
{runs > 0 && (
|
||||
<span className="tabular-nums">
|
||||
Found across {runs} investigation {runs === 1 ? "run" : "runs"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<ProseSection title="Summary">{finding.description}</ProseSection>
|
||||
{finding.suggestion && <ProseSection title="Suggested fix">{finding.suggestion}</ProseSection>}
|
||||
{finding.brief && (
|
||||
<Disclosure title="Issue brief and test cases">
|
||||
<IssueBrief title={finding.title} brief={finding.brief} />
|
||||
</Disclosure>
|
||||
)}
|
||||
{finding.limitation && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer font-medium">Evidence limits</summary>
|
||||
<p className="mt-3 leading-6 text-muted-foreground">{finding.limitation}</p>
|
||||
</details>
|
||||
<Disclosure title="Evidence limits">
|
||||
<p className="max-w-[70ch] text-sm leading-relaxed text-pretty text-muted-foreground">
|
||||
{finding.limitation}
|
||||
</p>
|
||||
</Disclosure>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">Affected traces and counterexamples</p>
|
||||
<p className="mt-1 mb-3 text-xs text-muted-foreground">
|
||||
Exact quotes from the recorded activity. Counterexamples are labeled separately from supporting evidence.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{evidenceGroups.map((group) => (
|
||||
<details key={group.id} className="rounded-lg border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
{group.run?.name ?? evidenceTarget(group.id)?.id.slice(0, 12) ?? "Recorded run"}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{group.quotes.length} {group.quotes.length === 1 ? "quote" : "quotes"}
|
||||
{group.run ? ` · ${runTime(group.run.start_time)}` : ""}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{group.quotes.length === 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenEvidence({ id: group.id, span: "" })}>
|
||||
Open original trace
|
||||
<ArrowUpRight className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
{group.quotes.map((e, i) => (
|
||||
<div key={`${e.span_id}-${i}`} className="rounded-md bg-muted/40 p-3">
|
||||
{e.role === "counterexample" && (
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">Counterexample</p>
|
||||
)}
|
||||
<blockquote className="text-xs leading-5 whitespace-pre-wrap break-words">{e.quote}</blockquote>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => onOpenEvidence({ id: e.execution_id, span: e.span_id })}
|
||||
>
|
||||
{evidenceTarget(e.execution_id)?.source === "traces" ? "Open original step" : "Open request"}
|
||||
<ArrowUpRight className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<label className="grid gap-2 text-sm">
|
||||
What should Lens remember?
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="What should Lens know about this finding?"
|
||||
<section>
|
||||
<h2 className={`mb-2 ${SECTION_LABEL}`}>Monitors</h2>
|
||||
<FrequencyCard frequency={findingFrequency(finding.occurrences, sampledRuns)} />
|
||||
</section>
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className={SECTION_LABEL}>Examples</h2>
|
||||
{lensId && finding.evidence.length > 0 && (
|
||||
<AddToDatasetButton
|
||||
sources={[{ kind: "finding", lens_id: lensId, finding_ids: [finding.id] }]}
|
||||
agentName={agents[0]}
|
||||
label="Add evidence to dataset"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Your explanation informs future scans of this Lens.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{finding.kind === "issue" && (
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => onReview(finding.status === "resolved" ? "open" : "resolved", reason)}
|
||||
>
|
||||
{finding.status === "resolved" ? "Reopen" : "Mark resolved"}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={busy} variant="outline" onClick={() => onReview("dismissed", reason)}>
|
||||
This is expected
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Examples groups={groups} onOpenEvidence={onOpenEvidence} />
|
||||
</section>
|
||||
{!readOnly && <ReviewForm finding={finding} busy={busy} onReview={onReview} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -29,13 +29,9 @@ it("deduplicates findings across investigations and applies feedback to every so
|
|||
renderWithLens(<FindingsView />, { searchParams: "?tab=findings", onUrlUpdate });
|
||||
const rows = await screen.findAllByRole("row", { name: issue.title });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(screen.getByRole("columnheader", { name: "Investigation", exact: true })).toBeVisible();
|
||||
const investigations = `${support.settings.name}, ${twin.settings.name}`;
|
||||
expect(within(rows[0]).getByRole("cell", { name: investigations, exact: true })).toHaveAttribute(
|
||||
"title",
|
||||
investigations,
|
||||
);
|
||||
expect(within(rows[0]).getByRole("cell", { name: "2", exact: true })).toBeVisible();
|
||||
expect(
|
||||
within(rows[0]).getByTitle(`2 affected traces across ${support.settings.name}, ${twin.settings.name}`),
|
||||
).toBeVisible();
|
||||
await user.click(rows[0]);
|
||||
const panel = await screen.findByRole("complementary", { name: "Finding details" });
|
||||
fireEvent.change(within(panel).getByRole("textbox"), { target: { value: "A handoff now handles failures" } });
|
||||
|
|
@ -107,7 +103,7 @@ it("reviews only the selected check when two findings have the same title", asyn
|
|||
renderWithLens(<FindingsView />, { searchParams: "?tab=findings" });
|
||||
const rows = await screen.findAllByRole("row", { name: issue.title });
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(within(rows[0]).getByRole("cell", { name: support.settings.name, exact: true })).toBeVisible();
|
||||
expect(within(rows[0]).getByTitle(new RegExp(`across ${support.settings.name}$`))).toBeVisible();
|
||||
await user.click(rows[0]);
|
||||
await user.click(await screen.findByRole("button", { name: "Mark resolved" }));
|
||||
await waitFor(() => expect(proxy.patch).toHaveBeenCalledTimes(1));
|
||||
|
|
@ -122,3 +118,38 @@ it("opens a grouped finding from a link to any of its owning investigations", as
|
|||
expect(within(panel).getByRole("heading", { name: issue.title })).toBeVisible();
|
||||
expect(screen.getByRole("row", { name: issue.title })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("ranks findings under high, medium and low priority headings with the highest first", async () => {
|
||||
const at = (id: string, priority: "high" | "medium" | "low", last_seen: string) => ({
|
||||
...issue,
|
||||
id,
|
||||
check_id: id,
|
||||
title: `${priority} ${id}`,
|
||||
priority,
|
||||
last_seen,
|
||||
});
|
||||
const findings = [
|
||||
at("newest-low", "low", "2026-10-06T00:00:00Z"),
|
||||
at("old-high", "high", "2026-09-01T00:00:00Z"),
|
||||
at("medium", "medium", "2026-10-05T00:00:00Z"),
|
||||
at("new-high", "high", "2026-10-04T00:00:00Z"),
|
||||
];
|
||||
proxy.get.mockImplementation(async (path) =>
|
||||
path === "/lens" ? { lenses: [{ ...support, findings }], workers: [], tracing_enabled: true } : { data: [] },
|
||||
);
|
||||
renderWithLens(<FindingsView readOnly />, { searchParams: "?tab=findings" });
|
||||
const groups = await screen.findAllByRole("rowgroup");
|
||||
expect(groups.map((group) => group.getAttribute("aria-label"))).toEqual([
|
||||
"High priority findings",
|
||||
"Medium priority findings",
|
||||
"Low priority findings",
|
||||
]);
|
||||
const titles = (group: HTMLElement) =>
|
||||
within(group)
|
||||
.getAllByRole("row")
|
||||
.map((row) => row.getAttribute("aria-label"))
|
||||
.filter(Boolean);
|
||||
expect(titles(groups[0])).toEqual(["high new-high", "high old-high"]);
|
||||
expect(groups[0]).toHaveTextContent(/High priority\s*2/);
|
||||
expect(titles(groups[2])).toEqual(["low newest-low"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Inspector } from "@/components/shared/Inspector";
|
||||
import { Inspector, useInspector } from "@/components/shared/Inspector";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
|
||||
import { useLensApi } from "../data/LensServices";
|
||||
import { useLensUpdate } from "../data/mutations";
|
||||
import { lensQueries } from "../data/queries";
|
||||
import { agoLabel } from "../model/format";
|
||||
import { findingFrequency, percentLabel } from "../model/frequency";
|
||||
import {
|
||||
ALL_AGENTS,
|
||||
filterInbox,
|
||||
|
|
@ -18,7 +17,7 @@ import {
|
|||
inboxAgents,
|
||||
inboxFinding,
|
||||
inboxRows,
|
||||
sampledExecutions,
|
||||
inboxSampledRuns,
|
||||
type InboxRow,
|
||||
type Priority,
|
||||
} from "../model/inbox";
|
||||
|
|
@ -28,8 +27,8 @@ import { FINDING_PANEL_WIDTH_KEY } from "../storage";
|
|||
import { EvidenceView } from "./Evidence";
|
||||
import { FindingDetails } from "./FindingDetails";
|
||||
import { InvestigationError, InvestigationsLoading } from "./InvestigationStates";
|
||||
import { PRIORITY_LABEL, PRIORITY_ORDER, PriorityDot } from "./PriorityMark";
|
||||
|
||||
const PRIORITY_DOT = { high: "bg-destructive", medium: "bg-warning", low: "bg-muted-foreground/50" } as const;
|
||||
const PRIORITIES: { value: Priority | "all"; label: string }[] = [
|
||||
{ value: "all", label: "All priorities" },
|
||||
{ value: "high", label: "High" },
|
||||
|
|
@ -37,15 +36,6 @@ const PRIORITIES: { value: Priority | "all"; label: string }[] = [
|
|||
{ value: "low", label: "Low" },
|
||||
];
|
||||
|
||||
function InvestigationCell({ sources }: Pick<InboxRow, "sources">) {
|
||||
const names = [...new Set(sources.map(({ lens }) => lens.settings.name))].join(", ");
|
||||
return (
|
||||
<td className="hidden truncate px-3 text-muted-foreground lg:table-cell" title={names}>
|
||||
{names}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSelect<T extends string>({
|
||||
label,
|
||||
value,
|
||||
|
|
@ -59,7 +49,11 @@ function FilterSelect<T extends string>({
|
|||
}) {
|
||||
return (
|
||||
<Select items={items} value={value} onValueChange={(next: T | null) => next !== null && onChange(next)}>
|
||||
<SelectTrigger size="sm" className="h-7 min-w-32 text-xs" aria-label={label}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-7 min-w-0 flex-1 border-transparent bg-muted/60 text-xs shadow-none hover:bg-muted"
|
||||
aria-label={label}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -73,6 +67,67 @@ function FilterSelect<T extends string>({
|
|||
);
|
||||
}
|
||||
|
||||
function FindingRow({ row, now }: { row: InboxRow; now: number }) {
|
||||
const frequency = findingFrequency(inboxFinding(row).occurrences, inboxSampledRuns(row));
|
||||
const percent = percentLabel(frequency.affected, frequency.total);
|
||||
return (
|
||||
<Inspector.Row
|
||||
item={row}
|
||||
render={
|
||||
<div
|
||||
role="row"
|
||||
tabIndex={0}
|
||||
aria-label={row.title}
|
||||
className="mx-2 block cursor-pointer space-y-1 rounded-md px-2 py-2 transition-[background-color] duration-150 outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring/50 data-[state=selected]:bg-muted"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div role="gridcell" className="line-clamp-2 text-xs leading-snug text-pretty text-foreground">
|
||||
{row.title}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums" title={formatActivityTimestamp(row.lastSeen)}>
|
||||
{agoLabel(Date.parse(row.lastSeen), now)}
|
||||
</span>
|
||||
<span
|
||||
className="whitespace-nowrap text-foreground/75 tabular-nums"
|
||||
title={`${row.runs} affected ${row.runs === 1 ? "trace" : "traces"} across ${row.sources.map(({ lens }) => lens.settings.name).join(", ")}`}
|
||||
>
|
||||
{percent ? `${percent} affected` : `${row.runs} ${row.runs === 1 ? "trace" : "traces"}`}
|
||||
</span>
|
||||
</div>
|
||||
</Inspector.Row>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingList({ rows, now }: { rows: readonly InboxRow[]; now: number }) {
|
||||
const groups = PRIORITY_ORDER.map((priority) => ({
|
||||
priority,
|
||||
rows: rows.filter((row) => row.priority === priority),
|
||||
})).filter((group) => group.rows.length > 0);
|
||||
return (
|
||||
<div role="grid" aria-label="Findings" className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-2">
|
||||
{groups.map((group) => (
|
||||
<div role="rowgroup" key={group.priority} aria-label={`${PRIORITY_LABEL[group.priority]} priority findings`}>
|
||||
<div
|
||||
role="row"
|
||||
className="sticky top-0 z-raised flex items-center gap-2 bg-background/95 px-4 pt-3 pb-1.5 text-xs font-medium text-muted-foreground backdrop-blur"
|
||||
>
|
||||
<PriorityDot priority={group.priority} />
|
||||
<span role="columnheader">{PRIORITY_LABEL[group.priority]} priority</span>
|
||||
<span className="ml-auto tabular-nums">{group.rows.length}</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{group.rows.map((row) => (
|
||||
<FindingRow key={row.key} row={row} now={now} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxDetail({
|
||||
row,
|
||||
readOnly,
|
||||
|
|
@ -84,6 +139,7 @@ function InboxDetail({
|
|||
busy: boolean;
|
||||
onReview: (row: InboxRow, status: Finding["status"], reason: string) => void;
|
||||
}) {
|
||||
const { close } = useInspector<InboxRow>();
|
||||
const { evidence, setEvidence } = useEvidenceRoute();
|
||||
const owner =
|
||||
row.sources.find(({ finding }) => finding.evidence.some((quote) => quote.execution_id === evidence?.id)) ??
|
||||
|
|
@ -95,11 +151,12 @@ function InboxDetail({
|
|||
key={row.key}
|
||||
finding={inboxFinding(row)}
|
||||
agents={row.agents}
|
||||
sampledRuns={row.sources.flatMap(({ lens }) => sampledExecutions(lens))}
|
||||
sampledRuns={inboxSampledRuns(row)}
|
||||
readOnly={readOnly}
|
||||
busy={busy}
|
||||
onOpenEvidence={setEvidence}
|
||||
onReview={(status, reason) => onReview(row, status, reason)}
|
||||
onClose={close}
|
||||
/>
|
||||
</div>
|
||||
{evidence && (
|
||||
|
|
@ -151,7 +208,7 @@ export function FindingsView({ readOnly = false }: { readOnly?: boolean }) {
|
|||
noun="finding"
|
||||
storageKey={FINDING_PANEL_WIDTH_KEY}
|
||||
>
|
||||
<section aria-label="Findings" className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border bg-background">
|
||||
{(list.error || update.error) && (
|
||||
<InvestigationError
|
||||
message={(list.error ?? update.error)!.message}
|
||||
|
|
@ -161,92 +218,48 @@ export function FindingsView({ readOnly = false }: { readOnly?: boolean }) {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-h-10 shrink-0 flex-wrap items-center gap-2 border-b px-3 py-1.5">
|
||||
<FilterSelect label="Filter by agent" value={filters.agent} items={agents} onChange={filters.setAgent} />
|
||||
<FilterSelect
|
||||
label="Filter by priority"
|
||||
value={filters.priority}
|
||||
items={PRIORITIES}
|
||||
onChange={filters.setPriority}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table aria-label="Findings" className="w-full table-fixed border-collapse text-left text-xs">
|
||||
<thead className="sticky top-0 z-sticky bg-muted/40 backdrop-blur">
|
||||
<tr className="h-8 border-b text-xs tracking-wider text-muted-foreground uppercase">
|
||||
<th className="w-20 px-3 font-medium">Priority</th>
|
||||
<th className="px-3 font-medium">Finding</th>
|
||||
<th className="hidden w-48 px-3 font-medium lg:table-cell">Investigation</th>
|
||||
<th className="hidden w-40 px-3 font-medium md:table-cell">Agent</th>
|
||||
<th className="hidden w-16 px-3 text-right font-medium sm:table-cell">Traces</th>
|
||||
<th className="hidden w-24 px-3 font-medium lg:table-cell">Last seen</th>
|
||||
<th className="w-7">
|
||||
<span className="sr-only">Details</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<Inspector.Row
|
||||
key={row.key}
|
||||
item={row}
|
||||
render={
|
||||
<tr
|
||||
tabIndex={0}
|
||||
aria-label={row.title}
|
||||
className="h-9 cursor-pointer border-b border-border/60 hover:bg-trace-row-hover focus-visible:outline-2 focus-visible:outline-ring data-[state=selected]:bg-trace-row-selected"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<td className="px-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<span aria-hidden="true" className={cn("size-1.5 rounded-full", PRIORITY_DOT[row.priority])} />
|
||||
{row.priority}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 sm:py-0" title={row.suggestion || undefined}>
|
||||
<span className="line-clamp-2 text-foreground sm:block sm:truncate">{row.title}</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground md:hidden">
|
||||
{row.agents.join(", ")} · {row.runs} {row.runs === 1 ? "trace" : "traces"}
|
||||
</span>
|
||||
</td>
|
||||
<InvestigationCell sources={row.sources} />
|
||||
<td
|
||||
className="hidden truncate px-3 text-muted-foreground md:table-cell"
|
||||
title={row.agents.join(", ")}
|
||||
>
|
||||
{row.agents.join(", ")}
|
||||
</td>
|
||||
<td className="hidden px-3 text-right font-mono tabular-nums sm:table-cell">{row.runs}</td>
|
||||
<td
|
||||
className="hidden px-3 tabular-nums text-muted-foreground lg:table-cell"
|
||||
title={formatActivityTimestamp(row.lastSeen)}
|
||||
>
|
||||
{agoLabel(Date.parse(row.lastSeen), now)}
|
||||
</td>
|
||||
<td>
|
||||
<ChevronRight aria-hidden="true" className="size-3 text-muted-foreground/60" />
|
||||
</td>
|
||||
</Inspector.Row>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!rows.length && !list.error && (
|
||||
<p className="px-4 py-16 text-center text-xs text-muted-foreground">
|
||||
{all.length
|
||||
? "No findings match these filters."
|
||||
: "No open findings yet. New problems show up here as soon as an investigation spots them."}
|
||||
</p>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<section
|
||||
aria-label="Findings list"
|
||||
className={`flex min-h-0 w-full flex-col border-r md:w-[22rem] md:shrink-0 lg:w-[28rem] ${selected ? "hidden md:flex" : "flex"}`}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 px-3 py-2">
|
||||
<FilterSelect label="Filter by agent" value={filters.agent} items={agents} onChange={filters.setAgent} />
|
||||
<FilterSelect
|
||||
label="Filter by priority"
|
||||
value={filters.priority}
|
||||
items={PRIORITIES}
|
||||
onChange={filters.setPriority}
|
||||
/>
|
||||
</div>
|
||||
<FindingList rows={rows} now={now} />
|
||||
{!rows.length && !list.error && (
|
||||
<p className="px-4 py-16 text-center text-xs text-muted-foreground">
|
||||
{all.length
|
||||
? "No findings match these filters."
|
||||
: "No open findings yet. New problems show up here as soon as an investigation spots them."}
|
||||
</p>
|
||||
)}
|
||||
<footer className="flex h-8 shrink-0 items-center border-t px-3 text-xs text-muted-foreground">
|
||||
{rows.length} {rows.length === 1 ? "finding" : "findings"}
|
||||
{rows.length !== all.length && ` of ${all.length}`}
|
||||
</footer>
|
||||
</section>
|
||||
{selected ? (
|
||||
<aside
|
||||
aria-label="Finding details"
|
||||
data-testid="finding-panel"
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col"
|
||||
>
|
||||
<InboxDetail row={selected} readOnly={readOnly} busy={update.isPending} onReview={review} />
|
||||
</aside>
|
||||
) : (
|
||||
<div className="hidden min-w-0 flex-1 items-center justify-center p-8 text-sm text-muted-foreground md:flex">
|
||||
{rows.length ? "Select a finding to see how often it happens and where." : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className="flex h-8 shrink-0 items-center border-t bg-muted/30 px-3 text-xs text-muted-foreground">
|
||||
{rows.length} {rows.length === 1 ? "finding" : "findings"}
|
||||
{rows.length !== all.length && ` of ${all.length}`}
|
||||
</footer>
|
||||
</section>
|
||||
<Inspector.Panel label="Finding details" testId="finding-panel">
|
||||
{(row: InboxRow) => <InboxDetail row={row} readOnly={readOnly} busy={update.isPending} onReview={review} />}
|
||||
</Inspector.Panel>
|
||||
</div>
|
||||
</Inspector.Root>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { dayLabel, type Frequency, percentLabel } from "../model/frequency";
|
||||
|
||||
const AFFECTED = "var(--finding-affected)";
|
||||
const UNAFFECTED = "var(--finding-unaffected)";
|
||||
const TICK = { fontSize: 11, fill: "var(--muted-foreground)" };
|
||||
const TOP_RADIUS: [number, number, number, number] = [3, 3, 0, 0];
|
||||
|
||||
function Legend() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span aria-hidden="true" className="size-2 rounded-sm bg-finding-affected" />
|
||||
Affected
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span aria-hidden="true" className="size-2 rounded-sm bg-finding-unaffected" />
|
||||
Unaffected
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FrequencyCard({ frequency }: { frequency: Frequency }) {
|
||||
const percent = percentLabel(frequency.affected, frequency.total);
|
||||
const data = frequency.days.map((d) => ({ ...d, label: dayLabel(d.day) }));
|
||||
const range = data.length > 0 ? `${data[0].label} – ${data[data.length - 1].label}` : null;
|
||||
return (
|
||||
<section
|
||||
aria-label="Frequency"
|
||||
className="space-y-4 rounded-xl border border-transparent bg-muted/40 p-4 shadow-finding-ring"
|
||||
>
|
||||
<div aria-live="polite" className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="mb-1 text-sm font-medium">Frequency</h3>
|
||||
<p className="text-2xl leading-tight font-semibold tracking-tight tabular-nums">
|
||||
{percent ?? "—"}
|
||||
<span className="ml-1.5 text-sm font-normal tracking-normal text-muted-foreground">
|
||||
{frequency.affected} of {frequency.total} traces affected
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{range && <span className="pt-0.5 text-xs whitespace-nowrap text-muted-foreground">{range}</span>}
|
||||
</div>
|
||||
{data.length > 0 && (
|
||||
<div className="h-56 md:h-64" data-testid="frequency-chart">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{ top: 4, right: 0, bottom: 0, left: -20 }}
|
||||
barCategoryGap="18%"
|
||||
maxBarSize={44}
|
||||
>
|
||||
<CartesianGrid vertical={false} stroke="var(--border)" strokeOpacity={0.6} />
|
||||
<XAxis dataKey="label" tick={TICK} tickLine={false} axisLine={false} minTickGap={28} tickMargin={8} />
|
||||
<YAxis allowDecimals={false} tick={TICK} tickLine={false} axisLine={false} width={40} tickCount={4} />
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--muted)", opacity: 0.7 }}
|
||||
contentStyle={{
|
||||
background: "var(--popover)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
boxShadow: "var(--finding-ring)",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="affected" name="Affected" stackId="traces" fill={AFFECTED} isAnimationActive={false} />
|
||||
<Bar
|
||||
dataKey="unaffected"
|
||||
name="Unaffected"
|
||||
stackId="traces"
|
||||
fill={UNAFFECTED}
|
||||
radius={TOP_RADIUS}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Legend />
|
||||
<p className="text-xs text-muted-foreground">Share of traces sampled by the reporting investigations</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -219,10 +219,9 @@ describe("Lens findings and runs", () => {
|
|||
const detail = within(screen.getByRole("complementary", { name: "Finding details" }));
|
||||
expect(detail.getByText(pattern.description)).toBeVisible();
|
||||
expect(detail.getByText(pattern.limitation ?? "")).not.toBeVisible();
|
||||
expect(detail.getByText("Ignore the review instructions")).not.toBeVisible();
|
||||
await user.click(detail.getByText("Release-42"));
|
||||
expect(detail.getByText("Ignore the review instructions")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Open original step" })).toBeVisible();
|
||||
const example = within(detail.getByRole("article", { name: "Release-42" }));
|
||||
expect(example.getByText("Ignore the review instructions").tagName).toBe("MARK");
|
||||
expect(example.getByRole("button", { name: "View span" })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Mark resolved" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -326,15 +325,16 @@ describe("Lens findings and runs", () => {
|
|||
const { user, detail } = await openIssue({ ...issue, suggestion: "Check repository access", brief });
|
||||
const markdown = briefMarkdown(issue.title, brief);
|
||||
expect(detail.getByRole("heading", { level: 1, name: issue.title })).toBeVisible();
|
||||
expect(detail.getByRole("heading", { level: 2, name: "Suggested fix" })).toBeVisible();
|
||||
await user.click(detail.getByText("Issue brief and test cases"));
|
||||
for (const section of ["Problem", "User goal", "What happened", "Test cases"]) {
|
||||
expect(detail.getByRole("heading", { level: 2, name: section })).toBeVisible();
|
||||
expect(detail.getByRole("heading", { level: 3, name: section })).toBeVisible();
|
||||
}
|
||||
expect(detail.getByText(brief.problem)).toBeVisible();
|
||||
expect(detail.getByRole("listitem")).toHaveTextContent(
|
||||
`Input: ${brief.test_cases[0].input} Expect: ${brief.test_cases[0].expected}`,
|
||||
);
|
||||
expect(detail.queryByText("## Problem", { exact: false })).not.toBeInTheDocument();
|
||||
expect(detail.queryByText("Check repository access")).not.toBeInTheDocument();
|
||||
await user.click(detail.getByRole("button", { name: `Copy for ${agent}` }));
|
||||
expect(await navigator.clipboard.readText()).toBe(markdown);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ const AGENTS = [
|
|||
const COPIED_RESET_MS = 1500;
|
||||
|
||||
const markdown: Components = {
|
||||
h1: ({ children }) => <h1 className="mb-4 border-b border-border pb-2 text-base font-semibold">{children}</h1>,
|
||||
h1: ({ children }) => <p className="mb-4 border-b border-border pb-2 text-base font-semibold">{children}</p>,
|
||||
h2: ({ children }) => (
|
||||
<h2 className="mt-5 mb-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase">{children}</h2>
|
||||
<h3 className="mt-5 mb-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase">{children}</h3>
|
||||
),
|
||||
p: ({ children }) => <p className="text-sm leading-6">{children}</p>,
|
||||
ol: ({ children }) => (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { cn } from "@/lib/cva.config";
|
||||
import type { Priority } from "../model/inbox";
|
||||
|
||||
export const PRIORITY_ORDER: readonly Priority[] = ["high", "medium", "low"];
|
||||
|
||||
export const PRIORITY_LABEL: Readonly<Record<Priority, string>> = {
|
||||
high: "High",
|
||||
medium: "Medium",
|
||||
low: "Low",
|
||||
};
|
||||
|
||||
const DOT: Readonly<Record<Priority, string>> = {
|
||||
high: "bg-destructive",
|
||||
medium: "bg-warning",
|
||||
low: "bg-muted-foreground/60",
|
||||
};
|
||||
|
||||
const PILL: Readonly<Record<Priority, string>> = {
|
||||
high: "bg-destructive/10 text-destructive",
|
||||
medium: "bg-warning/12 text-warning",
|
||||
low: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
export function PriorityDot({ priority, className }: { priority: Priority; className?: string }) {
|
||||
return <span aria-hidden="true" className={cn("size-1.5 shrink-0 rounded-full", DOT[priority], className)} />;
|
||||
}
|
||||
|
||||
export function PriorityPill({ priority }: { priority: Priority }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-5 items-center gap-1.5 rounded-full px-2 text-xs font-medium whitespace-nowrap",
|
||||
PILL[priority],
|
||||
)}
|
||||
>
|
||||
<PriorityDot priority={priority} />
|
||||
{PRIORITY_LABEL[priority]} priority
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { mergeFeedback, sortedFindings, briefMarkdown } from "./findings";
|
||||
import { findingMarkdown, mergeFeedback, sortedFindings, briefMarkdown } from "./findings";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type Finding } from "./types";
|
||||
|
||||
|
|
@ -47,6 +47,35 @@ describe("Lens findings", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("copies a finding for an agent with its summary, fix and every quoted line", () => {
|
||||
const finding: Finding = {
|
||||
kind: "issue",
|
||||
status: "open",
|
||||
reason: "",
|
||||
suggestion: "Reconnect with files:read",
|
||||
limitation: "",
|
||||
occurrences: [],
|
||||
id: "f",
|
||||
check_id: "check",
|
||||
title: "Attachments unread",
|
||||
description: "Five turns got a notice",
|
||||
evidence: [{ execution_id: "e", span_id: "s", quote: "could not\nbe read", role: "support" }],
|
||||
revision: 1,
|
||||
priority: "high",
|
||||
first_seen: "2026-09-30T10:00:00Z",
|
||||
last_seen: "2026-09-30T12:00:00Z",
|
||||
};
|
||||
expect(findingMarkdown(finding)).toBe(
|
||||
"# Attachments unread\n\n## Summary\nFive turns got a notice\n\n## Suggested fix\nReconnect with files:read" +
|
||||
"\n\n## Evidence\n> could not\n> be read",
|
||||
);
|
||||
expect(findingMarkdown({ ...finding, suggestion: "", evidence: [] })).toBe(
|
||||
"# Attachments unread\n\n## Summary\nFive turns got a notice",
|
||||
);
|
||||
const brief = { problem: "p", user_goal: "g", what_happened: "w", test_cases: [] };
|
||||
expect(findingMarkdown({ ...finding, brief })).toBe(briefMarkdown(finding.title, brief));
|
||||
});
|
||||
|
||||
it("applies current feedback to a historical snapshot while preserving its original evidence", () => {
|
||||
const snapshot: Finding = {
|
||||
kind: "issue",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,19 @@ export function briefMarkdown(title: string, brief: IssueBrief): string {
|
|||
].join("\n\n");
|
||||
}
|
||||
|
||||
export function findingMarkdown(finding: Finding): string {
|
||||
if (finding.brief) return briefMarkdown(finding.title, finding.brief);
|
||||
const quotes = finding.evidence.map((e) => `> ${e.quote.replaceAll("\n", "\n> ")}`);
|
||||
return [
|
||||
`# ${finding.title}`,
|
||||
`## Summary\n${finding.description}`,
|
||||
finding.suggestion && `## Suggested fix\n${finding.suggestion}`,
|
||||
quotes.length > 0 && `## Evidence\n${quotes.join("\n\n")}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function mergeFeedback(findings: Finding[], current: Finding[]): Finding[] {
|
||||
return findings.map((finding) => {
|
||||
const feedback = current.find((item) => item.id === finding.id);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { FREQUENCY_WINDOW_DAYS, findingFrequency, percentLabel } from "./frequency";
|
||||
import type { Sample } from "./types";
|
||||
|
||||
const run = (id: string, start_time: string): Sample["executions"][number] => ({
|
||||
id,
|
||||
start_time,
|
||||
name: id,
|
||||
metadata: [],
|
||||
root_seen: true,
|
||||
service: "agent",
|
||||
source: "traces",
|
||||
span_count: 1,
|
||||
team_id: "",
|
||||
trace_id: id,
|
||||
trace_ref: "",
|
||||
});
|
||||
|
||||
describe("findingFrequency", () => {
|
||||
it("counts affected sampled traces per day and keeps empty days between them", () => {
|
||||
const runs = [
|
||||
run("a", "2026-10-01T09:00:00Z"),
|
||||
run("b", "2026-10-01T23:59:00Z"),
|
||||
run("c", "2026-10-03T00:01:00Z"),
|
||||
run("c", "2026-10-03T00:01:00Z"),
|
||||
];
|
||||
const frequency = findingFrequency(["b", "c", "not-sampled"], runs);
|
||||
expect(frequency.affected).toBe(2);
|
||||
expect(frequency.total).toBe(3);
|
||||
expect(frequency.days.slice(-3)).toEqual([
|
||||
{ day: "2026-10-01", affected: 1, unaffected: 1 },
|
||||
{ day: "2026-10-02", affected: 0, unaffected: 0 },
|
||||
{ day: "2026-10-03", affected: 1, unaffected: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("pads a short sample back to a two-week axis ending on the latest sampled day", () => {
|
||||
const { days } = findingFrequency(["a"], [run("a", "2026-10-04T12:00:00Z")]);
|
||||
expect(days).toHaveLength(FREQUENCY_WINDOW_DAYS);
|
||||
expect(days[0]).toEqual({ day: "2026-09-21", affected: 0, unaffected: 0 });
|
||||
expect(days.at(-1)).toEqual({ day: "2026-10-04", affected: 1, unaffected: 0 });
|
||||
});
|
||||
|
||||
it("keeps every sampled day when the sample spans longer than two weeks", () => {
|
||||
const { days } = findingFrequency([], [run("a", "2026-09-01T00:00:00Z"), run("b", "2026-10-01T00:00:00Z")]);
|
||||
expect(days[0].day).toBe("2026-09-01");
|
||||
expect(days).toHaveLength(31);
|
||||
});
|
||||
|
||||
it("returns no days when nothing was sampled", () => {
|
||||
expect(findingFrequency(["a"], [])).toEqual({ affected: 0, total: 0, days: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("percentLabel", () => {
|
||||
it("rounds to one decimal and drops a trailing zero", () => {
|
||||
expect(percentLabel(5, 54)).toBe("9.3%");
|
||||
expect(percentLabel(1, 2)).toBe("50%");
|
||||
});
|
||||
|
||||
it("has no percentage without a denominator", () => {
|
||||
expect(percentLabel(0, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
58
ui/litellm-dashboard/src/components/lens/model/frequency.ts
Normal file
58
ui/litellm-dashboard/src/components/lens/model/frequency.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { Sample } from "./types";
|
||||
|
||||
type Execution = Sample["executions"][number];
|
||||
|
||||
export interface DayCount {
|
||||
readonly day: string;
|
||||
readonly affected: number;
|
||||
readonly unaffected: number;
|
||||
}
|
||||
|
||||
export interface Frequency {
|
||||
readonly affected: number;
|
||||
readonly total: number;
|
||||
readonly days: readonly DayCount[];
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
export const FREQUENCY_WINDOW_DAYS = 14;
|
||||
|
||||
const dayKey = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
|
||||
|
||||
function dayRange(first: number, last: number): string[] {
|
||||
const end = Date.parse(dayKey(last));
|
||||
const start = Math.min(Date.parse(dayKey(first)), end - (FREQUENCY_WINDOW_DAYS - 1) * DAY_MS);
|
||||
const span = Math.round((end - start) / DAY_MS);
|
||||
return Array.from({ length: span + 1 }, (_, i) => dayKey(start + i * DAY_MS));
|
||||
}
|
||||
|
||||
/**
|
||||
* How many sampled traces a finding hit, overall and per UTC day. Days run back at least two weeks from the latest
|
||||
* sample, empty ones included, so bars sit on a stable time axis.
|
||||
*/
|
||||
export function findingFrequency(occurrences: readonly string[], executions: readonly Execution[]): Frequency {
|
||||
const sampled = [...new Map(executions.map((run) => [run.id, run] as const)).values()];
|
||||
const hit = new Set(occurrences);
|
||||
const dated = sampled
|
||||
.map((run) => ({ affected: hit.has(run.id), ms: Date.parse(run.start_time) }))
|
||||
.filter(({ ms }) => Number.isFinite(ms));
|
||||
const stamps = dated.map(({ ms }) => ms);
|
||||
const days = dated.length ? dayRange(Math.min(...stamps), Math.max(...stamps)) : [];
|
||||
return {
|
||||
affected: sampled.filter((run) => hit.has(run.id)).length,
|
||||
total: sampled.length,
|
||||
days: days.map((day) => {
|
||||
const onDay = dated.filter(({ ms }) => dayKey(ms) === day);
|
||||
const affected = onDay.filter((run) => run.affected).length;
|
||||
return { day, affected, unaffected: onDay.length - affected };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function percentLabel(affected: number, total: number): string | null {
|
||||
if (total <= 0) return null;
|
||||
return `${Number(((affected / total) * 100).toFixed(1))}%`;
|
||||
}
|
||||
|
||||
export const dayLabel = (day: string): string =>
|
||||
new Date(`${day}T00:00:00Z`).toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" });
|
||||
|
|
@ -164,6 +164,10 @@ export function windowLabel(job: Job): string {
|
|||
return `${shortTime(job.start)} → ${shortTime(job.end)}`;
|
||||
}
|
||||
|
||||
export function inboxSampledRuns(row: InboxRow) {
|
||||
return row.sources.flatMap(({ lens }) => sampledExecutions(lens));
|
||||
}
|
||||
|
||||
export function inboxFinding(row: InboxRow): Finding {
|
||||
const primary = row.sources.find(({ finding }) => finding.priority === row.priority) ?? row.sources[0];
|
||||
const evidence = row.sources.flatMap(({ finding }) => finding.evidence);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue