mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
feat(ui): polish Lens investigation setup and sync its draft to the URL
The setup reads as one form: inline name, fused duration and unit inputs, a compact watch list, switches for scheduling, and a wider preview. A new investigation's filters, criteria and step live in the URL, and the Traces tab offers to investigate the runs a search shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
10058480b3
commit
61d65176bf
22 changed files with 689 additions and 363 deletions
|
|
@ -19,7 +19,7 @@ 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, useLensRoute, type LensDialog, type LensTab } from "./route";
|
||||
import { 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";
|
||||
|
|
@ -86,6 +86,7 @@ function LensContent({ userRole, readOnly }: Omit<WorkspaceProps, "accessToken">
|
|||
const accessToken = useLensAccessToken();
|
||||
const { tab, lensId, demo, settingUp, setTab, setDemo, setSetup } = useLensRoute();
|
||||
const { dialog, openDialog } = useDialogRoute();
|
||||
const investigate = useInvestigateRoute();
|
||||
const { openTrace } = useOpenTraceRouting();
|
||||
const [connecting] = useTracingSetupRoute();
|
||||
const [previewTarget, setPreviewTarget] = useState<HTMLDivElement | null>(null);
|
||||
|
|
@ -185,6 +186,7 @@ function LensContent({ userRole, readOnly }: Omit<WorkspaceProps, "accessToken">
|
|||
isActive={activeTab === "traces"}
|
||||
readOnly={readOnly}
|
||||
canMintTracingKey={isAdmin}
|
||||
onInvestigate={canViewInvestigations && isAdmin && !readOnly ? investigate : undefined}
|
||||
/>
|
||||
</LensPreviewContext.Provider>
|
||||
</TabsContent>
|
||||
|
|
|
|||
42
ui/litellm-dashboard/src/components/lens/route.test.tsx
Normal file
42
ui/litellm-dashboard/src/components/lens/route.test.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useDialogRoute, useInvestigateRoute } from "./route";
|
||||
|
||||
const renderRoute = <T,>(hook: () => T, searchParams: string) => {
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate} hasMemory>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
const view = renderHook(hook, { wrapper });
|
||||
const lastUrl = () => new URLSearchParams(onUrlUpdate.mock.lastCall?.[0].queryString ?? "");
|
||||
return { ...view, lastUrl };
|
||||
};
|
||||
|
||||
describe("setup routes", () => {
|
||||
it("opens a new investigation on the searched runs, dropping any older draft", async () => {
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
it("closing setup clears the draft but keeps the run search the Traces tab shares", async () => {
|
||||
const { result, lastUrl } = renderRoute(
|
||||
useDialogRoute,
|
||||
"?tab=investigations&dialog=new&q=agent:support&name=Refunds&lookback=72&watch=watch_unsafe&step=criteria",
|
||||
);
|
||||
await act(async () => result.current.closeDialog());
|
||||
await waitFor(() => expect(lastUrl().has("dialog")).toBe(false));
|
||||
expect(Object.fromEntries(lastUrl())).toEqual({ tab: "investigations", q: "agent:support" });
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
|
||||
import { useCallback } from "react";
|
||||
import { OPEN_TRACE_PARSERS, RUN_FILTER_PARSERS, TRACING_SETUP_PARSERS } from "@/components/lens/traces/routing";
|
||||
import { SETUP_DRAFT_PARSERS, SETUP_KEYS, SETUP_STEP_PARSERS } from "@/components/lens/setup/setupRoute";
|
||||
|
||||
export const LENS_TABS = { traces: "Traces", investigations: "Investigations", settings: "Settings" } as const;
|
||||
export type LensTab = keyof typeof LENS_TABS;
|
||||
|
|
@ -47,12 +48,15 @@ const SESSION_PARSERS = {
|
|||
...LIST_PARSERS,
|
||||
...RESULT_PARSERS,
|
||||
...DIALOG_PARSERS,
|
||||
...SETUP_DRAFT_PARSERS,
|
||||
...SETUP_STEP_PARSERS,
|
||||
};
|
||||
const nulls = <K extends string>(keys: readonly K[]) =>
|
||||
Object.fromEntries(keys.map((key) => [key, null])) as Record<K, null>;
|
||||
/** 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);
|
||||
|
||||
export interface LensRoute {
|
||||
readonly tab: LensTab | null;
|
||||
|
|
@ -100,16 +104,42 @@ export function useListSearchRoute(): [string, (search: string) => void] {
|
|||
return [search, useCallback((next: string) => void setParams({ search: next }), [setParams])];
|
||||
}
|
||||
|
||||
const SETUP_ROUTE_PARSERS = { ...DIALOG_PARSERS, ...SETUP_DRAFT_PARSERS, ...SETUP_STEP_PARSERS };
|
||||
|
||||
/** Opening or closing any dialog drops a setup draft left in the URL, so the next setup starts blank. */
|
||||
export function useDialogRoute() {
|
||||
const [{ dialog, target }, setParams] = useQueryStates(DIALOG_PARSERS, { history: "push" });
|
||||
const [{ dialog, target }, setParams] = useQueryStates(SETUP_ROUTE_PARSERS, { history: "push" });
|
||||
const openDialog = useCallback(
|
||||
(next: LensDialog, targetId: string | null = null) => void setParams({ dialog: next, target: targetId }),
|
||||
(next: LensDialog, targetId: string | null = null) =>
|
||||
void setParams({ ...CLEARED_SETUP, dialog: next, target: targetId }),
|
||||
[setParams],
|
||||
);
|
||||
const closeDialog = useCallback(() => void setParams({ dialog: null, target: null }), [setParams]);
|
||||
const closeDialog = useCallback(() => void setParams({ ...CLEARED_SETUP, dialog: null, target: null }), [setParams]);
|
||||
return { dialog, target, openDialog, closeDialog };
|
||||
}
|
||||
|
||||
export interface InvestigateScope {
|
||||
readonly q: string;
|
||||
readonly lookbackHours: number;
|
||||
}
|
||||
|
||||
/** Starts a new investigation over the runs a Traces search shows. */
|
||||
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,
|
||||
}),
|
||||
[setParams],
|
||||
);
|
||||
}
|
||||
|
||||
const isResultTab = (tab: string): tab is ResultTab => (RESULT_TABS as readonly string[]).includes(tab);
|
||||
|
||||
type FindingKind = (typeof FINDING_KINDS)[number];
|
||||
|
|
|
|||
|
|
@ -194,21 +194,21 @@ it("walks the three steps in order, reopens a finished step from its summary, an
|
|||
renderWithProviders(<InvestigationSetup mode="new" onClose={vi.fn()} onSave={vi.fn()} />);
|
||||
const steps = within(screen.getByRole("list", { name: "Investigation setup" }));
|
||||
expect(steps.getByRole("button", { name: /^Activity/ })).toHaveAttribute("aria-current", "step");
|
||||
expect(steps.getByRole("button", { name: /^Run/ })).toBeDisabled();
|
||||
expect(steps.getByRole("button", { name: /^Schedule/ })).toBeDisabled();
|
||||
expect(screen.getByRole("region", { name: "Matching activity" })).toBeVisible();
|
||||
expect(screen.queryByRole("textbox", { name: "What should the agent be doing?" })).not.toBeInTheDocument();
|
||||
await user.type(screen.getByRole("combobox", { name: "Search runs" }), "agent:support_agent");
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(steps.getByRole("button", { name: /^Activity.*agent:support_agent/ })).toBeEnabled();
|
||||
expect(screen.queryByRole("textbox", { name: "Investigation name" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("combobox", { name: "Search runs" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "What should the agent be doing?" })).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(screen.getByRole("checkbox", { name: "Keep watching for new traces" })).toBeVisible();
|
||||
expect(screen.getByRole("switch", { name: "Keep watching for new traces" })).toBeVisible();
|
||||
expect(screen.getByRole("region", { name: "Matching activity" })).toBeVisible();
|
||||
await user.click(steps.getByRole("button", { name: /^Activity/ }));
|
||||
expect(screen.getByRole("textbox", { name: "Investigation name" })).toBeVisible();
|
||||
expect(screen.queryByRole("checkbox", { name: "Keep watching for new traces" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox", { name: "Search runs" })).toBeVisible();
|
||||
expect(screen.queryByRole("switch", { name: "Keep watching for new traces" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("searches providers and saves custom history while preserving existing schedule values", async () => {
|
||||
|
|
@ -354,10 +354,9 @@ it("keeps a duplicated investigation's schedule off and saves the interval once
|
|||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled());
|
||||
expect(screen.getByRole("checkbox", { name: "Keep watching for new traces" })).not.toBeChecked();
|
||||
await user.click(screen.getByText("Advanced options"));
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Monthly limit (USD)" }), { target: { value: "8" } });
|
||||
await user.click(screen.getByRole("checkbox", { name: "Keep watching for new traces" }));
|
||||
expect(screen.getByRole("switch", { name: "Keep watching for new traces" })).not.toBeChecked();
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Monthly limit" }), { target: { value: "8" } });
|
||||
await user.click(screen.getByRole("switch", { name: "Keep watching for new traces" }));
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Check every" }), { target: { value: "120" } });
|
||||
await user.click(screen.getByRole("button", { name: "Run and monitor" }));
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
|
|
@ -365,14 +364,14 @@ it("keeps a duplicated investigation's schedule off and saves the interval once
|
|||
);
|
||||
});
|
||||
|
||||
it("watches new investigations every 15 minutes by default, outside advanced options", async () => {
|
||||
it("watches new investigations every 15 minutes by default", async () => {
|
||||
const user = userEvent.setup();
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
mockGateway({ keyModels: ["analysis"] });
|
||||
renderWithProviders(<InvestigationSetup mode="new" onClose={vi.fn()} onSave={save} />);
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
const watching = await screen.findByRole("checkbox", { name: "Keep watching for new traces" });
|
||||
const watching = await screen.findByRole("switch", { name: "Keep watching for new traces" });
|
||||
expect(watching).toBeChecked();
|
||||
expect(watching).toBeVisible();
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Run and monitor" })).toBeEnabled());
|
||||
|
|
@ -464,6 +463,51 @@ it.each(["empty", "error"])("blocks a new investigation when its preview is %s",
|
|||
expect(screen.getByRole("button", { name: "Run investigation" })).toBeDisabled();
|
||||
});
|
||||
|
||||
describe("URL draft", () => {
|
||||
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
|
||||
new URLSearchParams((onUrlUpdate.mock.lastCall?.[0] as { queryString: string } | undefined)?.queryString ?? "");
|
||||
|
||||
it("opens a new investigation filled from the URL, previews that search, and writes edits back", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<InvestigationSetup mode="new" onClose={vi.fn()} onSave={vi.fn()} />, {
|
||||
searchParams: "?q=agent:support_agent&lookback=72&sample=50&name=Refunds&step=criteria",
|
||||
onUrlUpdate,
|
||||
});
|
||||
expect(screen.getByRole("textbox", { name: "Investigation name" })).toHaveValue("Refunds");
|
||||
expect(screen.getByRole("button", { name: /^Criteria/ })).toHaveAttribute("aria-current", "step");
|
||||
expect(screen.getByRole("button", { name: /^Activity.*agent:support_agent.*3 days/ })).toBeEnabled();
|
||||
await waitFor(() =>
|
||||
expect(proxy.post).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
lookback_hours: 72,
|
||||
selection: expect.objectContaining({ q: "agent:support_agent", sample_percent: 50 }),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "What should the agent be doing?" }), "Refund with receipts");
|
||||
await waitFor(() => expect(lastUrl(onUrlUpdate).get("context")).toBe("Refund with receipts"));
|
||||
expect(lastUrl(onUrlUpdate).get("q")).toBe("agent:support_agent");
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await waitFor(() => expect(lastUrl(onUrlUpdate).get("step")).toBe("run"));
|
||||
});
|
||||
|
||||
it("edits an investigation from its saved settings, never from a draft left in the URL", () => {
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<InvestigationSetup mode="edit" initial={settings} onClose={vi.fn()} onSave={vi.fn()} />, {
|
||||
searchParams: "?name=Draft&lookback=72",
|
||||
onUrlUpdate,
|
||||
});
|
||||
expect(screen.getByRole("textbox", { name: "Investigation name" })).toHaveValue("Research quality");
|
||||
expect(screen.getByRole("spinbutton", { name: "Review the last" })).toHaveValue(1);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Investigation name" }), { target: { value: "Renamed" } });
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Watch for", () => {
|
||||
const tile = (name: string) => screen.getByRole("button", { name: new RegExp(`^${name}`) });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { FormProvider, useWatch, type UseFormReturn } from "react-hook-form";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FieldError } from "@/components/ui/field";
|
||||
import {
|
||||
investigationSchema,
|
||||
investigationDefaults,
|
||||
|
|
@ -13,8 +13,8 @@ import {
|
|||
investigationStepFields,
|
||||
type InvestigationInput,
|
||||
type InvestigationOutput,
|
||||
type SetupStep as SetupStepId,
|
||||
} from "./investigationSchema";
|
||||
import { draftFromParams, useSetupDraftRoute, useSetupStepRoute } from "./setupRoute";
|
||||
import { nextSetupStep, SetupStep, SetupSteps } from "./SetupSteps";
|
||||
import { ScopeFields } from "./fields/ScopeFields";
|
||||
import { SampleFields } from "./fields/SampleFields";
|
||||
|
|
@ -44,8 +44,11 @@ function saveLabelFor(mode: SetupMode, repeat: boolean): string {
|
|||
return repeat ? "Run and monitor" : "Run investigation";
|
||||
}
|
||||
|
||||
function activitySummary(selection: InvestigationInput["selection"]): string {
|
||||
return `${scopeLabel(selection)} · Last ${durationLabel(selection.lookback_hours ?? 24, "hours")}`;
|
||||
function activitySummary(selection: InvestigationInput["selection"], manual: boolean): string {
|
||||
const span = `Last ${durationLabel(selection.lookback_hours ?? 24, "hours")}`;
|
||||
const picked = selection.execution_ids.length;
|
||||
if (manual) return `${picked} picked ${picked === 1 ? "run" : "runs"} · ${span}`;
|
||||
return `${scopeLabel(selection)} · ${span}`;
|
||||
}
|
||||
|
||||
function criteriaSummary(values: Pick<InvestigationInput, "context" | "watching" | "questions">): string {
|
||||
|
|
@ -66,10 +69,16 @@ interface SetupProps {
|
|||
/** Replaces the Investigations tab body: a three-step setup on the left, the activity it matches on the right. */
|
||||
export function InvestigationSetup(props: SetupProps) {
|
||||
const { initial, mode } = props;
|
||||
const draft = useSetupDraftRoute();
|
||||
const form = useZodForm(investigationSchema, {
|
||||
defaultValues: investigationDefaults(initial, mode),
|
||||
defaultValues: mode === "new" ? draftFromParams(draft.params) : investigationDefaults(initial, mode),
|
||||
mode: "onChange",
|
||||
});
|
||||
const { saveDraft } = draft;
|
||||
useEffect(() => {
|
||||
if (mode !== "new") return;
|
||||
return form.subscribe({ formState: { values: true }, callback: ({ values }) => saveDraft(values) });
|
||||
}, [form, mode, saveDraft]);
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<SetupEditor {...props} form={form} />
|
||||
|
|
@ -86,13 +95,13 @@ function SetupEditor({
|
|||
form,
|
||||
}: SetupProps & { form: UseFormReturn<InvestigationInput, unknown, InvestigationOutput> }) {
|
||||
const analysis = useAnalysisModels();
|
||||
const [step, setStep] = useState<SetupStepId>("activity");
|
||||
const [step, setStep] = useSetupStepRoute();
|
||||
const [error, setError] = useState("");
|
||||
const [trace, setTrace] = useState<TraceRef | null>(null);
|
||||
const { control, register, setValue, subscribe, trigger, formState } = form;
|
||||
const [selectedModel, repeat, selection, context, watching, questions] = useWatch({
|
||||
const [selectedModel, repeat, selection, manualSelection, context, watching, questions] = useWatch({
|
||||
control,
|
||||
name: ["selectedModel", "repeat", "selection", "context", "watching", "questions"],
|
||||
name: ["selectedModel", "repeat", "selection", "manualSelection", "context", "watching", "questions"],
|
||||
});
|
||||
const activity = useMatchingActivity();
|
||||
const traceRuns = activity.preview.page.executions.flatMap((run) => (run.summary ? [traceRefOf(run.summary)] : []));
|
||||
|
|
@ -127,52 +136,50 @@ function SetupEditor({
|
|||
const saveLabel = saveLabelFor(mode, repeat);
|
||||
const offline = !ready && mode !== "edit";
|
||||
return (
|
||||
<section aria-label={TITLES[mode]} className="flex min-w-0 flex-1 flex-col gap-6">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-start gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="-ml-2 shrink-0 text-muted-foreground"
|
||||
aria-label="Back to investigations"
|
||||
disabled={formState.isSubmitting}
|
||||
onClick={onClose}
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">{TITLES[mode]}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Matching activity on the right updates as you change the setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" disabled={formState.isSubmitting} onClick={onClose}>
|
||||
<section aria-label={TITLES[mode]} className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex items-center gap-1 border-b pb-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label="Back to investigations"
|
||||
disabled={formState.isSubmitting}
|
||||
onClick={onClose}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<input
|
||||
{...register("name")}
|
||||
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"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" disabled={formState.isSubmitting} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</header>
|
||||
{offline && (
|
||||
<p role="status" className="text-sm text-warning">
|
||||
<p
|
||||
role="status"
|
||||
className="mt-4 rounded-md border border-warning/30 bg-warning/5 px-3 py-2 text-sm text-warning"
|
||||
>
|
||||
The worker or trace storage is unavailable. Your draft is safe; you can start when it reconnects.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid min-w-0 gap-8 lg:grid-cols-2">
|
||||
<div className="grid min-w-0 flex-1 items-start gap-8 pt-6 lg:grid-cols-[minmax(0,26rem)_minmax(0,1fr)] xl:gap-10">
|
||||
<SetupSteps aria-label="Investigation setup" current={step} onOpen={setStep}>
|
||||
<SetupStep
|
||||
id="activity"
|
||||
heading="Activity"
|
||||
description="Which traces or requests to review"
|
||||
summary={activitySummary(selection)}
|
||||
description="Which runs to review"
|
||||
summary={activitySummary(selection, manualSelection)}
|
||||
>
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
Investigation name
|
||||
<Input {...register("name")} placeholder="e.g. Support quality" />
|
||||
</label>
|
||||
<ScopeFields {...activity.scope} />
|
||||
<SampleFields />
|
||||
<div className="flex justify-end pt-1">
|
||||
<StepFooter>
|
||||
<Button onClick={() => void next()}>Continue</Button>
|
||||
</div>
|
||||
</StepFooter>
|
||||
</SetupStep>
|
||||
<SetupStep
|
||||
id="criteria"
|
||||
|
|
@ -181,22 +188,18 @@ function SetupEditor({
|
|||
summary={criteriaSummary({ context, watching, questions })}
|
||||
>
|
||||
<ExpectationsFields />
|
||||
<div className="flex justify-end pt-1">
|
||||
<StepFooter>
|
||||
<Button onClick={() => void next()}>Continue</Button>
|
||||
</div>
|
||||
</StepFooter>
|
||||
</SetupStep>
|
||||
<SetupStep id="run" heading="Run" description="Schedule, analysis model, and budget" summary="">
|
||||
<SetupStep id="run" heading="Schedule" description="How often to run, the model, and a budget" summary="">
|
||||
<RunFields models={analysis} gate={gate} />
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end pt-1">
|
||||
<FieldError>{formState.errors.selection?.execution_ids?.message || error}</FieldError>
|
||||
<StepFooter>
|
||||
<Button disabled={!canSave} onClick={() => void save()}>
|
||||
{formState.isSubmitting ? "Saving…" : saveLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</StepFooter>
|
||||
</SetupStep>
|
||||
</SetupSteps>
|
||||
<Inspector.Root
|
||||
|
|
@ -223,3 +226,7 @@ function SetupEditor({
|
|||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StepFooter({ children }: { children: ReactNode }) {
|
||||
return <div className="flex justify-end border-t pt-4">{children}</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,20 +28,20 @@ function PreviewFooter({ page, selection, className, ...props }: PreviewFooterPr
|
|||
return (
|
||||
<div
|
||||
data-slot="preview-footer"
|
||||
className={cn("flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3", className)}
|
||||
className={cn("flex flex-wrap items-center justify-between gap-3 border-t bg-muted/30 px-4 py-2", className)}
|
||||
{...props}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{count} selected for analysis
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{count.toLocaleString()} selected for analysis
|
||||
{partial && (
|
||||
<>
|
||||
{" "}
|
||||
· Showing {page.executions.length} of {page.eligible}
|
||||
· Showing {page.executions.length.toLocaleString()} of {page.eligible.toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{selection && picked > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={selection.clear}>
|
||||
<Button variant="ghost" size="xs" onClick={selection.clear}>
|
||||
Clear {picked} selected runs
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -65,26 +65,27 @@ export function MatchingActivityPreview({
|
|||
<section
|
||||
aria-label="Matching activity"
|
||||
data-slot="matching-activity-preview"
|
||||
className={cn("self-start overflow-hidden rounded-lg border", className)}
|
||||
className={cn("self-start overflow-hidden rounded-lg border bg-card", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium" role="status">
|
||||
<header className="flex items-start justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="grid min-w-0 gap-0.5">
|
||||
<p className="text-sm font-semibold tabular-nums" role="status">
|
||||
{status.title}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Refresh matching activity"
|
||||
onClick={status.refresh}
|
||||
disabled={!status.ready}
|
||||
>
|
||||
<RotateCw className="size-3" />
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{status.windowLabel} · Previewing is free</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{status.windowLabel} · No analysis cost</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="-mr-1.5 text-muted-foreground"
|
||||
aria-label="Refresh matching activity"
|
||||
onClick={status.refresh}
|
||||
disabled={!status.ready}
|
||||
>
|
||||
<RotateCw className={cn("size-3.5", (status.loading || status.stale) && "motion-safe:animate-spin")} />
|
||||
</Button>
|
||||
</header>
|
||||
{status.ready && status.error && (
|
||||
<p role="alert" className="px-4 py-3 text-sm text-destructive">
|
||||
{status.error.message}{" "}
|
||||
|
|
@ -94,12 +95,15 @@ export function MatchingActivityPreview({
|
|||
</p>
|
||||
)}
|
||||
{status.ready && page.eligible === 0 && (
|
||||
<p className="px-4 py-4 text-sm text-muted-foreground">
|
||||
No matches. Try removing a filter from the search. Recent trace updates need two minutes to settle.
|
||||
</p>
|
||||
<div className="grid gap-1 px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium">No matches</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Try removing a filter from the search. Recent trace updates need two minutes to settle.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(status.loading || (shown && !status.error && runs.length > 0)) && (
|
||||
<div className="max-h-[60dvh] min-h-0 overflow-auto">
|
||||
<div className="max-h-[calc(100dvh-16rem)] min-h-0 overflow-auto">
|
||||
<AgentTracesTable
|
||||
traces={runs}
|
||||
isLoading={status.loading}
|
||||
|
|
|
|||
|
|
@ -56,23 +56,40 @@ export function SetupStep({ id, heading, description, summary, className, childr
|
|||
<li
|
||||
data-slot="setup-step"
|
||||
data-state={state}
|
||||
className={cn("group relative flex gap-4 [&:not(:last-child)]:pb-8", className)}
|
||||
className={cn("group relative flex gap-3.5 [&:not(:last-child)]:pb-6", className)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden="true" className="absolute top-7 bottom-0 left-3.5 w-px bg-border group-last:hidden" />
|
||||
<StepIndicator aria-hidden="true" index={position} state={state} />
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-8 bottom-2 left-3 w-px bg-border group-last:hidden group-data-[state=complete]:bg-foreground/15"
|
||||
/>
|
||||
<StepIndicator aria-hidden="true" index={position} state={state} className="size-6" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={state !== "complete"}
|
||||
aria-current={state === "current" ? "step" : undefined}
|
||||
onClick={() => onOpen(id)}
|
||||
className="flex w-full flex-col items-start gap-0.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-default"
|
||||
className="group/step -mx-2 -my-1 flex w-[calc(100%+1rem)] items-start justify-between gap-3 rounded-md px-2 py-1 text-left outline-none transition-colors enabled:hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-default"
|
||||
>
|
||||
<span className="text-sm font-semibold group-data-[state=upcoming]:text-muted-foreground">{heading}</span>
|
||||
<span className="text-xs text-muted-foreground">{state === "complete" ? summary : description}</span>
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="text-sm leading-6 font-medium group-data-[state=upcoming]:text-muted-foreground">
|
||||
{heading}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{state === "complete" ? summary : description}
|
||||
</span>
|
||||
</span>
|
||||
{state === "complete" && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pt-0.5 text-xs leading-5 font-medium text-muted-foreground group-hover/step:text-foreground"
|
||||
>
|
||||
Edit
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{state === "current" && <div className="mt-4 space-y-5">{children}</div>}
|
||||
{state === "current" && <div className="mt-5 grid gap-5">{children}</div>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,79 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useRef, useState, type KeyboardEvent } from "react";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { watches } from "../model/watches";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const dotColors = ["#8b5cf6", "#22b3e8", "#e3a32b", "#eb6b93", "#22b3e8", "#8b5cf6", "#e3a32b", "#eb6b93"];
|
||||
const lensBlue = { light: "#0011b3", dark: "#8b9bff" };
|
||||
const columns = 120;
|
||||
const rows = 7;
|
||||
const cell = 6;
|
||||
|
||||
function dotColor(lit: boolean, pastLens: boolean, incoming: string, blue: string): string {
|
||||
if (!lit) return "#94a3b8";
|
||||
return pastLens ? blue : incoming;
|
||||
}
|
||||
|
||||
function DotFlow({ active }: { active: readonly string[] }) {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
const node = canvas.current;
|
||||
const context = node?.getContext("2d");
|
||||
if (!node || !context) return;
|
||||
const colors = active.length ? active : ["#94a3b8"];
|
||||
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const blue = document.documentElement.classList.contains("dark") ? lensBlue.dark : lensBlue.light;
|
||||
const lensColumn = Math.floor(columns * 0.62);
|
||||
const draw = (time: number) => {
|
||||
context.clearRect(0, 0, node.width, node.height);
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let column = 0; column < columns; column++) {
|
||||
const x = column * cell + cell / 2;
|
||||
const y = row * cell + cell / 2;
|
||||
const center = (rows - 1) / 2;
|
||||
const funnel =
|
||||
column < lensColumn ? Math.abs(row - center) <= center * (1 - column / lensColumn) + 0.6 : row === center;
|
||||
const wave = Math.sin(column * 0.55 - time / 260 + row * 1.7);
|
||||
const lit = funnel && wave > 0.35;
|
||||
context.globalAlpha = lit ? 0.9 : 0.12;
|
||||
context.fillStyle = dotColor(lit, column >= lensColumn, colors[(row + column) % colors.length], blue);
|
||||
context.beginPath();
|
||||
context.arc(x, y, lit ? 1.6 : 1, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
context.strokeStyle = blue;
|
||||
context.lineWidth = 1.5;
|
||||
const lx = lensColumn * cell - 1;
|
||||
context.beginPath();
|
||||
context.moveTo(lx + 3, 1);
|
||||
context.lineTo(lx, 1);
|
||||
context.lineTo(lx, rows * cell - 1);
|
||||
context.lineTo(lx + 3, rows * cell - 1);
|
||||
context.stroke();
|
||||
};
|
||||
if (still) {
|
||||
draw(0);
|
||||
return;
|
||||
}
|
||||
let frame = requestAnimationFrame(function loop(time) {
|
||||
draw(time);
|
||||
frame = requestAnimationFrame(loop);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [active]);
|
||||
return (
|
||||
<canvas
|
||||
ref={canvas}
|
||||
aria-hidden="true"
|
||||
width={columns * cell}
|
||||
height={rows * cell}
|
||||
className="h-10 w-full opacity-80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const COLUMNS = 2;
|
||||
|
||||
export function WatchPicker({
|
||||
selected,
|
||||
|
|
@ -97,26 +29,24 @@ export function WatchPicker({
|
|||
const digit = Number(event.key);
|
||||
if (event.key === "ArrowRight" || event.key === "l") move(cursor + 1);
|
||||
else if (event.key === "ArrowLeft" || event.key === "h") move(cursor - 1);
|
||||
else if (event.key === "ArrowDown" || event.key === "j") move(cursor + 4);
|
||||
else if (event.key === "ArrowUp" || event.key === "k") move(cursor - 4);
|
||||
else if (event.key === "ArrowDown" || event.key === "j") move(cursor + COLUMNS);
|
||||
else if (event.key === "ArrowUp" || event.key === "k") move(cursor - COLUMNS);
|
||||
else if (digit >= 1 && digit <= watches.length) {
|
||||
move(digit - 1);
|
||||
toggle(watches[digit - 1].id);
|
||||
} else return;
|
||||
event.preventDefault();
|
||||
};
|
||||
const activeColors = watches.flatMap((watch, index) => (selected.has(watch.id) ? [dotColors[index]] : []));
|
||||
|
||||
return (
|
||||
<fieldset className="space-y-2.5">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<fieldset className="grid gap-2">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<legend className="text-sm font-medium">Watch for</legend>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{selected.size} of {watches.length} selected
|
||||
{selected.size} of {watches.length}
|
||||
</span>
|
||||
</div>
|
||||
<DotFlow active={activeColors} />
|
||||
<div role="group" aria-label="Watch for" onKeyDown={onKey} className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
<div role="group" aria-label="Watch for" onKeyDown={onKey} className="grid grid-cols-2 gap-1.5">
|
||||
{watches.map((watch, index) => {
|
||||
const on = selected.has(watch.id);
|
||||
return (
|
||||
|
|
@ -127,36 +57,27 @@ export function WatchPicker({
|
|||
}}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
title={watch.summary}
|
||||
title={watch.instruction}
|
||||
tabIndex={index === cursor ? 0 : -1}
|
||||
data-state={on ? "active" : "inactive"}
|
||||
onFocus={() => setCursor(index)}
|
||||
onClick={() => toggle(watch.id)}
|
||||
className={cn(
|
||||
"flex h-[5.25rem] flex-col justify-start gap-1 rounded-xl px-3.5 py-3 text-left outline-none transition-all duration-200 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:scale-[0.97]",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:ring-[1.5px] data-[state=active]:ring-inset data-[state=active]:ring-foreground",
|
||||
"data-[state=inactive]:bg-muted/60 data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:bg-muted data-[state=inactive]:hover:text-foreground",
|
||||
"group/watch flex items-start gap-2.5 rounded-md border px-2.5 py-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
"data-[state=active]:border-foreground/25 data-[state=active]:bg-muted/50",
|
||||
"data-[state=inactive]:hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-1">
|
||||
<span className="text-sm font-medium">{watch.name}</span>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
data-state={on ? "active" : "inactive"}
|
||||
className="size-3 transition-all duration-200 data-[state=active]:scale-100 data-[state=active]:opacity-100 data-[state=inactive]:scale-50 data-[state=inactive]:opacity-0"
|
||||
>
|
||||
<path
|
||||
d="M3 8.5l3.2 3.2L13 5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-sm border border-input transition-colors group-data-[state=active]/watch:border-foreground group-data-[state=active]/watch:bg-foreground group-data-[state=active]/watch:text-background"
|
||||
>
|
||||
{on && <Check className="size-3" strokeWidth={3} />}
|
||||
</span>
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="text-sm leading-5 font-medium capitalize">{watch.name}</span>
|
||||
<span className="text-xs leading-snug text-muted-foreground">{watch.summary}</span>
|
||||
</span>
|
||||
<span className="line-clamp-2 text-xs leading-snug text-muted-foreground">{watch.summary}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -164,16 +85,10 @@ export function WatchPicker({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onAddCustom}
|
||||
className="flex h-11 w-full items-center gap-2.5 rounded-xl bg-muted/60 px-3.5 text-left text-sm outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
className="flex items-center gap-2 justify-self-start rounded-md px-1 py-1 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-5 items-center justify-center rounded-full bg-background text-sm leading-none"
|
||||
>
|
||||
+
|
||||
</span>
|
||||
<span className="font-medium">Add your own</span>
|
||||
<span className="text-muted-foreground">describe anything else in plain English</span>
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
Add your own check
|
||||
</button>
|
||||
</fieldset>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ export function AnalysisModelField({ models, gate }: AnalysisModelFieldProps) {
|
|||
const { control } = useFormContext<InvestigationInput>();
|
||||
const model = useWatch({ control, name: "selectedModel" });
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Analysis model</p>
|
||||
<div className="grid gap-2">
|
||||
<span className="text-sm font-medium">Analysis model</span>
|
||||
<Controller
|
||||
control={control}
|
||||
name="selectedModel"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FieldError } from "@/components/ui/field";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import { WatchPicker } from "../WatchPicker";
|
||||
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import type { InvestigationInput } from "../investigationSchema";
|
||||
|
||||
export function ExpectationsFields() {
|
||||
|
|
@ -17,20 +17,16 @@ export function ExpectationsFields() {
|
|||
} = useFormContext<InvestigationInput>();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "questions", keyName: "fieldId" });
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
What should the agent be doing?
|
||||
<>
|
||||
<label className="grid gap-2">
|
||||
<span className="text-sm font-medium">What should the agent be doing?</span>
|
||||
<Textarea
|
||||
{...register("context")}
|
||||
rows={4}
|
||||
rows={3}
|
||||
placeholder="Answer the customer's question using verified sources and explain when information is missing."
|
||||
/>
|
||||
<FieldError>{errors.context?.message}</FieldError>
|
||||
</label>
|
||||
{errors.context?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.context.message}
|
||||
</p>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="watching"
|
||||
|
|
@ -42,29 +38,33 @@ export function ExpectationsFields() {
|
|||
/>
|
||||
)}
|
||||
/>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="sr-only">Custom checks</legend>
|
||||
{fields.map((check, index) => (
|
||||
<div key={check.fieldId} className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Textarea
|
||||
aria-label={`Check ${index + 1}`}
|
||||
{...register(`questions.${index}.instruction`)}
|
||||
rows={2}
|
||||
placeholder="e.g. Quotes a price without checking the pricing tool"
|
||||
/>
|
||||
{errors.questions?.[index]?.instruction?.message && (
|
||||
<p role="alert" className="mt-1 text-sm text-destructive">
|
||||
{errors.questions[index].instruction.message}
|
||||
</p>
|
||||
)}
|
||||
{fields.length > 0 && (
|
||||
<fieldset className="grid gap-2">
|
||||
<legend className="sr-only">Custom checks</legend>
|
||||
{fields.map((check, index) => (
|
||||
<div key={check.fieldId} className="flex items-start gap-1.5">
|
||||
<div className="grid min-w-0 flex-1 gap-1">
|
||||
<Textarea
|
||||
aria-label={`Check ${index + 1}`}
|
||||
{...register(`questions.${index}.instruction`)}
|
||||
rows={2}
|
||||
placeholder="e.g. Quotes a price without checking the pricing tool"
|
||||
/>
|
||||
<FieldError>{errors.questions?.[index]?.instruction?.message}</FieldError>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="mt-1 text-muted-foreground"
|
||||
aria-label={`Remove check ${index + 1}`}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" aria-label={`Remove check ${index + 1}`} onClick={() => remove(index)}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,90 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DurationInput } from "@/components/shared/DurationInput";
|
||||
import { FieldError } from "@/components/ui/field";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import type { InvestigationInput } from "../investigationSchema";
|
||||
import { SwitchRow } from "./SwitchRow";
|
||||
import { AnalysisModelField, type AnalysisModelFieldProps } from "./AnalysisModelField";
|
||||
|
||||
export function RunFields({ models, gate }: AnalysisModelFieldProps) {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useFormContext<InvestigationInput>();
|
||||
const repeat = useWatch({ control, name: "repeat" });
|
||||
const id = useId();
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" className="size-4 rounded border-input accent-foreground" {...register("repeat")} />
|
||||
Keep watching for new traces
|
||||
</label>
|
||||
<div className="grid gap-4 rounded-md border px-3 py-2.5">
|
||||
<Controller
|
||||
control={control}
|
||||
name="repeat"
|
||||
render={({ field }) => (
|
||||
<SwitchRow
|
||||
label="Keep watching for new traces"
|
||||
description={field.value ? "Reviews new runs as they arrive" : "Reviews the runs that match now, once"}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{repeat && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="interval"
|
||||
render={({ field }) => (
|
||||
<DurationInput label="Check every" value={field.value} onChange={field.onChange} base="minutes" />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{errors.interval?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.interval.message}
|
||||
</p>
|
||||
<div className="grid gap-2 pb-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="interval"
|
||||
render={({ field }) => (
|
||||
<DurationInput label="Check every" value={field.value} onChange={field.onChange} base="minutes" />
|
||||
)}
|
||||
/>
|
||||
<FieldError>{errors.interval?.message}</FieldError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<details open={!gate.modelValid || undefined}>
|
||||
<summary className="cursor-pointer text-sm font-medium">Advanced options</summary>
|
||||
<div className="mt-4 space-y-5">
|
||||
<AnalysisModelField models={models} gate={gate} />
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<label className="grid content-start gap-2 text-sm font-medium">
|
||||
Maximum runs (optional)
|
||||
<Input
|
||||
{...register("selection.sample_size", {
|
||||
setValueAs: (value: unknown) => (value == null || value === "" ? null : Number(value)),
|
||||
})}
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="No limit"
|
||||
/>
|
||||
{errors.selection?.sample_size?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.selection.sample_size.message}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("manualSelection", {
|
||||
onChange: () => setValue("selection.execution_ids", [], { shouldValidate: true }),
|
||||
})}
|
||||
/>
|
||||
Choose individual runs
|
||||
</label>
|
||||
</div>
|
||||
{errors.selection?.execution_ids?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.selection.execution_ids.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<label className="grid content-start gap-2 text-sm font-medium">
|
||||
Monthly limit (USD)
|
||||
<Input {...register("budget", { valueAsNumber: true })} type="number" min="0.01" step="1" />
|
||||
{errors.budget?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.budget.message}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<AnalysisModelField models={models} gate={gate} />
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor={`${id}-budget`} className="text-sm font-medium">
|
||||
Monthly limit
|
||||
</label>
|
||||
<InputGroup>
|
||||
<InputGroupAddon aria-hidden="true">$</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id={`${id}-budget`}
|
||||
aria-describedby={`${id}-budget-hint`}
|
||||
{...register("budget", { valueAsNumber: true })}
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="1"
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<InputGroupAddon aria-hidden="true" align="inline-end">
|
||||
USD
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<p id={`${id}-budget-hint`} className="text-xs text-muted-foreground">
|
||||
Analysis pauses once this month's spend reaches the limit
|
||||
</p>
|
||||
<FieldError>{errors.budget?.message}</FieldError>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { DurationInput } from "@/components/shared/DurationInput";
|
||||
import { FieldError } from "@/components/ui/field";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import type { InvestigationInput } from "../investigationSchema";
|
||||
|
||||
const optionalNumber = (value: unknown) => (value == null || value === "" ? null : Number(value));
|
||||
|
||||
export function SampleFields() {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<InvestigationInput>();
|
||||
const selectionErrors = errors.selection;
|
||||
const id = useId();
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="selection.lookback_hours"
|
||||
render={({ field }) => (
|
||||
<DurationInput label="Review the last" value={field.value ?? 24} base="hours" onChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
{errors.selection?.lookback_hours?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.selection.lookback_hours.message}
|
||||
</p>
|
||||
)}
|
||||
<label className="grid gap-2 text-sm">
|
||||
Sample (%)
|
||||
<Input
|
||||
{...register("selection.sample_percent", { valueAsNumber: true })}
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="100"
|
||||
step="any"
|
||||
<div className="grid gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="selection.lookback_hours"
|
||||
render={({ field }) => (
|
||||
<DurationInput label="Review the last" value={field.value ?? 24} base="hours" onChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
{errors.selection?.sample_percent?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.selection.sample_percent.message}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
<FieldError>{selectionErrors?.lookback_hours?.message}</FieldError>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid content-start gap-2">
|
||||
<label htmlFor={`${id}-sample`} className="text-sm font-medium">
|
||||
Sample
|
||||
</label>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id={`${id}-sample`}
|
||||
{...register("selection.sample_percent", { valueAsNumber: true })}
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="100"
|
||||
step="any"
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<InputGroupAddon aria-hidden="true" align="inline-end">
|
||||
%
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FieldError>{selectionErrors?.sample_percent?.message}</FieldError>
|
||||
</div>
|
||||
<div className="grid content-start gap-2">
|
||||
<label htmlFor={`${id}-max`} className="text-sm font-medium">
|
||||
At most
|
||||
</label>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id={`${id}-max`}
|
||||
{...register("selection.sample_size", { setValueAs: optionalNumber })}
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="No limit"
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<InputGroupAddon aria-hidden="true" align="inline-end">
|
||||
runs
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FieldError>{selectionErrors?.sample_size?.message}</FieldError>
|
||||
</div>
|
||||
</div>
|
||||
<FieldError>{selectionErrors?.execution_ids?.message}</FieldError>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,17 +10,18 @@ import type { ScopeOptions } from "../useMatchingActivity";
|
|||
export function ScopeFields({ runs, range }: ScopeOptions) {
|
||||
const { control } = useFormContext<InvestigationInput>();
|
||||
return (
|
||||
<div className="grid gap-2 text-sm font-medium">
|
||||
Runs to review
|
||||
<div className="h-9 rounded-md border">
|
||||
<div className="grid gap-2">
|
||||
<span className="text-sm font-medium">Runs to review</span>
|
||||
<div className="h-9 overflow-hidden rounded-md border border-input shadow-xs focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30">
|
||||
<Controller
|
||||
control={control}
|
||||
name="selection.q"
|
||||
render={({ field }) => <RunSearch value={field.value} onChange={field.onChange} runs={runs} range={range} />}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs font-normal text-muted-foreground">
|
||||
Leave empty to review every run, or filter like agent:researcher status:error attr.environment:prod
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Same search as the Traces tab, like <code className="font-mono">agent:researcher status:error</code>. Leave
|
||||
empty to review every run.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export interface SwitchRowProps {
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly checked: boolean;
|
||||
readonly onCheckedChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function SwitchRow({ label, description, checked, onCheckedChange }: SwitchRowProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start justify-between gap-4">
|
||||
<span className="grid gap-0.5">
|
||||
<span id={`${id}-label`} className="text-sm font-medium">
|
||||
{label}
|
||||
</span>
|
||||
<span id={`${id}-description`} className="text-xs text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
className="mt-0.5"
|
||||
aria-labelledby={`${id}-label`}
|
||||
aria-describedby={`${id}-description`}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
@ -133,16 +133,16 @@ const stepOfField = {
|
|||
"selection.q": "activity",
|
||||
"selection.lookback_hours": "activity",
|
||||
"selection.sample_percent": "activity",
|
||||
"selection.sample_size": "activity",
|
||||
"selection.execution_ids": "activity",
|
||||
manualSelection: "activity",
|
||||
context: "criteria",
|
||||
questions: "criteria",
|
||||
watching: "criteria",
|
||||
"selection.execution_ids": "run",
|
||||
"selection.sample_size": "run",
|
||||
selectedModel: "run",
|
||||
budget: "run",
|
||||
interval: "run",
|
||||
repeat: "run",
|
||||
manualSelection: "run",
|
||||
} as const satisfies Record<InvestigationField, SetupStep>;
|
||||
|
||||
const fields = Object.keys(stepOfField) as readonly InvestigationField[];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { createSerializer } from "nuqs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { investigationDefaults } from "./investigationSchema";
|
||||
import { draftFromParams, paramsFromDraft, SETUP_DRAFT_PARSERS, type SetupDraftParams } from "./setupRoute";
|
||||
|
||||
const blank: SetupDraftParams = {
|
||||
q: "",
|
||||
name: null,
|
||||
lookback: null,
|
||||
sample: null,
|
||||
max: null,
|
||||
context: null,
|
||||
watch: null,
|
||||
checks: null,
|
||||
monitor: null,
|
||||
every: null,
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("round-trips every edited field through the URL, commas in checks included", () => {
|
||||
const edited: SetupDraftParams = {
|
||||
q: "agent:support status:error",
|
||||
name: "Refund quality",
|
||||
lookback: 72,
|
||||
sample: 12.5,
|
||||
max: 40,
|
||||
context: "Refund only with a receipt",
|
||||
watch: ["watch_looping"],
|
||||
checks: ["Quotes a price, then changes it"],
|
||||
monitor: false,
|
||||
every: 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);
|
||||
});
|
||||
|
||||
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(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);
|
||||
});
|
||||
});
|
||||
99
ui/litellm-dashboard/src/components/lens/setup/setupRoute.ts
Normal file
99
ui/litellm-dashboard/src/components/lens/setup/setupRoute.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
parseAsArrayOf,
|
||||
parseAsBoolean,
|
||||
parseAsFloat,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
parseAsStringLiteral,
|
||||
useQueryStates,
|
||||
type inferParserType,
|
||||
} 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";
|
||||
|
||||
/** 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,
|
||||
max: parseAsInteger,
|
||||
context: parseAsString,
|
||||
watch: parseAsArrayOf(parseAsString),
|
||||
checks: parseAsArrayOf(parseAsString),
|
||||
monitor: parseAsBoolean,
|
||||
every: parseAsInteger,
|
||||
};
|
||||
|
||||
export const SETUP_STEP_PARSERS = { step: parseAsStringLiteral(SETUP_STEPS) };
|
||||
|
||||
/** Every setup key but the shared run search, which belongs to the Traces tab as much as to the draft. */
|
||||
export const SETUP_KEYS = [...Object.keys(SETUP_DRAFT_PARSERS), ...Object.keys(SETUP_STEP_PARSERS)].filter(
|
||||
(key) => key !== "q",
|
||||
);
|
||||
|
||||
export type SetupDraftParams = inferParserType<typeof SETUP_DRAFT_PARSERS>;
|
||||
|
||||
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 = <T>(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,
|
||||
selection: {
|
||||
...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,
|
||||
},
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Only what differs from a blank draft reaches the URL, so an untouched setup keeps a short link. */
|
||||
export function paramsFromDraft(draft: InvestigationInput): SetupDraftParams {
|
||||
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),
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
export function useSetupDraftRoute() {
|
||||
const [params, setParams] = useQueryStates(SETUP_DRAFT_PARSERS, { history: "replace" });
|
||||
const saveDraft = useCallback((draft: InvestigationInput) => void setParams(paramsFromDraft(draft)), [setParams]);
|
||||
return { params, saveDraft };
|
||||
}
|
||||
|
||||
export function useSetupStepRoute(): [SetupStep, (step: SetupStep) => void] {
|
||||
const [{ step }, setParams] = useQueryStates(SETUP_STEP_PARSERS, { history: "replace" });
|
||||
const setStep = useCallback(
|
||||
(next: SetupStep) => void setParams({ step: next === SETUP_STEPS[0] ? null : next }),
|
||||
[setParams],
|
||||
);
|
||||
return [step ?? SETUP_STEPS[0], setStep];
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ function previewTitle(
|
|||
if (!state.valid) return "Complete your sampling settings to preview matches";
|
||||
if (state.pending) return "Finding matching runs…";
|
||||
if (!data) return "Preview unavailable";
|
||||
return `${data.eligible} matching run${data.eligible === 1 ? "" : "s"}`;
|
||||
return `${data.eligible.toLocaleString()} matching run${data.eligible === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function manualSelectedCount(selection: Selection): number {
|
||||
|
|
|
|||
|
|
@ -4,17 +4,20 @@ import { useRelativeRange } from "@/components/shared/timeRange/useRelativeRange
|
|||
|
||||
import { AgentTracesSection } from "./AgentTracesSection";
|
||||
import { useTracesLive } from "../api";
|
||||
import type { InvestigateScope } from "../../route";
|
||||
|
||||
export default function AgentTracesPage({
|
||||
accessToken,
|
||||
isActive = true,
|
||||
readOnly = false,
|
||||
canMintTracingKey = false,
|
||||
onInvestigate,
|
||||
}: {
|
||||
accessToken: string;
|
||||
isActive?: boolean;
|
||||
readOnly?: boolean;
|
||||
canMintTracingKey?: boolean;
|
||||
onInvestigate?: (scope: InvestigateScope) => void;
|
||||
}) {
|
||||
const time = useRelativeRange(useTracesLive());
|
||||
return (
|
||||
|
|
@ -26,6 +29,7 @@ export default function AgentTracesPage({
|
|||
readOnly={readOnly}
|
||||
canMintTracingKey={canMintTracingKey}
|
||||
timeControls={time}
|
||||
onInvestigate={onInvestigate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -115,6 +115,27 @@ describe("AgentTracesSection", () => {
|
|||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
it("offers to investigate exactly the searched runs over the shown range, and only once a search narrows them", async () => {
|
||||
pinNowToFixtures();
|
||||
serve(runs);
|
||||
const user = userEvent.setup();
|
||||
const onInvestigate = vi.fn();
|
||||
const week = { hours: 168, anchorMs: null };
|
||||
const unfiltered = renderWithProviders(
|
||||
<AgentTracesSection accessToken="sk-test" isActive range={week} onInvestigate={onInvestigate} />,
|
||||
);
|
||||
expect(await screen.findByRole("combobox", { name: "Search runs" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Investigate these runs" })).not.toBeInTheDocument();
|
||||
unfiltered.unmount();
|
||||
|
||||
renderWithProviders(
|
||||
<AgentTracesSection accessToken="sk-test" isActive range={week} onInvestigate={onInvestigate} />,
|
||||
{ searchParams: "?q=status:error" },
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name: "Investigate these runs" }));
|
||||
expect(onInvestigate).toHaveBeenCalledWith({ q: "status:error", lookbackHours: 168 });
|
||||
});
|
||||
|
||||
it("loads the next page only once the list scrolls near its end, then stops at the last page", async () => {
|
||||
vi.mocked(agentTraceListCall)
|
||||
.mockResolvedValueOnce({ data: runs.slice(0, 1), next_cursor: "next" })
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { ArrowLeft, ScanSearch } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { type ComponentProps, useMemo, useState } from "react";
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ import { TracingSetupCard } from "../../onboarding/tracing/TracingSetupCard";
|
|||
import { useTracesLive } from "../api";
|
||||
import { type AgentTracesResult, useAgentTraces, useTraceAvailability } from "./useAgentTraces";
|
||||
import { useTraceHistogram } from "./useTraceHistogram";
|
||||
import type { InvestigateScope } from "../../route";
|
||||
|
||||
const DRAWER_WIDTH_KEY = "litellm.agentTraces.drawerWidth";
|
||||
const RUN_NOUN = { singular: "run", plural: "runs" };
|
||||
|
|
@ -43,8 +44,12 @@ interface AgentTracesSectionProps {
|
|||
timeControls?: TimeControls;
|
||||
readOnly?: boolean;
|
||||
canMintTracingKey?: boolean;
|
||||
/** Offered once a search narrows the runs, to review exactly those runs in a new investigation. */
|
||||
onInvestigate?: (scope: InvestigateScope) => void;
|
||||
}
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
function useTracingSetup(traces: AgentTracesResult, isActive: boolean, rangeChanged: boolean) {
|
||||
const [setupResult, setSetupResult] = useState<{ detail: string | null } | null>(null);
|
||||
const waitingForFirstTrace = traces.traces.length === 0 && !rangeChanged;
|
||||
|
|
@ -80,6 +85,7 @@ export function AgentTracesSection({
|
|||
timeControls,
|
||||
readOnly = false,
|
||||
canMintTracingKey = false,
|
||||
onInvestigate,
|
||||
}: AgentTracesSectionProps) {
|
||||
const live = useTracesLive();
|
||||
const { trace: openTrace, openTrace: openRun, selection, fullScreen, setFullScreen } = useOpenTraceRouting();
|
||||
|
|
@ -104,6 +110,7 @@ export function AgentTracesSection({
|
|||
const minuteEndMs = moment().endOf("minute").valueOf();
|
||||
const window = useMemo(() => timeWindow(range, minuteEndMs), [range, minuteEndMs]);
|
||||
const histogram = useTraceHistogram(accessToken, { window, q: query }, isActive);
|
||||
const shownRange = zoom ?? window;
|
||||
const runs = traces.traces;
|
||||
const runRefs = useMemo(() => runs.map(traceRefOf), [runs]);
|
||||
|
||||
|
|
@ -163,10 +170,13 @@ export function AgentTracesSection({
|
|||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
runs={traces.traces}
|
||||
range={zoom ?? window}
|
||||
range={shownRange}
|
||||
order={order}
|
||||
busy={traces.isPlaceholder}
|
||||
>
|
||||
{onInvestigate && (
|
||||
<InvestigateButton query={query} startMs={shownRange.startMs} onInvestigate={onInvestigate} />
|
||||
)}
|
||||
{timeControls && (
|
||||
<TimeRangeControls
|
||||
range={range}
|
||||
|
|
@ -204,6 +214,28 @@ export function AgentTracesSection({
|
|||
);
|
||||
}
|
||||
|
||||
interface InvestigateButtonProps {
|
||||
readonly query: string;
|
||||
readonly startMs: number;
|
||||
readonly onInvestigate: (scope: InvestigateScope) => void;
|
||||
}
|
||||
|
||||
function InvestigateButton({ query, startMs, onInvestigate }: InvestigateButtonProps) {
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
const lookbackHours = () => Math.max(1, Math.ceil((Date.now() - startMs) / HOUR_MS));
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInvestigate({ q, lookbackHours: lookbackHours() })}
|
||||
className="flex items-center gap-1.5 border-l border-border px-3 text-sm font-medium whitespace-nowrap text-foreground transition-colors outline-none hover:bg-muted/60 focus-visible:bg-muted/60"
|
||||
>
|
||||
<ScanSearch aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
||||
Investigate these runs
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TracesReceived({ received }: { received: boolean }) {
|
||||
if (!received) return null;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import type { ComponentProps } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
|
|
@ -28,16 +27,13 @@ export function DurationInput({ label, value, onChange, base, max, className, ..
|
|||
{ label: "days", scale: 24 },
|
||||
];
|
||||
const [scale, setScale] = useState(() => [...units].reverse().find((unit) => value % unit.scale === 0)?.scale ?? 1);
|
||||
function changeUnit(next: number) {
|
||||
setScale(next);
|
||||
}
|
||||
return (
|
||||
<div {...props} data-slot="duration-input" className={cn("space-y-2", className)}>
|
||||
<label htmlFor={id} className="text-sm">
|
||||
<div {...props} data-slot="duration-input" className={cn("grid gap-2", className)}>
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
<div className="flex h-9 min-w-0 rounded-md border border-input shadow-xs transition-[color,box-shadow] has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/50 has-[input:invalid]:border-destructive dark:bg-input/30">
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
min={1 / scale}
|
||||
|
|
@ -45,13 +41,14 @@ 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"
|
||||
/>
|
||||
<div className="relative w-28 shrink-0">
|
||||
<div className="relative shrink-0 border-l border-input">
|
||||
<select
|
||||
aria-label={`${label} unit`}
|
||||
value={scale}
|
||||
className="h-9 w-full appearance-none rounded-md border border-input bg-background pl-3 pr-9 text-sm"
|
||||
onChange={(event) => changeUnit(Number(event.target.value))}
|
||||
className="h-full appearance-none rounded-r-md bg-transparent pr-8 pl-3 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:text-foreground"
|
||||
onChange={(event) => setScale(Number(event.target.value))}
|
||||
>
|
||||
{units.map((unit) => (
|
||||
<option key={unit.scale} value={unit.scale}>
|
||||
|
|
@ -61,7 +58,7 @@ export function DurationInput({ label, value, onChange, base, max, className, ..
|
|||
</select>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
className="pointer-events-none absolute top-1/2 right-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue