From 887c115e3a3011943bf2f1ea9c4ab0a4c1d1838a Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sun, 4 Oct 2026 19:50:05 -0700 Subject: [PATCH] feat(ui): drive Lens sampling with a slider and an editable run count Sample rate and the run cap are one control: the slider scales the run count against the matching runs, and typing a smaller count caps it. Bare inputs also opt out of the forms plugin's border. Co-Authored-By: Claude Opus 5.5 --- .../InvestigationSetup.integration.test.tsx | 41 ++++++ .../lens/setup/InvestigationSetup.tsx | 4 +- .../lens/setup/fields/SampleFields.tsx | 129 ++++++++++++------ .../src/components/shared/DurationInput.tsx | 4 +- .../src/components/ui/slider.tsx | 11 +- 5 files changed, 139 insertions(+), 50 deletions(-) diff --git a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.integration.test.tsx index 1497af353ca..d0fcc108aa1 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.integration.test.tsx @@ -463,6 +463,47 @@ it.each(["empty", "error"])("blocks a new investigation when its preview is %s", expect(screen.getByRole("button", { name: "Run investigation" })).toBeDisabled(); }); +describe("Sample", () => { + it("slides the sample rate to scale the run count, caps it by typing, and saves both", async () => { + const save = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + proxy.post.mockResolvedValue({ eligible: 2000, selected: 2000, executions: [run("Run one", 1)] }); + mockGateway({ keyModels: ["analysis"] }); + renderWithProviders(); + const count = screen.getByRole("textbox", { name: "Runs to analyze" }); + await waitFor(() => expect(count).toHaveValue("2,000")); + const slider = screen.getByLabelText("Sample", { selector: "input[type=range]" }); + slider.focus(); + await user.keyboard("{Home}{ArrowRight}{ArrowRight}"); + expect(screen.getByText("3% of 2,000")).toBeInTheDocument(); + expect(count).toHaveValue("60"); + await user.clear(count); + await user.type(count, "25"); + expect(count).toHaveValue("25"); + await user.click(screen.getByRole("button", { name: "Continue" })); + await user.click(screen.getByRole("button", { name: "Continue" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Run and monitor" })).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Run and monitor" })); + expect(save).toHaveBeenCalledWith(expect.objectContaining({ sample_percent: 3, sample_size: 25 })); + }); + + it("drops a cap that no longer limits anything once the field is left", async () => { + const user = userEvent.setup(); + proxy.post.mockResolvedValue({ eligible: 40, selected: 40, executions: [run("Run one", 1)] }); + renderWithProviders(); + const count = screen.getByRole("textbox", { name: "Runs to analyze" }); + await waitFor(() => expect(count).toHaveValue("40")); + await user.clear(count); + await user.type(count, "10"); + expect(screen.getByRole("button", { name: /Remove cap/ })).toBeInTheDocument(); + await user.clear(count); + await user.type(count, "500"); + await user.tab(); + expect(count).toHaveValue("40"); + expect(screen.queryByRole("button", { name: /Remove cap/ })).not.toBeInTheDocument(); + }); +}); + describe("URL draft", () => { const lastUrl = (onUrlUpdate: ReturnType) => new URLSearchParams((onUrlUpdate.mock.lastCall?.[0] as { queryString: string } | undefined)?.queryString ?? ""); diff --git a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx index 571b90cba0d..a6e0344f12a 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx @@ -153,7 +153,7 @@ function SetupEditor({ aria-label="Investigation name" placeholder={TITLES[mode]} autoComplete="off" - className="h-8 min-w-0 flex-1 appearance-none rounded-md border-0 bg-transparent px-2 text-lg font-semibold tracking-tight shadow-none ring-0 outline-none placeholder:text-muted-foreground/60 hover:bg-muted/50 focus:bg-muted/50 focus:ring-0 focus:outline-none" + className="h-8 min-w-0 flex-1 appearance-none rounded-md border-0 bg-transparent px-2 py-0 text-lg font-semibold tracking-tight shadow-none ring-0 outline-none placeholder:text-muted-foreground/60 hover:bg-muted/50 focus:bg-muted/50 focus:ring-0 focus:outline-none" /> diff --git a/ui/litellm-dashboard/src/components/lens/setup/fields/SampleFields.tsx b/ui/litellm-dashboard/src/components/lens/setup/fields/SampleFields.tsx index 9d650e21f5d..b5e53054559 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/fields/SampleFields.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/fields/SampleFields.tsx @@ -1,22 +1,45 @@ "use client"; -import { useId } from "react"; -import { Controller, useFormContext } from "react-hook-form"; +import { useState } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { X } from "lucide-react"; import { DurationInput } from "@/components/shared/DurationInput"; import { FieldError } from "@/components/ui/field"; -import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { Slider } from "@/components/ui/slider"; import type { InvestigationInput } from "../investigationSchema"; -const optionalNumber = (value: unknown) => (value == null || value === "" ? null : Number(value)); +/** Runs the sampling percentage keeps out of `eligible`, before any cap. */ +export const sampledRuns = (eligible: number, percent: number): number => Math.ceil((eligible * percent) / 100); -export function SampleFields() { +const capFromText = (text: string): number | null => { + const digits = text.replace(/\D/g, ""); + return digits ? Number(digits) : null; +}; + +export interface SampleFieldsProps { + /** Runs the search matches; undefined while the preview loads. */ + readonly eligible: number | undefined; +} + +export function SampleFields({ eligible }: SampleFieldsProps) { const { control, - register, + setValue, formState: { errors }, } = useFormContext(); + const [percentValue, cap] = useWatch({ control, name: ["selection.sample_percent", "selection.sample_size"] }); const selectionErrors = errors.selection; - const id = useId(); + const percent = Number.isFinite(percentValue) ? percentValue : 100; + const sampled = eligible === undefined ? undefined : sampledRuns(eligible, percent); + const capped = cap != null && (sampled === undefined || cap < sampled); + const analyzed = sampled === undefined ? cap : Math.min(sampled, cap ?? Infinity); + const setCap = (next: number | null) => setValue("selection.sample_size", next, { shouldValidate: true }); + const [typing, setTyping] = useState(null); + const shown = typing ?? analyzed?.toLocaleString() ?? ""; + const finishTyping = () => { + setTyping(null); + if (cap != null && sampled !== undefined && cap >= sampled) setCap(null); + }; return ( <>
@@ -29,46 +52,62 @@ export function SampleFields() { /> {selectionErrors?.lookback_hours?.message}
-
-
- - - - - - {selectionErrors?.sample_percent?.message} +
+
+
+ Sample + + {eligible === undefined ? `${percent}% of matching runs` : `${percent}% of ${eligible.toLocaleString()}`} + +
+
+ + {capped ? ( + + ) : ( + Type a number to cap + )} +
-
- - - ( + field.onChange(Array.isArray(value) ? value[0] : value)} /> - - - {selectionErrors?.sample_size?.message} -
+ )} + /> + {selectionErrors?.sample_percent?.message ?? selectionErrors?.sample_size?.message}
{selectionErrors?.execution_ids?.message} diff --git a/ui/litellm-dashboard/src/components/shared/DurationInput.tsx b/ui/litellm-dashboard/src/components/shared/DurationInput.tsx index e15dd2ea253..2c85e1d5ca0 100644 --- a/ui/litellm-dashboard/src/components/shared/DurationInput.tsx +++ b/ui/litellm-dashboard/src/components/shared/DurationInput.tsx @@ -41,13 +41,13 @@ export function DurationInput({ label, value, onChange, base, max, className, .. step={1 / scale} value={Number.isFinite(value) ? value / scale : ""} onChange={(event) => onChange(event.target.value === "" ? NaN : Number(event.target.value) * scale)} - className="min-w-0 flex-1 bg-transparent px-3 text-base tabular-nums outline-none md:text-sm" + className="min-w-0 flex-1 border-0 bg-transparent px-3 py-0 shadow-none focus:ring-0 text-base tabular-nums outline-none md:text-sm" />