diff --git a/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx b/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx index c4af5daeea2..54af09793a8 100644 --- a/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx +++ b/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx @@ -19,7 +19,14 @@ import { LensModeSwitch, type SetupBadge } from "./LensModeSwitch"; import { frameCard } from "./ui/frame"; import { investigationActivity, listPollInterval } from "./model/status"; import { cn } from "@/lib/cva.config"; -import { useDialogRoute, useInvestigateRoute, useLensRoute, type LensDialog, type LensTab } from "./route"; +import { + useDemoRoute, + useDialogRoute, + useInvestigateRoute, + useLensRoute, + type LensDialog, + type LensTab, +} from "./route"; import { LensIntroDialog, useLensIntro } from "./onboarding/LensIntroDialog"; import { OnboardingProvider, type Onboarding } from "./onboarding/OnboardingContext"; import { traceRefOf, useOpenTraceRouting, useTracingSetupRoute } from "@/components/lens/traces/routing"; @@ -84,7 +91,8 @@ const PANEL = function LensContent({ userRole, readOnly }: Omit) { const accessToken = useLensAccessToken(); - const { tab, lensId, demo, settingUp, setTab, setDemo, setSetup } = useLensRoute(); + const { tab, lensId, demo, settingUp, setTab, setSetup } = useLensRoute(); + const setDemo = useDemoRoute(); const { dialog, openDialog } = useDialogRoute(); const investigate = useInvestigateRoute(); const { openTrace } = useOpenTraceRouting(); diff --git a/ui/litellm-dashboard/src/components/lens/route.test.tsx b/ui/litellm-dashboard/src/components/lens/route.test.tsx index 1c7b8870f9f..853cfc76687 100644 --- a/ui/litellm-dashboard/src/components/lens/route.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/route.test.tsx @@ -3,7 +3,7 @@ import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/test import type { PropsWithChildren } from "react"; import { describe, expect, it, vi } from "vitest"; -import { useDialogRoute, useInvestigateRoute } from "./route"; +import { useDemoRoute, useDialogRoute, useInvestigateRoute } from "./route"; const renderRoute = (hook: () => T, searchParams: string) => { const onUrlUpdate = vi.fn(); @@ -22,12 +22,8 @@ describe("setup routes", () => { const { result, lastUrl } = renderRoute(useInvestigateRoute, "?tab=traces&q=old&context=stale&step=run"); await act(async () => result.current({ q: "agent:support status:error", lookbackHours: 168 })); await waitFor(() => expect(lastUrl().get("dialog")).toBe("new")); - expect(Object.fromEntries(lastUrl())).toEqual({ - tab: "investigations", - dialog: "new", - q: "agent:support status:error", - lookback: "168", - }); + const expected = { tab: "investigations", dialog: "new", q: "agent:support status:error", lookback: "168" }; + expect(Object.fromEntries(lastUrl())).toEqual(expected); }); it("closing setup clears the draft but keeps the run search the Traces tab shares", async () => { @@ -40,3 +36,21 @@ describe("setup routes", () => { expect(Object.fromEntries(lastUrl())).toEqual({ tab: "investigations", q: "agent:support" }); }); }); + +describe("demo route", () => { + const live = "?tab=investigations&lens=l1&run=b1&finding=f1&q=agent:support&dialog=new&name=Refunds"; + + it("entering the sample session keeps only the tab so live ids never leak into it", async () => { + const { result, lastUrl } = renderRoute(useDemoRoute, live); + await act(async () => result.current(true)); + await waitFor(() => expect(lastUrl().get("demo")).toBe("true")); + expect(Object.fromEntries(lastUrl())).toEqual({ tab: "investigations", demo: "true" }); + }); + + it("leaving the sample session drops its ids and keeps the tab", async () => { + const { result, lastUrl } = renderRoute(useDemoRoute, "?tab=traces&demo=true&lens=sample&q=agent:demo"); + await act(async () => result.current(false)); + await waitFor(() => expect(lastUrl().has("demo")).toBe(false)); + expect(Object.fromEntries(lastUrl())).toEqual({ tab: "traces" }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/lens/route.ts b/ui/litellm-dashboard/src/components/lens/route.ts index c237643ab20..9734bd30f60 100644 --- a/ui/litellm-dashboard/src/components/lens/route.ts +++ b/ui/litellm-dashboard/src/components/lens/route.ts @@ -53,8 +53,6 @@ const SESSION_PARSERS = { }; const nulls = (keys: readonly K[]) => Object.fromEntries(keys.map((key) => [key, null])) as Record; -/** Switching the sample session clears every Lens key but the tab so ids never cross between live and sample data. */ -const CLEARED_SESSION = nulls(Object.keys(SESSION_PARSERS).filter((key) => key !== "tab")); const CLEARED_RESULTS = nulls(Object.keys(RESULT_PARSERS)); const CLEARED_SETUP = nulls(SETUP_KEYS); @@ -65,24 +63,36 @@ export interface LensRoute { readonly settingUp: boolean; setTab(tab: LensTab): void; setLensId(lensId: string | null): void; - setDemo(demo: boolean): void; setSetup(settingUp: boolean): void; } /** Lens navigation lives in the URL, sample session included, so any view is a shareable link. */ export function useLensRoute(): LensRoute { - const [{ tab, lens, demo, setup }, setParams] = useQueryStates(SESSION_PARSERS, { history: "push" }); + const [{ tab, lens, demo, setup }, setParams] = useQueryStates( + { ...LENS_PARSERS, ...ISSUE_PARSERS, ...RESULT_PARSERS }, + { history: "push" }, + ); const setTab = useCallback((next: LensTab) => void setParams({ tab: next }), [setParams]); const setLensId = useCallback( (next: string | null) => void setParams({ ...CLEARED_RESULTS, lens: next, issue: null }), [setParams], ); - const setDemo = useCallback( - (next: boolean) => void setParams(next ? { ...CLEARED_SESSION, demo: true } : CLEARED_SESSION), + const setSetup = useCallback((next: boolean) => void setParams({ setup: next ? "lens" : null }), [setParams]); + return { tab, lensId: lens, demo, settingUp: setup === "lens", setTab, setLensId, setSetup }; +} + +const { tab: _tab, ...SWITCHED_PARSERS } = SESSION_PARSERS; + +/** Switching the sample session clears every Lens key but the tab so ids never cross between live and sample data. */ +export function useDemoRoute(): (demo: boolean) => void { + const [, setParams] = useQueryStates(SWITCHED_PARSERS, { history: "push" }); + return useCallback( + (next: boolean) => { + void setParams(null); + if (next) void setParams({ demo: true }); + }, [setParams], ); - const setSetup = useCallback((next: boolean) => void setParams({ setup: next ? "lens" : null }), [setParams]); - return { tab, lensId: lens, demo, settingUp: setup === "lens", setTab, setLensId, setDemo, setSetup }; } const ISSUE_ROUTE_PARSERS = { ...ISSUE_PARSERS, lens: LENS_PARSERS.lens, ...RESULT_PARSERS }; @@ -124,18 +134,12 @@ export interface InvestigateScope { } /** Starts a new investigation over the runs a Traces search shows. */ +const NEW_INVESTIGATION = { ...CLEARED_SETUP, tab: "investigations", dialog: "new", target: null } as const; + export function useInvestigateRoute(): (scope: InvestigateScope) => void { const [, setParams] = useQueryStates({ ...LENS_PARSERS, ...SETUP_ROUTE_PARSERS }, { history: "push" }); return useCallback( - ({ q, lookbackHours }: InvestigateScope) => - void setParams({ - ...CLEARED_SETUP, - tab: "investigations", - dialog: "new", - target: null, - q, - lookback: lookbackHours, - }), + ({ q, lookbackHours }: InvestigateScope) => void setParams({ ...NEW_INVESTIGATION, q, lookback: lookbackHours }), [setParams], ); } diff --git a/ui/litellm-dashboard/src/components/lens/setup/setupRoute.test.ts b/ui/litellm-dashboard/src/components/lens/setup/setupRoute.test.ts index 122db79b571..f0b69a24c96 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/setupRoute.test.ts +++ b/ui/litellm-dashboard/src/components/lens/setup/setupRoute.test.ts @@ -1,62 +1,54 @@ -import { createSerializer } from "nuqs"; +import { createLoader, createSerializer } from "nuqs"; import { describe, expect, it } from "vitest"; import { investigationDefaults } from "./investigationSchema"; -import { draftFromParams, paramsFromDraft, SETUP_DRAFT_PARSERS, type SetupDraftParams } from "./setupRoute"; +import { draftFromParams, paramsFromDraft, SETUP_DRAFT_PARSERS } from "./setupRoute"; -const blank: SetupDraftParams = { - q: "", - name: null, - lookback: null, - sample: null, - max: null, - context: null, - watch: null, - checks: null, - monitor: null, - every: null, -}; +const toUrl = createSerializer(SETUP_DRAFT_PARSERS); +const fromUrl = createLoader(SETUP_DRAFT_PARSERS); +const blank = investigationDefaults(undefined, "new"); describe("setup draft params", () => { it("opens a blank draft from an empty URL and keeps an untouched draft out of it", () => { - const draft = draftFromParams(blank); - expect(draft).toEqual(investigationDefaults(undefined, "new")); - expect(paramsFromDraft(draft)).toEqual(blank); + expect(draftFromParams(fromUrl(""))).toEqual(blank); + expect(toUrl(paramsFromDraft(blank))).toBe(""); }); it("round-trips every edited field through the URL, commas in checks included", () => { - const edited: SetupDraftParams = { - q: "agent:support status:error", + const edited = { + ...blank, name: "Refund quality", - lookback: 72, - sample: 12.5, - max: 40, + selection: { + ...blank.selection, + q: "agent:support status:error", + lookback_hours: 72, + sample_percent: 12.5, + sample_size: 40, + }, context: "Refund only with a receipt", - watch: ["watch_looping"], - checks: ["Quotes a price, then changes it"], - monitor: false, - every: 60, + watching: ["watch_looping"], + questions: [{ id: "c1", instruction: "Quotes a price, then changes it", enabled: true }], + repeat: !blank.repeat, + interval: 60, }; - const url = createSerializer(SETUP_DRAFT_PARSERS)(edited); - const parsed = Object.fromEntries( - Object.entries(SETUP_DRAFT_PARSERS).map(([key, parser]) => [ - key, - parser.parseServerSide(new URLSearchParams(url).get(key) ?? undefined), - ]), - ) as SetupDraftParams; - expect(parsed).toEqual(edited); - expect(paramsFromDraft(draftFromParams(parsed))).toEqual(edited); + const restored = draftFromParams(fromUrl(toUrl(paramsFromDraft(edited)))); + expect({ ...restored, questions: restored.questions.map((check) => check.instruction) }).toEqual({ + ...edited, + questions: ["Quotes a price, then changes it"], + }); }); it("drops watch ids it does not know and keeps an explicitly empty watch list", () => { - expect(draftFromParams({ ...blank, watch: ["watch_unsafe", "watch_gone"] }).watching).toEqual(["watch_unsafe"]); - const none = draftFromParams({ ...blank, watch: [] }); + expect(draftFromParams(fromUrl("?watch=watch_unsafe,watch_gone")).watching).toEqual(["watch_unsafe"]); + const none = draftFromParams(fromUrl(toUrl(paramsFromDraft({ ...blank, watching: [] })))); expect(none.watching).toEqual([]); - expect(paramsFromDraft(none).watch).toEqual([]); }); - it("leaves half-typed numbers out of the URL instead of writing NaN", () => { - const draft = draftFromParams(blank); - const typing = { ...draft, selection: { ...draft.selection, lookback_hours: NaN, sample_percent: NaN } }; - expect(paramsFromDraft(typing)).toEqual(blank); + it("leaves half-typed numbers and blank checks out of the URL", () => { + const typing = { + ...blank, + selection: { ...blank.selection, lookback_hours: NaN, sample_percent: NaN }, + questions: [{ id: "c1", instruction: " ", enabled: true }], + }; + expect(toUrl(paramsFromDraft(typing))).toBe(""); }); }); diff --git a/ui/litellm-dashboard/src/components/lens/setup/setupRoute.ts b/ui/litellm-dashboard/src/components/lens/setup/setupRoute.ts index 2a54f29f3d8..ff2d111ee01 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/setupRoute.ts +++ b/ui/litellm-dashboard/src/components/lens/setup/setupRoute.ts @@ -9,24 +9,28 @@ import { parseAsStringLiteral, useQueryStates, type inferParserType, + type Nullable, } from "nuqs"; import { useCallback } from "react"; import { RUN_FILTER_PARSERS } from "../traces/routing"; import { watches } from "../model/watches"; import { investigationDefaults, SETUP_STEPS, type InvestigationInput, type SetupStep } from "./investigationSchema"; +const NEW_DRAFT = investigationDefaults(undefined, "new"); +const knownWatch = new Set(watches.map((watch) => watch.id)); + /** A new investigation's draft lives in the URL, so a search on the Traces tab can open setup already filled in. */ export const SETUP_DRAFT_PARSERS = { q: RUN_FILTER_PARSERS.q, - name: parseAsString, - lookback: parseAsInteger, - sample: parseAsFloat, + name: parseAsString.withDefault(NEW_DRAFT.name), + lookback: parseAsInteger.withDefault(NEW_DRAFT.selection.lookback_hours), + sample: parseAsFloat.withDefault(NEW_DRAFT.selection.sample_percent), max: parseAsInteger, - context: parseAsString, - watch: parseAsArrayOf(parseAsString), - checks: parseAsArrayOf(parseAsString), - monitor: parseAsBoolean, - every: parseAsInteger, + context: parseAsString.withDefault(NEW_DRAFT.context), + watch: parseAsArrayOf(parseAsString).withDefault(NEW_DRAFT.watching), + checks: parseAsArrayOf(parseAsString).withDefault([]), + monitor: parseAsBoolean.withDefault(NEW_DRAFT.repeat), + every: parseAsInteger.withDefault(NEW_DRAFT.interval), }; export const SETUP_STEP_PARSERS = { step: parseAsStringLiteral(SETUP_STEPS) }; @@ -37,52 +41,46 @@ export const SETUP_KEYS = [...Object.keys(SETUP_DRAFT_PARSERS), ...Object.keys(S ); export type SetupDraftParams = inferParserType; +type SetupDraftUpdate = Nullable; export const DRAFT_FIELDS = ["name", "selection", "context", "watching", "questions", "repeat", "interval"] as const; export type DraftFields = Pick; -const NEW_DRAFT = investigationDefaults(undefined, "new"); -const knownWatch = new Set(watches.map((watch) => watch.id)); -const sameList = (a: readonly string[], b: readonly string[]) => - a.length === b.length && a.every((value, index) => value === b[index]); -const unlessDefault = (value: T, fallback: T): T | null => (value === fallback ? null : value); - export function draftFromParams(params: SetupDraftParams): InvestigationInput { - const selection = NEW_DRAFT.selection; return { ...NEW_DRAFT, - name: params.name ?? NEW_DRAFT.name, + name: params.name, selection: { - ...selection, + ...NEW_DRAFT.selection, q: params.q, - lookback_hours: params.lookback ?? selection.lookback_hours, - sample_percent: params.sample ?? selection.sample_percent, - sample_size: params.max ?? selection.sample_size, + lookback_hours: params.lookback, + sample_percent: params.sample, + sample_size: params.max ?? NEW_DRAFT.selection.sample_size, }, - context: params.context ?? NEW_DRAFT.context, - watching: params.watch ? params.watch.filter((id) => knownWatch.has(id)) : NEW_DRAFT.watching, - questions: (params.checks ?? []).map((instruction) => ({ id: crypto.randomUUID(), instruction, enabled: true })), - repeat: params.monitor ?? NEW_DRAFT.repeat, - interval: params.every ?? NEW_DRAFT.interval, + context: params.context, + watching: params.watch.filter((id) => knownWatch.has(id)), + questions: params.checks.map((instruction) => ({ id: crypto.randomUUID(), instruction, enabled: true })), + repeat: params.monitor, + interval: params.every, }; } -/** Only what differs from a blank draft reaches the URL, so an untouched setup keeps a short link. */ -export function paramsFromDraft(draft: DraftFields): SetupDraftParams { +const finite = (value: number | null) => (value != null && Number.isFinite(value) ? value : null); + +/** nuqs drops values equal to their defaults from the URL, so an untouched setup keeps a short link. */ +export function paramsFromDraft(draft: DraftFields): SetupDraftUpdate { const { selection } = draft; - const checks = draft.questions.map((check) => check.instruction).filter((instruction) => instruction.trim()); - const finite = (value: number | null) => (value != null && Number.isFinite(value) ? value : null); return { q: selection.q, - name: draft.name || null, - lookback: unlessDefault(finite(selection.lookback_hours), NEW_DRAFT.selection.lookback_hours), - sample: unlessDefault(finite(selection.sample_percent), NEW_DRAFT.selection.sample_percent), + name: draft.name, + lookback: finite(selection.lookback_hours), + sample: finite(selection.sample_percent), max: finite(selection.sample_size), - context: draft.context || null, - watch: sameList(draft.watching, NEW_DRAFT.watching) ? null : draft.watching, - checks: checks.length ? checks : null, - monitor: unlessDefault(draft.repeat, NEW_DRAFT.repeat), - every: unlessDefault(finite(draft.interval), NEW_DRAFT.interval), + context: draft.context, + watch: draft.watching, + checks: draft.questions.map((check) => check.instruction).filter((instruction) => instruction.trim()), + monitor: draft.repeat, + every: finite(draft.interval), }; }