mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
refactor(ui): reorganize Lens dashboard components (#44354)
* refactor(ui): reorganize Lens dashboard components * refactor(ui): align Lens forms with react-hook-form Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep Lens submit errors out of form validity Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(ui): reduce Lens lint budget usage Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(ui): use a query key factory for Lens queries Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Yujong Lee <yujong@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
cb00dbecd7
commit
09313bcf1b
87 changed files with 5166 additions and 3201 deletions
|
|
@ -1,513 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useId, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus, X, ChevronRight, RotateCw } from "lucide-react";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TracePanel } from "./TracePanel";
|
||||
import { type Sample, type Settings, runTime, durationLabel } from "./lensData";
|
||||
|
||||
import { DurationInput } from "./DurationInput";
|
||||
|
||||
export type ActivitySelection = Pick<Settings, "source"> &
|
||||
Partial<
|
||||
Pick<
|
||||
Settings,
|
||||
| "service"
|
||||
| "agent_name"
|
||||
| "filters"
|
||||
| "lookback_hours"
|
||||
| "sample_percent"
|
||||
| "sample_size"
|
||||
| "team_id"
|
||||
| "execution_ids"
|
||||
>
|
||||
>;
|
||||
|
||||
const selectClass = "h-9 w-full rounded-md border border-input bg-background px-3 text-sm";
|
||||
|
||||
export function RunList({ executions }: { executions: Sample["executions"] }) {
|
||||
return (
|
||||
<div className="divide-y">
|
||||
{executions.map((run) => (
|
||||
<div key={run.id} className="py-3">
|
||||
<p className="text-sm font-medium">{run.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{runTime(run.start_time)} ·{" "}
|
||||
{run.source === "traces" ? `${run.span_count} ${run.span_count === 1 ? "step" : "steps"}` : "LLM request"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityScope({
|
||||
value,
|
||||
onChange,
|
||||
accessToken,
|
||||
mode = "scope",
|
||||
onPreviewReady,
|
||||
manualSelection = false,
|
||||
nameField,
|
||||
}: {
|
||||
value: ActivitySelection;
|
||||
onChange: (selection: ActivitySelection) => void;
|
||||
accessToken: string;
|
||||
mode?: "scope" | "activity";
|
||||
onPreviewReady?: (ready: boolean) => void;
|
||||
manualSelection?: boolean;
|
||||
nameField?: ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const hasFilters = !!value.filters?.length || !!value.team_id;
|
||||
const [advanced, setAdvanced] = useState(hasFilters || !!value.service || value.source !== "traces");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [scope, setScope] = useState(value);
|
||||
const [trace, setTrace] = useState<{ id: string; ref?: string } | null>(null);
|
||||
const [asOf, setAsOf] = useState(() => new Date().toISOString());
|
||||
const serialized = JSON.stringify({ ...value, execution_ids: [] });
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setScope(JSON.parse(serialized) as ActivitySelection);
|
||||
setOffset(0);
|
||||
setAsOf(new Date().toISOString());
|
||||
}, 350);
|
||||
return () => clearTimeout(timer);
|
||||
}, [serialized]);
|
||||
const historyHours = value.lookback_hours ?? 24;
|
||||
const validWindow = Number.isInteger(historyHours) && historyHours >= 1 && historyHours <= 8760;
|
||||
const percent = scope.sample_percent ?? 100;
|
||||
const cap = scope.sample_size;
|
||||
const validCap = cap == null || (Number.isInteger(cap) && cap > 0);
|
||||
const validSampling = percent > 0 && percent <= 100 && validCap;
|
||||
const validFilters = (scope.filters ?? []).every((f) => f.key.trim() && f.value.trim());
|
||||
const valid = validWindow && validSampling && validFilters;
|
||||
const load = (selection: ActivitySelection, pageOffset = 0) => {
|
||||
const { lookback_hours, ...selectionSettings } = selection;
|
||||
return apiClient.post<Sample>("/lens/preview/sample", {
|
||||
accessToken,
|
||||
body: {
|
||||
offset: pageOffset,
|
||||
as_of: asOf,
|
||||
settings: {
|
||||
...selectionSettings,
|
||||
execution_ids: [],
|
||||
name: "Preview",
|
||||
model: "preview",
|
||||
|
||||
checks: [{ id: "preview", instruction: "Preview recorded activity" }],
|
||||
},
|
||||
lookback_hours: lookback_hours ?? 24,
|
||||
},
|
||||
});
|
||||
};
|
||||
const discoveryScope: ActivitySelection = {
|
||||
source: value.source,
|
||||
service: "",
|
||||
filters: [],
|
||||
lookback_hours: value.lookback_hours,
|
||||
};
|
||||
const discoveryOptions = {
|
||||
queryKey: ["lens-activity-options", value.source, value.lookback_hours, asOf, accessToken],
|
||||
queryFn: () => load(discoveryScope),
|
||||
staleTime: 60000,
|
||||
enabled: validWindow,
|
||||
};
|
||||
const discovery = useQuery(discoveryOptions);
|
||||
const agentOptions = {
|
||||
queryKey: ["lens-agents", accessToken, asOf],
|
||||
queryFn: () => apiClient.get<string[]>("/lens/agents", { accessToken }),
|
||||
enabled: value.source !== "requests",
|
||||
staleTime: 60000,
|
||||
};
|
||||
const agents = useQuery(agentOptions);
|
||||
const previewOptions = {
|
||||
queryKey: ["lens-activity-preview", scope, offset, asOf, accessToken],
|
||||
queryFn: () => load(scope, offset),
|
||||
enabled: valid,
|
||||
staleTime: 30000,
|
||||
};
|
||||
const preview = useQuery(previewOptions);
|
||||
const empty = preview.data?.eligible === 0;
|
||||
useEffect(() => {
|
||||
if (!empty || !valid) return;
|
||||
const timer = window.setTimeout(() => setAsOf(new Date().toISOString()), 15000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [empty, valid, asOf]);
|
||||
const refreshPreview = () => {
|
||||
setOffset(0);
|
||||
setAsOf(new Date().toISOString());
|
||||
};
|
||||
const runs = discovery.data?.executions ?? [];
|
||||
const services = [...new Set(runs.map((r) => r.service).filter(Boolean))].sort();
|
||||
const selectedName = value.source === "requests" ? value.service : value.agent_name;
|
||||
const names = value.source === "requests" ? services : agents.data ?? [];
|
||||
const selectName = (name: string) =>
|
||||
onChange({ ...value, [value.source === "requests" ? "service" : "agent_name"]: name, execution_ids: [] });
|
||||
const attributes = runs.flatMap((r) => r.metadata ?? []);
|
||||
const keys = [...new Set(attributes.map((a) => a.key).filter((key) => !key.startsWith("litellm.")))].sort();
|
||||
const pending = serialized !== JSON.stringify(scope) || preview.isFetching;
|
||||
const ready = !pending && valid;
|
||||
const hasSelection = !manualSelection || !!value.execution_ids?.length;
|
||||
const hasMatches = !preview.error && (preview.data?.selected ?? 0) > 0;
|
||||
const canReview = ready && hasMatches && hasSelection;
|
||||
useEffect(() => {
|
||||
onPreviewReady?.(canReview);
|
||||
}, [canReview, onPreviewReady]);
|
||||
const filters = value.filters ?? [];
|
||||
const edit = (index: number, field: "key" | "value", text: string) =>
|
||||
onChange({ ...value, filters: filters.map((f, i) => (i === index ? { ...f, [field]: text } : f)) });
|
||||
|
||||
const changeSource = (source: Settings["source"]) => {
|
||||
const selection = { ...value, source, service: "", agent_name: "", filters: [], execution_ids: [] };
|
||||
onChange(selection);
|
||||
};
|
||||
const windowLabel = validWindow
|
||||
? `Last ${durationLabel(value.lookback_hours ?? 24, "hours")}`
|
||||
: "Choose a valid history window";
|
||||
const previewTitle = () => {
|
||||
if (pending) return "Finding matching activity…";
|
||||
if (!validWindow) return "Choose a history window between 1 hour and 365 days";
|
||||
if (!valid) return "Complete your condition to preview matches";
|
||||
if (!preview.data) return "Preview unavailable";
|
||||
const noun = value.source === "requests" ? "request" : "run";
|
||||
return `${preview.data.eligible} matching ${noun}${preview.data.eligible === 1 ? "" : "s"}`;
|
||||
};
|
||||
return (
|
||||
<div className={mode === "activity" ? "grid gap-6 sm:grid-cols-2" : "space-y-5"}>
|
||||
<div className="space-y-5">
|
||||
{mode === "scope" ? (
|
||||
<>
|
||||
{nameField}
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
{value.source === "requests" ? "Model group (optional)" : "Agent (optional)"}
|
||||
<Combobox
|
||||
items={names}
|
||||
value={selectedName || null}
|
||||
inputValue={selectedName ?? ""}
|
||||
onInputValueChange={selectName}
|
||||
onValueChange={(name) => selectName(name ?? "")}
|
||||
>
|
||||
<ComboboxInput
|
||||
aria-label={value.source === "requests" ? "Model group (optional)" : "Agent (optional)"}
|
||||
placeholder={value.source === "requests" ? "All model groups" : "All agents and activity"}
|
||||
showClear={!!selectedName}
|
||||
className="w-full h-9"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>
|
||||
{agents.isFetching ? "Loading agents…" : "No matches. You can enter a recorded name."}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(name: string) => (
|
||||
<ComboboxItem key={name} value={name}>
|
||||
{name}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</label>
|
||||
{value.source !== "requests" && agents.isError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Could not load agents.{" "}
|
||||
<button type="button" className="underline" onClick={() => void agents.refetch()}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
<details open={advanced} onToggle={(event) => setAdvanced(event.currentTarget.open)} className="group">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Advanced filters{filters.length ? ` (${filters.length})` : ""}
|
||||
</summary>
|
||||
<div className="mt-4 space-y-4">
|
||||
{value.source !== "requests" && (
|
||||
<label className="grid gap-2 text-sm">
|
||||
Application (optional)
|
||||
<Input
|
||||
value={value.service ?? ""}
|
||||
placeholder="All applications"
|
||||
onChange={(event) => onChange({ ...value, service: event.target.value, execution_ids: [] })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="grid gap-2 text-sm">
|
||||
Activity type
|
||||
<select
|
||||
className={selectClass}
|
||||
value={value.source}
|
||||
onChange={(e) => changeSource(e.target.value as Settings["source"])}
|
||||
>
|
||||
<option value="traces">Agent traces</option>
|
||||
<option value="requests">LLM requests</option>
|
||||
<option value="both">Traces and LLM requests</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Match any recorded metadata, such as a user ID, environment, or tag. All conditions must match.
|
||||
</p>
|
||||
{filters.map((f, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={`Metadata key ${index + 1}`}
|
||||
list={`${id}-keys`}
|
||||
placeholder="Metadata key"
|
||||
value={f.key}
|
||||
onChange={(e) => edit(index, "key", e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove condition ${index + 1}`}
|
||||
onClick={() => onChange({ ...value, filters: filters.filter((_, i) => i !== index) })}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
aria-label={`Metadata value ${index + 1}`}
|
||||
list={`${id}-values-${index}`}
|
||||
placeholder="Equals"
|
||||
value={f.value}
|
||||
onChange={(e) => edit(index, "value", e.target.value)}
|
||||
/>
|
||||
<datalist id={`${id}-values-${index}`}>
|
||||
{[...new Set(attributes.filter((a) => a.key === f.key).map((a) => a.value))].sort().map((v) => (
|
||||
<option key={v} value={v} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
))}
|
||||
<datalist id={`${id}-keys`}>
|
||||
{keys.map((key) => (
|
||||
<option key={key} value={key} />
|
||||
))}
|
||||
</datalist>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={filters.length >= 8}
|
||||
onClick={() => onChange({ ...value, filters: [...filters, { key: "", value: "" }] })}
|
||||
>
|
||||
<Plus className="size-3" /> Add condition
|
||||
</Button>
|
||||
<label className="grid gap-2 text-sm">
|
||||
Team ID (optional)
|
||||
<Input
|
||||
value={value.team_id ?? ""}
|
||||
placeholder="All accessible teams"
|
||||
onChange={(e) => onChange({ ...value, team_id: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DurationInput
|
||||
label="Review the last"
|
||||
value={value.lookback_hours ?? 24}
|
||||
base="hours"
|
||||
max={8760}
|
||||
onChange={(lookback_hours) => onChange({ ...value, lookback_hours })}
|
||||
/>
|
||||
<label className="grid gap-2 text-sm">
|
||||
Sample (%)
|
||||
<Input
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="100"
|
||||
step="any"
|
||||
value={value.sample_percent ?? 100}
|
||||
onChange={(e) => onChange({ ...value, sample_percent: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{manualSelection && !!value.execution_ids?.length && (
|
||||
<Button variant="outline" size="sm" onClick={() => onChange({ ...value, execution_ids: [] })}>
|
||||
Clear {value.execution_ids.length} selected runs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{mode === "activity" && (
|
||||
<MatchingActivity
|
||||
offset={offset}
|
||||
onPage={setOffset}
|
||||
onSelect={(runId, checked) =>
|
||||
onChange({
|
||||
...value,
|
||||
execution_ids: checked
|
||||
? [...(value.execution_ids ?? []), runId]
|
||||
: (value.execution_ids ?? []).filter((id) => id !== runId),
|
||||
})
|
||||
}
|
||||
manualSelection={manualSelection}
|
||||
selectedIds={value.execution_ids ?? []}
|
||||
selectedCount={
|
||||
manualSelection
|
||||
? Math.min(
|
||||
Math.ceil(((value.execution_ids?.length ?? 0) * (value.sample_percent ?? 100)) / 100),
|
||||
value.sample_size ?? Infinity,
|
||||
)
|
||||
: preview.data?.selected ?? 0
|
||||
}
|
||||
title={previewTitle()}
|
||||
windowLabel={windowLabel}
|
||||
ready={ready}
|
||||
error={preview.error}
|
||||
data={preview.data}
|
||||
onRetry={refreshPreview}
|
||||
onOpen={(run) => setTrace({ id: run.trace_id, ref: run.trace_ref })}
|
||||
/>
|
||||
)}
|
||||
{trace && (
|
||||
<TracePanel
|
||||
open
|
||||
traceId={trace.id}
|
||||
traceRef={trace.ref}
|
||||
accessToken={accessToken}
|
||||
onClose={() => setTrace(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchingActivity({
|
||||
offset,
|
||||
onPage,
|
||||
onSelect,
|
||||
selectedIds,
|
||||
manualSelection,
|
||||
selectedCount,
|
||||
title,
|
||||
windowLabel,
|
||||
ready,
|
||||
error,
|
||||
data,
|
||||
onOpen,
|
||||
onRetry,
|
||||
}: {
|
||||
offset: number;
|
||||
onPage: (offset: number) => void;
|
||||
onSelect: (id: string, checked: boolean) => void;
|
||||
selectedIds: string[];
|
||||
manualSelection: boolean;
|
||||
selectedCount: number;
|
||||
title: string;
|
||||
windowLabel: string;
|
||||
ready: boolean;
|
||||
error: Error | null;
|
||||
data: Sample | undefined;
|
||||
onRetry: () => void;
|
||||
onOpen: (run: Sample["executions"][number]) => void;
|
||||
}) {
|
||||
const paginated = data?.next_offset != null || offset > 0;
|
||||
const showSelection = selectedCount !== data?.eligible || paginated;
|
||||
const selectionData = ready && showSelection ? data : undefined;
|
||||
return (
|
||||
<section aria-label="Matching activity" className="self-start rounded-lg border">
|
||||
<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">
|
||||
{title}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Refresh matching activity"
|
||||
onClick={onRetry}
|
||||
disabled={!ready}
|
||||
>
|
||||
<RotateCw className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{windowLabel} · No analysis cost</p>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto px-4">
|
||||
{ready && error && (
|
||||
<p role="alert" className="py-3 text-sm text-destructive">
|
||||
{error.message}{" "}
|
||||
<Button variant="link" onClick={onRetry}>
|
||||
Retry preview
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
{ready && data?.eligible === 0 && (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
No matches. Try removing a condition or check that your agent records this metadata. Recent trace updates
|
||||
need two minutes to settle.
|
||||
</p>
|
||||
)}
|
||||
{ready &&
|
||||
data?.executions.map((run) => (
|
||||
<div key={run.id} className="flex items-center justify-between gap-3 border-b last:border-0">
|
||||
{manualSelection && (
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${run.name}`}
|
||||
checked={selectedIds.includes(run.id)}
|
||||
onChange={(e) => onSelect(run.id, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<RunList executions={[run]} />
|
||||
</div>
|
||||
{run.source === "traces" && (
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`Open ${run.name}`} onClick={() => onOpen(run)}>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selectionData && (
|
||||
<div className="border-t px-4 py-3 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedCount} selected for analysis
|
||||
{paginated && (
|
||||
<>
|
||||
{" "}
|
||||
· Showing {offset + (selectionData.executions.length ? 1 : 0)}–
|
||||
{offset + selectionData.executions.length} of {selectionData.eligible}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{paginated && (
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={offset === 0}
|
||||
onClick={() => onPage(Math.max(0, offset - 100))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={selectionData.next_offset == null}
|
||||
onClick={() => onPage(selectionData.next_offset ?? offset)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AnalysisKeyDetails } from "./AnalysisKeyDetails";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
|
||||
const keySchema = z.object({ token: z.string(), key_alias: z.string().nullable().optional() });
|
||||
const pageSchema = z.object({ keys: z.array(keySchema), total_pages: z.number() });
|
||||
type Key = z.infer<typeof keySchema>;
|
||||
|
||||
export function AnalysisKey({
|
||||
accessToken,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
accessToken: string;
|
||||
value: string | null;
|
||||
onChange: (key: string | null) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<Key | null>(value ? { token: value } : null);
|
||||
|
||||
const queryOptions = {
|
||||
queryKey: ["lens-analysis-keys", accessToken, query],
|
||||
initialPageParam: 1,
|
||||
queryFn: async ({ pageParam, signal }: { pageParam: number; signal: AbortSignal }) =>
|
||||
pageSchema.parse(
|
||||
await apiClient.get("/key/list", {
|
||||
accessToken,
|
||||
signal,
|
||||
query: {
|
||||
page: String(pageParam),
|
||||
size: "25",
|
||||
return_full_object: "true",
|
||||
key_alias: query || undefined,
|
||||
substring_matching: "true",
|
||||
include_team_keys: "true",
|
||||
include_created_by_keys: "true",
|
||||
status: "active",
|
||||
},
|
||||
}),
|
||||
),
|
||||
getNextPageParam: (lastPage: z.infer<typeof pageSchema>, pages: z.infer<typeof pageSchema>[]) =>
|
||||
pages.length < lastPage.total_pages ? pages.length + 1 : undefined,
|
||||
};
|
||||
const keyPages = useInfiniteQuery(queryOptions);
|
||||
const keys = keyPages.data?.pages.flatMap((page) => page.keys) ?? [];
|
||||
const choice = keys.find((key) => key.token === value) ?? selected;
|
||||
const loading = keyPages.isFetching;
|
||||
|
||||
const changeKey = (key: Key | null, details: { cancel: () => void }) => {
|
||||
if (key?.token === "load-more") {
|
||||
details.cancel();
|
||||
if (!loading) void keyPages.fetchNextPage();
|
||||
return;
|
||||
}
|
||||
setSelected(key);
|
||||
onChange(key?.token ?? null);
|
||||
};
|
||||
const choices = choice && !keys.some((key) => key.token === choice.token) ? [choice, ...keys] : keys;
|
||||
const items = keyPages.hasNextPage
|
||||
? [...choices, { token: "load-more", key_alias: loading ? "Loading…" : "Load more keys" }]
|
||||
: choices;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">Charge analysis to</p>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Combobox
|
||||
items={items}
|
||||
value={choice}
|
||||
filter={null}
|
||||
itemToStringLabel={(key: Key) => key.key_alias || `${key.token.slice(0, 8)}…`}
|
||||
isItemEqualToValue={(a: Key, b: Key) => a.token === b.token}
|
||||
onInputValueChange={(text, details) => {
|
||||
if (details.reason === "input-change" || details.reason === "input-clear") {
|
||||
setQuery(text);
|
||||
}
|
||||
}}
|
||||
onValueChange={changeKey}
|
||||
>
|
||||
<ComboboxInput aria-label="Charge analysis to" placeholder="Search existing keys" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>{loading ? "Loading keys…" : "No matching keys"}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(key: Key) => (
|
||||
<ComboboxItem key={key.token} value={key}>
|
||||
{key.key_alias || `${key.token.slice(0, 8)}…`}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
{choice && <AnalysisKeyDetails accessToken={accessToken} keyId={choice.token} />}
|
||||
{keyPages.error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{keyPages.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type AnalysisAccess = { model: string | null; budget: string };
|
||||
|
||||
export function AnalysisAccessFields({
|
||||
accessToken,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
accessToken: string;
|
||||
value: AnalysisAccess;
|
||||
onChange: (value: AnalysisAccess) => void;
|
||||
}) {
|
||||
const models = useQuery({
|
||||
queryKey: ["lens-models", accessToken],
|
||||
queryFn: () => apiClient.get<{ data: { id: string }[] }>("/models", { accessToken }),
|
||||
});
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="analysis-access-model" className="block text-sm font-medium">
|
||||
Analysis model
|
||||
</label>
|
||||
<SearchSelect
|
||||
inputId="analysis-access-model"
|
||||
options={(models.data?.data ?? []).map(({ id }) => ({ label: id, value: id }))}
|
||||
value={value.model}
|
||||
onValueChange={(model) => onChange({ ...value, model })}
|
||||
placeholder={models.isLoading ? "Loading models…" : "Select a model"}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="analysis-access-budget" className="block text-sm font-medium">
|
||||
Monthly limit (USD)
|
||||
</label>
|
||||
<Input
|
||||
id="analysis-access-budget"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={value.budget}
|
||||
onChange={(e) => onChange({ ...value, budget: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Shared across all investigations.</p>
|
||||
</div>
|
||||
{models.error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{models.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAnalysisKey(accessToken: string, access: AnalysisAccess): Promise<string> {
|
||||
if (!access.model || !Number.isFinite(Number(access.budget)) || Number(access.budget) <= 0)
|
||||
throw new Error("Choose a model and a monthly limit greater than zero");
|
||||
const result = await apiClient.post("/key/generate", {
|
||||
accessToken,
|
||||
body: {
|
||||
key_alias: "Lens analysis",
|
||||
models: [access.model],
|
||||
max_budget: Number(access.budget),
|
||||
budget_duration: "1mo",
|
||||
metadata: { purpose: "lens" },
|
||||
},
|
||||
});
|
||||
if (!result.token_id) throw new Error("The proxy did not return the new key's ID");
|
||||
return result.token_id;
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { ChevronRight, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { NextCheck } from "./LensProgress";
|
||||
import { DurationInput } from "./DurationInput";
|
||||
import { lensStatus, runTime, scopeLabel, type Lens, type Settings, type Job } from "./lensData";
|
||||
|
||||
export function InvestigationList({
|
||||
lenses,
|
||||
connected,
|
||||
onSelect,
|
||||
}: {
|
||||
lenses: Lens[];
|
||||
connected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const shown = lenses.filter((lens) =>
|
||||
`${lens.settings.name} ${scopeLabel(lens.settings)}`.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
return (
|
||||
<section aria-label="Saved investigations" className="space-y-4">
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search investigations"
|
||||
placeholder="Search investigations"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="divide-y border-y">
|
||||
{shown.map((lens) => (
|
||||
<button
|
||||
key={lens.id}
|
||||
onClick={() => onSelect(lens.id)}
|
||||
className="group grid w-full grid-cols-[minmax(0,1fr)_16px] items-center gap-x-4 gap-y-2 py-4 text-left hover:bg-muted/30 focus-visible:outline-2 focus-visible:outline-ring sm:grid-cols-[minmax(0,1fr)_auto_16px]"
|
||||
>
|
||||
<div className="col-start-1 row-start-1 min-w-0">
|
||||
<p className="text-sm font-medium">{lens.settings.name}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{scopeLabel(lens.settings)}</p>
|
||||
</div>
|
||||
<div className="col-start-1 row-start-2 text-xs sm:col-start-2 sm:row-start-1 sm:text-right">
|
||||
<p className={lens.jobs[0]?.status === "failed" ? "text-destructive" : "text-muted-foreground"}>
|
||||
{lensStatus(lens, connected)}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{lens.jobs[0] ? runTime(lens.jobs[0].created_at) : "Not run yet"}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="col-start-2 row-start-1 size-4 text-muted-foreground sm:col-start-3" />
|
||||
</button>
|
||||
))}
|
||||
{!shown.length && <p className="py-8 text-sm text-muted-foreground">No investigations match your search.</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitoringSetup({
|
||||
settings,
|
||||
ready,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
settings: Settings;
|
||||
ready: boolean;
|
||||
onSave: (settings: Settings) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [interval, setInterval] = useState(settings.interval_minutes ?? 30);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSave({ ...settings, enabled: true, interval_minutes: interval });
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Could not enable monitoring");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const validInterval = Number.isInteger(interval) && interval >= 1 && interval <= 10080;
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !busy) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Keep monitoring</DialogTitle>
|
||||
<DialogDescription>
|
||||
Repeat this investigation with the saved scope, sample, model, and budget.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DurationInput label="Check every" value={interval} onChange={setInterval} base="minutes" max={10080} />
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Each investigation looks back over the saved time range. The interval starts after the previous run finishes.
|
||||
</p>
|
||||
{!ready && (
|
||||
<p role="status" className="text-sm text-amber-700">
|
||||
Reconnect the worker before enabling monitoring.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy || !ready || !validInterval} onClick={() => void save()}>
|
||||
{busy ? "Saving…" : "Enable monitoring"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvestigationSummary({ lens, connected }: { lens: Lens; connected: boolean }) {
|
||||
const lastCompleted = lens.jobs.find((job) => job.status === "completed");
|
||||
const lastSuccess = lastCompleted?.finished_at ?? lens.last_scan_at;
|
||||
const spent = lens.budget_month === new Date().toISOString().slice(0, 7) ? lens.spent ?? 0 : 0;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3 border-y py-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Latest run:{" "}
|
||||
<strong className={`font-medium ${lens.jobs[0]?.status === "failed" ? "text-destructive" : "text-foreground"}`}>
|
||||
{lensStatus(lens, connected)}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Last success: <span className="text-foreground">{lastSuccess ? runTime(lastSuccess) : "Not yet"}</span>
|
||||
</span>
|
||||
<span>
|
||||
This month:{" "}
|
||||
<span className="text-foreground">
|
||||
${spent.toFixed(3)} / ${lens.settings.monthly_budget ?? 100}
|
||||
</span>
|
||||
</span>
|
||||
{lens.settings.enabled && (
|
||||
<span>
|
||||
Monitoring every {lens.settings.interval_minutes} minutes
|
||||
<NextCheck lens={lens} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvestigationFailure({ job, connected }: { job: Job; connected: boolean }) {
|
||||
return (
|
||||
<div role="alert" className="space-y-2 rounded-md border border-destructive/20 p-3 text-sm">
|
||||
<p className="font-medium text-destructive">This investigation did not finish</p>
|
||||
<p>{job.error}</p>
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground">Troubleshooting details</summary>
|
||||
<dl className="mt-2 space-y-1 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<dt className="inline">Run: </dt>
|
||||
<dd className="inline">{job.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline">Model: </dt>
|
||||
<dd className="inline">{job.settings.model}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline">Worker: </dt>
|
||||
<dd className="inline">{connected ? "Connected now" : "Not connected"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline">Started: </dt>
|
||||
<dd className="inline">{runTime(job.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Use the run ID to find the error in proxy and worker logs. Check the worker key's model permissions and
|
||||
budget before retrying.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,413 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { DurationInput } from "./DurationInput";
|
||||
import { ActivityScope, type ActivitySelection } from "./ActivityScope";
|
||||
import { WatchPicker } from "./WatchPicker";
|
||||
import {
|
||||
analysisModelOptions,
|
||||
initialWatches,
|
||||
isWatch,
|
||||
normalizeFilters,
|
||||
watchChecks,
|
||||
type AnalysisModelInfo,
|
||||
type Settings,
|
||||
} from "./lensData";
|
||||
|
||||
function validateSample(selection: ActivitySelection) {
|
||||
const hours = selection.lookback_hours ?? 24;
|
||||
if (!Number.isInteger(hours) || hours < 1 || hours > 8760)
|
||||
throw new Error("Choose a time range between 1 hour and 365 days");
|
||||
const percent = selection.sample_percent ?? 100;
|
||||
if (!Number.isFinite(percent) || percent <= 0 || percent > 100)
|
||||
throw new Error("Choose a sampling percentage greater than 0 and up to 100");
|
||||
if (selection.sample_size != null && (!Number.isInteger(selection.sample_size) || selection.sample_size < 1))
|
||||
throw new Error("Choose a positive maximum or leave it blank for no limit");
|
||||
}
|
||||
|
||||
function newCheck(instruction = ""): Settings["checks"][number] {
|
||||
return { id: crypto.randomUUID(), instruction, enabled: true };
|
||||
}
|
||||
|
||||
export function LensSetup({
|
||||
initial,
|
||||
mode = initial ? "edit" : "new",
|
||||
models,
|
||||
modelDetails = [],
|
||||
modelsLoading = false,
|
||||
modelsError,
|
||||
defaultModel,
|
||||
defaultSource = "traces",
|
||||
accessToken,
|
||||
ready = true,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
initial?: Settings;
|
||||
mode?: "new" | "edit" | "duplicate";
|
||||
models: string[];
|
||||
modelDetails?: AnalysisModelInfo[];
|
||||
modelsLoading?: boolean;
|
||||
modelsError?: string;
|
||||
defaultModel?: string;
|
||||
defaultSource?: Settings["source"];
|
||||
accessToken: string;
|
||||
ready?: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (settings: Settings) => Promise<void>;
|
||||
}) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [previewReady, setPreviewReady] = useState(false);
|
||||
const [manualSelection, setManualSelection] = useState(!!initial?.execution_ids?.length);
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const initialSelection: Required<ActivitySelection> = {
|
||||
source: initial?.source ?? defaultSource,
|
||||
service: initial?.service ?? "",
|
||||
agent_name: initial?.agent_name ?? "",
|
||||
filters: initial?.filters ?? [],
|
||||
lookback_hours: initial?.lookback_hours ?? 24,
|
||||
sample_size: initial?.sample_size ?? null,
|
||||
sample_percent: initial?.sample_percent ?? 100,
|
||||
team_id: initial?.team_id ?? "",
|
||||
execution_ids: initial?.execution_ids ?? [],
|
||||
};
|
||||
const [selection, setSelection] = useState(initialSelection);
|
||||
const [context, setContext] = useState(initial?.context ?? "");
|
||||
const [watching, setWatching] = useState<ReadonlySet<string>>(() => initialWatches(initial?.checks));
|
||||
const [questions, setQuestions] = useState(() => (initial?.checks ?? []).filter((check) => !isWatch(check)));
|
||||
const [selectedModel, setModel] = useState<string | null>(initial?.model ?? null);
|
||||
const model = selectedModel ?? defaultModel ?? "";
|
||||
const [budget, setBudget] = useState(initial?.monthly_budget ?? 100);
|
||||
const [repeat, setRepeat] = useState(mode === "edit" && !!initial?.enabled);
|
||||
const [interval, setInterval] = useState(initial?.interval_minutes ?? 30);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const filledChecks = questions.filter((check) => check.instruction.trim());
|
||||
const suggestedName = filledChecks[0]?.instruction.trim() || context.trim().split("\n")[0] || "Investigation";
|
||||
const title = name.trim() || suggestedName.slice(0, 100);
|
||||
const changeSelection = (next: ActivitySelection) => {
|
||||
const pool = (s: ActivitySelection) =>
|
||||
JSON.stringify([s.source, s.service, s.agent_name, s.filters, s.lookback_hours, s.team_id]);
|
||||
setSelection({
|
||||
...selection,
|
||||
...next,
|
||||
execution_ids: pool(next) === pool(selection) ? next.execution_ids ?? [] : [],
|
||||
});
|
||||
};
|
||||
const validate = () => {
|
||||
normalizeFilters(selection.filters ?? []);
|
||||
if (step >= 2 && manualSelection && !selection.execution_ids?.length)
|
||||
throw new Error("Choose at least one run or turn off individual selection");
|
||||
validateSample(selection);
|
||||
const nothingToCheck = !context.trim() && !filledChecks.length && !watching.size;
|
||||
if (step >= 1 && nothingToCheck) throw new Error("Describe the expected behavior or pick something to watch for");
|
||||
if (filledChecks.some((check) => check.instruction.trim().length < 3))
|
||||
throw new Error("Use at least three characters for each check");
|
||||
};
|
||||
const next = () => {
|
||||
try {
|
||||
validate();
|
||||
setError("");
|
||||
setStep(step + 1);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Check your settings");
|
||||
}
|
||||
};
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
validate();
|
||||
const settings: Settings = {
|
||||
...initial,
|
||||
...selection,
|
||||
name: title,
|
||||
context: context.trim(),
|
||||
model,
|
||||
monthly_budget: budget,
|
||||
enabled: repeat,
|
||||
interval_minutes: interval,
|
||||
concurrency: initial?.concurrency ?? 8,
|
||||
filters: normalizeFilters(selection.filters ?? []),
|
||||
checks: [
|
||||
...watchChecks(watching),
|
||||
...filledChecks.map((check) => ({ ...check, instruction: check.instruction.trim() })),
|
||||
],
|
||||
};
|
||||
await onSave(settings);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Could not save investigation");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const unsupported = modelDetails.some((m) => m.model_group === model && m.mode && m.mode !== "chat");
|
||||
const budgetValid = Number.isFinite(budget) && budget > 0 && budget <= 100000;
|
||||
const canRun = ready || mode === "edit";
|
||||
const modelsReady = !modelsLoading && !modelsError;
|
||||
const unavailable = !!model && modelsReady && !models.includes(model);
|
||||
const supported = !unsupported && !unavailable;
|
||||
const preservingSavedModel = mode === "edit" && model === initial?.model;
|
||||
const modelReady = modelsReady || preservingSavedModel;
|
||||
const modelValid = !!model && supported && modelReady;
|
||||
const intervalRangeValid = interval >= 1 && interval <= 10080;
|
||||
const intervalValid = !repeat || (Number.isInteger(interval) && intervalRangeValid);
|
||||
const configurationValid = modelValid && budgetValid && intervalValid;
|
||||
const runReady = canRun && (mode === "edit" || previewReady);
|
||||
const selectionValid = !manualSelection || !!selection.execution_ids?.length;
|
||||
const validSettings = configurationValid && selectionValid;
|
||||
const canSave = !busy && runReady && validSettings;
|
||||
const createLabel = repeat ? "Run and monitor" : "Run investigation";
|
||||
const saveLabel = mode === "edit" ? "Save changes" : createLabel;
|
||||
const headings = [
|
||||
"Which activity should we investigate?",
|
||||
"What should Lens look for?",
|
||||
mode === "edit" ? "Review changes" : "Ready to investigate",
|
||||
];
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !busy) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={`flex max-h-[90dvh] flex-col gap-6 overflow-hidden ${["sm:max-w-xl", "sm:max-w-2xl", "sm:max-w-3xl"][step]}`}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">{headings[step]}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{
|
||||
[
|
||||
"Start with an agent, or use filters to investigate any recorded activity.",
|
||||
"Describe the expected behavior, the questions you have, or both.",
|
||||
"Review the selected activity, then start your investigation.",
|
||||
][step]
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<nav aria-label="Investigation setup" className="flex gap-2 text-xs">
|
||||
{["Activity", "Expectations", "Run"].map((label, index) => (
|
||||
<button
|
||||
key={label}
|
||||
disabled={index > step || busy}
|
||||
aria-current={index === step ? "step" : undefined}
|
||||
onClick={() => {
|
||||
setStep(index);
|
||||
setError("");
|
||||
}}
|
||||
className={`flex-1 border-t-2 pt-2 text-left ${index === step ? "border-foreground font-medium" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{index + 1}. {label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="min-h-0 overflow-y-auto pr-1 space-y-5">
|
||||
{(step === 0 || step === 2) && (
|
||||
<ActivityScope
|
||||
key={step}
|
||||
value={selection}
|
||||
onChange={changeSelection}
|
||||
accessToken={accessToken}
|
||||
nameField={
|
||||
step === 0 ? (
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
Investigation name
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Support quality"
|
||||
maxLength={100}
|
||||
/>
|
||||
</label>
|
||||
) : undefined
|
||||
}
|
||||
mode={step === 0 ? "scope" : "activity"}
|
||||
onPreviewReady={setPreviewReady}
|
||||
manualSelection={manualSelection}
|
||||
/>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
What should the agent be doing?
|
||||
<Textarea
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
maxLength={6000}
|
||||
rows={4}
|
||||
placeholder="Answer the customer's question using verified sources and explain when information is missing."
|
||||
/>
|
||||
</label>
|
||||
<WatchPicker
|
||||
selected={watching}
|
||||
onChange={setWatching}
|
||||
onAddCustom={() => setQuestions([...questions, newCheck()])}
|
||||
/>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="sr-only">Custom checks</legend>
|
||||
{questions.map((check, index) => (
|
||||
<div key={check.id} className="flex items-start gap-2">
|
||||
<Textarea
|
||||
aria-label={`Check ${index + 1}`}
|
||||
value={check.instruction}
|
||||
onChange={(event) =>
|
||||
setQuestions(
|
||||
questions.map((item) =>
|
||||
item.id === check.id ? { ...item, instruction: event.target.value } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
rows={2}
|
||||
placeholder="e.g. Quotes a price without checking the pricing tool"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove check ${index + 1}`}
|
||||
onClick={() => setQuestions(questions.filter((item) => item.id !== check.id))}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<>
|
||||
<details open={!modelValid || undefined}>
|
||||
<summary className="cursor-pointer text-sm font-medium">Advanced options</summary>
|
||||
<div className="mt-4 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Analysis model</p>
|
||||
<SearchSelect
|
||||
aria-label="Analysis model"
|
||||
options={analysisModelOptions(models, modelDetails)}
|
||||
value={model}
|
||||
onValueChange={(value) => setModel(value ?? "")}
|
||||
placeholder={modelsLoading ? "Loading models…" : "Choose a model"}
|
||||
disabled={modelsLoading}
|
||||
emptyText="No matching models configured on this gateway"
|
||||
/>
|
||||
{modelsError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Could not load models: {modelsError}
|
||||
</p>
|
||||
)}
|
||||
{unavailable && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{model} is no longer available. Choose another analysis model.
|
||||
</p>
|
||||
)}
|
||||
{unsupported && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Choose a chat model that supports JSON output.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<label className="grid content-start gap-2 text-sm font-medium">
|
||||
Maximum runs (optional)
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="No limit"
|
||||
value={selection.sample_size ?? ""}
|
||||
onChange={(event) =>
|
||||
setSelection({
|
||||
...selection,
|
||||
sample_size: event.target.value ? Number(event.target.value) : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={manualSelection}
|
||||
onChange={(event) => {
|
||||
setManualSelection(event.target.checked);
|
||||
setSelection({ ...selection, execution_ids: [] });
|
||||
}}
|
||||
/>
|
||||
Choose individual runs
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<label className="grid content-start gap-2 text-sm font-medium">
|
||||
Monthly limit (USD)
|
||||
<Input
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="100000"
|
||||
step="1"
|
||||
value={budget}
|
||||
onChange={(e) => setBudget(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={repeat}
|
||||
onChange={(e) => setRepeat(e.target.checked)}
|
||||
className="size-4 rounded border-input accent-foreground"
|
||||
/>
|
||||
Repeat this investigation
|
||||
</label>
|
||||
{repeat && (
|
||||
<DurationInput
|
||||
label="Repeat every"
|
||||
value={interval}
|
||||
onChange={setInterval}
|
||||
base="minutes"
|
||||
max={10080}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
{!ready && mode !== "edit" && (
|
||||
<p role="status" className="text-sm text-amber-700">
|
||||
The worker or trace storage is unavailable. Your draft is safe; you can start when it reconnects.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<Button variant="outline" disabled={busy} onClick={() => (step ? setStep(step - 1) : onClose())}>
|
||||
{step ? "Back" : "Cancel"}
|
||||
</Button>
|
||||
{step < 2 ? (
|
||||
<Button onClick={next}>Continue</Button>
|
||||
) : (
|
||||
<Button disabled={!canSave} onClick={() => void save()}>
|
||||
{busy ? "Saving…" : saveLabel}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,785 +0,0 @@
|
|||
"use client";
|
||||
import { useLensDemo } from "@/components/lens/LensDemoContext";
|
||||
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Info,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
Settings2,
|
||||
MoreHorizontal,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { LensFinding } from "./LensFinding";
|
||||
import { apiClient as liveApiClient } from "@/components/networking";
|
||||
import { TracePanel } from "./TracePanel";
|
||||
import { LensSetup } from "./LensSetup";
|
||||
import { LensRuns } from "./LensRuns";
|
||||
import { LensProgress, ScanDuration } from "./LensProgress";
|
||||
import { WorkerSetup } from "./WorkerSetup";
|
||||
import { useAnalysisKeyInfo } from "./AnalysisKeyDetails";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
import { InvestigationList, MonitoringSetup, InvestigationSummary, InvestigationFailure } from "./LensOverview";
|
||||
import { LensWelcome } from "./LensWelcome";
|
||||
import {
|
||||
workerConnected,
|
||||
scopeLabel,
|
||||
evidenceTarget,
|
||||
sortedFindings,
|
||||
runTime,
|
||||
type Lens,
|
||||
type LensList,
|
||||
type Finding,
|
||||
type Settings,
|
||||
type Job,
|
||||
watches,
|
||||
} from "./lensData";
|
||||
|
||||
const money = (n: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 3 }).format(n);
|
||||
const when = (value?: string | null) => (value ? runTime(value) : "Not yet");
|
||||
|
||||
const sourceLabels = { both: "Traces and requests", requests: "LLM requests", traces: "Agent traces" };
|
||||
const priorityColors = { high: "bg-red-500", medium: "bg-amber-500", low: "bg-slate-400" };
|
||||
function emptyFindingTitle(active: boolean, scanned: boolean, status?: string) {
|
||||
if (status === "failed" || status === "cancelled") return "No findings from this run";
|
||||
if (active) return "Your findings will appear here";
|
||||
return scanned ? "No matching findings" : "Ready for the first analysis";
|
||||
}
|
||||
|
||||
export function LensView({
|
||||
accessToken,
|
||||
readOnly = false,
|
||||
onDemo,
|
||||
}: {
|
||||
accessToken: string;
|
||||
readOnly?: boolean;
|
||||
onDemo?: () => void;
|
||||
}) {
|
||||
const demo = useLensDemo();
|
||||
const apiClient = demo?.client ?? liveApiClient;
|
||||
const client = useQueryClient();
|
||||
const [workerSetup, setWorkerSetup] = useState(false);
|
||||
const [monitoring, setMonitoring] = useState(false);
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 2000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const key = ["lenses", accessToken];
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => apiClient.get<LensList>("/lens", { accessToken }),
|
||||
refetchInterval: (current) => {
|
||||
if (demo) return false;
|
||||
const running = current.state.data?.lenses.some((item) =>
|
||||
item.jobs.some((job) => ["queued", "running"].includes(job.status)),
|
||||
);
|
||||
return workerSetup || running ? 2000 : 10000;
|
||||
},
|
||||
});
|
||||
const models = useQuery({
|
||||
queryKey: ["lens-models", accessToken],
|
||||
queryFn: () => apiClient.get<{ data: { id: string }[] }>("/models", { accessToken }),
|
||||
});
|
||||
const modelDetails = useQuery({
|
||||
queryKey: ["lens-model-details", accessToken],
|
||||
queryFn: () =>
|
||||
apiClient.get<{ data: import("./lensData").AnalysisModelInfo[] }>("/model_group/info", { accessToken }),
|
||||
});
|
||||
const [liveSelected, setLiveSelected] = useQueryState("lens", parseAsString.withOptions({ history: "push" }));
|
||||
const [demoSelected, setDemoSelected] = useState<string | null>(null);
|
||||
const selected = demo ? demoSelected : liveSelected;
|
||||
const setSelected = demo ? setDemoSelected : setLiveSelected;
|
||||
const [editing, setEditing] = useState<"new" | "edit" | "duplicate" | null>(null);
|
||||
const [batchId, setBatchId] = useState("latest");
|
||||
const [historyOffset, setHistoryOffset] = useState(0);
|
||||
const [tab, setTab] = useState("findings");
|
||||
const [findingId, setFindingId] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState("open");
|
||||
const [kind, setKind] = useState<"issue" | "pattern">("issue");
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [evidence, setEvidence] = useState<{ id: string; span: string } | null>(null);
|
||||
const selectLens = (id: string | null) => {
|
||||
void setSelected(id);
|
||||
setBatchId("latest");
|
||||
setHistoryOffset(0);
|
||||
setFindingId(null);
|
||||
setTab("findings");
|
||||
};
|
||||
useEffect(() => {
|
||||
const restoreLocation = () => {
|
||||
setEditing(null);
|
||||
setBatchId("latest");
|
||||
setHistoryOffset(0);
|
||||
setFindingId(null);
|
||||
setTab("findings");
|
||||
};
|
||||
window.addEventListener("popstate", restoreLocation);
|
||||
return () => window.removeEventListener("popstate", restoreLocation);
|
||||
}, []);
|
||||
const lenses = [...(query.data?.lenses ?? [])].sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
|
||||
const showEmpty = !query.isLoading && !query.error && lenses.length === 0;
|
||||
const loaded = !query.isLoading && !query.error;
|
||||
const showActions = !readOnly && !!query.data && !showEmpty;
|
||||
const showReadiness = loaded && !showEmpty && !readOnly;
|
||||
const missingSelection = !!selected && loaded;
|
||||
const lens = lenses.find((e) => e.id === selected);
|
||||
const connected = query.data?.workers?.some((w) => workerConnected(w, now)) ?? false;
|
||||
const activeWorkers = query.data?.workers.filter((worker) => !worker.revoked) ?? [];
|
||||
const defaultKeyId = activeWorkers.length === 1 ? activeWorkers[0].analysis_key_id : undefined;
|
||||
const analysisAccess = useAnalysisKeyInfo(accessToken, defaultKeyId ?? undefined);
|
||||
const defaultModel = analysisAccess.data?.models.length === 1 ? analysisAccess.data.models[0] : undefined;
|
||||
const activityOptions = {
|
||||
queryKey: ["lens-activity-available", accessToken],
|
||||
queryFn: () => apiClient.get<{ traces: boolean; requests: boolean }>("/lens/activity/available", { accessToken }),
|
||||
enabled: loaded,
|
||||
refetchInterval: demo ? (false as const) : 5000,
|
||||
};
|
||||
const activity = useQuery(activityOptions);
|
||||
const tracesReady = activity.data?.traces === true && !activity.error;
|
||||
const requestsReady = activity.data?.requests === true && !activity.error;
|
||||
const activityReady = tracesReady || requestsReady;
|
||||
const ready = activityReady && connected && !query.error;
|
||||
const historyQuery = {
|
||||
queryKey: ["lens-history", lens?.id, historyOffset, accessToken],
|
||||
enabled: !!lens,
|
||||
queryFn: () => apiClient.get<Job[]>(`/lens/${lens?.id}/runs`, { accessToken, query: { offset: historyOffset } }),
|
||||
refetchInterval: demo ? (false as const) : 10000,
|
||||
};
|
||||
const history = useQuery(historyQuery);
|
||||
const historical = useQuery({
|
||||
queryKey: ["lens-batch", lens?.id, batchId, accessToken],
|
||||
enabled: !!lens && !["latest", "all"].includes(batchId),
|
||||
queryFn: () => apiClient.get<Job>(`/lens/${lens?.id}/runs/${batchId}`, { accessToken }),
|
||||
});
|
||||
const job = ["latest", "all"].includes(batchId) ? lens?.jobs?.[0] : historical.data;
|
||||
const missingSnapshot = job?.status === "completed" && job.findings == null && batchId !== "all";
|
||||
const selectedOutsideHistory = !["latest", "all"].includes(batchId) && !history.data?.some((j) => j.id === batchId);
|
||||
const batchSettings = job?.settings ?? lens?.settings;
|
||||
const batchFindings = (batchId === "all" ? lens?.findings ?? [] : job?.findings ?? []).map((f) => {
|
||||
const feedback = lens?.findings?.find((current) => current.id === f.id);
|
||||
return feedback ? { ...f, status: feedback.status, reason: feedback.reason } : f;
|
||||
});
|
||||
const finding = batchFindings.find((f) => f.id === findingId);
|
||||
const openBatch = (id: string) => {
|
||||
setBatchId(id);
|
||||
setTab("findings");
|
||||
setFindingId(null);
|
||||
};
|
||||
const setupSettings = () => {
|
||||
if (editing === "new") return undefined;
|
||||
if (editing === "duplicate" && lens)
|
||||
return { ...lens.settings, name: `${lens.settings.name} copy`, enabled: false };
|
||||
return lens?.settings;
|
||||
};
|
||||
const active = lens?.jobs?.find((j) => j.status === "queued" || j.status === "running");
|
||||
const visibleFindings = sortedFindings(
|
||||
batchFindings.filter((f) => (filter === "all" || f.status === filter) && f.kind === kind),
|
||||
);
|
||||
const sampledRuns = job?.sample?.executions ?? [];
|
||||
const target = evidence ? evidenceTarget(evidence.id) : null;
|
||||
const [requestOffset, setRequestOffset] = useState(0);
|
||||
const requestEvidence = useQuery({
|
||||
queryKey: ["lens-evidence", lens?.id, evidence?.id, requestOffset, accessToken],
|
||||
enabled: !!lens && target?.source === "requests",
|
||||
queryFn: () =>
|
||||
apiClient.get<components["schemas"]["ExecutionContent"]>(
|
||||
`/lens/${lens?.id}/executions/${encodeURIComponent(evidence?.id ?? "")}`,
|
||||
{ accessToken, query: { offset: requestOffset } },
|
||||
),
|
||||
});
|
||||
const refresh = () => {
|
||||
void client.invalidateQueries({ queryKey: key });
|
||||
void client.invalidateQueries({ queryKey: ["lens-history"] });
|
||||
};
|
||||
const update = async (path: string, body: unknown, method: "post" | "put" | "patch" = "post") => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await apiClient[method](path, { accessToken, body });
|
||||
await client.invalidateQueries({ queryKey: key });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not update lens");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const save = async (settings: Settings) => {
|
||||
if (editing === "edit" && (!lens || lens.id !== selected))
|
||||
throw new Error("Reopen the investigation to edit its settings");
|
||||
if (editing !== "edit" && !ready)
|
||||
throw new Error("Wait for recorded activity and a connected worker before starting an investigation");
|
||||
const saved = await apiClient.request<Lens>(
|
||||
editing === "edit" ? "PUT" : "POST",
|
||||
editing === "edit" ? `/lens/${lens?.id}` : "/lens",
|
||||
{ accessToken, body: settings },
|
||||
);
|
||||
selectLens(saved.id);
|
||||
setEditing(null);
|
||||
refresh();
|
||||
};
|
||||
const changeFinding = async (status: Finding["status"]) => {
|
||||
if (!lens || !finding) return;
|
||||
await update(`/lens/${lens.id}/findings/${finding.id}`, { status, reason }, "patch");
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Investigations" className="w-full min-w-0 space-y-6">
|
||||
{!showEmpty && (
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
{lens ? (
|
||||
<Button variant="ghost" size="sm" className="-ml-3" onClick={() => selectLens(null)}>
|
||||
<ArrowLeft className="size-4" /> All investigations
|
||||
</Button>
|
||||
) : (
|
||||
<h2 className="text-lg font-semibold">Investigations</h2>
|
||||
)}
|
||||
{showActions && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="ghost" disabled={!activityReady} onClick={() => setWorkerSetup(true)}>
|
||||
<Circle
|
||||
className={`size-2 ${connected ? "fill-emerald-500 text-emerald-500" : "fill-amber-500 text-amber-500"}`}
|
||||
/>
|
||||
{connected ? "Worker connected" : "Connect worker"}
|
||||
</Button>
|
||||
<Button variant={lens ? "outline" : "default"} disabled={!ready} onClick={() => setEditing("new")}>
|
||||
<Plus className="size-4" /> New investigation
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
{(error || query.error) && (
|
||||
<div role="alert" className="rounded-lg border border-destructive/30 p-4 text-sm text-destructive">
|
||||
{query.error instanceof ApiError && query.error.status === 404
|
||||
? "The Lens API is unavailable. Reload this page to use the current dashboard; if it persists, check the proxy deployment."
|
||||
: error || query.error?.message}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
query.error instanceof ApiError && query.error.status === 404 ? window.location.reload() : refresh()
|
||||
}
|
||||
>
|
||||
{query.error instanceof ApiError && query.error.status === 404 ? "Reload page" : "Retry"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{query.isLoading && (
|
||||
<p role="status" className="py-8 text-sm text-muted-foreground">
|
||||
Loading investigations…
|
||||
</p>
|
||||
)}
|
||||
{loaded && showEmpty && (
|
||||
<LensWelcome
|
||||
tracesReady={tracesReady}
|
||||
requestsReady={requestsReady}
|
||||
checking={activity.isPending}
|
||||
traceError={activity.error?.message}
|
||||
connected={connected}
|
||||
readOnly={!!readOnly}
|
||||
onRetry={() => {
|
||||
void activity.refetch();
|
||||
refresh();
|
||||
}}
|
||||
onConnect={() => setWorkerSetup(true)}
|
||||
onCreate={() => setEditing("new")}
|
||||
onDemo={activity.isSuccess && !ready ? onDemo : undefined}
|
||||
/>
|
||||
)}
|
||||
{showReadiness && !ready && (
|
||||
<div role="status" className="flex flex-wrap items-center gap-2 border-y py-3 text-sm text-muted-foreground">
|
||||
{activityReady
|
||||
? "Connect a worker to run new investigations. Saved results are still available."
|
||||
: "Recorded activity is not ready. Saved results are still available."}
|
||||
{!activityReady && (
|
||||
<a className="font-medium underline" href={uiHref("lens/?tab=traces")}>
|
||||
Check traces
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!selected && lenses.length > 0 && (
|
||||
<InvestigationList lenses={lenses} connected={connected} onSelect={selectLens} />
|
||||
)}
|
||||
{missingSelection && !lens && (
|
||||
<div role="alert" className="text-sm">
|
||||
This investigation was not found.{" "}
|
||||
<Button variant="link" onClick={() => selectLens(null)}>
|
||||
View all investigations
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{lens && (
|
||||
<div>
|
||||
<section className="min-w-0 space-y-5">
|
||||
<div className="flex flex-wrap justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{lens.settings.name}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{sourceLabels[lens.settings.source ?? "traces"]} · {scopeLabel(lens.settings)}
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" size="icon" aria-label="Investigation actions" />}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => setEditing("edit")}>
|
||||
<Settings2 />
|
||||
Edit investigation
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!ready} onClick={() => setEditing("duplicate")}>
|
||||
<Copy />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
{lens.settings.enabled ? (
|
||||
<DropdownMenuItem
|
||||
disabled={busy}
|
||||
onClick={() => update(`/lens/${lens.id}`, { ...lens.settings, enabled: false }, "put")}
|
||||
>
|
||||
<Pause />
|
||||
Pause monitoring
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem disabled={!ready || busy} onClick={() => setMonitoring(true)}>
|
||||
<Play />
|
||||
Enable monitoring
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button disabled={busy || !!active || !ready} onClick={() => update(`/lens/${lens.id}/runs`, {})}>
|
||||
<Play className="size-3" />
|
||||
Run now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InvestigationSummary lens={lens} connected={connected} />
|
||||
{active && (
|
||||
<LensProgress
|
||||
key={active.id}
|
||||
job={active}
|
||||
onCancel={
|
||||
readOnly
|
||||
? undefined
|
||||
: () => {
|
||||
void update(`/lens/${lens.id}/cancel`, {});
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{job?.error && <InvestigationFailure job={job} connected={connected} />}
|
||||
<Tabs value={tab} onValueChange={setTab} key={lens.id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="findings">Findings</TabsTrigger>
|
||||
<TabsTrigger value="checks">Criteria</TabsTrigger>
|
||||
<TabsTrigger value="runs">
|
||||
{
|
||||
{ requests: "Requests", both: "Traces & requests", traces: "Traces" }[
|
||||
batchSettings?.source ?? "traces"
|
||||
]
|
||||
}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity">History</TabsTrigger>
|
||||
</TabsList>
|
||||
{tab !== "activity" && (
|
||||
<div className="flex min-w-0 items-center gap-1 pb-1">
|
||||
<select
|
||||
aria-label="Investigation run"
|
||||
className="h-8 w-44 max-w-full truncate rounded-md border-0 bg-transparent px-2 text-xs text-muted-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"
|
||||
value={batchId}
|
||||
onChange={(e) => {
|
||||
setBatchId(e.target.value);
|
||||
setFindingId(null);
|
||||
}}
|
||||
>
|
||||
<option value="latest">Latest run</option>
|
||||
{job && selectedOutsideHistory && (
|
||||
<option value={batchId}>
|
||||
{when(job.created_at)} · {job.status}
|
||||
</option>
|
||||
)}
|
||||
{(history.data ?? lens.jobs)?.map((j) => (
|
||||
<option key={j.id} value={j.id}>
|
||||
{when(j.created_at)} · {j.status}
|
||||
</option>
|
||||
))}
|
||||
<option value="all">All accumulated findings</option>
|
||||
</select>
|
||||
{job && batchId !== "all" && (
|
||||
<Popover key={job.id}>
|
||||
<PopoverTrigger
|
||||
aria-label="Run details"
|
||||
render={<Button variant="ghost" size="icon" className="size-7 text-muted-foreground" />}
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="gap-3">
|
||||
<PopoverTitle>Run details</PopoverTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{job.coverage?.screened ?? 0} / {job.coverage?.selected ?? 0} selected runs reviewed
|
||||
<ScanDuration job={job} />
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{job.coverage?.partial ?? 0} partial traces · {job.coverage?.unassessable ?? 0} could not be
|
||||
assessed · {job.coverage?.inconclusive ?? 0} inconclusive
|
||||
</p>
|
||||
<dl className="space-y-2 text-xs">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Activity window</dt>
|
||||
<dd className="mt-1">
|
||||
{when(job.start)} to {when(job.end)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-muted-foreground">Analysis cost</dt>
|
||||
<dd>{money(job.cost ?? 0)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-muted-foreground">Status</dt>
|
||||
<dd className="capitalize">{job.status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{historical.error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Could not load this run.{" "}
|
||||
<Button variant="link" onClick={() => void historical.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
{missingSnapshot && tab !== "activity" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This older batch predates saved result snapshots. Its findings remain available under All accumulated
|
||||
findings.
|
||||
</p>
|
||||
)}
|
||||
<TabsContent value="findings" className="pt-4 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex gap-1" aria-label="Finding category">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={kind === "issue" ? "secondary" : "ghost"}
|
||||
onClick={() => setKind("issue")}
|
||||
>
|
||||
Needs attention (
|
||||
{batchFindings.filter((f) => f.kind === "issue" && f.status === "open").length ?? 0})
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={kind === "pattern" ? "secondary" : "ghost"}
|
||||
onClick={() => setKind("pattern")}
|
||||
>
|
||||
Patterns ({batchFindings.filter((f) => f.kind === "pattern" && f.status === "open").length ?? 0})
|
||||
</Button>
|
||||
</div>
|
||||
<select
|
||||
aria-label="Finding status"
|
||||
className="rounded-md border bg-background px-2 py-1 text-xs"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="dismissed">Dismissed</option>
|
||||
<option value="all">All statuses</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{kind === "issue"
|
||||
? "Problems worth investigating, highest priority first."
|
||||
: "Useful behavior and trends. These do not necessarily need a fix."}
|
||||
</p>
|
||||
<div className="divide-y border-y">
|
||||
{visibleFindings.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => {
|
||||
setFindingId(f.id);
|
||||
setReason(f.reason ?? "");
|
||||
}}
|
||||
className="flex w-full gap-3 py-4 text-left hover:bg-muted/30 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
<span
|
||||
className={`mt-1 size-2 shrink-0 rounded-full ${priorityColors[f.priority ?? "medium"]}`}
|
||||
aria-label={`${f.priority} priority`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{f.title}</p>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{f.description}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{f.occurrences?.length ?? 0} linked {f.occurrences?.length === 1 ? "run" : "runs"} ·{" "}
|
||||
{f.kind === "issue" ? `${f.priority} priority` : "Pattern"}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
{visibleFindings.length === 0 && (
|
||||
<div className="px-6 py-14 text-center">
|
||||
<CheckCircle2 className="mx-auto mb-3 size-5 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">
|
||||
{emptyFindingTitle(!!active, !!lens.last_scan_at, job?.status)}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{active
|
||||
? "Lens is reviewing the selected activity."
|
||||
: "Findings reflect the runs analyzed, not a guarantee about all activity."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="checks" className="pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Used for this run</p>
|
||||
{!readOnly && (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing("edit")}>
|
||||
Edit criteria
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{batchSettings?.context && (
|
||||
<div className="space-y-2 border-b pb-5">
|
||||
<h3 className="text-base font-semibold">What should the agent be doing?</h3>
|
||||
<p className="text-xs text-muted-foreground">The purpose and expected outcome of the agent.</p>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm">{batchSettings.context}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2">
|
||||
<h3 className="text-base font-semibold">Watch for</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Specific problems or patterns to investigate.</p>
|
||||
</div>
|
||||
{batchSettings?.checks.map((c, index) => (
|
||||
<div key={c.id} className="flex items-start gap-3 border-b py-4">
|
||||
<span className="mt-0.5 text-xs tabular-nums text-muted-foreground">{index + 1}.</span>
|
||||
<CheckSummary check={c} />
|
||||
{!c.enabled && <span className="text-xs text-muted-foreground">Disabled</span>}
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Changes apply to future scans. Rechecking history uses your analysis budget.
|
||||
</p>
|
||||
</TabsContent>
|
||||
<TabsContent value="runs" className="pt-4 space-y-4">
|
||||
<LensRuns
|
||||
key={job?.id ?? batchId}
|
||||
job={job}
|
||||
onOpen={(id) => {
|
||||
setRequestOffset(0);
|
||||
setEvidence({ id, span: "" });
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="activity" className="pt-4 space-y-4">
|
||||
{history.error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Could not load run history.{" "}
|
||||
<Button variant="link" size="sm" onClick={() => void history.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
<div className="divide-y border-y">
|
||||
{(history.data ?? lens.jobs)?.map((j) => (
|
||||
<button
|
||||
key={j.id}
|
||||
onClick={() => openBatch(j.id)}
|
||||
className="flex w-full items-center gap-4 py-4 text-left hover:bg-muted/30 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{when(j.created_at)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{j.coverage?.screened ?? 0} runs reviewed
|
||||
{j.findings != null && <> · {j.findings.length} findings</>}
|
||||
<ScanDuration job={j} />
|
||||
</p>
|
||||
{j.error && <p className="mt-2 line-clamp-2 text-xs text-destructive">{j.error}</p>}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p className={`capitalize ${j.status === "failed" ? "text-destructive" : ""}`}>{j.status}</p>
|
||||
<p className="mt-1">{money(j.cost ?? 0)}</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(historyOffset > 0 || (history.data?.length ?? 0) >= 50) && (
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!historyOffset}
|
||||
onClick={() => setHistoryOffset(Math.max(0, historyOffset - 50))}
|
||||
>
|
||||
Newer runs
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={(history.data?.length ?? 0) < 50}
|
||||
onClick={() => setHistoryOffset(historyOffset + 50)}
|
||||
>
|
||||
Older runs
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{editing && (
|
||||
<LensSetup
|
||||
ready={ready}
|
||||
mode={editing}
|
||||
initial={setupSettings()}
|
||||
defaultModel={defaultModel}
|
||||
defaultSource={!tracesReady && requestsReady ? "requests" : "traces"}
|
||||
models={models.data?.data.map((m) => m.id) ?? []}
|
||||
modelDetails={modelDetails.data?.data ?? []}
|
||||
modelsLoading={models.isLoading}
|
||||
modelsError={models.error?.message}
|
||||
accessToken={accessToken}
|
||||
onClose={() => setEditing(null)}
|
||||
onSave={save}
|
||||
/>
|
||||
)}
|
||||
{workerSetup && (
|
||||
<WorkerSetup
|
||||
accessToken={accessToken}
|
||||
workers={query.data?.workers ?? []}
|
||||
onClose={() => setWorkerSetup(false)}
|
||||
onChanged={refresh}
|
||||
onReady={
|
||||
ready && showEmpty
|
||||
? () => {
|
||||
setWorkerSetup(false);
|
||||
setEditing("new");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{monitoring && lens && (
|
||||
<MonitoringSetup
|
||||
settings={lens.settings}
|
||||
ready={ready}
|
||||
onClose={() => setMonitoring(false)}
|
||||
onSave={async (settings) => {
|
||||
await apiClient.put(`/lens/${lens.id}`, { accessToken, body: settings });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<LensFinding
|
||||
finding={finding}
|
||||
sampledRuns={sampledRuns}
|
||||
readOnly={readOnly}
|
||||
reason={reason}
|
||||
busy={busy}
|
||||
onClose={() => setFindingId(null)}
|
||||
onReason={setReason}
|
||||
changeFinding={changeFinding}
|
||||
onEvidence={(value) => {
|
||||
setRequestOffset(0);
|
||||
setEvidence(value);
|
||||
}}
|
||||
/>
|
||||
{lens && target?.source === "traces" && (
|
||||
<TracePanel
|
||||
open={!!evidence}
|
||||
traceId={target.id}
|
||||
traceRef={target.traceRef}
|
||||
initialSpanId={evidence?.span}
|
||||
accessToken={accessToken}
|
||||
onClose={() => setEvidence(null)}
|
||||
/>
|
||||
)}
|
||||
<Sheet
|
||||
open={target?.source === "requests"}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEvidence(null);
|
||||
}}
|
||||
>
|
||||
<SheetContent className="overflow-y-auto data-[side=right]:sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Request evidence</SheetTitle>
|
||||
<SheetDescription>Original logged input and output</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="p-4 space-y-3">
|
||||
{requestEvidence.isLoading && <p role="status">Loading request…</p>}
|
||||
{requestEvidence.error && <p role="alert">{requestEvidence.error.message}</p>}
|
||||
{requestEvidence.data?.parts.map((p) => (
|
||||
<pre className="whitespace-pre-wrap break-words text-xs" key={p.span_id}>
|
||||
{p.content}
|
||||
</pre>
|
||||
))}
|
||||
{requestEvidence.data?.parts.length === 0 && <p>Request was not found or is past retention</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{requestOffset > 0 && (
|
||||
<Button variant="outline" onClick={() => setRequestOffset(Math.max(0, requestOffset - 8000))}>
|
||||
Previous section
|
||||
</Button>
|
||||
)}
|
||||
{requestEvidence.data?.parts.some((p) => p.truncated) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRequestOffset(requestOffset === 0 ? 1 : requestOffset + 8000)}
|
||||
>
|
||||
Next section
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckSummary({ check }: { check: Settings["checks"][number] }) {
|
||||
const watch = watches.find((item) => item.id === check.id);
|
||||
if (!watch) return <p className="flex-1 text-sm leading-6">{check.instruction}</p>;
|
||||
return (
|
||||
<p className="grid flex-1 gap-0.5">
|
||||
<span className="text-sm font-medium">{watch.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{watch.summary}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { serverRootPath } from "@/lib/serverRootPath";
|
||||
import { apiClient, proxyBaseUrl } from "@/components/networking";
|
||||
import { CheckCircle2, Copy, Loader2 } from "lucide-react";
|
||||
import { workerConnected } from "./lensData";
|
||||
import { AnalysisKeyDetails } from "./AnalysisKeyDetails";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { AnalysisKey, AnalysisAccessFields, createAnalysisKey, type AnalysisAccess } from "./AnalysisKey";
|
||||
import type { LensList, WorkerCreated } from "./lensData";
|
||||
|
||||
export const LENS_WORKER_IMAGE =
|
||||
"ghcr.io/berriai/litellm-lens-worker@sha256:44f0597c7583dcfef999ece9a8bc02cfeb9f0f5167a1221cee3bd10b1b79271b";
|
||||
|
||||
function initialProxyAddress(): string {
|
||||
const url = new URL(proxyBaseUrl || serverRootPath, window.location.origin);
|
||||
if (["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) url.hostname = "host.docker.internal";
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export function workerSetupCommand(address: string, token: string): string {
|
||||
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
return [
|
||||
"docker run -d --restart unless-stopped --read-only --cap-drop ALL",
|
||||
" --tmpfs /tmp:rw,noexec,nosuid,size=1g",
|
||||
" --security-opt no-new-privileges --platform linux/amd64 --add-host host.docker.internal:host-gateway",
|
||||
` -e ${quote("LITELLM_URL=" + address)}`,
|
||||
` -e ${quote("LENS_WORKER_TOKEN=" + token)}`,
|
||||
` ${LENS_WORKER_IMAGE}`,
|
||||
].join(" \\\n");
|
||||
}
|
||||
|
||||
function workerStatus(worker: LensList["workers"][number], now: number): string {
|
||||
if (!worker.analysis_key_id) return "Billing key required";
|
||||
return workerConnected(worker, now) ? "Connected" : "Not connected";
|
||||
}
|
||||
|
||||
export function WorkerSetup({
|
||||
accessToken,
|
||||
workers,
|
||||
onClose,
|
||||
onChanged,
|
||||
onReady,
|
||||
}: {
|
||||
accessToken: string;
|
||||
workers: LensList["workers"];
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
onReady?: () => void;
|
||||
}) {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 2000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const [access, setAccess] = useState<AnalysisAccess>({ model: null, budget: "100" });
|
||||
const [useExisting, setUseExisting] = useState(false);
|
||||
const [analysisKey, setAnalysisKey] = useState<string | null>(null);
|
||||
const [editingWorker, setEditingWorker] = useState<string | null>(null);
|
||||
const [address, setAddress] = useState(initialProxyAddress);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [created, setCreated] = useState<WorkerCreated | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const connected = created
|
||||
? workers.some((w) => w.id === created.worker.id && workerConnected(w, now))
|
||||
: workers.some((w) => workerConnected(w, now));
|
||||
const formVisible = !workers.some((w) => !w.revoked) || !!editingWorker;
|
||||
const createdTitle = connected ? "Worker connected" : "Run the worker";
|
||||
const formTitle = editingWorker ? "Analysis access" : "Connect a worker";
|
||||
const validAccess = useExisting
|
||||
? !!analysisKey
|
||||
: !!access.model && Number.isFinite(Number(access.budget)) && Number(access.budget) > 0;
|
||||
const baseTitle = formVisible ? formTitle : "Analysis worker";
|
||||
const dialogTitle = created ? createdTitle : baseTitle;
|
||||
const actionLabel = editingWorker ? "Save analysis access" : "Get install command";
|
||||
const cancelForm = () => {
|
||||
if (!workers.some((w) => !w.revoked)) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setEditingWorker(null);
|
||||
setAnalysisKey(null);
|
||||
};
|
||||
const editBilling = (worker: LensList["workers"][number]) => {
|
||||
setUseExisting(true);
|
||||
setCreated(null);
|
||||
setEditingWorker(worker.id);
|
||||
setAnalysisKey(worker.analysis_key_id ?? null);
|
||||
};
|
||||
const createWorker = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
let newKey: string | null = null;
|
||||
try {
|
||||
const parsedAddress = new URL(address);
|
||||
if (!["http:", "https:"].includes(parsedAddress.protocol) || parsedAddress.username || parsedAddress.password)
|
||||
throw new Error("Enter an HTTP or HTTPS proxy URL without credentials");
|
||||
const keyId = useExisting ? analysisKey : await createAnalysisKey(accessToken, access);
|
||||
if (!useExisting) newKey = keyId;
|
||||
if (editingWorker) {
|
||||
await apiClient.put(`/lens/workers/${editingWorker}/billing-key`, {
|
||||
accessToken,
|
||||
body: { analysis_key_id: keyId },
|
||||
});
|
||||
setEditingWorker(null);
|
||||
setAnalysisKey(null);
|
||||
onChanged();
|
||||
return;
|
||||
}
|
||||
setCreated(
|
||||
await apiClient.post<WorkerCreated>("/lens/workers/register", {
|
||||
accessToken,
|
||||
body: { name: "Lens worker", analysis_key_id: keyId },
|
||||
}),
|
||||
);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Could not create credential";
|
||||
setError(message);
|
||||
if (newKey) {
|
||||
try {
|
||||
const current = await apiClient.get<LensList>("/lens", { accessToken });
|
||||
if (!current.workers.some((worker) => !worker.revoked && worker.analysis_key_id === newKey)) {
|
||||
await apiClient.post("/key/delete", { accessToken, body: { keys: [newKey] } });
|
||||
}
|
||||
onChanged();
|
||||
} catch {
|
||||
setError(
|
||||
`${message}. Could not confirm cleanup. Check the Lens analysis key in Virtual Keys before retrying.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const setupDescription = created
|
||||
? "Run this command on a server with Docker."
|
||||
: "Deploy the worker on your server to run investigations.";
|
||||
const awaitingConnection = !editingWorker && !connected;
|
||||
const describeSetup = awaitingConnection && (formVisible || !!created);
|
||||
const completed = !!created && connected;
|
||||
const installSize = connected ? "sm:max-w-sm p-8" : "sm:max-w-lg";
|
||||
const modalSize = created ? installSize : "sm:max-w-xl";
|
||||
const description = describeSetup ? setupDescription : "Worker status and model access";
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !busy) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className={`max-h-[90dvh] overflow-y-auto ${modalSize}`}>
|
||||
<DialogHeader className={completed ? "items-center gap-3 text-center sm:text-center" : undefined}>
|
||||
{completed && (
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400">
|
||||
<CheckCircle2 className="size-6" />
|
||||
</div>
|
||||
)}
|
||||
<DialogTitle className="text-xl leading-7">{dialogTitle}</DialogTitle>
|
||||
<DialogDescription className={describeSetup || completed ? undefined : "sr-only"}>
|
||||
{completed ? "Ready to run investigations." : description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{formVisible && !created && (
|
||||
<div className="min-w-0 space-y-5">
|
||||
{useExisting ? (
|
||||
<AnalysisKey accessToken={accessToken} value={analysisKey} onChange={setAnalysisKey} />
|
||||
) : (
|
||||
<AnalysisAccessFields
|
||||
accessToken={accessToken}
|
||||
value={access}
|
||||
onChange={(next) => {
|
||||
setAccess(next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<details className="text-sm" open={editingWorker ? true : undefined}>
|
||||
<summary className="cursor-pointer font-medium">Advanced options</summary>
|
||||
<div className="mt-4 space-y-5">
|
||||
<label className="flex items-center justify-between gap-4">
|
||||
Use an existing virtual key
|
||||
<Switch checked={useExisting} onCheckedChange={setUseExisting} />
|
||||
</label>
|
||||
{!editingWorker && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="worker-proxy-address" className="block text-sm font-medium">
|
||||
LiteLLM proxy URL
|
||||
</label>
|
||||
<Input id="worker-proxy-address" value={address} onChange={(e) => setAddress(e.target.value)} />
|
||||
<p className="text-xs text-muted-foreground">Your server must be able to reach this address.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
{created ? (
|
||||
<div className="min-w-0 space-y-5">
|
||||
{!connected && (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full gap-2"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(workerSetupCommand(address, created.token));
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setError("Clipboard access failed. Allow clipboard access and try again.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? <CheckCircle2 className="size-4" /> : <Copy className="size-4" />}
|
||||
{copied ? "Copied" : "Copy Docker command"}
|
||||
</Button>
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground">View command</summary>
|
||||
<p className="mt-3 text-xs text-muted-foreground">Contains a private worker token.</p>
|
||||
<pre
|
||||
aria-label="Docker command preview"
|
||||
className="mt-3 max-h-48 overflow-auto rounded-md bg-muted/40 p-3 text-xs leading-5"
|
||||
>
|
||||
{workerSetupCommand(address, created.token)}
|
||||
</pre>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
{!connected && (
|
||||
<div className="space-y-3 border-t pt-5">
|
||||
<div role="status" className="flex items-center gap-2.5 text-sm">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" /> Waiting for your worker to
|
||||
connect…
|
||||
</div>
|
||||
<details className="pl-6.5 text-sm text-muted-foreground">
|
||||
<summary className="cursor-pointer">Not connecting?</summary>
|
||||
<p className="mt-2 break-words leading-6">
|
||||
Check that Docker is running and can reach {address}. Inspect the container logs for connection or
|
||||
authentication errors. This page updates automatically.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
{connected && (
|
||||
<Button className="w-full" onClick={onReady ?? onClose}>
|
||||
{onReady ? "New investigation" : "Done"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{!created && formVisible && (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={cancelForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy || !address.trim() || !validAccess} onClick={createWorker}>
|
||||
{busy ? "Preparing…" : actionLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
{!created &&
|
||||
!formVisible &&
|
||||
workers
|
||||
.filter((w) => !w.revoked)
|
||||
.map((worker) => (
|
||||
<section key={worker.id} className="space-y-5 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{workers.filter((w) => !w.revoked).length > 1 && <h3 className="font-medium">{worker.name}</h3>}
|
||||
<span
|
||||
className={`flex items-center gap-2 ${workerConnected(worker, now) ? "text-emerald-700 dark:text-emerald-400" : "text-muted-foreground"}`}
|
||||
>
|
||||
{workerConnected(worker, now) && <CheckCircle2 className="size-4" />}
|
||||
{workerStatus(worker, now)}
|
||||
</span>
|
||||
</div>
|
||||
{worker.analysis_key_id && (
|
||||
<AnalysisKeyDetails accessToken={accessToken} keyId={worker.analysis_key_id} showName />
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => editBilling(worker)}>
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiClient.delete(`/lens/workers/${worker.id}`, { accessToken });
|
||||
setAnalysisKey(null);
|
||||
setUseExisting(false);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not revoke worker");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Revoke access
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,361 +0,0 @@
|
|||
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type Lens = components["schemas"]["Lens"];
|
||||
export type Settings = components["schemas"]["LensSettings"];
|
||||
export type LensList = components["schemas"]["LensList"];
|
||||
export type Finding = components["schemas"]["Finding"];
|
||||
export type Sample = components["schemas"]["Sample"];
|
||||
export type WorkerCreated = components["schemas"]["WorkerCreated"];
|
||||
|
||||
export function workerConnected(worker: LensList["workers"][number], now = Date.now()): boolean {
|
||||
return !worker.revoked && !!worker.analysis_key_id && now - Date.parse(worker.last_seen) < 120000;
|
||||
}
|
||||
|
||||
export function scopeLabel(settings: Partial<Pick<Settings, "service" | "agent_name" | "filters">>): string {
|
||||
return (
|
||||
[settings.agent_name, settings.service, ...(settings.filters ?? []).map((f) => `${f.key}: ${f.value}`)]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "All activity"
|
||||
);
|
||||
}
|
||||
|
||||
export interface Watch {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
instruction: string;
|
||||
defaultOn: boolean;
|
||||
}
|
||||
|
||||
export const watches: readonly Watch[] = [
|
||||
{
|
||||
id: "watch_unsolved",
|
||||
name: "unsolved",
|
||||
summary: "didn't finish what was asked",
|
||||
instruction:
|
||||
"Find runs where the agent failed to solve what the user asked for: wrong or partial answers, giving up, or stopping mid-task.",
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
id: "watch_blocked",
|
||||
name: "blocked",
|
||||
summary: "missing a tool, data or skill",
|
||||
instruction:
|
||||
"Find runs where the agent could not do a step because it lacked a tool, data or capability, including when it tells the user it cannot help.",
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
id: "watch_permissions",
|
||||
name: "permissions",
|
||||
summary: "denied, unapproved or overstepped",
|
||||
instruction:
|
||||
"Find runs with permission problems: access denied, an approval or confirmation the agent skipped or mishandled, or the agent acting on resources it was not granted.",
|
||||
defaultOn: false,
|
||||
},
|
||||
{
|
||||
id: "watch_unhappy",
|
||||
name: "unhappy",
|
||||
summary: "user annoyed or had to repeat",
|
||||
instruction:
|
||||
"Find runs where the user seems dissatisfied: repeating or rephrasing the same request, correcting the agent, or expressing annoyance.",
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
id: "watch_swallowed",
|
||||
name: "swallowed",
|
||||
summary: "ignored a failed tool call",
|
||||
instruction:
|
||||
"Find runs where a tool call failed or returned an error and the agent continued as if it had succeeded, without retrying or telling the user.",
|
||||
defaultOn: false,
|
||||
},
|
||||
{
|
||||
id: "watch_looping",
|
||||
name: "looping",
|
||||
summary: "repeats steps without progress",
|
||||
instruction:
|
||||
"Find runs where the agent repeats the same tool call, search or step several times without getting new information or making progress.",
|
||||
defaultOn: false,
|
||||
},
|
||||
{
|
||||
id: "watch_invented",
|
||||
name: "invented",
|
||||
summary: "claims no tool ever returned",
|
||||
instruction:
|
||||
"Find runs where the agent states facts, identifiers, numbers or results that do not appear in any tool output or source it had.",
|
||||
defaultOn: false,
|
||||
},
|
||||
{
|
||||
id: "watch_unsafe",
|
||||
name: "unsafe",
|
||||
summary: "harmful, deceptive or rule-bending",
|
||||
instruction:
|
||||
"Find runs with malicious or unsafe behavior from the agent or the user: destructive or irreversible actions, deception, leaking secrets or private data, or attempts to bypass instructions or safeguards.",
|
||||
defaultOn: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function watchChecks(enabled: ReadonlySet<string>): Settings["checks"] {
|
||||
return watches
|
||||
.filter((watch) => enabled.has(watch.id))
|
||||
.map(({ id, instruction }) => ({ id, instruction, enabled: true }));
|
||||
}
|
||||
|
||||
export function initialWatches(checks: Settings["checks"] | undefined): ReadonlySet<string> {
|
||||
if (!checks?.length) return new Set(watches.filter((watch) => watch.defaultOn).map((watch) => watch.id));
|
||||
const ids = new Set(watches.map((watch) => watch.id));
|
||||
return new Set(checks.filter((check) => ids.has(check.id) && check.enabled).map((check) => check.id));
|
||||
}
|
||||
|
||||
export function isWatch(check: Settings["checks"][number]): boolean {
|
||||
return watches.some((watch) => watch.id === check.id);
|
||||
}
|
||||
|
||||
export function normalizeFilters(filters: NonNullable<Settings["filters"]>): Settings["filters"] {
|
||||
return filters.map((f) => {
|
||||
if (!f.key.trim() || !f.value.trim()) throw new Error("Choose a key and value for every condition, or remove it");
|
||||
return { key: f.key.trim(), value: f.value.trim() };
|
||||
});
|
||||
}
|
||||
|
||||
export { formatActivityTimestamp as runTime } from "@/utils/activityTimestamp";
|
||||
|
||||
export function sortedFindings(findings: Finding[]): Finding[] {
|
||||
const rank = { high: 0, medium: 1, low: 2 };
|
||||
return [...findings].sort(
|
||||
(a, b) =>
|
||||
rank[a.priority ?? "medium"] - rank[b.priority ?? "medium"] || Date.parse(b.last_seen) - Date.parse(a.last_seen),
|
||||
);
|
||||
}
|
||||
|
||||
export function lensStatus(lens: Lens, connected: boolean): string {
|
||||
const active = lens.jobs?.find((job) => ["queued", "running"].includes(job.status ?? ""));
|
||||
if (active) return connected ? active.stage ?? "Queued" : "Waiting for analyzer";
|
||||
const spent = lens.budget_month === new Date().toISOString().slice(0, 7) ? lens.spent ?? 0 : 0;
|
||||
if (spent >= (lens.settings.monthly_budget ?? 100)) return "Budget reached";
|
||||
const latest = lens.jobs?.[0];
|
||||
if (latest?.status === "failed") return "Failed";
|
||||
if (latest?.status === "cancelled") return "Cancelled";
|
||||
if (latest?.status === "completed") return "Completed";
|
||||
return "Ready";
|
||||
}
|
||||
|
||||
export function evidenceTarget(id: string): { source: string; team: string; id: string; traceRef?: string } | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(atob(id.replace(/-/g, "+").replace(/_/g, "/")));
|
||||
if (!Array.isArray(parsed) || ![3, 4].includes(parsed.length) || !parsed.every((item) => typeof item === "string"))
|
||||
return null;
|
||||
return { source: parsed[0], team: parsed[1], id: parsed[2], ...(parsed[3] ? { traceRef: parsed[3] } : {}) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Job = components["schemas"]["Job"];
|
||||
|
||||
export function analysisProgress(job: Job) {
|
||||
const {
|
||||
screened = 0,
|
||||
selected = 0,
|
||||
grouped_batches = 0,
|
||||
grouping_batches = 0,
|
||||
investigated = 0,
|
||||
candidates = 0,
|
||||
} = job.coverage ?? {};
|
||||
if (job.status === "queued") {
|
||||
return {
|
||||
step: -1,
|
||||
title: "Queued for your worker",
|
||||
done: 0,
|
||||
total: 0,
|
||||
detail: "The worker picks up queued investigations automatically.",
|
||||
};
|
||||
}
|
||||
if (job.stage === "Grouping observations") {
|
||||
return {
|
||||
step: 1,
|
||||
title: "Finding patterns",
|
||||
done: grouped_batches,
|
||||
total: grouping_batches,
|
||||
detail: grouping_batches
|
||||
? `${grouped_batches} of ${grouping_batches} observation batches compared`
|
||||
: `Comparing observations across ${screened} reviewed runs`,
|
||||
};
|
||||
}
|
||||
if (job.stage === "Checking original evidence") {
|
||||
return {
|
||||
step: 2,
|
||||
title: "Checking evidence",
|
||||
done: investigated,
|
||||
total: candidates,
|
||||
detail: candidates
|
||||
? `${investigated} of ${candidates} patterns checked against the original activity`
|
||||
: `${investigated} patterns checked against the original activity`,
|
||||
};
|
||||
}
|
||||
if (!selected) {
|
||||
return {
|
||||
step: -1,
|
||||
title: "Preparing activity",
|
||||
done: 0,
|
||||
total: 0,
|
||||
detail: "Loading the runs selected for this investigation.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
step: 0,
|
||||
title: "Reviewing activity",
|
||||
done: screened,
|
||||
total: selected,
|
||||
detail: `${screened} of ${selected} selected runs reviewed`,
|
||||
};
|
||||
}
|
||||
|
||||
export function analysisStages(job: Job): { done: number; total: number }[] {
|
||||
const {
|
||||
screened = 0,
|
||||
selected = 0,
|
||||
grouped_batches = 0,
|
||||
grouping_batches = 0,
|
||||
investigated = 0,
|
||||
candidates = 0,
|
||||
} = job.coverage ?? {};
|
||||
return [
|
||||
{ done: screened, total: selected },
|
||||
{ done: grouped_batches, total: grouping_batches },
|
||||
{ done: investigated, total: candidates },
|
||||
];
|
||||
}
|
||||
|
||||
export interface ProgressSample {
|
||||
at: number;
|
||||
step: number;
|
||||
done: number;
|
||||
fraction: number;
|
||||
}
|
||||
|
||||
export const stageWeights = [0.6, 0.2, 0.2];
|
||||
|
||||
export function analysisFraction({
|
||||
step,
|
||||
done,
|
||||
total,
|
||||
}: Pick<ReturnType<typeof analysisProgress>, "step" | "done" | "total">): number {
|
||||
if (step < 0) return 0;
|
||||
const before = stageWeights.slice(0, step).reduce((sum, weight) => sum + weight, 0);
|
||||
return before + stageWeights[step] * (total ? Math.min(1, done / total) : 0);
|
||||
}
|
||||
|
||||
function windowStart(samples: readonly ProgressSample[], now: number): ProgressSample | undefined {
|
||||
return samples.findLast((sample) => now - sample.at >= 60000) ?? samples[0];
|
||||
}
|
||||
|
||||
export function analysisPace(samples: readonly ProgressSample[], now: number) {
|
||||
const latest = samples.at(-1);
|
||||
if (!latest) return { perMinute: null, secondsLeft: null };
|
||||
const first = windowStart(
|
||||
samples.filter((sample) => sample.step === latest.step),
|
||||
now,
|
||||
);
|
||||
const anchor = windowStart(samples, now);
|
||||
if (!first || !anchor) return { perMinute: null, secondsLeft: null };
|
||||
const stepMinutes = (now - first.at) / 60000;
|
||||
const perMinute = stepMinutes >= 1 / 6 ? (latest.done - first.done) / stepMinutes : null;
|
||||
const spanSeconds = (now - anchor.at) / 1000;
|
||||
const gained = latest.fraction - anchor.fraction;
|
||||
const secondsLeft = spanSeconds >= 10 && gained > 0 ? ((1 - latest.fraction) * spanSeconds) / gained : null;
|
||||
return { perMinute, secondsLeft };
|
||||
}
|
||||
|
||||
export function stageDurations(samples: readonly ProgressSample[], createdAt: string, now: number): (number | null)[] {
|
||||
const current = samples.at(-1)?.step ?? -1;
|
||||
const starts = [0, 1, 2].map((stage) => {
|
||||
if (stage === 0) return Date.parse(createdAt);
|
||||
const entered = samples.findIndex(
|
||||
(sample, index) => index > 0 && sample.step >= stage && samples[index - 1].step < stage,
|
||||
);
|
||||
return entered < 0 ? null : samples[entered].at;
|
||||
});
|
||||
return starts.map((start, stage) => {
|
||||
if (start === null || stage > current) return null;
|
||||
const end = stage === current ? now : starts[stage + 1];
|
||||
return end === null ? null : Math.max(0, Math.floor((end - start) / 1000));
|
||||
});
|
||||
}
|
||||
|
||||
export function remainingLabel(seconds: number | null): string {
|
||||
if (seconds === null) return "estimating";
|
||||
if (seconds < 60) return "<1m";
|
||||
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
||||
return `~${Math.floor(seconds / 3600)}h ${Math.ceil((seconds % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
export function analysisElapsed(createdAt: string, now: number): string {
|
||||
return durationText(Math.max(0, Math.floor((now - Date.parse(createdAt)) / 1000)));
|
||||
}
|
||||
|
||||
export function durationText(seconds: number): string {
|
||||
if (!Number.isFinite(seconds)) return "0s";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
export interface AnalysisModelInfo {
|
||||
model_group: string;
|
||||
providers: string[];
|
||||
mode?: string | null;
|
||||
supported_openai_params?: string[] | null;
|
||||
}
|
||||
|
||||
export function analysisModelOptions(models: string[], details: AnalysisModelInfo[]) {
|
||||
return [...new Set(models)].sort().map((name) => {
|
||||
const info = details.find((item) => item.model_group === name);
|
||||
const capability = () => {
|
||||
if (info?.mode && info.mode !== "chat") return `${info.mode}: not suitable for Lens`;
|
||||
if (info?.supported_openai_params?.includes("response_format")) return "JSON output supported";
|
||||
return "JSON output support unverified";
|
||||
};
|
||||
return {
|
||||
value: name,
|
||||
label: name,
|
||||
sublabel: [info?.providers.join(", "), capability()].filter(Boolean).join(" · "),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function durationLabel(value: number, base: "minutes" | "hours" = "minutes"): string {
|
||||
const minutes = base === "hours" ? value * 60 : value;
|
||||
if (minutes >= 1440) {
|
||||
const days = Number((minutes / 1440).toFixed(2));
|
||||
return `${days} ${days === 1 ? "day" : "days"}`;
|
||||
}
|
||||
if (minutes % 60 === 0) return `${minutes / 60} ${minutes === 60 ? "hour" : "hours"}`;
|
||||
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
||||
}
|
||||
|
||||
export function nextCheckStatus(lens: Lens, now: number): string | null {
|
||||
if (!lens.settings.enabled) return null;
|
||||
const active = lens.jobs.find((job) => job.status === "queued" || job.status === "running");
|
||||
if (active?.status === "running") return "Next check scheduled after this scan finishes";
|
||||
if (active?.status === "queued") return "Waiting for an analyzer";
|
||||
const next = new Date(lens.next_run_at);
|
||||
const remaining = next.getTime() - now;
|
||||
if (remaining <= 0) return "Due now · waiting for an analyzer";
|
||||
const minutes = Math.ceil(remaining / 60000);
|
||||
const relative = minutes === 1 ? "in less than a minute" : `in ${minutes} minutes`;
|
||||
const time = formatActivityTimestamp(lens.next_run_at);
|
||||
return `Next check ${time} · ${relative}`;
|
||||
}
|
||||
|
||||
export type IssueBrief = NonNullable<Finding["brief"]>;
|
||||
|
||||
export function briefMarkdown(title: string, brief: IssueBrief): string {
|
||||
return [
|
||||
`# ${title}`,
|
||||
`## Problem\n${brief.problem}`,
|
||||
`## User goal\n${brief.user_goal}`,
|
||||
`## What happened\n${brief.what_happened}`,
|
||||
`## Test cases\n${brief.test_cases.map((t, i) => `${i + 1}. **Input:** ${t.input} \n **Expect:** ${t.expected}`).join("\n")}`,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
|
@ -1,69 +1,38 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import LensPage from "./page";
|
||||
|
||||
const { auth } = vi.hoisted(() => ({ auth: vi.fn() }));
|
||||
const { auth, workspace } = vi.hoisted(() => ({ auth: vi.fn(), workspace: vi.fn() }));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: auth }));
|
||||
vi.mock("@/components/view_logs/TraceView/AgentTracesPage", () => ({
|
||||
default: ({ isActive }: { isActive: boolean }) => <div>Trace polling {isActive ? "active" : "paused"}</div>,
|
||||
}));
|
||||
vi.mock("./_components/LensView", () => ({
|
||||
LensView: ({ readOnly }: { readOnly: boolean }) => (
|
||||
<div>{readOnly ? "Read-only investigations" : "Manage investigations"}</div>
|
||||
),
|
||||
vi.mock("@/components/lens/LensWorkspace", () => ({
|
||||
LensWorkspace: (props: { accessToken: string; userRole: string; readOnly: boolean }) => {
|
||||
workspace(props);
|
||||
return <div>Lens workspace</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Lens navigation", () => {
|
||||
beforeEach(() => auth.mockReturnValue({ accessToken: "test-token", userRole: "Admin", isViewOnly: false }));
|
||||
describe("Lens route", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("opens traces by default and pauses polling while viewing investigations", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<LensPage />, { onUrlUpdate });
|
||||
expect(screen.getByRole("tab", { name: "Traces" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText("Trace polling active")).toBeVisible();
|
||||
await user.click(screen.getByRole("tab", { name: "Investigations" }));
|
||||
expect(screen.getByText("Manage investigations")).toBeVisible();
|
||||
expect(screen.getByText("Trace polling paused")).not.toBeVisible();
|
||||
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("tab")).toBe("investigations");
|
||||
await user.click(screen.getByRole("tab", { name: "Traces" }));
|
||||
expect(screen.getByText("Trace polling active")).toBeVisible();
|
||||
it("waits for an access token", () => {
|
||||
auth.mockReturnValue({ accessToken: null, userRole: "Admin", isViewOnly: false });
|
||||
render(<LensPage />);
|
||||
expect(screen.queryByText("Lens workspace")).not.toBeInTheDocument();
|
||||
expect(workspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens existing lens links directly in investigations", () => {
|
||||
renderWithProviders(<LensPage />, { searchParams: "?lens=saved-lens" });
|
||||
expect(screen.getByRole("tab", { name: "Investigations" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText("Manage investigations")).toBeVisible();
|
||||
});
|
||||
|
||||
it("honors an explicit traces tab even when a saved investigation is in the URL", () => {
|
||||
renderWithProviders(<LensPage />, { searchParams: "?tab=traces&lens=saved-lens" });
|
||||
expect(screen.getByRole("tab", { name: "Traces" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText("Trace polling active")).toBeVisible();
|
||||
});
|
||||
|
||||
it.each(["Internal User", "Internal Viewer", "Org Admin"])(
|
||||
"preserves trace access without granting investigations to %s",
|
||||
async (userRole) => {
|
||||
auth.mockReturnValue({ accessToken: "test-token", userRole, isViewOnly: false });
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LensPage />);
|
||||
expect(screen.getByText("Trace polling active")).toBeVisible();
|
||||
await user.click(screen.getByRole("tab", { name: "Investigations" }));
|
||||
expect(screen.getByText(/Investigations require proxy administrator access/)).toBeVisible();
|
||||
expect(screen.queryByText("Manage investigations")).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ userRole: "Admin Viewer", isViewOnly: false },
|
||||
{ userRole: "Admin", isViewOnly: true },
|
||||
])("preserves read-only investigation access for $userRole with isViewOnly=$isViewOnly", (session) => {
|
||||
{ userRole: "Admin", isViewOnly: false },
|
||||
{ userRole: "Admin Viewer", isViewOnly: true },
|
||||
{ userRole: null, isViewOnly: false },
|
||||
])("passes authorization to the workspace for $userRole", (session) => {
|
||||
auth.mockReturnValue({ accessToken: "test-token", ...session });
|
||||
renderWithProviders(<LensPage />, { searchParams: "?tab=investigations" });
|
||||
expect(screen.getByText("Read-only investigations")).toBeVisible();
|
||||
expect(screen.queryByText("Manage investigations")).not.toBeInTheDocument();
|
||||
render(<LensPage />);
|
||||
expect(screen.getByText("Lens workspace")).toBeVisible();
|
||||
expect(workspace).toHaveBeenCalledWith({
|
||||
accessToken: "test-token",
|
||||
userRole: session.userRole ?? "",
|
||||
readOnly: session.isViewOnly,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { LensWorkspace } from "./_components/LensWorkspace";
|
||||
import { LensWorkspace } from "@/components/lens/LensWorkspace";
|
||||
|
||||
export default function LensPage() {
|
||||
const { accessToken, userRole, isViewOnly } = useAuthorized();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import LensPage from "@/app/(dashboard)/lens/page";
|
||||
|
||||
const { auth } = vi.hoisted(() => ({ auth: vi.fn() }));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: auth }));
|
||||
vi.mock("@/components/view_logs/TraceView/AgentTracesPage", () => ({
|
||||
default: ({ isActive }: { isActive: boolean }) => <div>Trace polling {isActive ? "active" : "paused"}</div>,
|
||||
}));
|
||||
vi.mock("./investigations/InvestigationsView", () => ({
|
||||
InvestigationsView: ({ readOnly }: { readOnly: boolean }) => (
|
||||
<div>{readOnly ? "Read-only investigations" : "Manage investigations"}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Lens navigation", () => {
|
||||
beforeEach(() => auth.mockReturnValue({ accessToken: "test-token", userRole: "Admin", isViewOnly: false }));
|
||||
|
||||
it("opens traces by default and pauses polling while viewing investigations", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<LensPage />, { onUrlUpdate });
|
||||
expect(screen.getByRole("tab", { name: "Traces" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText("Trace polling active")).toBeVisible();
|
||||
await user.click(screen.getByRole("tab", { name: "Investigations" }));
|
||||
expect(screen.getByText("Manage investigations")).toBeVisible();
|
||||
expect(screen.getByText("Trace polling paused")).not.toBeVisible();
|
||||
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("tab")).toBe("investigations");
|
||||
await user.click(screen.getByRole("tab", { name: "Traces" }));
|
||||
expect(screen.getByText("Trace polling active")).toBeVisible();
|
||||
});
|
||||
|
||||
it("opens existing lens links directly in investigations", () => {
|
||||
renderWithProviders(<LensPage />, { searchParams: "?lens=saved-lens" });
|
||||
expect(screen.getByRole("tab", { name: "Investigations" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText("Manage investigations")).toBeVisible();
|
||||
});
|
||||
|
||||
it("honors an explicit traces tab even when a saved investigation is in the URL", () => {
|
||||
renderWithProviders(<LensPage />, { searchParams: "?tab=traces&lens=saved-lens" });
|
||||
expect(screen.getByRole("tab", { name: "Traces" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText("Trace polling active")).toBeVisible();
|
||||
});
|
||||
|
||||
it.each(["Internal User", "Internal Viewer", "Org Admin"])(
|
||||
"preserves trace access without granting investigations to %s",
|
||||
async (userRole) => {
|
||||
auth.mockReturnValue({ accessToken: "test-token", userRole, isViewOnly: false });
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LensPage />);
|
||||
expect(screen.getByText("Trace polling active")).toBeVisible();
|
||||
await user.click(screen.getByRole("tab", { name: "Investigations" }));
|
||||
expect(screen.getByText(/Investigations require proxy administrator access/)).toBeVisible();
|
||||
expect(screen.queryByText("Manage investigations")).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ userRole: "Admin Viewer", isViewOnly: false },
|
||||
{ userRole: "Admin", isViewOnly: true },
|
||||
])("preserves read-only investigation access for $userRole with isViewOnly=$isViewOnly", (session) => {
|
||||
auth.mockReturnValue({ accessToken: "test-token", ...session });
|
||||
renderWithProviders(<LensPage />, { searchParams: "?tab=investigations" });
|
||||
expect(screen.getByText("Read-only investigations")).toBeVisible();
|
||||
expect(screen.queryByText("Manage investigations")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { chooseSelectOption, renderWithProviders, testQueryClient } from "@/../tests/test-utils";
|
||||
import { LensWorkspace } from "./LensWorkspace";
|
||||
import { createLensDemoData } from "./lensDemoData";
|
||||
import { createLensDemoData } from "./demo/createLensDemo";
|
||||
|
||||
const network = vi.fn<typeof fetch>();
|
||||
beforeEach(() => {
|
||||
|
|
@ -31,7 +31,7 @@ describe("Lens interactive demo", () => {
|
|||
expect(screen.getByText("You’re viewing demo data")).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Set up tracing" })).not.toBeInTheDocument();
|
||||
network.mockClear();
|
||||
await user.click(screen.getByRole("button", { name: "Refresh", exact: true }));
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(screen.getByText("Where is order #1042?")).toBeVisible();
|
||||
fireEvent.change(screen.getByPlaceholderText("Search input or trace ID"), { target: { value: "headphones" } });
|
||||
expect(screen.getByText("Can I return my headphones?")).toBeVisible();
|
||||
|
|
@ -40,7 +40,7 @@ describe("Lens interactive demo", () => {
|
|||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by agent" }), "support_agent");
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by status" }), "Failed");
|
||||
expect(within(screen.getByRole("table", { name: "Agent runs" })).getAllByRole("row")).toHaveLength(4);
|
||||
await user.click(screen.getByRole("tab", { name: "Investigations", exact: true }));
|
||||
await user.click(screen.getByRole("tab", { name: "Investigations" }));
|
||||
expect(await screen.findByRole("button", { name: /Support quality/ })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "New investigation" })).not.toBeInTheDocument();
|
||||
expect(network).not.toHaveBeenCalled();
|
||||
|
|
@ -77,9 +77,9 @@ describe("Lens interactive demo", () => {
|
|||
await user.click(screen.getByRole("tab", { name: "Attributes" }));
|
||||
expect(await screen.findByText("gen_ai.agent.name")).toBeVisible();
|
||||
await user.click(
|
||||
within(screen.getByRole("dialog", { name: "Original run" })).getByRole("button", { name: "Close", exact: true }),
|
||||
within(screen.getByRole("dialog", { name: "Original run" })).getByRole("button", { name: "Close" }),
|
||||
);
|
||||
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Close", exact: true }));
|
||||
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Close" }));
|
||||
await user.click(screen.getByRole("tab", { name: "History" }));
|
||||
await user.click(screen.getAllByRole("button", { name: /runs reviewed/ })[1]);
|
||||
expect(await screen.findByText(/1 linked run · high priority/)).toBeVisible();
|
||||
|
|
@ -7,11 +7,11 @@ import { parseAsString, parseAsStringLiteral, useQueryState } from "nuqs";
|
|||
import AgentTracesPage from "@/components/view_logs/TraceView/AgentTracesPage";
|
||||
import { DemoNotice } from "@/components/shared/DemoNotice";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LensDemoContext, useLensDemo } from "@/components/lens/LensDemoContext";
|
||||
import { LensPreviewTarget } from "@/components/lens/LensPreviewButton";
|
||||
import { LensDemoContext, useLensDemo } from "./LensDemoContext";
|
||||
import { LensPreviewTarget } from "./LensPreviewButton";
|
||||
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
|
||||
import { LensView } from "./LensView";
|
||||
import { createLensDemo } from "./lensDemoData";
|
||||
import { InvestigationsView } from "./investigations/InvestigationsView";
|
||||
import { createLensDemo } from "./demo/createLensDemo";
|
||||
|
||||
type Tab = "traces" | "investigations";
|
||||
type WorkspaceProps = { accessToken: string; userRole: string; readOnly: boolean };
|
||||
|
|
@ -99,7 +99,7 @@ function LensContent({
|
|||
</TabsContent>
|
||||
<TabsContent value="investigations" keepMounted={!!demo}>
|
||||
{demo || isProxyAdminTierRole(userRole) ? (
|
||||
<LensView
|
||||
<InvestigationsView
|
||||
accessToken={accessToken}
|
||||
readOnly={readOnly || !isProxyAdminRole(userRole)}
|
||||
onDemo={activeTab === "investigations" ? openDemo : undefined}
|
||||
118
ui/litellm-dashboard/src/components/lens/api/mutations.ts
Normal file
118
ui/litellm-dashboard/src/components/lens/api/mutations.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"use client";
|
||||
|
||||
import { validateWorkerAddress, analysisAccessSchema, type AnalysisAccess } from "../setup/worker/workerSchema";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { ApiClient } from "@/lib/http/client";
|
||||
import type { Lens, Settings, WorkerCreated, LensList } from "../model/types";
|
||||
|
||||
import { useLensApi } from "./useLensApi";
|
||||
|
||||
export function useLensUpdate(accessToken: string) {
|
||||
const apiClient = useLensApi();
|
||||
return useMutation({
|
||||
retry: false,
|
||||
mutationFn: ({ path, body, method = "post" }: { path: string; body: unknown; method?: "post" | "put" | "patch" }) =>
|
||||
apiClient[method]<unknown>(path, { accessToken, body }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveLens(accessToken: string) {
|
||||
const apiClient = useLensApi();
|
||||
return useMutation({
|
||||
retry: false,
|
||||
mutationFn: ({ id, settings }: { id?: string; settings: Settings }) =>
|
||||
apiClient.request<Lens>(id ? "PUT" : "POST", id ? `/lens/${id}` : "/lens", { accessToken, body: settings }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeWorker(accessToken: string) {
|
||||
const apiClient = useLensApi();
|
||||
return useMutation({
|
||||
retry: false,
|
||||
mutationFn: (id: string) => apiClient.delete<unknown>(`/lens/workers/${id}`, { accessToken }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAnalysisKey(
|
||||
apiClient: ApiClient,
|
||||
accessToken: string,
|
||||
access: AnalysisAccess,
|
||||
): Promise<string> {
|
||||
const parsed = analysisAccessSchema.safeParse(access);
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
const result = await apiClient.post<{ token_id?: string }>("/key/generate", {
|
||||
accessToken,
|
||||
body: {
|
||||
key_alias: "Lens analysis",
|
||||
models: [parsed.data.model],
|
||||
max_budget: parsed.data.budget,
|
||||
budget_duration: "1mo",
|
||||
metadata: { purpose: "lens" },
|
||||
},
|
||||
});
|
||||
if (!result.token_id) throw new Error("The proxy did not return the new key's ID");
|
||||
return result.token_id;
|
||||
}
|
||||
|
||||
export function usePrepareWorker(
|
||||
accessToken: string,
|
||||
{ onChanged, onPrepared }: { onChanged: () => void; onPrepared: (created: WorkerCreated | null) => void },
|
||||
) {
|
||||
const apiClient = useLensApi();
|
||||
return useMutation({
|
||||
retry: false,
|
||||
mutationFn: async ({
|
||||
address,
|
||||
useExisting,
|
||||
analysisKey,
|
||||
access,
|
||||
editingWorker,
|
||||
}: {
|
||||
address: string;
|
||||
useExisting: boolean;
|
||||
analysisKey: string | null;
|
||||
access: AnalysisAccess;
|
||||
editingWorker: string | null;
|
||||
}) => {
|
||||
let newKey: string | null = null;
|
||||
try {
|
||||
validateWorkerAddress(address);
|
||||
const keyId = useExisting ? analysisKey : await createAnalysisKey(apiClient, accessToken, access);
|
||||
if (!useExisting) newKey = keyId;
|
||||
if (editingWorker) {
|
||||
await apiClient.put<unknown>(`/lens/workers/${editingWorker}/billing-key`, {
|
||||
accessToken,
|
||||
body: { analysis_key_id: keyId },
|
||||
});
|
||||
onPrepared(null);
|
||||
onChanged();
|
||||
return null;
|
||||
}
|
||||
const created = await apiClient.post<WorkerCreated>("/lens/workers/register", {
|
||||
accessToken,
|
||||
body: { name: "Lens worker", analysis_key_id: keyId },
|
||||
});
|
||||
onPrepared(created);
|
||||
onChanged();
|
||||
return created;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Could not create credential";
|
||||
if (newKey) {
|
||||
try {
|
||||
const current = await apiClient.get<LensList>("/lens", { accessToken });
|
||||
if (!current.workers.some((worker) => !worker.revoked && worker.analysis_key_id === newKey)) {
|
||||
await apiClient.post<unknown>("/key/delete", { accessToken, body: { keys: [newKey] } });
|
||||
}
|
||||
onChanged();
|
||||
} catch {
|
||||
throw new Error(
|
||||
`${message}. Could not confirm cleanup. Check the Lens analysis key in Virtual Keys before retrying.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
239
ui/litellm-dashboard/src/components/lens/api/queries.ts
Normal file
239
ui/litellm-dashboard/src/components/lens/api/queries.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { infiniteQueryOptions, queryOptions, type Query } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import type { ApiClient } from "@/lib/http/client";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import { type LensList, type Job, type Sample, type Settings, type ActivitySelection } from "../model/types";
|
||||
import type { AnalysisModelInfo } from "../setup/fields/analysisModels";
|
||||
|
||||
export const lensKeys = {
|
||||
all: ["lens"] as const,
|
||||
lists: () => [...lensKeys.all, "list"] as const,
|
||||
list: (accessToken: string) => [...lensKeys.lists(), { accessToken }] as const,
|
||||
histories: () => [...lensKeys.all, "history"] as const,
|
||||
history: (accessToken: string, lensId: string | undefined, offset: number) =>
|
||||
[...lensKeys.histories(), { accessToken, lensId, offset }] as const,
|
||||
runs: () => [...lensKeys.all, "run"] as const,
|
||||
run: (accessToken: string, lensId: string | undefined, batchId: string) =>
|
||||
[...lensKeys.runs(), { accessToken, lensId, batchId }] as const,
|
||||
evidence: (accessToken: string, lensId: string | undefined, evidenceId: string | undefined, offset: number) =>
|
||||
[...lensKeys.all, "evidence", { accessToken, lensId, evidenceId, offset }] as const,
|
||||
models: (accessToken: string) => [...lensKeys.all, "models", { accessToken }] as const,
|
||||
modelDetails: (accessToken: string) => [...lensKeys.all, "model-details", { accessToken }] as const,
|
||||
activity: (accessToken: string) => [...lensKeys.all, "activity-available", { accessToken }] as const,
|
||||
discovery: (accessToken: string, source: Settings["source"], hours: number | undefined, asOf: string) =>
|
||||
[...lensKeys.all, "discovery", { accessToken, source, hours, asOf }] as const,
|
||||
preview: (accessToken: string, scope: ActivitySelection, offset: number, asOf: string) =>
|
||||
[...lensKeys.all, "preview", { accessToken, scope, offset, asOf }] as const,
|
||||
agents: (accessToken: string, asOf: string) => [...lensKeys.all, "agents", { accessToken, asOf }] as const,
|
||||
analysisKeys: (accessToken: string, query: string) =>
|
||||
[...lensKeys.all, "analysis-keys", { accessToken, query }] as const,
|
||||
analysisKeyInfo: (accessToken: string, keyId: string | undefined) =>
|
||||
[...lensKeys.all, "analysis-key-info", { accessToken, keyId }] as const,
|
||||
};
|
||||
|
||||
export const lensQueries = {
|
||||
list(apiClient: ApiClient, accessToken: string, demo: boolean, workerSetup: boolean) {
|
||||
const options = {
|
||||
queryKey: lensKeys.list(accessToken),
|
||||
queryFn: () => apiClient.get<LensList>("/lens", { accessToken }),
|
||||
refetchInterval: (current: Query<LensList>): number | false => {
|
||||
if (demo) return false;
|
||||
const running = current.state.data?.lenses.some((item) =>
|
||||
item.jobs.some((job) => ["queued", "running"].includes(job.status)),
|
||||
);
|
||||
return workerSetup || running ? 2000 : 10000;
|
||||
},
|
||||
};
|
||||
return queryOptions(options);
|
||||
},
|
||||
models(apiClient: ApiClient, accessToken: string) {
|
||||
const options = {
|
||||
queryKey: lensKeys.models(accessToken),
|
||||
queryFn: () => apiClient.get<{ data: { id: string }[] }>("/models", { accessToken }),
|
||||
};
|
||||
return queryOptions(options);
|
||||
},
|
||||
modelDetails(apiClient: ApiClient, accessToken: string) {
|
||||
const options = {
|
||||
queryKey: lensKeys.modelDetails(accessToken),
|
||||
queryFn: () => apiClient.get<{ data: AnalysisModelInfo[] }>("/model_group/info", { accessToken }),
|
||||
};
|
||||
return queryOptions(options);
|
||||
},
|
||||
activity(apiClient: ApiClient, accessToken: string, loaded: boolean, demo: boolean) {
|
||||
const options = {
|
||||
queryKey: lensKeys.activity(accessToken),
|
||||
queryFn: () => apiClient.get<{ traces: boolean; requests: boolean }>("/lens/activity/available", { accessToken }),
|
||||
enabled: loaded,
|
||||
refetchInterval: demo ? (false as const) : 5000,
|
||||
};
|
||||
return queryOptions(options);
|
||||
},
|
||||
history(
|
||||
apiClient: ApiClient,
|
||||
accessToken: string,
|
||||
{ lensId, historyOffset, demo }: { lensId: string | undefined; historyOffset: number; demo: boolean },
|
||||
) {
|
||||
const options = {
|
||||
queryKey: lensKeys.history(accessToken, lensId, historyOffset),
|
||||
enabled: !!lensId,
|
||||
queryFn: () => apiClient.get<Job[]>(`/lens/${lensId}/runs`, { accessToken, query: { offset: historyOffset } }),
|
||||
refetchInterval: demo ? (false as const) : 10000,
|
||||
};
|
||||
return queryOptions(options);
|
||||
},
|
||||
run(apiClient: ApiClient, accessToken: string, lensId: string | undefined, batchId: string) {
|
||||
const options = {
|
||||
queryKey: lensKeys.run(accessToken, lensId, batchId),
|
||||
enabled: !!lensId && !["latest", "all"].includes(batchId),
|
||||
queryFn: () => apiClient.get<Job>(`/lens/${lensId}/runs/${batchId}`, { accessToken }),
|
||||
};
|
||||
return queryOptions(options);
|
||||
},
|
||||
evidence(
|
||||
apiClient: ApiClient,
|
||||
accessToken: string,
|
||||
{
|
||||
lensId,
|
||||
evidenceId,
|
||||
requestOffset,
|
||||
source,
|
||||
}: {
|
||||
lensId: string | undefined;
|
||||
evidenceId: string | undefined;
|
||||
requestOffset: number;
|
||||
source: string | undefined;
|
||||
},
|
||||
) {
|
||||
const options = {
|
||||
queryKey: lensKeys.evidence(accessToken, lensId, evidenceId, requestOffset),
|
||||
enabled: !!lensId && source === "requests",
|
||||
queryFn: () =>
|
||||
apiClient.get<components["schemas"]["ExecutionContent"]>(
|
||||
`/lens/${lensId}/executions/${encodeURIComponent(evidenceId ?? "")}`,
|
||||
{ accessToken, query: { offset: requestOffset } },
|
||||
),
|
||||
};
|
||||
return queryOptions(options);
|
||||
},
|
||||
sample(
|
||||
apiClient: ApiClient,
|
||||
accessToken: string,
|
||||
{ selection, pageOffset, asOf }: { selection: ActivitySelection; pageOffset: number; asOf: string },
|
||||
) {
|
||||
const { lookback_hours, ...selectionSettings } = selection;
|
||||
return apiClient.post<Sample>("/lens/preview/sample", {
|
||||
accessToken,
|
||||
body: {
|
||||
offset: pageOffset,
|
||||
as_of: asOf,
|
||||
settings: {
|
||||
...selectionSettings,
|
||||
execution_ids: [],
|
||||
name: "Preview",
|
||||
model: "preview",
|
||||
checks: [{ id: "preview", instruction: "Preview recorded activity" }],
|
||||
},
|
||||
lookback_hours: lookback_hours ?? 24,
|
||||
},
|
||||
});
|
||||
},
|
||||
discovery(
|
||||
apiClient: ApiClient,
|
||||
accessToken: string,
|
||||
{ value, asOf, enabled }: { value: ActivitySelection; asOf: string; enabled: boolean },
|
||||
) {
|
||||
const options = {
|
||||
queryKey: lensKeys.discovery(accessToken, value.source, value.lookback_hours, asOf),
|
||||
queryFn: () =>
|
||||
lensQueries.sample(apiClient, accessToken, {
|
||||
selection: {
|
||||
source: value.source,
|
||||
service: "",
|
||||
filters: [],
|
||||
lookback_hours: value.lookback_hours,
|
||||
},
|
||||
pageOffset: 0,
|
||||
asOf,
|
||||
}),
|
||||
staleTime: 60000,
|
||||
enabled,
|
||||
};
|
||||
return queryOptions(options);
|
||||
},
|
||||
preview(
|
||||
apiClient: ApiClient,
|
||||
accessToken: string,
|
||||
{ scope, offset, asOf, enabled }: { scope: ActivitySelection; offset: number; asOf: string; enabled: boolean },
|
||||
) {
|
||||
const options = {
|
||||
queryKey: lensKeys.preview(accessToken, scope, offset, asOf),
|
||||
queryFn: () => lensQueries.sample(apiClient, accessToken, { selection: scope, pageOffset: offset, asOf }),
|
||||
enabled,
|
||||
staleTime: 30000,
|
||||
};
|
||||
return queryOptions(options);
|
||||
},
|
||||
agents(apiClient: ApiClient, accessToken: string, asOf: string, source: Settings["source"]) {
|
||||
const options = {
|
||||
queryKey: lensKeys.agents(accessToken, asOf),
|
||||
queryFn: () => apiClient.get<string[]>("/lens/agents", { accessToken }),
|
||||
enabled: source !== "requests",
|
||||
staleTime: 60000,
|
||||
};
|
||||
return queryOptions(options);
|
||||
},
|
||||
};
|
||||
|
||||
const keySchema = z.object({ token: z.string(), key_alias: z.string().nullable().optional() });
|
||||
const pageSchema = z.object({ keys: z.array(keySchema), total_pages: z.number() });
|
||||
export type Key = z.infer<typeof keySchema>;
|
||||
|
||||
export function analysisKeysQuery(apiClient: ApiClient, accessToken: string, query: string) {
|
||||
const options = {
|
||||
queryKey: lensKeys.analysisKeys(accessToken, query),
|
||||
initialPageParam: 1,
|
||||
queryFn: async ({ pageParam, signal }: { pageParam: number; signal: AbortSignal }) =>
|
||||
pageSchema.parse(
|
||||
await apiClient.get("/key/list", {
|
||||
accessToken,
|
||||
signal,
|
||||
query: {
|
||||
page: String(pageParam),
|
||||
size: "25",
|
||||
return_full_object: "true",
|
||||
key_alias: query || undefined,
|
||||
substring_matching: "true",
|
||||
include_team_keys: "true",
|
||||
include_created_by_keys: "true",
|
||||
status: "active",
|
||||
},
|
||||
}),
|
||||
),
|
||||
getNextPageParam: (lastPage: z.infer<typeof pageSchema>, pages: z.infer<typeof pageSchema>[]) =>
|
||||
pages.length < lastPage.total_pages ? pages.length + 1 : undefined,
|
||||
};
|
||||
return infiniteQueryOptions(options);
|
||||
}
|
||||
|
||||
const keyInfoFields = {
|
||||
key_alias: z.string().nullable().optional(),
|
||||
models: z.array(z.string()),
|
||||
max_budget: z.number().nullable(),
|
||||
budget_duration: z.string().nullable().optional(),
|
||||
rpm_limit: z.number().nullable().optional(),
|
||||
tpm_limit: z.number().nullable().optional(),
|
||||
expires: z.string().nullable().optional(),
|
||||
status: z.string().optional(),
|
||||
};
|
||||
const keyInfoSchema = z.object({ info: z.object(keyInfoFields) });
|
||||
|
||||
export function analysisKeyInfoQuery(apiClient: ApiClient, accessToken: string, keyId?: string) {
|
||||
const options = {
|
||||
queryKey: lensKeys.analysisKeyInfo(accessToken, keyId),
|
||||
enabled: !!keyId,
|
||||
queryFn: async () =>
|
||||
keyInfoSchema.parse(await apiClient.get("/key/info", { accessToken, query: { key: keyId } })).info,
|
||||
};
|
||||
return queryOptions(options);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { useLensDemo } from "../LensDemoContext";
|
||||
|
||||
export function useLensApi() {
|
||||
return useLensDemo()?.client ?? apiClient;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createLensDemo, createLensDemoData } from "./lensDemoData";
|
||||
import { createLensDemo, createLensDemoData } from "./createLensDemo";
|
||||
import type { TracePage } from "@/components/view_logs/TraceView/traceTypes";
|
||||
import { evidenceTarget } from "./lensData";
|
||||
import { evidenceTarget } from "../model/findings";
|
||||
|
||||
describe("Lens demo data", () => {
|
||||
it("links every finding to the quoted original step and assessed run", () => {
|
||||
|
|
@ -1,107 +1,10 @@
|
|||
import { createApiClient } from "@/lib/http/client";
|
||||
import type { LensDemo } from "@/components/lens/LensDemoContext";
|
||||
import type { LensDemo } from "../LensDemoContext";
|
||||
import type { Trace, Span, SpanDetail } from "@/components/view_logs/TraceView/traceTypes";
|
||||
import type { Lens, Finding, Job, Settings } from "./lensData";
|
||||
import type { Lens, Finding, Job, Settings } from "../model/types";
|
||||
import { withReleaseCases } from "./lensDemoLongTrace";
|
||||
|
||||
type Scenario = {
|
||||
agent: string;
|
||||
question: string;
|
||||
tool: string;
|
||||
result: string;
|
||||
answer: string;
|
||||
failed?: boolean;
|
||||
};
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
agent: "support_agent",
|
||||
question: "Where is order #1042?",
|
||||
tool: "lookup_order",
|
||||
result: "Order service timed out",
|
||||
answer: "I will check that for you.",
|
||||
failed: true,
|
||||
},
|
||||
{
|
||||
agent: "research_agent",
|
||||
question: "Which database should we use for analytics?",
|
||||
tool: "search_docs",
|
||||
result: "Column stores are designed for analytical queries. No comparative benchmark is available.",
|
||||
answer: "The column store is 40% faster than the row store for your workload.",
|
||||
},
|
||||
{
|
||||
agent: "support_agent",
|
||||
question: "Can I return my headphones?",
|
||||
tool: "lookup_return_policy",
|
||||
result: "Returns are accepted within 30 days. This order was delivered 10 days ago.",
|
||||
answer: "Yes. Your order is eligible for a return. I can help you start it.",
|
||||
},
|
||||
{
|
||||
agent: "release_agent",
|
||||
question: "Is the checkout release ready to ship?",
|
||||
tool: "read_test_results",
|
||||
result: "Checkout: 120 passed, 0 failed. Rollback plan approved.",
|
||||
answer: "All 120 checkout tests passed and the rollback plan is approved. The release is ready.",
|
||||
},
|
||||
{
|
||||
agent: "support_agent",
|
||||
question: "My order #1088 has not arrived",
|
||||
tool: "lookup_order",
|
||||
result: "Order service timed out",
|
||||
answer: "I will check that for you.",
|
||||
failed: true,
|
||||
},
|
||||
{
|
||||
agent: "research_agent",
|
||||
question: "How long do we retain audit logs?",
|
||||
tool: "search_docs",
|
||||
result: "Audit log retention is 90 days on the current plan.",
|
||||
answer: "Audit logs are retained for 90 days, according to the plan documentation.",
|
||||
},
|
||||
{
|
||||
agent: "release_agent",
|
||||
question: "Review the search release",
|
||||
tool: "read_test_results",
|
||||
result: "Search: 117 passed, 3 failed. Cases 17, 63 and 104 returned empty results.",
|
||||
answer: "Hold the release. Three of 120 cases returned empty results. Review cases 17, 63 and 104 before shipping.",
|
||||
},
|
||||
{
|
||||
agent: "support_agent",
|
||||
question: "Update the address on order #1091",
|
||||
tool: "lookup_order",
|
||||
result: "Order #1091 has not shipped. Address changes are allowed.",
|
||||
answer: "Your order has not shipped. Please confirm the new delivery address.",
|
||||
},
|
||||
{
|
||||
agent: "research_agent",
|
||||
question: "Can this plan store data in Europe?",
|
||||
tool: "search_docs",
|
||||
result: "Regional availability is not documented for this plan.",
|
||||
answer: "I could not verify European data residency for this plan. Please confirm with support before choosing it.",
|
||||
},
|
||||
{
|
||||
agent: "support_agent",
|
||||
question: "Check the delivery date for order #1107",
|
||||
tool: "lookup_order",
|
||||
result: "Order service timed out",
|
||||
answer: "The order service is unavailable. I can connect you to support instead.",
|
||||
failed: true,
|
||||
},
|
||||
{
|
||||
agent: "release_agent",
|
||||
question: "Summarize the authentication release checks",
|
||||
tool: "read_test_results",
|
||||
result: "Authentication: 214 passed, 0 failed. Security review approved.",
|
||||
answer: "All 214 tests passed and the security review is approved.",
|
||||
},
|
||||
{
|
||||
agent: "research_agent",
|
||||
question: "Does the API support request retries?",
|
||||
tool: "search_docs",
|
||||
result: "Retry requests with exponential backoff after a 429 or 503 response.",
|
||||
answer: "Yes. Use exponential backoff for 429 and 503 responses, as documented.",
|
||||
},
|
||||
];
|
||||
import { scenarios, type Scenario } from "./scenarios";
|
||||
|
||||
const executionId = (traceId: string) => btoa(JSON.stringify(["traces", "", traceId]));
|
||||
const iso = (time: number) => new Date(time).toISOString();
|
||||
98
ui/litellm-dashboard/src/components/lens/demo/scenarios.ts
Normal file
98
ui/litellm-dashboard/src/components/lens/demo/scenarios.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
export type Scenario = {
|
||||
agent: string;
|
||||
question: string;
|
||||
tool: string;
|
||||
result: string;
|
||||
answer: string;
|
||||
failed?: boolean;
|
||||
};
|
||||
|
||||
export const scenarios: Scenario[] = [
|
||||
{
|
||||
agent: "support_agent",
|
||||
question: "Where is order #1042?",
|
||||
tool: "lookup_order",
|
||||
result: "Order service timed out",
|
||||
answer: "I will check that for you.",
|
||||
failed: true,
|
||||
},
|
||||
{
|
||||
agent: "research_agent",
|
||||
question: "Which database should we use for analytics?",
|
||||
tool: "search_docs",
|
||||
result: "Column stores are designed for analytical queries. No comparative benchmark is available.",
|
||||
answer: "The column store is 40% faster than the row store for your workload.",
|
||||
},
|
||||
{
|
||||
agent: "support_agent",
|
||||
question: "Can I return my headphones?",
|
||||
tool: "lookup_return_policy",
|
||||
result: "Returns are accepted within 30 days. This order was delivered 10 days ago.",
|
||||
answer: "Yes. Your order is eligible for a return. I can help you start it.",
|
||||
},
|
||||
{
|
||||
agent: "release_agent",
|
||||
question: "Is the checkout release ready to ship?",
|
||||
tool: "read_test_results",
|
||||
result: "Checkout: 120 passed, 0 failed. Rollback plan approved.",
|
||||
answer: "All 120 checkout tests passed and the rollback plan is approved. The release is ready.",
|
||||
},
|
||||
{
|
||||
agent: "support_agent",
|
||||
question: "My order #1088 has not arrived",
|
||||
tool: "lookup_order",
|
||||
result: "Order service timed out",
|
||||
answer: "I will check that for you.",
|
||||
failed: true,
|
||||
},
|
||||
{
|
||||
agent: "research_agent",
|
||||
question: "How long do we retain audit logs?",
|
||||
tool: "search_docs",
|
||||
result: "Audit log retention is 90 days on the current plan.",
|
||||
answer: "Audit logs are retained for 90 days, according to the plan documentation.",
|
||||
},
|
||||
{
|
||||
agent: "release_agent",
|
||||
question: "Review the search release",
|
||||
tool: "read_test_results",
|
||||
result: "Search: 117 passed, 3 failed. Cases 17, 63 and 104 returned empty results.",
|
||||
answer: "Hold the release. Three of 120 cases returned empty results. Review cases 17, 63 and 104 before shipping.",
|
||||
},
|
||||
{
|
||||
agent: "support_agent",
|
||||
question: "Update the address on order #1091",
|
||||
tool: "lookup_order",
|
||||
result: "Order #1091 has not shipped. Address changes are allowed.",
|
||||
answer: "Your order has not shipped. Please confirm the new delivery address.",
|
||||
},
|
||||
{
|
||||
agent: "research_agent",
|
||||
question: "Can this plan store data in Europe?",
|
||||
tool: "search_docs",
|
||||
result: "Regional availability is not documented for this plan.",
|
||||
answer: "I could not verify European data residency for this plan. Please confirm with support before choosing it.",
|
||||
},
|
||||
{
|
||||
agent: "support_agent",
|
||||
question: "Check the delivery date for order #1107",
|
||||
tool: "lookup_order",
|
||||
result: "Order service timed out",
|
||||
answer: "The order service is unavailable. I can connect you to support instead.",
|
||||
failed: true,
|
||||
},
|
||||
{
|
||||
agent: "release_agent",
|
||||
question: "Summarize the authentication release checks",
|
||||
tool: "read_test_results",
|
||||
result: "Authentication: 214 passed, 0 failed. Security review approved.",
|
||||
answer: "All 214 tests passed and the security review is approved.",
|
||||
},
|
||||
{
|
||||
agent: "research_agent",
|
||||
question: "Does the API support request retries?",
|
||||
tool: "search_docs",
|
||||
result: "Retry requests with exponential backoff after a 429 or 503 response.",
|
||||
answer: "Yes. Use exponential backoff for 429 and 503 responses, as documented.",
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import type { Finding } from "../model/types";
|
||||
import { FindingSheet } from "./FindingSheet";
|
||||
|
||||
const finding: Finding = {
|
||||
id: "finding",
|
||||
check_id: "check",
|
||||
title: "Repeated retries",
|
||||
description: "The tool kept failing",
|
||||
kind: "issue",
|
||||
priority: "high",
|
||||
status: "open",
|
||||
revision: 1,
|
||||
first_seen: "2026-09-30T10:00:00Z",
|
||||
last_seen: "2026-09-30T10:00:00Z",
|
||||
reason: "Saved feedback",
|
||||
suggestion: "",
|
||||
limitation: "",
|
||||
occurrences: [],
|
||||
evidence: [],
|
||||
};
|
||||
|
||||
it("keeps a feedback draft during refreshes, sends it with status changes, and restores saved feedback on reopening", async () => {
|
||||
const user = userEvent.setup();
|
||||
const changeFinding = vi.fn().mockResolvedValue(undefined);
|
||||
const props = { sampledRuns: [], readOnly: false, busy: false, onClose: vi.fn(), onEvidence: vi.fn(), changeFinding };
|
||||
const view = renderWithProviders(<FindingSheet {...props} finding={finding} />);
|
||||
const reason = () => screen.getByRole("textbox", { name: "What should Lens remember?" });
|
||||
expect(reason()).toHaveValue("Saved feedback");
|
||||
fireEvent.change(reason(), { target: { value: "Draft feedback" } });
|
||||
await user.click(screen.getByRole("button", { name: "Mark resolved" }));
|
||||
expect(changeFinding).toHaveBeenLastCalledWith("resolved", "Draft feedback");
|
||||
|
||||
const refreshed: Finding = { ...finding, reason: "Server feedback", status: "resolved" };
|
||||
view.rerender(<FindingSheet {...props} finding={refreshed} />);
|
||||
expect(reason()).toHaveValue("Draft feedback");
|
||||
await user.click(screen.getByRole("button", { name: "Reopen" }));
|
||||
expect(changeFinding).toHaveBeenLastCalledWith("open", "Draft feedback");
|
||||
await user.click(screen.getByRole("button", { name: "This is expected" }));
|
||||
expect(changeFinding).toHaveBeenLastCalledWith("dismissed", "Draft feedback");
|
||||
|
||||
view.rerender(<FindingSheet {...props} />);
|
||||
view.rerender(<FindingSheet {...props} finding={refreshed} />);
|
||||
expect(reason()).toHaveValue("Server feedback");
|
||||
const other: Finding = { ...finding, id: "other", reason: "Other feedback" };
|
||||
view.rerender(<FindingSheet {...props} finding={other} />);
|
||||
expect(reason()).toHaveValue("Other feedback");
|
||||
});
|
||||
|
|
@ -1,31 +1,31 @@
|
|||
import { useState } from "react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
|
||||
import { LensIssueBrief } from "./LensIssueBrief";
|
||||
import { evidenceTarget, runTime, type Finding, type Sample } from "./lensData";
|
||||
import { IssueBrief } from "./IssueBrief";
|
||||
import { evidenceTarget } from "../model/findings";
|
||||
import { runTime } from "../model/format";
|
||||
import { type Finding, type Sample } from "../model/types";
|
||||
|
||||
export function LensFinding({
|
||||
export function FindingSheet({
|
||||
finding,
|
||||
sampledRuns,
|
||||
readOnly,
|
||||
reason,
|
||||
busy,
|
||||
onClose,
|
||||
onReason,
|
||||
onEvidence,
|
||||
changeFinding,
|
||||
}: {
|
||||
finding?: Finding;
|
||||
sampledRuns: Sample["executions"];
|
||||
readOnly: boolean;
|
||||
reason: string;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onReason: (reason: string) => void;
|
||||
onEvidence: (evidence: { id: string; span: string }) => void;
|
||||
changeFinding: (status: Finding["status"]) => Promise<void>;
|
||||
changeFinding: (status: Finding["status"], reason: string) => Promise<void>;
|
||||
}) {
|
||||
const [reason, setReason] = useFindingReason(finding);
|
||||
const evidenceGroups = finding
|
||||
? [...new Set(finding.evidence.map((e) => e.execution_id))].map((id) => ({
|
||||
id,
|
||||
|
|
@ -52,7 +52,7 @@ export function LensFinding({
|
|||
</SheetHeader>
|
||||
<div className="space-y-6 p-4">
|
||||
{finding.brief ? (
|
||||
<LensIssueBrief title={finding.title} brief={finding.brief} />
|
||||
<IssueBrief title={finding.title} brief={finding.brief} />
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
|
|
@ -124,7 +124,7 @@ export function LensFinding({
|
|||
What should Lens remember?
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => onReason(e.target.value)}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="What should Lens know about this finding?"
|
||||
/>
|
||||
</label>
|
||||
|
|
@ -133,12 +133,12 @@ export function LensFinding({
|
|||
{finding.kind === "issue" && (
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => changeFinding(finding.status === "resolved" ? "open" : "resolved")}
|
||||
onClick={() => changeFinding(finding.status === "resolved" ? "open" : "resolved", reason)}
|
||||
>
|
||||
{finding.status === "resolved" ? "Reopen" : "Mark resolved"}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={busy} variant="outline" onClick={() => changeFinding("dismissed")}>
|
||||
<Button disabled={busy} variant="outline" onClick={() => changeFinding("dismissed", reason)}>
|
||||
This is expected
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -151,3 +151,11 @@ export function LensFinding({
|
|||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function useFindingReason(finding: Finding | undefined) {
|
||||
const [draft, setDraft] = useState({ id: finding?.id, reason: finding?.reason ?? "" });
|
||||
if (draft.id !== finding?.id) setDraft({ id: finding?.id, reason: finding?.reason ?? "" });
|
||||
const reason = draft.reason;
|
||||
const setReason = (reason: string) => setDraft({ ...draft, reason });
|
||||
return [reason, setReason] as const;
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { ListRow } from "@/components/shared/ListRow";
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, Search } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { lensStatus } from "../model/status";
|
||||
import { runTime, scopeLabel } from "../model/format";
|
||||
import { type Lens } from "../model/types";
|
||||
|
||||
export function InvestigationList({
|
||||
lenses,
|
||||
connected,
|
||||
onSelect,
|
||||
}: {
|
||||
lenses: Lens[];
|
||||
connected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const shown = lenses.filter((lens) =>
|
||||
`${lens.settings.name} ${scopeLabel(lens.settings)}`.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
return (
|
||||
<section aria-label="Saved investigations" className="space-y-4">
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search investigations"
|
||||
placeholder="Search investigations"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="divide-y border-y">
|
||||
{shown.map((lens) => (
|
||||
<ListRow
|
||||
key={lens.id}
|
||||
onClick={() => onSelect(lens.id)}
|
||||
className="group grid w-full grid-cols-[minmax(0,1fr)_16px] items-center gap-x-4 gap-y-2 py-4 text-left hover:bg-muted/30 focus-visible:outline-2 focus-visible:outline-ring sm:grid-cols-[minmax(0,1fr)_auto_16px]"
|
||||
>
|
||||
<div className="col-start-1 row-start-1 min-w-0">
|
||||
<p className="text-sm font-medium">{lens.settings.name}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{scopeLabel(lens.settings)}</p>
|
||||
</div>
|
||||
<div className="col-start-1 row-start-2 text-xs sm:col-start-2 sm:row-start-1 sm:text-right">
|
||||
<p
|
||||
data-state={lens.jobs[0]?.status === "failed" ? "failed" : "other"}
|
||||
className="data-[state=failed]:text-destructive data-[state=other]:text-muted-foreground"
|
||||
>
|
||||
{lensStatus(lens, connected)}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{lens.jobs[0] ? runTime(lens.jobs[0].created_at) : "Not run yet"}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="col-start-2 row-start-1 size-4 text-muted-foreground sm:col-start-3" />
|
||||
</ListRow>
|
||||
))}
|
||||
{!shown.length && <p className="py-8 text-sm text-muted-foreground">No investigations match your search.</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
import { StatusDot } from "@/components/shared/StatusDot";
|
||||
|
||||
import { ArrowLeft, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Lens } from "../model/types";
|
||||
|
||||
export function InvestigationNavigation({
|
||||
lens,
|
||||
showActions,
|
||||
activityReady,
|
||||
connected,
|
||||
ready,
|
||||
selectLens,
|
||||
setWorkerSetup,
|
||||
setEditing,
|
||||
}: {
|
||||
lens: Lens | undefined;
|
||||
showActions: boolean;
|
||||
activityReady: boolean;
|
||||
connected: boolean;
|
||||
ready: boolean;
|
||||
selectLens: (id: string | null) => void;
|
||||
setWorkerSetup: (open: boolean) => void;
|
||||
setEditing: (mode: "new") => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
{lens ? (
|
||||
<Button variant="ghost" size="sm" className="-ml-3" onClick={() => selectLens(null)}>
|
||||
<ArrowLeft className="size-4" /> All investigations
|
||||
</Button>
|
||||
) : (
|
||||
<h2 className="text-lg font-semibold">Investigations</h2>
|
||||
)}
|
||||
{showActions && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="ghost" disabled={!activityReady} onClick={() => setWorkerSetup(true)}>
|
||||
<StatusDot state={connected ? "ok" : "warn"} />
|
||||
{connected ? "Worker connected" : "Connect worker"}
|
||||
</Button>
|
||||
<Button variant={lens ? "outline" : "default"} disabled={!ready} onClick={() => setEditing("new")}>
|
||||
<Plus className="size-4" /> New investigation
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
analysisElapsed,
|
||||
|
|
@ -8,18 +10,17 @@ import {
|
|||
analysisPace,
|
||||
analysisProgress,
|
||||
analysisStages,
|
||||
durationText,
|
||||
nextCheckStatus,
|
||||
remainingLabel,
|
||||
stageDurations,
|
||||
type Lens,
|
||||
type Job,
|
||||
type ProgressSample,
|
||||
} from "./lensData";
|
||||
} from "../model/progress";
|
||||
import { durationText } from "../model/format";
|
||||
import { nextCheckStatus } from "../model/status";
|
||||
import { type Lens, type Job } from "../model/types";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const steps = ["review runs", "find patterns", "check evidence"];
|
||||
const markers = { done: "✓", active: "▸", todo: "·" };
|
||||
const rowText = { done: "text-foreground", active: "font-medium text-foreground", todo: "text-muted-foreground" };
|
||||
const blocks = 32;
|
||||
|
||||
function stageState(index: number, current: number): keyof typeof markers {
|
||||
|
|
@ -27,12 +28,8 @@ function stageState(index: number, current: number): keyof typeof markers {
|
|||
return index === current ? "active" : "todo";
|
||||
}
|
||||
|
||||
export function LensProgress({ job, onCancel }: { job: Job; onCancel?: () => void }) {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
export function InvestigationProgress({ job, onCancel }: { job: Job; onCancel?: () => void }) {
|
||||
const now = useNow(1000);
|
||||
const progress = analysisProgress(job);
|
||||
const fraction = analysisFraction(progress);
|
||||
const [samples, setSamples] = useState<ProgressSample[]>([]);
|
||||
|
|
@ -82,9 +79,12 @@ export function LensProgress({ job, onCancel }: { job: Job; onCancel?: () => voi
|
|||
{Array.from({ length: blocks }, (_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`flex-1 ${index < filled ? "bg-foreground" : "bg-foreground/10"} ${
|
||||
queued && index < blocks / 3 ? "motion-safe:animate-pulse bg-foreground/30" : ""
|
||||
}`}
|
||||
data-state={index < filled ? "active" : "inactive"}
|
||||
className={cn(
|
||||
"flex-1",
|
||||
index < filled ? "bg-foreground" : "bg-foreground/10",
|
||||
queued && index < blocks / 3 && "motion-safe:animate-pulse bg-foreground/30",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -101,8 +101,9 @@ export function LensProgress({ job, onCancel }: { job: Job; onCancel?: () => voi
|
|||
return (
|
||||
<li
|
||||
key={label}
|
||||
data-state={state}
|
||||
aria-current={state === "active" ? "step" : undefined}
|
||||
className={`grid grid-cols-[1rem_minmax(0,9rem)_6rem_auto] items-baseline gap-2 tabular-nums ${rowText[state]}`}
|
||||
className="grid grid-cols-[1rem_minmax(0,9rem)_6rem_auto] items-baseline gap-2 tabular-nums data-[state=done]:text-foreground data-[state=active]:font-medium data-[state=active]:text-foreground data-[state=todo]:text-muted-foreground"
|
||||
>
|
||||
<span aria-hidden="true">{markers[state]}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
|
|
@ -131,11 +132,7 @@ export function LensProgress({ job, onCancel }: { job: Job; onCancel?: () => voi
|
|||
}
|
||||
|
||||
export function NextCheck({ lens }: { lens: Lens }) {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 15000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const now = useNow(15000);
|
||||
const label = nextCheckStatus(lens, now);
|
||||
if (!label) return null;
|
||||
return <p className="mt-1 text-xs text-muted-foreground">{label}</p>;
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
|
||||
export function InvestigationError({
|
||||
error,
|
||||
queryError,
|
||||
refresh,
|
||||
}: {
|
||||
error: string;
|
||||
queryError: unknown;
|
||||
refresh: () => void;
|
||||
}) {
|
||||
const unavailable = queryError instanceof ApiError && queryError.status === 404;
|
||||
const queryMessage = queryError instanceof Error ? queryError.message : undefined;
|
||||
return (
|
||||
<div role="alert" className="rounded-lg border border-destructive/30 p-4 text-sm text-destructive">
|
||||
{unavailable
|
||||
? "The Lens API is unavailable. Reload this page to use the current dashboard; if it persists, check the proxy deployment."
|
||||
: error || queryMessage}
|
||||
<Button variant="ghost" size="sm" onClick={() => (unavailable ? window.location.reload() : refresh())}>
|
||||
{unavailable ? "Reload page" : "Retry"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function InvestigationsLoading() {
|
||||
return (
|
||||
<p role="status" className="py-8 text-sm text-muted-foreground">
|
||||
Loading investigations…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
export function InvestigationMissing({ selectLens }: { selectLens: (id: string | null) => void }) {
|
||||
return (
|
||||
<div role="alert" className="text-sm">
|
||||
This investigation was not found.{" "}
|
||||
<Button variant="link" onClick={() => selectLens(null)}>
|
||||
View all investigations
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,8 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { renderWithProviders as renderProviders, testQueryClient } from "@/../tests/test-utils";
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { LensView } from "./LensView";
|
||||
import { briefMarkdown, nextCheckStatus, runTime, type Lens, type Finding } from "./lensData";
|
||||
import { lensKeys } from "../api/queries";
|
||||
import { InvestigationsView } from "./InvestigationsView";
|
||||
import { briefMarkdown } from "../model/findings";
|
||||
import { runTime } from "../model/format";
|
||||
import { type Lens, type Finding } from "../model/types";
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, options?: Parameters<typeof renderProviders>[1]) {
|
||||
return renderProviders(ui, { searchParams: window.location.search, ...options });
|
||||
|
|
@ -158,7 +161,7 @@ describe("Lens findings and runs", () => {
|
|||
|
||||
it("separates patterns from issues and reveals original evidence only when requested", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LensView accessToken="test" readOnly />);
|
||||
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
|
||||
expect(await screen.findByText("Review used the wrong defect rate")).toBeInTheDocument();
|
||||
expect(screen.queryByText(pattern.title)).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Patterns (1)" }));
|
||||
|
|
@ -190,7 +193,7 @@ describe("Lens findings and runs", () => {
|
|||
return { data: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LensView accessToken="test" readOnly />);
|
||||
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
|
||||
await user.click(await screen.findByRole("button", { name: new RegExp(finding.title) }));
|
||||
return { user, detail: within(screen.getByRole("dialog", { name: finding.title })) };
|
||||
}
|
||||
|
|
@ -221,7 +224,7 @@ describe("Lens findings and runs", () => {
|
|||
|
||||
it("shows the actual frozen run selection in the Runs tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LensView accessToken="test" readOnly />);
|
||||
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
|
||||
await user.click(await screen.findByRole("tab", { name: "Traces" }));
|
||||
expect(screen.getByText("Release-42")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("trace-42")).toHaveTextContent("Release-42");
|
||||
|
|
@ -229,24 +232,6 @@ describe("Lens findings and runs", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows the actual next schedule and avoids a stale countdown during active scans", () => {
|
||||
const now = Date.parse("2026-09-30T10:00:00Z");
|
||||
const monitoring = {
|
||||
...lens,
|
||||
settings: { ...lens.settings, enabled: true },
|
||||
next_run_at: "2026-09-30T10:12:00Z",
|
||||
};
|
||||
expect(nextCheckStatus(monitoring, now)).toContain("in 12 minutes");
|
||||
expect(nextCheckStatus(monitoring, now + 12 * 60000)).toBe("Due now · waiting for an analyzer");
|
||||
expect(nextCheckStatus({ ...monitoring, jobs: [{ ...lens.jobs[0], status: "running" }] }, now)).toBe(
|
||||
"Next check scheduled after this scan finishes",
|
||||
);
|
||||
expect(nextCheckStatus({ ...monitoring, jobs: [{ ...lens.jobs[0], status: "queued" }] }, now)).toBe(
|
||||
"Waiting for an analyzer",
|
||||
);
|
||||
expect(nextCheckStatus(lens, now)).toBeNull();
|
||||
});
|
||||
|
||||
it("runs saved settings immediately without opening setup", async () => {
|
||||
testQueryClient.clear();
|
||||
vi.mocked(apiClient.get).mockImplementation(async (path) => {
|
||||
|
|
@ -271,7 +256,7 @@ it("runs saved settings immediately without opening setup", async () => {
|
|||
});
|
||||
vi.mocked(apiClient.post).mockResolvedValue(lens);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LensView accessToken="test" />);
|
||||
renderWithProviders(<InvestigationsView accessToken="test" />);
|
||||
await user.click(await screen.findByRole("button", { name: "Run now" }));
|
||||
expect(apiClient.post).toHaveBeenCalledWith("/lens/lens/runs", { accessToken: "test", body: {} });
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
|
@ -286,7 +271,7 @@ it("offers the interactive demo without starting an investigation", async () =>
|
|||
});
|
||||
const onDemo = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LensView accessToken="test" onDemo={onDemo} />);
|
||||
renderWithProviders(<InvestigationsView accessToken="test" onDemo={onDemo} />);
|
||||
await user.click(await screen.findByRole("button", { name: "Preview sample" }));
|
||||
expect(onDemo).toHaveBeenCalledOnce();
|
||||
expect(apiClient.post).not.toHaveBeenCalled();
|
||||
|
|
@ -301,7 +286,7 @@ it("guides a first-time administrator into worker connection and lens setup", as
|
|||
return { traces: true, requests: false, data: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LensView accessToken="test" onDemo={vi.fn()} />);
|
||||
renderWithProviders(<InvestigationsView accessToken="test" onDemo={vi.fn()} />);
|
||||
const guide = within(await screen.findByRole("region", { name: "Find what needs attention" }));
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/lens/activity/available", { accessToken: "test" });
|
||||
expect(await guide.findByRole("link", { name: "View traces" })).toHaveAttribute(
|
||||
|
|
@ -315,7 +300,7 @@ it("guides a first-time administrator into worker connection and lens setup", as
|
|||
await user.click(connection.getByRole("button", { name: "Close" }));
|
||||
expect(guide.getByRole("button", { name: "New investigation" })).toBeDisabled();
|
||||
await act(async () => {
|
||||
testQueryClient.setQueryData(["lenses", "test"], {
|
||||
testQueryClient.setQueryData(lensKeys.list("test"), {
|
||||
lenses: [],
|
||||
tracing_enabled: true,
|
||||
workers: [
|
||||
|
|
@ -352,7 +337,7 @@ it("opens the saved results of an older batch", async () => {
|
|||
return { data: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LensView accessToken="test" readOnly />);
|
||||
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
|
||||
await screen.findByRole("option", { name: `${runTime(older.created_at)} · completed` });
|
||||
await user.selectOptions(screen.getByRole("combobox", { name: "Investigation run" }), "older");
|
||||
expect(await screen.findByText("Earlier batch finding")).toBeVisible();
|
||||
|
|
@ -391,7 +376,7 @@ it("reads request content from the beginning after its abbreviated preview", asy
|
|||
};
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LensView accessToken="test" readOnly />);
|
||||
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
|
||||
await user.click(await screen.findByRole("tab", { name: "Traces" }));
|
||||
await user.click(screen.getByRole("button", { name: /Release-42/ }));
|
||||
expect(await screen.findByText("Abbreviated preview")).toBeVisible();
|
||||
|
|
@ -413,7 +398,7 @@ it.each([false, true])(
|
|||
vi.mocked(apiClient.get).mockImplementation(async (path) =>
|
||||
path === "/lens" ? { lenses: [], workers: [], tracing_enabled: enabled } : { data: [] },
|
||||
);
|
||||
renderWithProviders(<LensView accessToken="test" />);
|
||||
renderWithProviders(<InvestigationsView accessToken="test" />);
|
||||
expect(await screen.findByRole("heading", { name: "Find what needs attention" })).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "Set up traces" })).toHaveAttribute(
|
||||
"href",
|
||||
|
|
@ -434,7 +419,7 @@ it("enables first-lens setup when a trace arrives without leaving Investigations
|
|||
);
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const view = renderWithProviders(<LensView accessToken="test" />);
|
||||
const view = renderWithProviders(<InvestigationsView accessToken="test" />);
|
||||
await act(async () => vi.advanceTimersByTimeAsync(50));
|
||||
expect(screen.getByRole("link", { name: "Set up traces" })).toBeVisible();
|
||||
|
||||
|
|
@ -466,7 +451,7 @@ it("allows retrying a failed trace readiness check without treating it as an emp
|
|||
return { data: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LensView accessToken="test" />);
|
||||
renderWithProviders(<InvestigationsView accessToken="test" />);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not check recorded activity. Trace storage unavailable",
|
||||
);
|
||||
|
|
@ -482,7 +467,7 @@ it("keeps saved investigations accessible when tracing is disabled", async () =>
|
|||
if (path === "/lens/lens/runs") return lens.jobs;
|
||||
return { data: [] };
|
||||
});
|
||||
renderWithProviders(<LensView accessToken="test" readOnly />);
|
||||
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
|
||||
expect(await screen.findByText(issue.title)).toBeVisible();
|
||||
expect(screen.queryByRole("link", { name: "Set up traces" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -495,7 +480,7 @@ it("allows request-only accounts to connect a worker without requiring agent tra
|
|||
if (path === "/lens/activity/available") return { traces: false, requests: true };
|
||||
return { data: [] };
|
||||
});
|
||||
renderWithProviders(<LensView accessToken="test" />);
|
||||
renderWithProviders(<InvestigationsView accessToken="test" />);
|
||||
expect(await screen.findByText("Request logs received")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Connect worker" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "New investigation" })).toBeDisabled();
|
||||
|
|
@ -510,7 +495,7 @@ it("closes editing when browser navigation leaves the investigation", async () =
|
|||
return { data: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LensView accessToken="test" />);
|
||||
renderWithProviders(<InvestigationsView accessToken="test" />);
|
||||
await user.click(await screen.findByRole("button", { name: "Investigation actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Edit investigation" }));
|
||||
expect(await screen.findByRole("dialog")).toBeVisible();
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
"use client";
|
||||
import { InvestigationMissing, InvestigationsLoading, InvestigationError } from "./InvestigationStatus";
|
||||
|
||||
import { InvestigationNavigation } from "./InvestigationNavigation";
|
||||
import { useInvestigationResults } from "./useInvestigationResults";
|
||||
|
||||
import { useLensUpdate, useSaveLens } from "../api/mutations";
|
||||
import { lensKeys, lensQueries } from "../api/queries";
|
||||
import { useLensApi } from "../api/useLensApi";
|
||||
|
||||
import { InvestigationDetail } from "./detail/InvestigationDetail";
|
||||
import { ReadinessBanner } from "./ReadinessBanner";
|
||||
import { RequestEvidenceSheet } from "./RequestEvidenceSheet";
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
|
||||
import { useLensDemo } from "../LensDemoContext";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { FindingSheet } from "./FindingSheet";
|
||||
import { TraceSheet } from "./TraceSheet";
|
||||
import { InvestigationSetupDialog } from "../setup/InvestigationSetupDialog";
|
||||
import { WorkerDialog } from "../setup/worker/WorkerDialog";
|
||||
import { useAnalysisKeyInfo } from "../setup/worker/AnalysisKeyDetails";
|
||||
import { InvestigationList } from "./InvestigationList";
|
||||
import { MonitoringDialog } from "../setup/MonitoringDialog";
|
||||
import { InvestigationsWelcome } from "./InvestigationsWelcome";
|
||||
import { workerConnected, readiness } from "../model/status";
|
||||
import { type Finding, type Settings } from "../model/types";
|
||||
|
||||
export function InvestigationsView({
|
||||
accessToken,
|
||||
readOnly = false,
|
||||
onDemo,
|
||||
}: {
|
||||
accessToken: string;
|
||||
readOnly?: boolean;
|
||||
onDemo?: () => void;
|
||||
}) {
|
||||
const demo = useLensDemo();
|
||||
const apiClient = useLensApi();
|
||||
const client = useQueryClient();
|
||||
const updateLens = useLensUpdate(accessToken);
|
||||
const saveLens = useSaveLens(accessToken);
|
||||
const [workerSetup, setWorkerSetup] = useState(false);
|
||||
const [monitoring, setMonitoring] = useState(false);
|
||||
const now = useNow(2000);
|
||||
const query = useQuery(lensQueries.list(apiClient, accessToken, !!demo, workerSetup));
|
||||
const models = useQuery(lensQueries.models(apiClient, accessToken));
|
||||
const modelDetails = useQuery(lensQueries.modelDetails(apiClient, accessToken));
|
||||
const [liveSelected, setLiveSelected] = useQueryState("lens", parseAsString.withOptions({ history: "push" }));
|
||||
const [demoSelected, setDemoSelected] = useState<string | null>(null);
|
||||
const selected = demo ? demoSelected : liveSelected;
|
||||
const setSelected = demo ? setDemoSelected : setLiveSelected;
|
||||
const [editing, setEditing] = useState<"new" | "edit" | "duplicate" | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const lenses = [...(query.data?.lenses ?? [])].sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
|
||||
const showEmpty = !query.isLoading && !query.error && lenses.length === 0;
|
||||
const loaded = !query.isLoading && !query.error;
|
||||
const showActions = !readOnly && !!query.data && !showEmpty;
|
||||
const showReadiness = loaded && !showEmpty && !readOnly;
|
||||
const missingSelection = !!selected && loaded;
|
||||
const lens = lenses.find((e) => e.id === selected);
|
||||
const results = useInvestigationResults(accessToken, lens);
|
||||
const {
|
||||
finding,
|
||||
sampledRuns,
|
||||
target,
|
||||
evidence,
|
||||
requestEvidenceData,
|
||||
requestEvidenceError,
|
||||
requestEvidenceLoading,
|
||||
requestOffset,
|
||||
setRequestOffset,
|
||||
setEvidence,
|
||||
setFindingId,
|
||||
reset: resetResults,
|
||||
} = results;
|
||||
const selectLens = (id: string | null) => {
|
||||
void setSelected(id);
|
||||
resetResults();
|
||||
};
|
||||
useEffect(() => {
|
||||
const restoreLocation = () => {
|
||||
setEditing(null);
|
||||
resetResults();
|
||||
};
|
||||
window.addEventListener("popstate", restoreLocation);
|
||||
return () => window.removeEventListener("popstate", restoreLocation);
|
||||
}, [resetResults]);
|
||||
const connected = query.data?.workers?.some((w) => workerConnected(w, now)) ?? false;
|
||||
const activeWorkers = query.data?.workers.filter((worker) => !worker.revoked) ?? [];
|
||||
const defaultKeyId = activeWorkers.length === 1 ? activeWorkers[0].analysis_key_id : undefined;
|
||||
const analysisAccess = useAnalysisKeyInfo(accessToken, defaultKeyId ?? undefined);
|
||||
const defaultModel = analysisAccess.data?.models.length === 1 ? analysisAccess.data.models[0] : undefined;
|
||||
const activity = useQuery(lensQueries.activity(apiClient, accessToken, loaded, !!demo));
|
||||
const { tracesReady, requestsReady, activityReady, ready } = readiness(
|
||||
activity.data,
|
||||
activity.error,
|
||||
connected,
|
||||
query.error,
|
||||
);
|
||||
const setupSettings = () => {
|
||||
if (editing === "new") return undefined;
|
||||
if (editing === "duplicate" && lens)
|
||||
return { ...lens.settings, name: `${lens.settings.name} copy`, enabled: false };
|
||||
return lens?.settings;
|
||||
};
|
||||
const refresh = () => {
|
||||
void client.invalidateQueries({ queryKey: lensKeys.list(accessToken) });
|
||||
void client.invalidateQueries({ queryKey: lensKeys.histories() });
|
||||
};
|
||||
const update = async (path: string, body: unknown, method: "post" | "put" | "patch" = "post") => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateLens.mutateAsync({ path, body, method });
|
||||
await client.invalidateQueries({ queryKey: lensKeys.list(accessToken) });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not update lens");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const save = async (settings: Settings) => {
|
||||
if (editing === "edit" && (!lens || lens.id !== selected))
|
||||
throw new Error("Reopen the investigation to edit its settings");
|
||||
if (editing !== "edit" && !ready)
|
||||
throw new Error("Wait for recorded activity and a connected worker before starting an investigation");
|
||||
const saved = await saveLens.mutateAsync({ id: editing === "edit" ? lens?.id : undefined, settings });
|
||||
selectLens(saved.id);
|
||||
setEditing(null);
|
||||
refresh();
|
||||
};
|
||||
const changeFinding = async (status: Finding["status"], reason: string) => {
|
||||
if (!lens || !finding) return;
|
||||
await update(`/lens/${lens.id}/findings/${finding.id}`, { status, reason }, "patch");
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Investigations" className="w-full min-w-0 space-y-6">
|
||||
{!showEmpty && (
|
||||
<InvestigationNavigation
|
||||
lens={lens}
|
||||
showActions={showActions}
|
||||
activityReady={activityReady}
|
||||
connected={connected}
|
||||
ready={ready}
|
||||
selectLens={selectLens}
|
||||
setWorkerSetup={setWorkerSetup}
|
||||
setEditing={setEditing}
|
||||
/>
|
||||
)}
|
||||
{(error || query.error) && <InvestigationError error={error} queryError={query.error} refresh={refresh} />}
|
||||
{query.isLoading && <InvestigationsLoading />}
|
||||
{loaded && showEmpty && (
|
||||
<InvestigationsWelcome
|
||||
tracesReady={tracesReady}
|
||||
requestsReady={requestsReady}
|
||||
checking={activity.isPending}
|
||||
traceError={activity.error?.message}
|
||||
connected={connected}
|
||||
readOnly={!!readOnly}
|
||||
onRetry={() => {
|
||||
void activity.refetch();
|
||||
refresh();
|
||||
}}
|
||||
onConnect={() => setWorkerSetup(true)}
|
||||
onCreate={() => setEditing("new")}
|
||||
onDemo={activity.isSuccess && !ready ? onDemo : undefined}
|
||||
/>
|
||||
)}
|
||||
{showReadiness && !ready && <ReadinessBanner activityReady={activityReady} />}
|
||||
{!selected && lenses.length > 0 && (
|
||||
<InvestigationList lenses={lenses} connected={connected} onSelect={selectLens} />
|
||||
)}
|
||||
{missingSelection && !lens && <InvestigationMissing selectLens={selectLens} />}
|
||||
{lens && (
|
||||
<InvestigationDetail
|
||||
lens={lens}
|
||||
readOnly={readOnly}
|
||||
ready={ready}
|
||||
busy={busy}
|
||||
setEditing={setEditing}
|
||||
setMonitoring={setMonitoring}
|
||||
update={update}
|
||||
connected={connected}
|
||||
results={results}
|
||||
/>
|
||||
)}
|
||||
{editing && (
|
||||
<InvestigationSetupDialog
|
||||
ready={ready}
|
||||
mode={editing}
|
||||
initial={setupSettings()}
|
||||
defaultModel={defaultModel}
|
||||
defaultSource={!tracesReady && requestsReady ? "requests" : "traces"}
|
||||
models={models.data?.data.map((m) => m.id) ?? []}
|
||||
modelDetails={modelDetails.data?.data ?? []}
|
||||
modelsLoading={models.isLoading}
|
||||
modelsError={models.error?.message}
|
||||
accessToken={accessToken}
|
||||
onClose={() => setEditing(null)}
|
||||
onSave={save}
|
||||
/>
|
||||
)}
|
||||
{workerSetup && (
|
||||
<WorkerDialog
|
||||
accessToken={accessToken}
|
||||
workers={query.data?.workers ?? []}
|
||||
onClose={() => setWorkerSetup(false)}
|
||||
onChanged={refresh}
|
||||
onReady={
|
||||
ready && showEmpty
|
||||
? () => {
|
||||
setWorkerSetup(false);
|
||||
setEditing("new");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{monitoring && lens && (
|
||||
<MonitoringDialog
|
||||
settings={lens.settings}
|
||||
ready={ready}
|
||||
onClose={() => setMonitoring(false)}
|
||||
onSave={async (settings) => {
|
||||
await saveLens.mutateAsync({ id: lens.id, settings });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<FindingSheet
|
||||
finding={finding}
|
||||
sampledRuns={sampledRuns}
|
||||
readOnly={readOnly}
|
||||
busy={busy}
|
||||
onClose={() => setFindingId(null)}
|
||||
changeFinding={changeFinding}
|
||||
onEvidence={(value) => {
|
||||
setRequestOffset(0);
|
||||
setEvidence(value);
|
||||
}}
|
||||
/>
|
||||
{lens && target?.source === "traces" && (
|
||||
<TraceSheet
|
||||
open={!!evidence}
|
||||
traceId={target.id}
|
||||
traceRef={target.traceRef}
|
||||
initialSpanId={evidence?.span}
|
||||
accessToken={accessToken}
|
||||
onClose={() => setEvidence(null)}
|
||||
/>
|
||||
)}
|
||||
<RequestEvidenceSheet
|
||||
target={target}
|
||||
setEvidence={setEvidence}
|
||||
requestEvidenceData={requestEvidenceData}
|
||||
requestEvidenceError={requestEvidenceError}
|
||||
requestEvidenceLoading={requestEvidenceLoading}
|
||||
requestOffset={requestOffset}
|
||||
setRequestOffset={setRequestOffset}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import Link from "next/link";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { LensPreviewButton } from "@/components/lens/LensPreviewButton";
|
||||
import { LensPreviewButton } from "../LensPreviewButton";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export function LensWelcome({
|
||||
export function InvestigationsWelcome({
|
||||
tracesReady,
|
||||
requestsReady = false,
|
||||
checking,
|
||||
|
|
@ -33,7 +34,6 @@ export function LensWelcome({
|
|||
const canConnect = activityReady && !readOnly;
|
||||
const traceStatus = activityStatus(tracesReady, requestsReady, checking);
|
||||
const waitingForWorker = activityReady && !connected;
|
||||
const pendingClass = activityReady ? "" : "text-muted-foreground";
|
||||
const firstStepTitle = requestsReady && !tracesReady ? "Recorded activity" : "Set up traces";
|
||||
const traceButtonClass = buttonVariants({
|
||||
variant: activityReady ? "ghost" : "default",
|
||||
|
|
@ -60,7 +60,8 @@ export function LensWelcome({
|
|||
</Link>
|
||||
</li>
|
||||
<li
|
||||
className={`grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 sm:grid-cols-[28px_minmax(0,1fr)_auto] ${pendingClass}`}
|
||||
data-state={activityReady ? "active" : "inactive"}
|
||||
className="grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 text-muted-foreground data-[state=active]:text-foreground sm:grid-cols-[28px_minmax(0,1fr)_auto]"
|
||||
>
|
||||
<Step number={2} complete={workerReady} active={waitingForWorker} />
|
||||
<div>
|
||||
|
|
@ -79,7 +80,8 @@ export function LensWelcome({
|
|||
</Button>
|
||||
</li>
|
||||
<li
|
||||
className={`grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 sm:grid-cols-[28px_minmax(0,1fr)_auto] ${!workerReady ? "text-muted-foreground" : ""}`}
|
||||
data-state={workerReady ? "active" : "inactive"}
|
||||
className="grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 text-muted-foreground data-[state=active]:text-foreground sm:grid-cols-[28px_minmax(0,1fr)_auto]"
|
||||
>
|
||||
<Step number={3} complete={false} active={workerReady} />
|
||||
<div>
|
||||
|
|
@ -122,10 +124,16 @@ function Step({
|
|||
active?: boolean;
|
||||
}) {
|
||||
const incomplete = checking ? <Loader2 className="size-4 animate-spin" /> : number;
|
||||
const unfinishedStyle = active ? "bg-foreground text-background" : "border text-muted-foreground";
|
||||
const state = stepState(complete, active);
|
||||
return (
|
||||
<span
|
||||
className={`flex size-7 items-center justify-center rounded-full text-xs font-medium ${complete ? "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400" : unfinishedStyle}`}
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full text-xs font-medium",
|
||||
"data-[state=complete]:bg-emerald-50 data-[state=complete]:text-emerald-700 dark:data-[state=complete]:bg-emerald-950 dark:data-[state=complete]:text-emerald-400",
|
||||
"data-[state=active]:bg-foreground data-[state=active]:text-background",
|
||||
"data-[state=inactive]:border data-[state=inactive]:text-muted-foreground",
|
||||
)}
|
||||
aria-label={complete ? `Step ${number} complete` : `Step ${number}`}
|
||||
>
|
||||
{complete ? <Check className="size-4" /> : incomplete}
|
||||
|
|
@ -133,6 +141,12 @@ function Step({
|
|||
);
|
||||
}
|
||||
|
||||
function stepState(complete: boolean, active?: boolean) {
|
||||
if (complete) return "complete";
|
||||
if (active) return "active";
|
||||
return "inactive";
|
||||
}
|
||||
|
||||
function activityStatus(traces: boolean, requests: boolean, checking: boolean) {
|
||||
if (traces) return "Traces received";
|
||||
if (requests) return "Request logs received";
|
||||
|
|
@ -2,9 +2,10 @@ import { useState } from "react";
|
|||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import { Check } from "lucide-react";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
import anthropicLogo from "../../../../../public/assets/logos/anthropic.svg";
|
||||
import openaiLogo from "../../../../../public/assets/logos/openai_small.svg";
|
||||
import { briefMarkdown, type IssueBrief } from "./lensData";
|
||||
import anthropicLogo from "@/../public/assets/logos/anthropic.svg";
|
||||
import openaiLogo from "@/../public/assets/logos/openai_small.svg";
|
||||
import { briefMarkdown } from "../model/findings";
|
||||
import { type IssueBrief } from "../model/types";
|
||||
|
||||
const AGENTS = [
|
||||
{ name: "Claude Code", logo: anthropicLogo.src },
|
||||
|
|
@ -27,7 +28,7 @@ const markdown: Components = {
|
|||
code: ({ children }) => <code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">{children}</code>,
|
||||
};
|
||||
|
||||
export function LensIssueBrief({ title, brief }: { title: string; brief: IssueBrief }) {
|
||||
export function IssueBrief({ title, brief }: { title: string; brief: IssueBrief }) {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const source = briefMarkdown(title, brief);
|
||||
const copy = async (agent: string) => {
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export type ReadinessBannerProps = ComponentProps<"div"> & { activityReady: boolean };
|
||||
|
||||
export function ReadinessBanner({ activityReady, className, ...props }: ReadinessBannerProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
data-slot="readiness-banner"
|
||||
role="status"
|
||||
className={cn("flex flex-wrap items-center gap-2 border-y py-3 text-sm text-muted-foreground", className)}
|
||||
>
|
||||
{activityReady
|
||||
? "Connect a worker to run new investigations. Saved results are still available."
|
||||
: "Recorded activity is not ready. Saved results are still available."}
|
||||
{!activityReady && (
|
||||
<a className="font-medium underline" href={uiHref("lens/?tab=traces")}>
|
||||
Check traces
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
|
||||
import { evidenceTarget } from "../model/findings";
|
||||
|
||||
type RequestEvidence = components["schemas"]["ExecutionContent"];
|
||||
|
||||
export function RequestEvidenceSheet({
|
||||
target,
|
||||
setEvidence,
|
||||
requestEvidenceData,
|
||||
requestEvidenceError,
|
||||
requestEvidenceLoading,
|
||||
requestOffset,
|
||||
setRequestOffset,
|
||||
}: {
|
||||
target: ReturnType<typeof evidenceTarget>;
|
||||
setEvidence: (value: { id: string; span: string } | null) => void;
|
||||
requestEvidenceData: RequestEvidence | undefined;
|
||||
requestEvidenceError: Error | null | undefined;
|
||||
requestEvidenceLoading: boolean;
|
||||
requestOffset: number;
|
||||
setRequestOffset: (offset: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Sheet
|
||||
open={target?.source === "requests"}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEvidence(null);
|
||||
}}
|
||||
>
|
||||
<SheetContent className="overflow-y-auto data-[side=right]:sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Request evidence</SheetTitle>
|
||||
<SheetDescription>Original logged input and output</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="p-4 space-y-3">
|
||||
{requestEvidenceLoading && <p role="status">Loading request…</p>}
|
||||
{requestEvidenceError && <p role="alert">{requestEvidenceError.message}</p>}
|
||||
{requestEvidenceData?.parts.map((p) => (
|
||||
<pre className="whitespace-pre-wrap break-words text-xs" key={p.span_id}>
|
||||
{p.content}
|
||||
</pre>
|
||||
))}
|
||||
{requestEvidenceData?.parts.length === 0 && <p>Request was not found or is past retention</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{requestOffset > 0 && (
|
||||
<Button variant="outline" onClick={() => setRequestOffset(Math.max(0, requestOffset - 8000))}>
|
||||
Previous section
|
||||
</Button>
|
||||
)}
|
||||
{requestEvidenceData?.parts.some((p) => p.truncated) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRequestOffset(requestOffset === 0 ? 1 : requestOffset + 8000)}
|
||||
>
|
||||
Next section
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { RunView } from "@/components/view_logs/TraceView/TraceDrawer";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
|
||||
|
||||
export function TracePanel({
|
||||
export function TraceSheet({
|
||||
open,
|
||||
traceId,
|
||||
traceRef,
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { type Settings } from "../../model/types";
|
||||
import { watches } from "../../setup/watches";
|
||||
|
||||
export function CriteriaTab({
|
||||
batchSettings,
|
||||
readOnly,
|
||||
setEditing,
|
||||
}: {
|
||||
batchSettings: Settings | undefined;
|
||||
readOnly: boolean;
|
||||
setEditing: (mode: "edit") => void;
|
||||
}) {
|
||||
return (
|
||||
<TabsContent value="checks" className="pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Used for this run</p>
|
||||
{!readOnly && (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing("edit")}>
|
||||
Edit criteria
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{batchSettings?.context && (
|
||||
<div className="space-y-2 border-b pb-5">
|
||||
<h3 className="text-base font-semibold">What should the agent be doing?</h3>
|
||||
<p className="text-xs text-muted-foreground">The purpose and expected outcome of the agent.</p>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm">{batchSettings.context}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2">
|
||||
<h3 className="text-base font-semibold">Watch for</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Specific problems or patterns to investigate.</p>
|
||||
</div>
|
||||
{batchSettings?.checks.map((c, index) => (
|
||||
<div key={c.id} className="flex items-start gap-3 border-b py-4">
|
||||
<span className="mt-0.5 text-xs tabular-nums text-muted-foreground">{index + 1}.</span>
|
||||
<CheckSummary check={c} />
|
||||
{!c.enabled && <span className="text-xs text-muted-foreground">Disabled</span>}
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Changes apply to future scans. Rechecking history uses your analysis budget.
|
||||
</p>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
function CheckSummary({ check }: { check: Settings["checks"][number] }) {
|
||||
const watch = watches.find((item) => item.id === check.id);
|
||||
if (!watch) return <p className="flex-1 text-sm leading-6">{check.instruction}</p>;
|
||||
return (
|
||||
<p className="grid flex-1 gap-0.5">
|
||||
<span className="text-sm font-medium">{watch.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{watch.summary}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
"use client";
|
||||
import { ListRow } from "@/components/shared/ListRow";
|
||||
|
||||
import { ChevronRight, CheckCircle2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { type Lens, type Finding, type Job } from "../../model/types";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const priorityColors = { high: "bg-red-500", medium: "bg-amber-500", low: "bg-slate-400" };
|
||||
function emptyFindingTitle(active: boolean, scanned: boolean, status?: string) {
|
||||
if (status === "failed" || status === "cancelled") return "No findings from this run";
|
||||
if (active) return "Your findings will appear here";
|
||||
return scanned ? "No matching findings" : "Ready for the first analysis";
|
||||
}
|
||||
|
||||
export function FindingsTab({
|
||||
kind,
|
||||
setKind,
|
||||
batchFindings,
|
||||
filter,
|
||||
setFilter,
|
||||
visibleFindings,
|
||||
setFindingId,
|
||||
active,
|
||||
lens,
|
||||
job,
|
||||
}: {
|
||||
kind: "issue" | "pattern";
|
||||
setKind: (kind: "issue" | "pattern") => void;
|
||||
batchFindings: Finding[];
|
||||
filter: string;
|
||||
setFilter: (filter: string) => void;
|
||||
visibleFindings: Finding[];
|
||||
setFindingId: (id: string) => void;
|
||||
|
||||
active: Job | undefined;
|
||||
lens: Lens;
|
||||
job: Job | undefined;
|
||||
}) {
|
||||
return (
|
||||
<TabsContent value="findings" className="pt-4 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex gap-1" aria-label="Finding category">
|
||||
<Button size="sm" variant={kind === "issue" ? "secondary" : "ghost"} onClick={() => setKind("issue")}>
|
||||
Needs attention ({batchFindings.filter((f) => f.kind === "issue" && f.status === "open").length ?? 0})
|
||||
</Button>
|
||||
<Button size="sm" variant={kind === "pattern" ? "secondary" : "ghost"} onClick={() => setKind("pattern")}>
|
||||
Patterns ({batchFindings.filter((f) => f.kind === "pattern" && f.status === "open").length ?? 0})
|
||||
</Button>
|
||||
</div>
|
||||
<select
|
||||
aria-label="Finding status"
|
||||
className="rounded-md border bg-background px-2 py-1 text-xs"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="dismissed">Dismissed</option>
|
||||
<option value="all">All statuses</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{kind === "issue"
|
||||
? "Problems worth investigating, highest priority first."
|
||||
: "Useful behavior and trends. These do not necessarily need a fix."}
|
||||
</p>
|
||||
<div className="divide-y border-y">
|
||||
{visibleFindings.map((f) => (
|
||||
<ListRow
|
||||
key={f.id}
|
||||
onClick={() => {
|
||||
setFindingId(f.id);
|
||||
}}
|
||||
className="flex w-full gap-3 py-4 text-left hover:bg-muted/30 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
<span
|
||||
data-state={f.priority ?? "medium"}
|
||||
className={cn("mt-1 size-2 shrink-0 rounded-full", priorityColors[f.priority ?? "medium"])}
|
||||
aria-label={`${f.priority} priority`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{f.title}</p>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{f.description}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{f.occurrences?.length ?? 0} linked {f.occurrences?.length === 1 ? "run" : "runs"} ·{" "}
|
||||
{f.kind === "issue" ? `${f.priority} priority` : "Pattern"}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</ListRow>
|
||||
))}
|
||||
{visibleFindings.length === 0 && (
|
||||
<div className="px-6 py-14 text-center">
|
||||
<CheckCircle2 className="mx-auto mb-3 size-5 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">{emptyFindingTitle(!!active, !!lens.last_scan_at, job?.status)}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{active
|
||||
? "Lens is reviewing the selected activity."
|
||||
: "Findings reflect the runs analyzed, not a guarantee about all activity."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
import { ListRow } from "@/components/shared/ListRow";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { ScanDuration } from "../InvestigationProgress";
|
||||
import type { Lens, Job } from "../../model/types";
|
||||
|
||||
import { money, when } from "../../model/format";
|
||||
|
||||
export function HistoryTab({
|
||||
history,
|
||||
historyError,
|
||||
refetchHistory,
|
||||
lens,
|
||||
openBatch,
|
||||
historyOffset,
|
||||
setHistoryOffset,
|
||||
}: {
|
||||
history: Job[] | undefined;
|
||||
historyError: Error | null | undefined;
|
||||
refetchHistory: () => void;
|
||||
lens: Lens;
|
||||
openBatch: (id: string) => void;
|
||||
historyOffset: number;
|
||||
setHistoryOffset: (offset: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<TabsContent value="activity" className="pt-4 space-y-4">
|
||||
{historyError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Could not load run history.{" "}
|
||||
<Button variant="link" size="sm" onClick={() => void refetchHistory()}>
|
||||
Retry
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
<div className="divide-y border-y">
|
||||
{(history ?? lens.jobs)?.map((j) => (
|
||||
<ListRow
|
||||
key={j.id}
|
||||
onClick={() => openBatch(j.id)}
|
||||
className="flex w-full items-center gap-4 py-4 text-left hover:bg-muted/30 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{when(j.created_at)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{j.coverage?.screened ?? 0} runs reviewed
|
||||
{j.findings != null && <> · {j.findings.length} findings</>}
|
||||
<ScanDuration job={j} />
|
||||
</p>
|
||||
{j.error && <p className="mt-2 line-clamp-2 text-xs text-destructive">{j.error}</p>}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p
|
||||
data-state={j.status === "failed" ? "failed" : "other"}
|
||||
className="capitalize data-[state=failed]:text-destructive"
|
||||
>
|
||||
{j.status}
|
||||
</p>
|
||||
<p className="mt-1">{money(j.cost ?? 0)}</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</ListRow>
|
||||
))}
|
||||
</div>
|
||||
{(historyOffset > 0 || (history?.length ?? 0) >= 50) && (
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!historyOffset}
|
||||
onClick={() => setHistoryOffset(Math.max(0, historyOffset - 50))}
|
||||
>
|
||||
Newer runs
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={(history?.length ?? 0) < 50}
|
||||
onClick={() => setHistoryOffset(historyOffset + 50)}
|
||||
>
|
||||
Older runs
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import { Pause, Play, Settings2, MoreHorizontal, Copy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { type Lens, type Job } from "../../model/types";
|
||||
|
||||
export function InvestigationActions({
|
||||
lens,
|
||||
ready,
|
||||
busy,
|
||||
active,
|
||||
setEditing,
|
||||
setMonitoring,
|
||||
update,
|
||||
}: {
|
||||
lens: Lens;
|
||||
ready: boolean;
|
||||
busy: boolean;
|
||||
active: Job | undefined;
|
||||
setEditing: (mode: "new" | "edit" | "duplicate") => void;
|
||||
setMonitoring: (open: boolean) => void;
|
||||
update: (path: string, body: unknown, method?: "post" | "put" | "patch") => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" aria-label="Investigation actions" />}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => setEditing("edit")}>
|
||||
<Settings2 />
|
||||
Edit investigation
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!ready} onClick={() => setEditing("duplicate")}>
|
||||
<Copy />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
{lens.settings.enabled ? (
|
||||
<DropdownMenuItem
|
||||
disabled={busy}
|
||||
onClick={() => update(`/lens/${lens.id}`, { ...lens.settings, enabled: false }, "put")}
|
||||
>
|
||||
<Pause />
|
||||
Pause monitoring
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem disabled={!ready || busy} onClick={() => setMonitoring(true)}>
|
||||
<Play />
|
||||
Enable monitoring
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button disabled={busy || !!active || !ready} onClick={() => update(`/lens/${lens.id}/runs`, {})}>
|
||||
<Play className="size-3" />
|
||||
Run now
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
"use client";
|
||||
import type { useInvestigationResults } from "../useInvestigationResults";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { RunsTab } from "./RunsTab";
|
||||
import { InvestigationProgress } from "../InvestigationProgress";
|
||||
import { InvestigationSummary } from "./InvestigationSummary";
|
||||
import { InvestigationFailure } from "./InvestigationFailure";
|
||||
import { scopeLabel, sourceLabels } from "../../model/format";
|
||||
import { type Lens } from "../../model/types";
|
||||
|
||||
import { InvestigationActions } from "./InvestigationActions";
|
||||
import { RunPicker } from "./RunPicker";
|
||||
import { FindingsTab } from "./FindingsTab";
|
||||
import { CriteriaTab } from "./CriteriaTab";
|
||||
import { HistoryTab } from "./HistoryTab";
|
||||
export function InvestigationDetail({
|
||||
lens,
|
||||
readOnly,
|
||||
ready,
|
||||
busy,
|
||||
setEditing,
|
||||
setMonitoring,
|
||||
update,
|
||||
connected,
|
||||
results,
|
||||
}: {
|
||||
lens: Lens;
|
||||
readOnly: boolean;
|
||||
ready: boolean;
|
||||
busy: boolean;
|
||||
setEditing: (mode: "new" | "edit" | "duplicate") => void;
|
||||
setMonitoring: (open: boolean) => void;
|
||||
update: (path: string, body: unknown, method?: "post" | "put" | "patch") => Promise<void>;
|
||||
connected: boolean;
|
||||
results: ReturnType<typeof useInvestigationResults>;
|
||||
}) {
|
||||
const {
|
||||
active,
|
||||
job,
|
||||
tab,
|
||||
setTab,
|
||||
batchSettings,
|
||||
batchId,
|
||||
setBatchId,
|
||||
setFindingId,
|
||||
selectedOutsideHistory,
|
||||
history,
|
||||
historyError,
|
||||
refetchHistory,
|
||||
historicalError,
|
||||
refetchHistorical,
|
||||
missingSnapshot,
|
||||
kind,
|
||||
setKind,
|
||||
batchFindings,
|
||||
filter,
|
||||
setFilter,
|
||||
visibleFindings,
|
||||
setRequestOffset,
|
||||
setEvidence,
|
||||
openBatch,
|
||||
historyOffset,
|
||||
setHistoryOffset,
|
||||
} = results;
|
||||
return (
|
||||
<div>
|
||||
<section className="min-w-0 space-y-5">
|
||||
<div className="flex flex-wrap justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{lens.settings.name}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{sourceLabels[lens.settings.source ?? "traces"]} · {scopeLabel(lens.settings)}
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<InvestigationActions
|
||||
lens={lens}
|
||||
ready={ready}
|
||||
busy={busy}
|
||||
active={active}
|
||||
setEditing={setEditing}
|
||||
setMonitoring={setMonitoring}
|
||||
update={update}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<InvestigationSummary lens={lens} connected={connected} />
|
||||
{active && (
|
||||
<InvestigationProgress
|
||||
key={active.id}
|
||||
job={active}
|
||||
onCancel={
|
||||
readOnly
|
||||
? undefined
|
||||
: () => {
|
||||
void update(`/lens/${lens.id}/cancel`, {});
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{job?.error && <InvestigationFailure job={job} connected={connected} />}
|
||||
<Tabs value={tab} onValueChange={setTab} key={lens.id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="findings">Findings</TabsTrigger>
|
||||
<TabsTrigger value="checks">Criteria</TabsTrigger>
|
||||
<TabsTrigger value="runs">
|
||||
{
|
||||
{ requests: "Requests", both: "Traces & requests", traces: "Traces" }[
|
||||
batchSettings?.source ?? "traces"
|
||||
]
|
||||
}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity">History</TabsTrigger>
|
||||
</TabsList>
|
||||
{tab !== "activity" && (
|
||||
<RunPicker
|
||||
batchId={batchId}
|
||||
setBatchId={setBatchId}
|
||||
setFindingId={setFindingId}
|
||||
job={job}
|
||||
selectedOutsideHistory={selectedOutsideHistory}
|
||||
history={history}
|
||||
lens={lens}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{historicalError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Could not load this run.{" "}
|
||||
<Button variant="link" onClick={() => void refetchHistorical()}>
|
||||
Retry
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
{missingSnapshot && tab !== "activity" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This older batch predates saved result snapshots. Its findings remain available under All accumulated
|
||||
findings.
|
||||
</p>
|
||||
)}
|
||||
<FindingsTab
|
||||
kind={kind}
|
||||
setKind={setKind}
|
||||
batchFindings={batchFindings}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
visibleFindings={visibleFindings}
|
||||
setFindingId={setFindingId}
|
||||
active={active}
|
||||
lens={lens}
|
||||
job={job}
|
||||
/>
|
||||
<CriteriaTab batchSettings={batchSettings} readOnly={readOnly} setEditing={setEditing} />
|
||||
<TabsContent value="runs" className="pt-4 space-y-4">
|
||||
<RunsTab
|
||||
key={job?.id ?? batchId}
|
||||
job={job}
|
||||
onOpen={(id) => {
|
||||
setRequestOffset(0);
|
||||
setEvidence({ id, span: "" });
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<HistoryTab
|
||||
history={history}
|
||||
historyError={historyError}
|
||||
refetchHistory={refetchHistory}
|
||||
lens={lens}
|
||||
openBatch={openBatch}
|
||||
historyOffset={historyOffset}
|
||||
setHistoryOffset={setHistoryOffset}
|
||||
/>
|
||||
</Tabs>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { ComponentProps } from "react";
|
||||
import { runTime } from "../../model/format";
|
||||
import { type Job } from "../../model/types";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export type InvestigationFailureProps = ComponentProps<"div"> & {
|
||||
job: Job;
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
export function InvestigationFailure({ job, connected, className, ...props }: InvestigationFailureProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
data-slot="investigation-failure"
|
||||
role="alert"
|
||||
className={cn("space-y-2 rounded-md border border-destructive/20 p-3 text-sm", className)}
|
||||
>
|
||||
<p className="font-medium text-destructive">This investigation did not finish</p>
|
||||
<p>{job.error}</p>
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground">Troubleshooting details</summary>
|
||||
<dl className="mt-2 space-y-1 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<dt className="inline">Run: </dt>
|
||||
<dd className="inline">{job.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline">Model: </dt>
|
||||
<dd className="inline">{job.settings.model}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline">Worker: </dt>
|
||||
<dd className="inline">{connected ? "Connected now" : "Not connected"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline">Started: </dt>
|
||||
<dd className="inline">{runTime(job.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Use the run ID to find the error in proxy and worker logs. Check the worker key's model permissions and
|
||||
budget before retrying.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { ComponentProps } from "react";
|
||||
import { NextCheck } from "../InvestigationProgress";
|
||||
import { lensStatus } from "../../model/status";
|
||||
import { runTime } from "../../model/format";
|
||||
import { type Lens } from "../../model/types";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export type InvestigationSummaryProps = ComponentProps<"div"> & {
|
||||
lens: Lens;
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
export function InvestigationSummary({ lens, connected, className, ...props }: InvestigationSummaryProps) {
|
||||
const lastCompleted = lens.jobs.find((job) => job.status === "completed");
|
||||
const lastSuccess = lastCompleted?.finished_at ?? lens.last_scan_at;
|
||||
const spent = lens.budget_month === new Date().toISOString().slice(0, 7) ? lens.spent ?? 0 : 0;
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
data-slot="investigation-summary"
|
||||
className={cn("flex flex-wrap gap-x-8 gap-y-3 border-y py-3 text-xs text-muted-foreground", className)}
|
||||
>
|
||||
<span>
|
||||
Latest run:{" "}
|
||||
<strong
|
||||
data-state={lens.jobs[0]?.status === "failed" ? "failed" : "ok"}
|
||||
className="font-medium data-[state=failed]:text-destructive data-[state=ok]:text-foreground"
|
||||
>
|
||||
{lensStatus(lens, connected)}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Last success: <span className="text-foreground">{lastSuccess ? runTime(lastSuccess) : "Not yet"}</span>
|
||||
</span>
|
||||
<span>
|
||||
This month:{" "}
|
||||
<span className="text-foreground">
|
||||
${spent.toFixed(3)} / ${lens.settings.monthly_budget ?? 100}
|
||||
</span>
|
||||
</span>
|
||||
{lens.settings.enabled && (
|
||||
<span>
|
||||
Monitoring every {lens.settings.interval_minutes} minutes
|
||||
<NextCheck lens={lens} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScanDuration } from "../InvestigationProgress";
|
||||
import type { Lens, Job } from "../../model/types";
|
||||
|
||||
import { money, when } from "../../model/format";
|
||||
|
||||
export function RunPicker({
|
||||
batchId,
|
||||
setBatchId,
|
||||
setFindingId,
|
||||
job,
|
||||
selectedOutsideHistory,
|
||||
history,
|
||||
lens,
|
||||
}: {
|
||||
batchId: string;
|
||||
setBatchId: (id: string) => void;
|
||||
setFindingId: (id: string | null) => void;
|
||||
job: Job | undefined;
|
||||
selectedOutsideHistory: boolean;
|
||||
history: Job[] | undefined;
|
||||
lens: Lens;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1 pb-1">
|
||||
<select
|
||||
aria-label="Investigation run"
|
||||
className="h-8 w-44 max-w-full truncate rounded-md border-0 bg-transparent px-2 text-xs text-muted-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"
|
||||
value={batchId}
|
||||
onChange={(e) => {
|
||||
setBatchId(e.target.value);
|
||||
setFindingId(null);
|
||||
}}
|
||||
>
|
||||
<option value="latest">Latest run</option>
|
||||
{job && selectedOutsideHistory && (
|
||||
<option value={batchId}>
|
||||
{when(job.created_at)} · {job.status}
|
||||
</option>
|
||||
)}
|
||||
{(history ?? lens.jobs)?.map((j) => (
|
||||
<option key={j.id} value={j.id}>
|
||||
{when(j.created_at)} · {j.status}
|
||||
</option>
|
||||
))}
|
||||
<option value="all">All accumulated findings</option>
|
||||
</select>
|
||||
{job && batchId !== "all" && (
|
||||
<Popover key={job.id}>
|
||||
<PopoverTrigger
|
||||
aria-label="Run details"
|
||||
render={<Button variant="ghost" size="icon" className="size-7 text-muted-foreground" />}
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="gap-3">
|
||||
<PopoverTitle>Run details</PopoverTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{job.coverage?.screened ?? 0} / {job.coverage?.selected ?? 0} selected runs reviewed
|
||||
<ScanDuration job={job} />
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{job.coverage?.partial ?? 0} partial traces · {job.coverage?.unassessable ?? 0} could not be assessed ·{" "}
|
||||
{job.coverage?.inconclusive ?? 0} inconclusive
|
||||
</p>
|
||||
<dl className="space-y-2 text-xs">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Activity window</dt>
|
||||
<dd className="mt-1">
|
||||
{when(job.start)} to {when(job.end)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-muted-foreground">Analysis cost</dt>
|
||||
<dd>{money(job.cost ?? 0)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-muted-foreground">Status</dt>
|
||||
<dd className="capitalize">{job.status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { ListRow } from "@/components/shared/ListRow";
|
||||
import { useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { runTime, type Job } from "./lensData";
|
||||
import { runTime } from "../../model/format";
|
||||
import { type Job } from "../../model/types";
|
||||
|
||||
function assessmentLabel(assessment: Job["assessments"][number] | undefined): string {
|
||||
if (!assessment) return "Not reviewed";
|
||||
|
|
@ -9,7 +11,7 @@ function assessmentLabel(assessment: Job["assessments"][number] | undefined): st
|
|||
return assessment.issue_checks?.length ? "Issue observed" : "No issue observed";
|
||||
}
|
||||
|
||||
export function LensRuns({ job, onOpen }: { job?: Job; onOpen: (id: string) => void }) {
|
||||
export function RunsTab({ job, onOpen }: { job?: Job; onOpen: (id: string) => void }) {
|
||||
const [runOffset, setRunOffset] = useState(0);
|
||||
const [runFilter, setRunFilter] = useState("all");
|
||||
const assessments = new Map(job?.assessments?.map((a) => [a.execution_id, a]));
|
||||
|
|
@ -49,7 +51,7 @@ export function LensRuns({ job, onOpen }: { job?: Job; onOpen: (id: string) => v
|
|||
</div>
|
||||
<div className="divide-y border-y">
|
||||
{visibleRuns.slice(runOffset, runOffset + 50).map((run) => (
|
||||
<button
|
||||
<ListRow
|
||||
key={run.id}
|
||||
title={run.trace_id}
|
||||
onClick={() => onOpen(run.id)}
|
||||
|
|
@ -66,7 +68,7 @@ export function LensRuns({ job, onOpen }: { job?: Job; onOpen: (id: string) => v
|
|||
aria-hidden="true"
|
||||
className="col-start-2 row-start-1 size-4 text-muted-foreground group-hover:text-foreground sm:col-start-4"
|
||||
/>
|
||||
</button>
|
||||
</ListRow>
|
||||
))}
|
||||
{!visibleRuns.length && (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLensDemo } from "../LensDemoContext";
|
||||
import { useLensApi } from "../api/useLensApi";
|
||||
import { lensQueries } from "../api/queries";
|
||||
import { evidenceTarget, mergeFeedback, sortedFindings } from "../model/findings";
|
||||
import type { Job, Lens } from "../model/types";
|
||||
|
||||
export function useInvestigationResults(accessToken: string, lens: Lens | undefined) {
|
||||
const apiClient = useLensApi();
|
||||
const demo = useLensDemo();
|
||||
const [batchId, setBatchId] = useState("latest");
|
||||
const [historyOffset, setHistoryOffset] = useState(0);
|
||||
const [tab, setTab] = useState("findings");
|
||||
const [findingId, setFindingId] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState("open");
|
||||
const [kind, setKind] = useState<"issue" | "pattern">("issue");
|
||||
const [evidence, setEvidence] = useState<{ id: string; span: string } | null>(null);
|
||||
const history = useQuery(
|
||||
lensQueries.history(apiClient, accessToken, { lensId: lens?.id, historyOffset, demo: !!demo }),
|
||||
);
|
||||
const historical = useQuery(lensQueries.run(apiClient, accessToken, lens?.id, batchId));
|
||||
const { job, missingSnapshot, selectedOutsideHistory, batchSettings, batchFindings } = runSnapshot(
|
||||
lens,
|
||||
batchId,
|
||||
historical.data,
|
||||
history.data,
|
||||
);
|
||||
const finding = batchFindings.find((f) => f.id === findingId);
|
||||
const openBatch = (id: string) => {
|
||||
setBatchId(id);
|
||||
setTab("findings");
|
||||
setFindingId(null);
|
||||
};
|
||||
const active = lens?.jobs?.find((j) => j.status === "queued" || j.status === "running");
|
||||
const visibleFindings = sortedFindings(
|
||||
batchFindings.filter((f) => (filter === "all" || f.status === filter) && f.kind === kind),
|
||||
);
|
||||
const sampledRuns = job?.sample?.executions ?? [];
|
||||
const target = evidence ? evidenceTarget(evidence.id) : null;
|
||||
const [requestOffset, setRequestOffset] = useState(0);
|
||||
const evidenceInput = { lensId: lens?.id, evidenceId: evidence?.id, requestOffset, source: target?.source };
|
||||
const requestEvidence = useQuery(lensQueries.evidence(apiClient, accessToken, evidenceInput));
|
||||
const reset = useCallback(() => {
|
||||
setBatchId("latest");
|
||||
setHistoryOffset(0);
|
||||
setFindingId(null);
|
||||
setTab("findings");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
batchId,
|
||||
setBatchId,
|
||||
historyOffset,
|
||||
setHistoryOffset,
|
||||
tab,
|
||||
setTab,
|
||||
findingId,
|
||||
setFindingId,
|
||||
filter,
|
||||
setFilter,
|
||||
kind,
|
||||
setKind,
|
||||
evidence,
|
||||
setEvidence,
|
||||
history: history.data,
|
||||
historyError: history.error,
|
||||
refetchHistory: history.refetch,
|
||||
historicalError: historical.error,
|
||||
refetchHistorical: historical.refetch,
|
||||
job,
|
||||
missingSnapshot,
|
||||
selectedOutsideHistory,
|
||||
batchSettings,
|
||||
batchFindings,
|
||||
finding,
|
||||
openBatch,
|
||||
active,
|
||||
visibleFindings,
|
||||
sampledRuns,
|
||||
target,
|
||||
requestOffset,
|
||||
setRequestOffset,
|
||||
requestEvidenceData: requestEvidence.data,
|
||||
requestEvidenceError: requestEvidence.error,
|
||||
requestEvidenceLoading: requestEvidence.isLoading,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
function runSnapshot(lens: Lens | undefined, batchId: string, historical: Job | undefined, history: Job[] | undefined) {
|
||||
const job = ["latest", "all"].includes(batchId) ? lens?.jobs?.[0] : historical;
|
||||
const missingSnapshot = job?.status === "completed" && job.findings == null && batchId !== "all";
|
||||
const selectedOutsideHistory = !["latest", "all"].includes(batchId) && !history?.some((j) => j.id === batchId);
|
||||
const batchSettings = job?.settings ?? lens?.settings;
|
||||
const batchFindings = mergeFeedback(
|
||||
batchId === "all" ? lens?.findings ?? [] : job?.findings ?? [],
|
||||
lens?.findings ?? [],
|
||||
);
|
||||
return { job, missingSnapshot, selectedOutsideHistory, batchSettings, batchFindings };
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { mergeFeedback, sortedFindings, briefMarkdown } from "./findings";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type Finding } from "./types";
|
||||
|
||||
describe("Lens findings", () => {
|
||||
it("puts high priority issues ahead of newer low priority findings", () => {
|
||||
const base: Finding = {
|
||||
kind: "issue",
|
||||
status: "open",
|
||||
reason: "",
|
||||
suggestion: "",
|
||||
limitation: "",
|
||||
occurrences: [],
|
||||
id: "low",
|
||||
check_id: "check",
|
||||
title: "Recovered error",
|
||||
description: "The run recovered.",
|
||||
evidence: [],
|
||||
revision: 1,
|
||||
priority: "low",
|
||||
first_seen: "2026-09-30T10:00:00Z",
|
||||
last_seen: "2026-09-30T12:00:00Z",
|
||||
};
|
||||
const high: Finding = { ...base, id: "high", priority: "high", last_seen: "2026-09-30T11:00:00Z" };
|
||||
expect(sortedFindings([base, high]).map((f) => f.id)).toEqual(["high", "low"]);
|
||||
});
|
||||
it("turns an issue brief into a pasteable markdown document", () => {
|
||||
const briefInput = {
|
||||
problem: "The workspace was not a Git repository.",
|
||||
user_goal: "Open a PR fixing a typo",
|
||||
what_happened: 'Git returned "fatal: not a git repository"',
|
||||
test_cases: [
|
||||
{ input: "Fix the typo and open a PR", expected: "A PR URL is returned" },
|
||||
{ input: "Rename greet", expected: "The rename is committed" },
|
||||
],
|
||||
};
|
||||
expect(briefMarkdown("PRs were never opened", briefInput)).toBe(
|
||||
[
|
||||
"# PRs were never opened",
|
||||
"## Problem\nThe workspace was not a Git repository.",
|
||||
"## User goal\nOpen a PR fixing a typo",
|
||||
'## What happened\nGit returned "fatal: not a git repository"',
|
||||
"## Test cases\n1. **Input:** Fix the typo and open a PR \n **Expect:** A PR URL is returned\n" +
|
||||
"2. **Input:** Rename greet \n **Expect:** The rename is committed",
|
||||
].join("\n\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies current feedback to a historical snapshot while preserving its original evidence", () => {
|
||||
const snapshot: Finding = {
|
||||
kind: "issue",
|
||||
status: "open",
|
||||
reason: "",
|
||||
suggestion: "",
|
||||
limitation: "",
|
||||
occurrences: [],
|
||||
id: "finding",
|
||||
check_id: "check",
|
||||
title: "Historical title",
|
||||
description: "Historical description",
|
||||
evidence: [],
|
||||
revision: 1,
|
||||
priority: "high",
|
||||
first_seen: "2026-09-30T10:00:00Z",
|
||||
last_seen: "2026-09-30T12:00:00Z",
|
||||
};
|
||||
const current: Finding = { ...snapshot, title: "Updated title", status: "resolved", reason: "Fixed", revision: 2 };
|
||||
expect(mergeFeedback([snapshot], [current])).toEqual([{ ...snapshot, status: "resolved", reason: "Fixed" }]);
|
||||
expect(snapshot.status).toBe("open");
|
||||
expect(mergeFeedback([snapshot], [])).toEqual([snapshot]);
|
||||
});
|
||||
37
ui/litellm-dashboard/src/components/lens/model/findings.ts
Normal file
37
ui/litellm-dashboard/src/components/lens/model/findings.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { Finding, IssueBrief } from "./types";
|
||||
|
||||
export function sortedFindings(findings: Finding[]): Finding[] {
|
||||
const rank = { high: 0, medium: 1, low: 2 };
|
||||
return [...findings].sort(
|
||||
(a, b) =>
|
||||
rank[a.priority ?? "medium"] - rank[b.priority ?? "medium"] || Date.parse(b.last_seen) - Date.parse(a.last_seen),
|
||||
);
|
||||
}
|
||||
|
||||
export function evidenceTarget(id: string): { source: string; team: string; id: string; traceRef?: string } | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(atob(id.replace(/-/g, "+").replace(/_/g, "/")));
|
||||
if (!Array.isArray(parsed) || ![3, 4].includes(parsed.length) || !parsed.every((item) => typeof item === "string"))
|
||||
return null;
|
||||
return { source: parsed[0], team: parsed[1], id: parsed[2], ...(parsed[3] ? { traceRef: parsed[3] } : {}) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function briefMarkdown(title: string, brief: IssueBrief): string {
|
||||
return [
|
||||
`# ${title}`,
|
||||
`## Problem\n${brief.problem}`,
|
||||
`## User goal\n${brief.user_goal}`,
|
||||
`## What happened\n${brief.what_happened}`,
|
||||
`## Test cases\n${brief.test_cases.map((t, i) => `${i + 1}. **Input:** ${t.input} \n **Expect:** ${t.expected}`).join("\n")}`,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
export function mergeFeedback(findings: Finding[], current: Finding[]): Finding[] {
|
||||
return findings.map((finding) => {
|
||||
const feedback = current.find((item) => item.id === finding.id);
|
||||
return feedback ? { ...finding, status: feedback.status, reason: feedback.reason } : finding;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { durationLabel, durationText, money, scopeLabel, when } from "./format";
|
||||
|
||||
describe("Lens labels", () => {
|
||||
it("formats an empty scope and joins recorded scope constraints in their original order", () => {
|
||||
expect(scopeLabel({})).toBe("All activity");
|
||||
const settings = { agent_name: "research", service: "shared", filters: [{ key: "team", value: "quality" }] };
|
||||
expect(scopeLabel(settings)).toBe("research · shared · team: quality");
|
||||
});
|
||||
|
||||
it("formats elapsed time and treats a non-finite duration as zero", () => {
|
||||
expect(durationText(NaN)).toBe("0s");
|
||||
expect(durationText(59)).toBe("59s");
|
||||
expect(durationText(61)).toBe("1m 1s");
|
||||
expect(durationText(3661)).toBe("1h 1m");
|
||||
});
|
||||
|
||||
it("keeps the units and singular forms of sampling and monitoring windows", () => {
|
||||
expect(durationLabel(1)).toBe("1 minute");
|
||||
expect(durationLabel(60)).toBe("1 hour");
|
||||
expect(durationLabel(90)).toBe("90 minutes");
|
||||
expect(durationLabel(24, "hours")).toBe("1 day");
|
||||
expect(durationLabel(36, "hours")).toBe("1.5 days");
|
||||
});
|
||||
|
||||
it("keeps USD precision and the placeholder for a scan that has not run", () => {
|
||||
expect(money(12.12345)).toBe("$12.123");
|
||||
expect(money(0)).toBe("$0.00");
|
||||
expect(when()).toBe("Not yet");
|
||||
expect(when(null)).toBe("Not yet");
|
||||
});
|
||||
});
|
||||
34
ui/litellm-dashboard/src/components/lens/model/format.ts
Normal file
34
ui/litellm-dashboard/src/components/lens/model/format.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { formatActivityTimestamp as runTime } from "@/utils/activityTimestamp";
|
||||
import type { Settings } from "./types";
|
||||
export { formatActivityTimestamp as runTime } from "@/utils/activityTimestamp";
|
||||
|
||||
export function scopeLabel(settings: Partial<Pick<Settings, "service" | "agent_name" | "filters">>): string {
|
||||
return (
|
||||
[settings.agent_name, settings.service, ...(settings.filters ?? []).map((f) => `${f.key}: ${f.value}`)]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "All activity"
|
||||
);
|
||||
}
|
||||
|
||||
export function durationText(seconds: number): string {
|
||||
if (!Number.isFinite(seconds)) return "0s";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
export function durationLabel(value: number, base: "minutes" | "hours" = "minutes"): string {
|
||||
const minutes = base === "hours" ? value * 60 : value;
|
||||
if (minutes >= 1440) {
|
||||
const days = Number((minutes / 1440).toFixed(2));
|
||||
return `${days} ${days === 1 ? "day" : "days"}`;
|
||||
}
|
||||
if (minutes % 60 === 0) return `${minutes / 60} ${minutes === 60 ? "hour" : "hours"}`;
|
||||
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
||||
}
|
||||
|
||||
export const money = (n: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 3 }).format(n);
|
||||
export const when = (value?: string | null) => (value ? runTime(value) : "Not yet");
|
||||
|
||||
export const sourceLabels = { both: "Traces and requests", requests: "LLM requests", traces: "Agent traces" };
|
||||
|
|
@ -1,19 +1,13 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
analysisElapsed,
|
||||
workerConnected,
|
||||
type LensList,
|
||||
analysisProgress,
|
||||
analysisFraction,
|
||||
analysisPace,
|
||||
remainingLabel,
|
||||
stageDurations,
|
||||
normalizeFilters,
|
||||
sortedFindings,
|
||||
briefMarkdown,
|
||||
type Finding,
|
||||
type Job,
|
||||
} from "./lensData";
|
||||
} from "./progress";
|
||||
import { type Job } from "./types";
|
||||
|
||||
const coverage: Job["coverage"] = {
|
||||
eligible: 0,
|
||||
|
|
@ -66,23 +60,25 @@ describe("Analysis progress", () => {
|
|||
it.each(["Collecting executions", "Reading executions"])(
|
||||
"does not turn an unreported run count into zero runs during %s",
|
||||
(stage) => {
|
||||
expect(analysisProgress({ ...job, stage })).toEqual({
|
||||
const expected = {
|
||||
step: -1,
|
||||
title: "Preparing activity",
|
||||
done: 0,
|
||||
total: 0,
|
||||
detail: "Loading the runs selected for this investigation.",
|
||||
});
|
||||
};
|
||||
expect(analysisProgress({ ...job, stage })).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("shows the selected count as soon as the worker reports it", () => {
|
||||
expect(analysisProgress({ ...job, coverage: { ...coverage, selected: 7 } })).toMatchObject({
|
||||
const expected = {
|
||||
step: 0,
|
||||
done: 0,
|
||||
total: 7,
|
||||
detail: "0 of 7 selected runs reviewed",
|
||||
});
|
||||
};
|
||||
expect(analysisProgress({ ...job, coverage: { ...coverage, selected: 7 } })).toMatchObject(expected);
|
||||
});
|
||||
|
||||
it("measures review progress against the sample, not all eligible runs", () => {
|
||||
|
|
@ -230,76 +226,3 @@ describe("Stage durations", () => {
|
|||
expect(stageDurations(samples, createdAt, start + 100000)).toEqual([null, null, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lens selection and findings", () => {
|
||||
it("preserves literal equals signs in a metadata value", () => {
|
||||
expect(normalizeFilters([{ key: " swarm ", value: " research=v2 " }])).toEqual([
|
||||
{ key: "swarm", value: "research=v2" },
|
||||
]);
|
||||
});
|
||||
it("rejects an incomplete condition instead of broadening the scan", () => {
|
||||
expect(() => normalizeFilters([{ key: "swarm", value: " " }])).toThrow("Choose a key and value");
|
||||
});
|
||||
it("puts high priority issues ahead of newer low priority findings", () => {
|
||||
const base: Finding = {
|
||||
kind: "issue",
|
||||
status: "open",
|
||||
reason: "",
|
||||
suggestion: "",
|
||||
limitation: "",
|
||||
occurrences: [],
|
||||
id: "low",
|
||||
check_id: "check",
|
||||
title: "Recovered error",
|
||||
description: "The run recovered.",
|
||||
evidence: [],
|
||||
revision: 1,
|
||||
priority: "low",
|
||||
first_seen: "2026-09-30T10:00:00Z",
|
||||
last_seen: "2026-09-30T12:00:00Z",
|
||||
};
|
||||
const high: Finding = { ...base, id: "high", priority: "high", last_seen: "2026-09-30T11:00:00Z" };
|
||||
expect(sortedFindings([base, high]).map((f) => f.id)).toEqual(["high", "low"]);
|
||||
});
|
||||
it("turns an issue brief into a pasteable markdown document", () => {
|
||||
expect(
|
||||
briefMarkdown("PRs were never opened", {
|
||||
problem: "The workspace was not a Git repository.",
|
||||
user_goal: "Open a PR fixing a typo",
|
||||
what_happened: 'Git returned "fatal: not a git repository"',
|
||||
test_cases: [
|
||||
{ input: "Fix the typo and open a PR", expected: "A PR URL is returned" },
|
||||
{ input: "Rename greet", expected: "The rename is committed" },
|
||||
],
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"# PRs were never opened",
|
||||
"## Problem\nThe workspace was not a Git repository.",
|
||||
"## User goal\nOpen a PR fixing a typo",
|
||||
'## What happened\nGit returned "fatal: not a git repository"',
|
||||
"## Test cases\n1. **Input:** Fix the typo and open a PR \n **Expect:** A PR URL is returned\n" +
|
||||
"2. **Input:** Rename greet \n **Expect:** The rename is committed",
|
||||
].join("\n\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Worker readiness", () => {
|
||||
const now = Date.parse("2026-10-01T12:00:00Z");
|
||||
const worker: LensList["workers"][number] = {
|
||||
id: "worker",
|
||||
name: "Worker",
|
||||
revoked: false,
|
||||
analysis_key_id: "key",
|
||||
last_seen: "2026-10-01T11:59:59Z",
|
||||
scope: { all_teams: true, api_key_hash: "", team_id: "" },
|
||||
};
|
||||
it("requires a current heartbeat and assigned billing key", () => {
|
||||
expect(workerConnected(worker, now)).toBe(true);
|
||||
expect(workerConnected({ ...worker, last_seen: "" }, now)).toBe(false);
|
||||
expect(workerConnected({ ...worker, last_seen: "2026-10-01T11:58:00Z" }, now)).toBe(false);
|
||||
expect(workerConnected({ ...worker, analysis_key_id: "" }, now)).toBe(false);
|
||||
expect(workerConnected({ ...worker, revoked: true }, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
143
ui/litellm-dashboard/src/components/lens/model/progress.ts
Normal file
143
ui/litellm-dashboard/src/components/lens/model/progress.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import type { Job } from "./types";
|
||||
import { durationText } from "./format";
|
||||
|
||||
export function analysisProgress(job: Job) {
|
||||
const {
|
||||
screened = 0,
|
||||
selected = 0,
|
||||
grouped_batches = 0,
|
||||
grouping_batches = 0,
|
||||
investigated = 0,
|
||||
candidates = 0,
|
||||
} = job.coverage ?? {};
|
||||
if (job.status === "queued") {
|
||||
return {
|
||||
step: -1,
|
||||
title: "Queued for your worker",
|
||||
done: 0,
|
||||
total: 0,
|
||||
detail: "The worker picks up queued investigations automatically.",
|
||||
};
|
||||
}
|
||||
if (job.stage === "Grouping observations") {
|
||||
return {
|
||||
step: 1,
|
||||
title: "Finding patterns",
|
||||
done: grouped_batches,
|
||||
total: grouping_batches,
|
||||
detail: grouping_batches
|
||||
? `${grouped_batches} of ${grouping_batches} observation batches compared`
|
||||
: `Comparing observations across ${screened} reviewed runs`,
|
||||
};
|
||||
}
|
||||
if (job.stage === "Checking original evidence") {
|
||||
return {
|
||||
step: 2,
|
||||
title: "Checking evidence",
|
||||
done: investigated,
|
||||
total: candidates,
|
||||
detail: candidates
|
||||
? `${investigated} of ${candidates} patterns checked against the original activity`
|
||||
: `${investigated} patterns checked against the original activity`,
|
||||
};
|
||||
}
|
||||
if (!selected) {
|
||||
return {
|
||||
step: -1,
|
||||
title: "Preparing activity",
|
||||
done: 0,
|
||||
total: 0,
|
||||
detail: "Loading the runs selected for this investigation.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
step: 0,
|
||||
title: "Reviewing activity",
|
||||
done: screened,
|
||||
total: selected,
|
||||
detail: `${screened} of ${selected} selected runs reviewed`,
|
||||
};
|
||||
}
|
||||
|
||||
export function analysisStages(job: Job): { done: number; total: number }[] {
|
||||
const {
|
||||
screened = 0,
|
||||
selected = 0,
|
||||
grouped_batches = 0,
|
||||
grouping_batches = 0,
|
||||
investigated = 0,
|
||||
candidates = 0,
|
||||
} = job.coverage ?? {};
|
||||
return [
|
||||
{ done: screened, total: selected },
|
||||
{ done: grouped_batches, total: grouping_batches },
|
||||
{ done: investigated, total: candidates },
|
||||
];
|
||||
}
|
||||
|
||||
export interface ProgressSample {
|
||||
at: number;
|
||||
step: number;
|
||||
done: number;
|
||||
fraction: number;
|
||||
}
|
||||
|
||||
export const stageWeights = [0.6, 0.2, 0.2];
|
||||
|
||||
export function analysisFraction({
|
||||
step,
|
||||
done,
|
||||
total,
|
||||
}: Pick<ReturnType<typeof analysisProgress>, "step" | "done" | "total">): number {
|
||||
if (step < 0) return 0;
|
||||
const before = stageWeights.slice(0, step).reduce((sum, weight) => sum + weight, 0);
|
||||
return before + stageWeights[step] * (total ? Math.min(1, done / total) : 0);
|
||||
}
|
||||
|
||||
function windowStart(samples: readonly ProgressSample[], now: number): ProgressSample | undefined {
|
||||
return samples.findLast((sample) => now - sample.at >= 60000) ?? samples[0];
|
||||
}
|
||||
|
||||
export function analysisPace(samples: readonly ProgressSample[], now: number) {
|
||||
const latest = samples.at(-1);
|
||||
if (!latest) return { perMinute: null, secondsLeft: null };
|
||||
const first = windowStart(
|
||||
samples.filter((sample) => sample.step === latest.step),
|
||||
now,
|
||||
);
|
||||
const anchor = windowStart(samples, now);
|
||||
if (!first || !anchor) return { perMinute: null, secondsLeft: null };
|
||||
const stepMinutes = (now - first.at) / 60000;
|
||||
const perMinute = stepMinutes >= 1 / 6 ? (latest.done - first.done) / stepMinutes : null;
|
||||
const spanSeconds = (now - anchor.at) / 1000;
|
||||
const gained = latest.fraction - anchor.fraction;
|
||||
const secondsLeft = spanSeconds >= 10 && gained > 0 ? ((1 - latest.fraction) * spanSeconds) / gained : null;
|
||||
return { perMinute, secondsLeft };
|
||||
}
|
||||
|
||||
export function stageDurations(samples: readonly ProgressSample[], createdAt: string, now: number): (number | null)[] {
|
||||
const current = samples.at(-1)?.step ?? -1;
|
||||
const starts = [0, 1, 2].map((stage) => {
|
||||
if (stage === 0) return Date.parse(createdAt);
|
||||
const entered = samples.findIndex(
|
||||
(sample, index) => index > 0 && sample.step >= stage && samples[index - 1].step < stage,
|
||||
);
|
||||
return entered < 0 ? null : samples[entered].at;
|
||||
});
|
||||
return starts.map((start, stage) => {
|
||||
if (start === null || stage > current) return null;
|
||||
const end = stage === current ? now : starts[stage + 1];
|
||||
return end === null ? null : Math.max(0, Math.floor((end - start) / 1000));
|
||||
});
|
||||
}
|
||||
|
||||
export function remainingLabel(seconds: number | null): string {
|
||||
if (seconds === null) return "estimating";
|
||||
if (seconds < 60) return "<1m";
|
||||
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
||||
return `~${Math.floor(seconds / 3600)}h ${Math.ceil((seconds % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
export function analysisElapsed(createdAt: string, now: number): string {
|
||||
return durationText(Math.max(0, Math.floor((now - Date.parse(createdAt)) / 1000)));
|
||||
}
|
||||
102
ui/litellm-dashboard/src/components/lens/model/status.test.ts
Normal file
102
ui/litellm-dashboard/src/components/lens/model/status.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { nextCheckStatus, workerConnected } from "./status";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Job, Lens, LensList } from "./types";
|
||||
|
||||
const coverage: Job["coverage"] = {
|
||||
eligible: 0,
|
||||
selected: 0,
|
||||
screened: 0,
|
||||
investigated: 0,
|
||||
inconclusive: 0,
|
||||
grouping_batches: 0,
|
||||
grouped_batches: 0,
|
||||
candidates: 0,
|
||||
partial: 0,
|
||||
unassessable: 0,
|
||||
};
|
||||
|
||||
const job: Job = {
|
||||
assessments: [],
|
||||
coverage,
|
||||
attempts: 0,
|
||||
error: "",
|
||||
cost: 0,
|
||||
id: "scan",
|
||||
status: "running",
|
||||
stage: "Reading executions",
|
||||
created_at: "2026-09-30T12:00:00Z",
|
||||
start: "2026-09-29T12:00:00Z",
|
||||
end: "2026-09-30T12:00:00Z",
|
||||
revision: 1,
|
||||
settings: {
|
||||
context: "",
|
||||
source: "traces",
|
||||
lookback_hours: 24,
|
||||
service: "",
|
||||
agent_name: "",
|
||||
filters: [],
|
||||
enabled: false,
|
||||
interval_minutes: 15,
|
||||
sample_size: 100,
|
||||
sample_percent: 100,
|
||||
concurrency: 8,
|
||||
team_id: "",
|
||||
execution_ids: [],
|
||||
monthly_budget: 20,
|
||||
name: "Release reviews",
|
||||
model: "analysis",
|
||||
checks: [{ enabled: true, id: "failures", instruction: "Find failed outcomes" }],
|
||||
},
|
||||
};
|
||||
|
||||
describe("Worker readiness", () => {
|
||||
const now = Date.parse("2026-10-01T12:00:00Z");
|
||||
const worker: LensList["workers"][number] = {
|
||||
id: "worker",
|
||||
name: "Worker",
|
||||
revoked: false,
|
||||
analysis_key_id: "key",
|
||||
last_seen: "2026-10-01T11:59:59Z",
|
||||
scope: { all_teams: true, api_key_hash: "", team_id: "" },
|
||||
};
|
||||
it("requires a current heartbeat and assigned billing key", () => {
|
||||
expect(workerConnected(worker, now)).toBe(true);
|
||||
expect(workerConnected({ ...worker, last_seen: "" }, now)).toBe(false);
|
||||
expect(workerConnected({ ...worker, last_seen: "2026-10-01T11:58:00Z" }, now)).toBe(false);
|
||||
expect(workerConnected({ ...worker, analysis_key_id: "" }, now)).toBe(false);
|
||||
expect(workerConnected({ ...worker, revoked: true }, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const lens: Lens = {
|
||||
version: 0,
|
||||
spent: 0,
|
||||
id: "lens",
|
||||
scope: { all_teams: true, api_key_hash: "", team_id: "" },
|
||||
settings: { ...job.settings, enabled: false },
|
||||
revision: 1,
|
||||
created_at: job.created_at,
|
||||
next_run_at: job.created_at,
|
||||
budget_month: "2026-09",
|
||||
findings: [],
|
||||
jobs: [{ ...job, status: "completed" }],
|
||||
};
|
||||
|
||||
it("shows the actual next schedule and avoids a stale countdown during active scans", () => {
|
||||
const now = Date.parse("2026-09-30T10:00:00Z");
|
||||
const monitoring = {
|
||||
...lens,
|
||||
settings: { ...lens.settings, enabled: true },
|
||||
next_run_at: "2026-09-30T10:12:00Z",
|
||||
};
|
||||
expect(nextCheckStatus(monitoring, now)).toContain("in 12 minutes");
|
||||
expect(nextCheckStatus(monitoring, now + 12 * 60000)).toBe("Due now · waiting for an analyzer");
|
||||
expect(nextCheckStatus({ ...monitoring, jobs: [{ ...lens.jobs[0], status: "running" }] }, now)).toBe(
|
||||
"Next check scheduled after this scan finishes",
|
||||
);
|
||||
expect(nextCheckStatus({ ...monitoring, jobs: [{ ...lens.jobs[0], status: "queued" }] }, now)).toBe(
|
||||
"Waiting for an analyzer",
|
||||
);
|
||||
expect(nextCheckStatus(lens, now)).toBeNull();
|
||||
});
|
||||
45
ui/litellm-dashboard/src/components/lens/model/status.ts
Normal file
45
ui/litellm-dashboard/src/components/lens/model/status.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
|
||||
import type { Lens, LensList } from "./types";
|
||||
|
||||
export function workerConnected(worker: LensList["workers"][number], now = Date.now()): boolean {
|
||||
return !worker.revoked && !!worker.analysis_key_id && now - Date.parse(worker.last_seen) < 120000;
|
||||
}
|
||||
|
||||
export function lensStatus(lens: Lens, connected: boolean): string {
|
||||
const active = lens.jobs?.find((job) => ["queued", "running"].includes(job.status ?? ""));
|
||||
if (active) return connected ? active.stage ?? "Queued" : "Waiting for analyzer";
|
||||
const spent = lens.budget_month === new Date().toISOString().slice(0, 7) ? lens.spent ?? 0 : 0;
|
||||
if (spent >= (lens.settings.monthly_budget ?? 100)) return "Budget reached";
|
||||
const latest = lens.jobs?.[0];
|
||||
if (latest?.status === "failed") return "Failed";
|
||||
if (latest?.status === "cancelled") return "Cancelled";
|
||||
if (latest?.status === "completed") return "Completed";
|
||||
return "Ready";
|
||||
}
|
||||
|
||||
export function nextCheckStatus(lens: Lens, now: number): string | null {
|
||||
if (!lens.settings.enabled) return null;
|
||||
const active = lens.jobs.find((job) => job.status === "queued" || job.status === "running");
|
||||
if (active?.status === "running") return "Next check scheduled after this scan finishes";
|
||||
if (active?.status === "queued") return "Waiting for an analyzer";
|
||||
const next = new Date(lens.next_run_at);
|
||||
const remaining = next.getTime() - now;
|
||||
if (remaining <= 0) return "Due now · waiting for an analyzer";
|
||||
const minutes = Math.ceil(remaining / 60000);
|
||||
const relative = minutes === 1 ? "in less than a minute" : `in ${minutes} minutes`;
|
||||
const time = formatActivityTimestamp(lens.next_run_at);
|
||||
return `Next check ${time} · ${relative}`;
|
||||
}
|
||||
|
||||
export function readiness(
|
||||
activity: { traces: boolean; requests: boolean } | undefined,
|
||||
activityError: unknown,
|
||||
connected: boolean,
|
||||
listError: unknown,
|
||||
) {
|
||||
const tracesReady = activity?.traces === true && !activityError;
|
||||
const requestsReady = activity?.requests === true && !activityError;
|
||||
const activityReady = tracesReady || requestsReady;
|
||||
const ready = activityReady && connected && !listError;
|
||||
return { tracesReady, requestsReady, activityReady, ready };
|
||||
}
|
||||
34
ui/litellm-dashboard/src/components/lens/model/types.ts
Normal file
34
ui/litellm-dashboard/src/components/lens/model/types.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type Lens = components["schemas"]["Lens"];
|
||||
|
||||
export type Settings = components["schemas"]["LensSettings"];
|
||||
|
||||
export type LensList = components["schemas"]["LensList"];
|
||||
|
||||
export type Finding = components["schemas"]["Finding"];
|
||||
|
||||
export type Sample = components["schemas"]["Sample"];
|
||||
|
||||
export type WorkerCreated = components["schemas"]["WorkerCreated"];
|
||||
|
||||
export type Job = components["schemas"]["Job"];
|
||||
|
||||
export type IssueBrief = NonNullable<Finding["brief"]>;
|
||||
|
||||
export type ActivitySelection = Pick<Settings, "source"> &
|
||||
Partial<
|
||||
Pick<
|
||||
Settings,
|
||||
| "service"
|
||||
| "agent_name"
|
||||
| "filters"
|
||||
| "lookback_hours"
|
||||
| "sample_percent"
|
||||
| "sample_size"
|
||||
| "team_id"
|
||||
| "execution_ids"
|
||||
>
|
||||
>;
|
||||
|
||||
export type Worker = LensList["workers"][number];
|
||||
|
|
@ -2,10 +2,11 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import { MonitoringSetup } from "./LensOverview";
|
||||
import { LensSetup } from "./LensSetup";
|
||||
import { MonitoringDialog } from "./MonitoringDialog";
|
||||
import { InvestigationSetupDialog } from "./InvestigationSetupDialog";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { initialWatches, watchChecks, type Settings } from "./lensData";
|
||||
import { initialWatches, watchChecks } from "./watches";
|
||||
import { type Settings } from "../model/types";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ apiClient: { post: vi.fn(), get: vi.fn() } }));
|
||||
|
||||
|
|
@ -39,11 +40,33 @@ beforeEach(() => {
|
|||
vi.mocked(apiClient.post).mockResolvedValue({ eligible: 1, selected: 1, executions: [] });
|
||||
});
|
||||
describe("Lens setup", () => {
|
||||
it("preserves saved manual run selections when editing an investigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<InvestigationSetupDialog
|
||||
initial={{ ...settings, execution_ids: ["saved-run"] }}
|
||||
models={["analysis"]}
|
||||
accessToken="test"
|
||||
onClose={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(await screen.findByRole("button", { name: "Clear 1 selected runs" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves check identity and disabled state when a check is edited", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<LensSetup initial={settings} models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={save} />,
|
||||
<InvestigationSetupDialog
|
||||
initial={settings}
|
||||
models={["analysis"]}
|
||||
accessToken="test"
|
||||
onClose={vi.fn()}
|
||||
onSave={save}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Check 1" }), {
|
||||
|
|
@ -62,16 +85,6 @@ describe("Lens setup", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("rejects invalid metadata before reviewing the selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LensSetup models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={vi.fn()} />);
|
||||
await user.click(screen.getByText("Advanced filters"));
|
||||
await user.click(screen.getByRole("button", { name: "Add condition" }));
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "Metadata key 1" }), { target: { value: "swarm" } });
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Choose a key and value for every condition, or remove it");
|
||||
expect(screen.queryByRole("textbox", { name: "Check 1" })).not.toBeInTheDocument();
|
||||
});
|
||||
it("previews identifiable matching runs and saves the same filter selection", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
|
|
@ -95,7 +108,13 @@ describe("Lens setup", () => {
|
|||
: { eligible: 0, executions: [] };
|
||||
});
|
||||
renderWithProviders(
|
||||
<LensSetup models={["analysis"]} defaultModel="analysis" accessToken="test" onClose={vi.fn()} onSave={save} />,
|
||||
<InvestigationSetupDialog
|
||||
models={["analysis"]}
|
||||
defaultModel="analysis"
|
||||
accessToken="test"
|
||||
onClose={vi.fn()}
|
||||
onSave={save}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Investigation name" }), {
|
||||
target: { value: "Research follow-up" },
|
||||
|
|
@ -143,7 +162,7 @@ it("searches providers and saves custom history while preserving existing schedu
|
|||
const user = userEvent.setup();
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithProviders(
|
||||
<LensSetup
|
||||
<InvestigationSetupDialog
|
||||
initial={settings}
|
||||
models={["review", "other"]}
|
||||
modelDetails={[
|
||||
|
|
@ -173,12 +192,51 @@ it("searches providers and saves custom history while preserving existing schedu
|
|||
it("configures monitoring separately and rejects a zero interval", async () => {
|
||||
const user = userEvent.setup();
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithProviders(<MonitoringSetup settings={settings} ready onSave={save} onClose={vi.fn()} />);
|
||||
renderWithProviders(<MonitoringDialog settings={settings} ready onSave={save} onClose={vi.fn()} />);
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Check every" }), { target: { value: "2" } });
|
||||
await user.click(screen.getByRole("button", { name: "Enable monitoring" }));
|
||||
expect(save).toHaveBeenCalledWith(expect.objectContaining({ interval_minutes: 2, enabled: true }));
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Check every" }), { target: { value: "0" } });
|
||||
expect(screen.getByRole("button", { name: "Enable monitoring" })).toBeDisabled();
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Enable monitoring" })).toBeDisabled());
|
||||
});
|
||||
|
||||
it("allows retrying an investigation save after a server error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const save = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValue(undefined);
|
||||
renderWithProviders(
|
||||
<InvestigationSetupDialog
|
||||
initial={settings}
|
||||
models={["analysis"]}
|
||||
accessToken="test"
|
||||
onClose={vi.fn()}
|
||||
onSave={save}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
const saveButton = screen.getByRole("button", { name: "Save changes" });
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
|
||||
await user.click(saveButton);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("boom");
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
|
||||
await user.click(saveButton);
|
||||
await waitFor(() => expect(save).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("allows retrying monitoring after a server error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const save = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValue(undefined);
|
||||
renderWithProviders(<MonitoringDialog settings={settings} ready onSave={save} onClose={vi.fn()} />);
|
||||
const saveButton = screen.getByRole("button", { name: "Enable monitoring" });
|
||||
|
||||
await user.click(saveButton);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("boom");
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
|
||||
await user.click(saveButton);
|
||||
await waitFor(() => expect(save).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("keeps the draft when readiness changes and blocks a run until the worker recovers", async () => {
|
||||
|
|
@ -192,14 +250,14 @@ it("keeps the draft when readiness changes and blocks a run until the worker rec
|
|||
onClose: vi.fn(),
|
||||
onSave: save,
|
||||
};
|
||||
const view = renderWithProviders(<LensSetup {...props} ready />);
|
||||
const view = renderWithProviders(<InvestigationSetupDialog {...props} ready />);
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "What should the agent be doing?" }), "Finish the report");
|
||||
view.rerender(<LensSetup {...props} ready={false} />);
|
||||
view.rerender(<InvestigationSetupDialog {...props} ready={false} />);
|
||||
expect(screen.getByRole("textbox", { name: "What should the agent be doing?" })).toHaveValue("Finish the report");
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(screen.getByRole("button", { name: "Run investigation" })).toBeDisabled();
|
||||
view.rerender(<LensSetup {...props} ready />);
|
||||
view.rerender(<InvestigationSetupDialog {...props} ready />);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled());
|
||||
});
|
||||
|
||||
|
|
@ -208,7 +266,13 @@ it.each(["empty", "error"])("allows editing saved settings when the preview is %
|
|||
else vi.mocked(apiClient.post).mockResolvedValue({ eligible: 0, selected: 0, executions: [] });
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<LensSetup initial={settings} models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={vi.fn()} />,
|
||||
<InvestigationSetupDialog
|
||||
initial={settings}
|
||||
models={["analysis"]}
|
||||
accessToken="test"
|
||||
onClose={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
|
@ -221,7 +285,7 @@ it.each(["loading", "error"])("saves edits with the existing model while models
|
|||
const user = userEvent.setup();
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithProviders(
|
||||
<LensSetup
|
||||
<InvestigationSetupDialog
|
||||
initial={settings}
|
||||
models={[]}
|
||||
modelsLoading={state === "loading"}
|
||||
|
|
@ -246,7 +310,7 @@ it.each(["new", "duplicate"] as const)("blocks a %s investigation until its mode
|
|||
const user = userEvent.setup();
|
||||
const save = vi.fn();
|
||||
renderWithProviders(
|
||||
<LensSetup
|
||||
<InvestigationSetupDialog
|
||||
initial={settings}
|
||||
mode={mode}
|
||||
models={[]}
|
||||
|
|
@ -267,7 +331,7 @@ it("shows optional budget and repeat controls only under advanced options and sa
|
|||
const user = userEvent.setup();
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithProviders(
|
||||
<LensSetup
|
||||
<InvestigationSetupDialog
|
||||
initial={settings}
|
||||
mode="duplicate"
|
||||
models={["analysis"]}
|
||||
|
|
@ -307,7 +371,13 @@ it("does not silently analyze everything after individual selection is enabled",
|
|||
],
|
||||
});
|
||||
renderWithProviders(
|
||||
<LensSetup initial={settings} models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={vi.fn()} />,
|
||||
<InvestigationSetupDialog
|
||||
initial={settings}
|
||||
models={["analysis"]}
|
||||
accessToken="test"
|
||||
onClose={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
|
@ -323,7 +393,9 @@ it("refreshes agent suggestions when the first activity arrives", async () => {
|
|||
try {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ eligible: 0, selected: 0, executions: [] });
|
||||
renderWithProviders(<LensSetup models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={vi.fn()} />);
|
||||
renderWithProviders(
|
||||
<InvestigationSetupDialog models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={vi.fn()} />,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
await user.click(screen.getByRole("combobox", { name: "Agent (optional)" }));
|
||||
expect(await screen.findByText(/No matches. You can enter/)).toBeVisible();
|
||||
|
|
@ -357,7 +429,7 @@ it.each(["empty", "error"])("blocks a new investigation when its preview is %s",
|
|||
else vi.mocked(apiClient.post).mockResolvedValue({ eligible: 0, selected: 0, executions: [] });
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<LensSetup
|
||||
<InvestigationSetupDialog
|
||||
mode="duplicate"
|
||||
initial={settings}
|
||||
models={["analysis"]}
|
||||
|
|
@ -374,7 +446,7 @@ it.each(["empty", "error"])("blocks a new investigation when its preview is %s",
|
|||
it("exposes an unsupported inherited model before allowing a run", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<LensSetup
|
||||
<InvestigationSetupDialog
|
||||
mode="duplicate"
|
||||
initial={settings}
|
||||
models={["analysis"]}
|
||||
|
|
@ -396,7 +468,7 @@ it("saves a discovered agent independently of the application name", async () =>
|
|||
const user = userEvent.setup();
|
||||
const save = vi.fn();
|
||||
renderWithProviders(
|
||||
<LensSetup
|
||||
<InvestigationSetupDialog
|
||||
initial={{ ...settings, service: "shared-service" }}
|
||||
models={["analysis"]}
|
||||
accessToken="test"
|
||||
|
|
@ -421,7 +493,13 @@ describe("Watch for", () => {
|
|||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<LensSetup models={["analysis"]} defaultModel="analysis" accessToken="test" onClose={vi.fn()} onSave={save} />,
|
||||
<InvestigationSetupDialog
|
||||
models={["analysis"]}
|
||||
defaultModel="analysis"
|
||||
accessToken="test"
|
||||
onClose={vi.fn()}
|
||||
onSave={save}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await user.click(tile("unhappy"));
|
||||
|
|
@ -446,7 +524,13 @@ describe("Watch for", () => {
|
|||
checks: [{ id: "watch_invented", instruction: "old wording", enabled: true }, settings.checks[1]],
|
||||
};
|
||||
renderWithProviders(
|
||||
<LensSetup initial={initial} models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={save} />,
|
||||
<InvestigationSetupDialog
|
||||
initial={initial}
|
||||
models={["analysis"]}
|
||||
accessToken="test"
|
||||
onClose={vi.fn()}
|
||||
onSave={save}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(tile("invented")).toHaveAttribute("aria-pressed", "true");
|
||||
|
|
@ -464,7 +548,13 @@ describe("Watch for", () => {
|
|||
it("lets a run start from presets alone and blocks it once nothing is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<LensSetup models={["analysis"]} defaultModel="analysis" accessToken="test" onClose={vi.fn()} onSave={vi.fn()} />,
|
||||
<InvestigationSetupDialog
|
||||
models={["analysis"]}
|
||||
defaultModel="analysis"
|
||||
accessToken="test"
|
||||
onClose={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
|
@ -472,6 +562,8 @@ describe("Watch for", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
for (const name of ["unsolved", "blocked", "unhappy"]) await user.click(tile(name));
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("pick something to watch for");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"Describe the expected behavior or pick something to watch for",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
"use client";
|
||||
|
||||
import { FormProvider, useWatch } from "react-hook-form";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import {
|
||||
investigationSchema,
|
||||
investigationDefaults,
|
||||
investigationSettings,
|
||||
investigationStepFields,
|
||||
} from "./investigationSchema";
|
||||
import { ScopeStep } from "./steps/ScopeStep";
|
||||
import { ExpectationsStep } from "./steps/ExpectationsStep";
|
||||
import { RunStep } from "./steps/RunStep";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { type Settings } from "../model/types";
|
||||
import { type AnalysisModelInfo } from "./fields/analysisModels";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export function InvestigationSetupDialog({
|
||||
initial,
|
||||
mode = initial ? "edit" : "new",
|
||||
models,
|
||||
modelDetails = [],
|
||||
modelsLoading = false,
|
||||
modelsError,
|
||||
defaultModel,
|
||||
defaultSource = "traces",
|
||||
accessToken,
|
||||
ready = true,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
initial?: Settings;
|
||||
mode?: "new" | "edit" | "duplicate";
|
||||
models: string[];
|
||||
modelDetails?: AnalysisModelInfo[];
|
||||
modelsLoading?: boolean;
|
||||
modelsError?: string;
|
||||
defaultModel?: string;
|
||||
defaultSource?: Settings["source"];
|
||||
accessToken: string;
|
||||
ready?: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (settings: Settings) => Promise<void>;
|
||||
}) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [previewReady, setPreviewReady] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const form = useZodForm(investigationSchema, {
|
||||
defaultValues: investigationDefaults(initial, mode, defaultSource),
|
||||
mode: "onChange",
|
||||
});
|
||||
const { control, setValue, subscribe, trigger, formState } = form;
|
||||
const [selectedModel, repeat] = useWatch({
|
||||
control,
|
||||
name: ["selectedModel", "repeat"],
|
||||
});
|
||||
const model = selectedModel ?? defaultModel ?? "";
|
||||
useEffect(
|
||||
() =>
|
||||
subscribe({
|
||||
name: [
|
||||
"selection.source",
|
||||
"selection.service",
|
||||
"selection.agent_name",
|
||||
"selection.filters",
|
||||
"selection.lookback_hours",
|
||||
"selection.team_id",
|
||||
],
|
||||
formState: { values: true },
|
||||
callback: ({ values }) => {
|
||||
if (values.selection.execution_ids.length) setValue("selection.execution_ids", []);
|
||||
},
|
||||
}),
|
||||
[setValue, subscribe],
|
||||
);
|
||||
const next = async () => {
|
||||
if (await trigger(investigationStepFields[step])) setStep(step + 1);
|
||||
};
|
||||
const save = form.handleSubmit(async (values) => {
|
||||
setError("");
|
||||
try {
|
||||
await onSave(investigationSettings(values, initial, model));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Could not save investigation");
|
||||
}
|
||||
});
|
||||
const unsupported = modelDetails.some((m) => m.model_group === model && m.mode && m.mode !== "chat");
|
||||
const canRun = ready || mode === "edit";
|
||||
const modelsReady = !modelsLoading && !modelsError;
|
||||
const unavailable = !!model && modelsReady && !models.includes(model);
|
||||
const supported = !unsupported && !unavailable;
|
||||
const preservingSavedModel = mode === "edit" && model === initial?.model;
|
||||
const modelReady = modelsReady || preservingSavedModel;
|
||||
const modelValid = !!model && supported && modelReady;
|
||||
const runReady = canRun && (mode === "edit" || previewReady);
|
||||
const formReady = formState.isValid && !formState.isSubmitting;
|
||||
const canSave = formReady && modelValid && runReady;
|
||||
const createLabel = repeat ? "Run and monitor" : "Run investigation";
|
||||
const saveLabel = mode === "edit" ? "Save changes" : createLabel;
|
||||
const headings = [
|
||||
"Which activity should we investigate?",
|
||||
"What should Lens look for?",
|
||||
mode === "edit" ? "Review changes" : "Ready to investigate",
|
||||
];
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !formState.isSubmitting) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"flex max-h-[90dvh] flex-col gap-6 overflow-hidden",
|
||||
["sm:max-w-xl", "sm:max-w-2xl", "sm:max-w-3xl"][step],
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">{headings[step]}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{
|
||||
[
|
||||
"Start with an agent, or use filters to investigate any recorded activity.",
|
||||
"Describe the expected behavior, the questions you have, or both.",
|
||||
"Review the selected activity, then start your investigation.",
|
||||
][step]
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<nav aria-label="Investigation setup" className="flex gap-2 text-xs">
|
||||
{["Activity", "Expectations", "Run"].map((label, index) => (
|
||||
<button
|
||||
key={label}
|
||||
disabled={index > step || formState.isSubmitting}
|
||||
aria-current={index === step ? "step" : undefined}
|
||||
onClick={() => {
|
||||
setStep(index);
|
||||
}}
|
||||
data-state={index === step ? "active" : "inactive"}
|
||||
className="flex-1 border-t-2 border-border pt-2 text-left text-muted-foreground data-[state=active]:border-foreground data-[state=active]:font-medium data-[state=active]:text-foreground"
|
||||
>
|
||||
{index + 1}. {label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="min-h-0 overflow-y-auto pr-1 space-y-5">
|
||||
{(step === 0 || step === 2) && (
|
||||
<ScopeStep step={step} accessToken={accessToken} setPreviewReady={setPreviewReady} />
|
||||
)}
|
||||
{step === 1 && <ExpectationsStep />}
|
||||
{step === 2 && (
|
||||
<RunStep
|
||||
modelValid={modelValid}
|
||||
models={models}
|
||||
modelDetails={modelDetails}
|
||||
modelsLoading={modelsLoading}
|
||||
modelsError={modelsError}
|
||||
unavailable={unavailable}
|
||||
unsupported={unsupported}
|
||||
/>
|
||||
)}
|
||||
{!ready && mode !== "edit" && (
|
||||
<p role="status" className="text-sm text-amber-700">
|
||||
The worker or trace storage is unavailable. Your draft is safe; you can start when it reconnects.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={formState.isSubmitting}
|
||||
onClick={() => (step ? setStep(step - 1) : onClose())}
|
||||
>
|
||||
{step ? "Back" : "Cancel"}
|
||||
</Button>
|
||||
{step < 2 ? (
|
||||
<Button onClick={() => void next()}>Continue</Button>
|
||||
) : (
|
||||
<Button disabled={!canSave} onClick={() => void save()}>
|
||||
{formState.isSubmitting ? "Saving…" : saveLabel}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
"use client";
|
||||
|
||||
import { ScopeFields } from "./fields/ScopeFields";
|
||||
import { SampleFields } from "./fields/SampleFields";
|
||||
import { MatchingActivityPreview } from "./MatchingActivityPreview";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import type { InvestigationInput } from "./investigationSchema";
|
||||
|
||||
import { lensQueries } from "../api/queries";
|
||||
|
||||
import { useEffect, useId, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLensApi } from "../api/useLensApi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { TraceSheet } from "../investigations/TraceSheet";
|
||||
import { durationLabel } from "../model/format";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const selectClass = "h-9 w-full rounded-md border border-input bg-background px-3 text-sm";
|
||||
|
||||
export function MatchingActivity({
|
||||
accessToken,
|
||||
mode = "scope",
|
||||
onPreviewReady,
|
||||
manualSelection = false,
|
||||
nameField,
|
||||
}: {
|
||||
accessToken: string;
|
||||
mode?: "scope" | "activity";
|
||||
onPreviewReady?: (ready: boolean) => void;
|
||||
manualSelection?: boolean;
|
||||
nameField?: ReactNode;
|
||||
}) {
|
||||
const { control, setValue } = useFormContext<InvestigationInput>();
|
||||
const selection = useWatch({ control, name: "selection" });
|
||||
const apiClient = useLensApi();
|
||||
const id = useId();
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [scope, setScope] = useState(selection);
|
||||
const [trace, setTrace] = useState<{ id: string; ref?: string } | null>(null);
|
||||
const [asOf, setAsOf] = useState(() => new Date().toISOString());
|
||||
const serialized = JSON.stringify({ ...selection, execution_ids: [] });
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setScope(JSON.parse(serialized) as InvestigationInput["selection"]);
|
||||
setOffset(0);
|
||||
setAsOf(new Date().toISOString());
|
||||
}, 350);
|
||||
return () => clearTimeout(timer);
|
||||
}, [serialized]);
|
||||
const historyHours = selection.lookback_hours ?? 24;
|
||||
const validWindow = Number.isInteger(historyHours) && historyHours >= 1 && historyHours <= 8760;
|
||||
const percent = scope.sample_percent ?? 100;
|
||||
const cap = scope.sample_size;
|
||||
const validCap = cap == null || (Number.isInteger(cap) && cap > 0);
|
||||
const validSampling = percent > 0 && percent <= 100 && validCap;
|
||||
const validFilters = (scope.filters ?? []).every((f) => f.key.trim() && f.value.trim());
|
||||
const valid = validWindow && validSampling && validFilters;
|
||||
const discovery = useQuery(
|
||||
lensQueries.discovery(apiClient, accessToken, { value: selection, asOf, enabled: validWindow }),
|
||||
);
|
||||
const agents = useQuery(lensQueries.agents(apiClient, accessToken, asOf, selection.source));
|
||||
const previewInput = { scope, offset, asOf, enabled: valid };
|
||||
const preview = useQuery(lensQueries.preview(apiClient, accessToken, previewInput));
|
||||
const empty = preview.data?.eligible === 0;
|
||||
useEffect(() => {
|
||||
if (!empty || !valid) return;
|
||||
const timer = window.setTimeout(() => setAsOf(new Date().toISOString()), 15000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [empty, valid, asOf]);
|
||||
const refreshPreview = () => {
|
||||
setOffset(0);
|
||||
setAsOf(new Date().toISOString());
|
||||
};
|
||||
const runs = discovery.data?.executions ?? [];
|
||||
const services = [...new Set(runs.map((r) => r.service).filter(Boolean))].sort();
|
||||
const names = selection.source === "requests" ? services : agents.data ?? [];
|
||||
const attributes = runs.flatMap((r) => r.metadata ?? []);
|
||||
const keys = [...new Set(attributes.map((a) => a.key).filter((key) => !key.startsWith("litellm.")))].sort();
|
||||
const pending = serialized !== JSON.stringify(scope) || preview.isFetching;
|
||||
const ready = !pending && valid;
|
||||
const hasSelection = !manualSelection || !!selection.execution_ids.length;
|
||||
const hasMatches = !preview.error && (preview.data?.selected ?? 0) > 0;
|
||||
const canReview = ready && hasMatches && hasSelection;
|
||||
useEffect(() => {
|
||||
onPreviewReady?.(canReview);
|
||||
}, [canReview, onPreviewReady]);
|
||||
const windowLabel = validWindow
|
||||
? `Last ${durationLabel(selection.lookback_hours ?? 24, "hours")}`
|
||||
: "Choose a valid history window";
|
||||
const previewTitle = () => {
|
||||
if (pending) return "Finding matching activity…";
|
||||
if (!validWindow) return "Choose a history window between 1 hour and 365 days";
|
||||
if (!valid) return "Complete your condition to preview matches";
|
||||
if (!preview.data) return "Preview unavailable";
|
||||
const noun = selection.source === "requests" ? "request" : "run";
|
||||
return `${preview.data.eligible} matching ${noun}${preview.data.eligible === 1 ? "" : "s"}`;
|
||||
};
|
||||
return (
|
||||
<div className={cn(mode === "activity" ? "grid gap-6 sm:grid-cols-2" : "space-y-5")}>
|
||||
<div className="space-y-5">
|
||||
{mode === "scope" ? (
|
||||
<ScopeFields
|
||||
nameField={nameField}
|
||||
names={names}
|
||||
agentsLoading={agents.isFetching}
|
||||
agentsError={agents.isError}
|
||||
retryAgents={() => void agents.refetch()}
|
||||
attributes={attributes}
|
||||
keys={keys}
|
||||
id={id}
|
||||
/>
|
||||
) : (
|
||||
<SampleFields />
|
||||
)}
|
||||
{manualSelection && !!selection.execution_ids.length && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setValue("selection.execution_ids", [], { shouldValidate: true })}
|
||||
>
|
||||
Clear {selection.execution_ids.length} selected runs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{mode === "activity" && (
|
||||
<MatchingActivityPreview
|
||||
offset={offset}
|
||||
onPage={setOffset}
|
||||
onSelect={(runId, checked) =>
|
||||
setValue(
|
||||
"selection.execution_ids",
|
||||
checked ? [...selection.execution_ids, runId] : selection.execution_ids.filter((id) => id !== runId),
|
||||
{ shouldValidate: true },
|
||||
)
|
||||
}
|
||||
manualSelection={manualSelection}
|
||||
selectedIds={selection.execution_ids}
|
||||
selectedCount={
|
||||
manualSelection
|
||||
? Math.min(
|
||||
Math.ceil((selection.execution_ids.length * (selection.sample_percent ?? 100)) / 100),
|
||||
selection.sample_size ?? Infinity,
|
||||
)
|
||||
: preview.data?.selected ?? 0
|
||||
}
|
||||
title={previewTitle()}
|
||||
windowLabel={windowLabel}
|
||||
ready={ready}
|
||||
error={preview.error}
|
||||
data={preview.data}
|
||||
onRetry={refreshPreview}
|
||||
onOpen={(run) => setTrace({ id: run.trace_id, ref: run.trace_ref })}
|
||||
/>
|
||||
)}
|
||||
{trace && (
|
||||
<TraceSheet
|
||||
open
|
||||
traceId={trace.id}
|
||||
traceRef={trace.ref}
|
||||
accessToken={accessToken}
|
||||
onClose={() => setTrace(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight, RotateCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { type Sample } from "../model/types";
|
||||
import { runTime } from "../model/format";
|
||||
|
||||
export function RunList({ executions }: { executions: Sample["executions"] }) {
|
||||
return (
|
||||
<div className="divide-y">
|
||||
{executions.map((run) => (
|
||||
<div key={run.id} className="py-3">
|
||||
<p className="text-sm font-medium">{run.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{runTime(run.start_time)} ·{" "}
|
||||
{run.source === "traces" ? `${run.span_count} ${run.span_count === 1 ? "step" : "steps"}` : "LLM request"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MatchingActivityPreview({
|
||||
offset,
|
||||
onPage,
|
||||
onSelect,
|
||||
selectedIds,
|
||||
manualSelection,
|
||||
selectedCount,
|
||||
title,
|
||||
windowLabel,
|
||||
ready,
|
||||
error,
|
||||
data,
|
||||
onOpen,
|
||||
onRetry,
|
||||
}: {
|
||||
offset: number;
|
||||
onPage: (offset: number) => void;
|
||||
onSelect: (id: string, checked: boolean) => void;
|
||||
selectedIds: string[];
|
||||
manualSelection: boolean;
|
||||
selectedCount: number;
|
||||
title: string;
|
||||
windowLabel: string;
|
||||
ready: boolean;
|
||||
error: Error | null;
|
||||
data: Sample | undefined;
|
||||
onRetry: () => void;
|
||||
onOpen: (run: Sample["executions"][number]) => void;
|
||||
}) {
|
||||
const paginated = data?.next_offset != null || offset > 0;
|
||||
const showSelection = selectedCount !== data?.eligible || paginated;
|
||||
const selectionData = ready && showSelection ? data : undefined;
|
||||
return (
|
||||
<section aria-label="Matching activity" className="self-start rounded-lg border">
|
||||
<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">
|
||||
{title}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Refresh matching activity"
|
||||
onClick={onRetry}
|
||||
disabled={!ready}
|
||||
>
|
||||
<RotateCw className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{windowLabel} · No analysis cost</p>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto px-4">
|
||||
{ready && error && (
|
||||
<p role="alert" className="py-3 text-sm text-destructive">
|
||||
{error.message}{" "}
|
||||
<Button variant="link" onClick={onRetry}>
|
||||
Retry preview
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
{ready && data?.eligible === 0 && (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
No matches. Try removing a condition or check that your agent records this metadata. Recent trace updates
|
||||
need two minutes to settle.
|
||||
</p>
|
||||
)}
|
||||
{ready &&
|
||||
data?.executions.map((run) => (
|
||||
<div key={run.id} className="flex items-center justify-between gap-3 border-b last:border-0">
|
||||
{manualSelection && (
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${run.name}`}
|
||||
checked={selectedIds.includes(run.id)}
|
||||
onChange={(e) => onSelect(run.id, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<RunList executions={[run]} />
|
||||
</div>
|
||||
{run.source === "traces" && (
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`Open ${run.name}`} onClick={() => onOpen(run)}>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selectionData && (
|
||||
<div className="border-t px-4 py-3 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedCount} selected for analysis
|
||||
{paginated && (
|
||||
<>
|
||||
{" "}
|
||||
· Showing {offset + (selectionData.executions.length ? 1 : 0)}–
|
||||
{offset + selectionData.executions.length} of {selectionData.eligible}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{paginated && (
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={offset === 0}
|
||||
onClick={() => onPage(Math.max(0, offset - 100))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={selectionData.next_offset == null}
|
||||
onClick={() => onPage(selectionData.next_offset ?? offset)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DurationInput } from "@/components/shared/DurationInput";
|
||||
import { type Settings } from "../model/types";
|
||||
|
||||
const monitoringSchema = z.object({
|
||||
interval_minutes: z.number().int().min(1).max(10080),
|
||||
});
|
||||
|
||||
export function MonitoringDialog({
|
||||
settings,
|
||||
ready,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
settings: Settings;
|
||||
ready: boolean;
|
||||
onSave: (settings: Settings) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [error, setError] = useState("");
|
||||
const form = useZodForm(monitoringSchema, {
|
||||
defaultValues: { interval_minutes: settings.interval_minutes ?? 30 },
|
||||
mode: "onChange",
|
||||
});
|
||||
const { formState, control } = form;
|
||||
const save = form.handleSubmit(async ({ interval_minutes }) => {
|
||||
setError("");
|
||||
try {
|
||||
await onSave({ ...settings, enabled: true, interval_minutes });
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Could not enable monitoring");
|
||||
}
|
||||
});
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !formState.isSubmitting) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Keep monitoring</DialogTitle>
|
||||
<DialogDescription>
|
||||
Repeat this investigation with the saved scope, sample, model, and budget.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Controller
|
||||
control={control}
|
||||
name="interval_minutes"
|
||||
render={({ field }) => (
|
||||
<DurationInput
|
||||
label="Check every"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
base="minutes"
|
||||
max={10080}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{formState.errors.interval_minutes?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{formState.errors.interval_minutes.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Each investigation looks back over the saved time range. The interval starts after the previous run finishes.
|
||||
</p>
|
||||
{!ready && (
|
||||
<p role="status" className="text-sm text-amber-700">
|
||||
Reconnect the worker before enabling monitoring.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!formState.isValid || formState.isSubmitting || !ready} onClick={() => void save()}>
|
||||
{formState.isSubmitting ? "Saving…" : "Enable monitoring"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { watches } from "./lensData";
|
||||
import { watches } from "./watches";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const dotColors = ["#8b5cf6", "#22b3e8", "#e3a32b", "#eb6b93", "#22b3e8", "#8b5cf6", "#e3a32b", "#eb6b93"];
|
||||
const lensBlue = { light: "#0011b3", dark: "#8b9bff" };
|
||||
|
|
@ -128,20 +129,22 @@ export function WatchPicker({
|
|||
aria-pressed={on}
|
||||
title={watch.summary}
|
||||
tabIndex={index === cursor ? 0 : -1}
|
||||
data-state={on ? "active" : "inactive"}
|
||||
onFocus={() => setCursor(index)}
|
||||
onClick={() => toggle(watch.id)}
|
||||
className={`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] ${
|
||||
on
|
||||
? "bg-background text-foreground ring-[1.5px] ring-inset ring-foreground"
|
||||
: "bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<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"
|
||||
className={`size-3 transition-all duration-200 ${on ? "scale-100 opacity-100" : "scale-50 opacity-0"}`}
|
||||
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"
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { analysisModelOptions, type AnalysisModelInfo } from "./analysisModels";
|
||||
import type { InvestigationInput } from "../investigationSchema";
|
||||
|
||||
export function AnalysisModelField({
|
||||
models,
|
||||
modelDetails,
|
||||
modelsLoading,
|
||||
modelsError,
|
||||
unavailable,
|
||||
unsupported,
|
||||
}: {
|
||||
models: string[];
|
||||
modelDetails: AnalysisModelInfo[];
|
||||
modelsLoading: boolean;
|
||||
modelsError?: string;
|
||||
unavailable: boolean;
|
||||
unsupported: boolean;
|
||||
}) {
|
||||
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>
|
||||
<Controller
|
||||
control={control}
|
||||
name="selectedModel"
|
||||
render={({ field }) => (
|
||||
<SearchSelect
|
||||
aria-label="Analysis model"
|
||||
options={analysisModelOptions(models, modelDetails)}
|
||||
value={field.value ?? ""}
|
||||
onValueChange={(value) => field.onChange(value ?? "")}
|
||||
placeholder={modelsLoading ? "Loading models…" : "Choose a model"}
|
||||
disabled={modelsLoading}
|
||||
emptyText="No matching models configured on this gateway"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{modelsError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Could not load models: {modelsError}
|
||||
</p>
|
||||
)}
|
||||
{unavailable && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{model} is no longer available. Choose another analysis model.
|
||||
</p>
|
||||
)}
|
||||
{unsupported && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Choose a chat model that supports JSON output.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Sample } from "../../model/types";
|
||||
|
||||
import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
|
||||
import type { InvestigationInput } from "../investigationSchema";
|
||||
export function MetadataFilters({
|
||||
attributes,
|
||||
keys,
|
||||
id,
|
||||
}: {
|
||||
attributes: NonNullable<Sample["executions"][number]["metadata"]>;
|
||||
keys: string[];
|
||||
id: string;
|
||||
}) {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<InvestigationInput>();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "selection.filters", keyName: "fieldId" });
|
||||
const filters = useWatch({ control, name: "selection.filters" });
|
||||
return (
|
||||
<>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.fieldId} className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
{...register(`selection.filters.${index}.key`)}
|
||||
aria-label={`Metadata key ${index + 1}`}
|
||||
list={`${id}-keys`}
|
||||
placeholder="Metadata key"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove condition ${index + 1}`}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{errors.selection?.filters?.[index]?.key?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.selection.filters[index].key.message}
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
{...register(`selection.filters.${index}.value`)}
|
||||
aria-label={`Metadata value ${index + 1}`}
|
||||
list={`${id}-values-${index}`}
|
||||
placeholder="Equals"
|
||||
/>
|
||||
{errors.selection?.filters?.[index]?.value?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.selection.filters[index].value.message}
|
||||
</p>
|
||||
)}
|
||||
<datalist id={`${id}-values-${index}`}>
|
||||
{[...new Set(attributes.filter((a) => a.key === filters[index].key).map((a) => a.value))]
|
||||
.sort()
|
||||
.map((v) => (
|
||||
<option key={v} value={v} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
))}
|
||||
<datalist id={`${id}-keys`}>
|
||||
{keys.map((key) => (
|
||||
<option key={key} value={key} />
|
||||
))}
|
||||
</datalist>
|
||||
<Button variant="outline" size="sm" disabled={filters.length >= 8} onClick={() => append({ key: "", value: "" })}>
|
||||
<Plus className="size-3" /> Add condition
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { DurationInput } from "@/components/shared/DurationInput";
|
||||
import type { InvestigationInput } from "../investigationSchema";
|
||||
|
||||
export function SampleFields() {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<InvestigationInput>();
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="selection.lookback_hours"
|
||||
render={({ field }) => (
|
||||
<DurationInput
|
||||
label="Review the last"
|
||||
value={field.value ?? 24}
|
||||
base="hours"
|
||||
max={8760}
|
||||
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"
|
||||
/>
|
||||
{errors.selection?.sample_percent?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.selection.sample_percent.message}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Sample } from "../../model/types";
|
||||
import type { InvestigationInput } from "../investigationSchema";
|
||||
|
||||
import { MetadataFilters } from "./MetadataFilters";
|
||||
const selectClass = "h-9 w-full rounded-md border border-input bg-background px-3 text-sm";
|
||||
export function ScopeFields({
|
||||
nameField,
|
||||
names,
|
||||
agentsLoading,
|
||||
agentsError,
|
||||
retryAgents,
|
||||
attributes,
|
||||
keys,
|
||||
id,
|
||||
}: {
|
||||
nameField?: ReactNode;
|
||||
names: string[];
|
||||
agentsLoading: boolean;
|
||||
agentsError: boolean;
|
||||
retryAgents: () => void;
|
||||
attributes: NonNullable<Sample["executions"][number]["metadata"]>;
|
||||
keys: string[];
|
||||
id: string;
|
||||
}) {
|
||||
const { control, register, setValue } = useFormContext<InvestigationInput>();
|
||||
const selection = useWatch({ control, name: "selection" });
|
||||
const filters = selection.filters ?? [];
|
||||
const hasOptionalScope = !!selection.team_id || !!selection.service;
|
||||
const showAdvancedByDefault = !!selection.filters.length || hasOptionalScope || selection.source !== "traces";
|
||||
const [advanced, setAdvanced] = useState(showAdvancedByDefault);
|
||||
const nameFieldName = selection.source === "requests" ? "selection.service" : "selection.agent_name";
|
||||
const selectedName = selection.source === "requests" ? selection.service : selection.agent_name;
|
||||
const nameLabel = selection.source === "requests" ? "Model group (optional)" : "Agent (optional)";
|
||||
return (
|
||||
<>
|
||||
{nameField}
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
{nameLabel}
|
||||
<Controller
|
||||
control={control}
|
||||
name={nameFieldName}
|
||||
render={({ field }) => (
|
||||
<Combobox
|
||||
items={names}
|
||||
value={field.value || null}
|
||||
inputValue={field.value ?? ""}
|
||||
onInputValueChange={field.onChange}
|
||||
onValueChange={(name) => field.onChange(name ?? "")}
|
||||
>
|
||||
<ComboboxInput
|
||||
aria-label={nameLabel}
|
||||
placeholder={selection.source === "requests" ? "All model groups" : "All agents and activity"}
|
||||
showClear={!!selectedName}
|
||||
className="w-full h-9"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>
|
||||
{agentsLoading ? "Loading agents…" : "No matches. You can enter a recorded name."}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(name: string) => (
|
||||
<ComboboxItem key={name} value={name}>
|
||||
{name}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
{selection.source !== "requests" && agentsError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Could not load agents.{" "}
|
||||
<button type="button" className="underline" onClick={retryAgents}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
<details open={advanced} onToggle={(event) => setAdvanced(event.currentTarget.open)} className="group">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Advanced filters{filters.length ? ` (${filters.length})` : ""}
|
||||
</summary>
|
||||
<div className="mt-4 space-y-4">
|
||||
{selection.source !== "requests" && (
|
||||
<label className="grid gap-2 text-sm">
|
||||
Application (optional)
|
||||
<Input {...register("selection.service")} placeholder="All applications" />
|
||||
</label>
|
||||
)}
|
||||
<label className="grid gap-2 text-sm">
|
||||
Activity type
|
||||
<select
|
||||
{...register("selection.source", {
|
||||
onChange: () => {
|
||||
setValue("selection.service", "");
|
||||
setValue("selection.agent_name", "");
|
||||
setValue("selection.filters", []);
|
||||
},
|
||||
})}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="traces">Agent traces</option>
|
||||
<option value="requests">LLM requests</option>
|
||||
<option value="both">Traces and LLM requests</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Match any recorded metadata, such as a user ID, environment, or tag. All conditions must match.
|
||||
</p>
|
||||
<MetadataFilters attributes={attributes} keys={keys} id={id} />
|
||||
<label className="grid gap-2 text-sm">
|
||||
Team ID (optional)
|
||||
<Input {...register("selection.team_id")} placeholder="All accessible teams" />
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
export interface AnalysisModelInfo {
|
||||
model_group: string;
|
||||
providers: string[];
|
||||
mode?: string | null;
|
||||
supported_openai_params?: string[] | null;
|
||||
}
|
||||
|
||||
export function analysisModelOptions(models: string[], details: AnalysisModelInfo[]) {
|
||||
return [...new Set(models)].sort().map((name) => {
|
||||
const info = details.find((item) => item.model_group === name);
|
||||
const capability = () => {
|
||||
if (info?.mode && info.mode !== "chat") return `${info.mode}: not suitable for Lens`;
|
||||
if (info?.supported_openai_params?.includes("response_format")) return "JSON output supported";
|
||||
return "JSON output support unverified";
|
||||
};
|
||||
return {
|
||||
value: name,
|
||||
label: name,
|
||||
sublabel: [info?.providers.join(", "), capability()].filter(Boolean).join(" · "),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeFilters } from "./filters";
|
||||
|
||||
describe("Lens selection and findings", () => {
|
||||
it("preserves literal equals signs in a metadata value", () => {
|
||||
expect(normalizeFilters([{ key: " swarm ", value: " research=v2 " }])).toEqual([
|
||||
{ key: "swarm", value: "research=v2" },
|
||||
]);
|
||||
});
|
||||
it("rejects an incomplete condition instead of broadening the scan", () => {
|
||||
expect(() => normalizeFilters([{ key: "swarm", value: " " }])).toThrow("Choose a key and value");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import type { Settings } from "../model/types";
|
||||
|
||||
export function normalizeFilters(filters: NonNullable<Settings["filters"]>): Settings["filters"] {
|
||||
return filters.map((f) => {
|
||||
if (!f.key.trim() || !f.value.trim()) throw new Error("Choose a key and value for every condition, or remove it");
|
||||
return { key: f.key.trim(), value: f.value.trim() };
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
investigationDefaults,
|
||||
investigationSchema,
|
||||
investigationSettings,
|
||||
type InvestigationInput,
|
||||
} from "./investigationSchema";
|
||||
import { watchChecks } from "./watches";
|
||||
|
||||
const defaults = investigationDefaults(undefined, "new", "traces");
|
||||
|
||||
function parse(
|
||||
patch: Partial<Omit<InvestigationInput, "selection">> & { selection?: Partial<InvestigationInput["selection"]> } = {},
|
||||
) {
|
||||
return investigationSchema.safeParse({
|
||||
...defaults,
|
||||
...patch,
|
||||
selection: { ...defaults.selection, ...patch.selection },
|
||||
});
|
||||
}
|
||||
|
||||
function expectIssue(
|
||||
patch: Partial<Omit<InvestigationInput, "selection">> & { selection?: Partial<InvestigationInput["selection"]> },
|
||||
path: PropertyKey[],
|
||||
message: string,
|
||||
) {
|
||||
const result = parse(patch);
|
||||
if (result.success) throw new Error("Expected the draft to be invalid");
|
||||
expect(result.error.issues).toContainEqual(expect.objectContaining({ path, message }));
|
||||
}
|
||||
|
||||
describe("investigation validation", () => {
|
||||
it("attaches incomplete metadata errors to the missing filter field", () => {
|
||||
expectIssue(
|
||||
{ selection: { filters: [{ key: "swarm", value: " " }] } },
|
||||
["selection", "filters", 0, "value"],
|
||||
"Choose a key and value for every condition, or remove it",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([0, 0.5, 8761, NaN, Infinity])("rejects an invalid history window: %s", (lookback_hours) => {
|
||||
expectIssue(
|
||||
{ selection: { lookback_hours } },
|
||||
["selection", "lookback_hours"],
|
||||
"Choose a time range between 1 hour and 365 days",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([0, -1, 101, NaN, Infinity])("rejects an invalid sample percentage: %s", (sample_percent) => {
|
||||
expectIssue(
|
||||
{ selection: { sample_percent } },
|
||||
["selection", "sample_percent"],
|
||||
"Choose a sampling percentage greater than 0 and up to 100",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([0, -1, 1.5, NaN])("rejects an invalid maximum: %s", (sample_size) => {
|
||||
expectIssue(
|
||||
{ selection: { sample_size } },
|
||||
["selection", "sample_size"],
|
||||
"Choose a positive maximum or leave it blank for no limit",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([1, 8760])("accepts the history boundary %s with fractional sampling and no maximum", (lookback_hours) => {
|
||||
const result = parse({ selection: { lookback_hours, sample_percent: 0.01, sample_size: null } });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires individual runs only on the Run step", () => {
|
||||
expectIssue(
|
||||
{ manualSelection: true },
|
||||
["selection", "execution_ids"],
|
||||
"Choose at least one run or turn off individual selection",
|
||||
);
|
||||
expect(parse({ selection: { execution_ids: ["run"] }, manualSelection: true }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires expectations from context, presets, or a filled custom check", () => {
|
||||
expectIssue({ watching: [] }, ["context"], "Describe the expected behavior or pick something to watch for");
|
||||
expect(parse({ watching: [], context: "Expected behavior" }).success).toBe(true);
|
||||
const questions = [{ id: "custom", instruction: "Investigate retries", enabled: false }];
|
||||
expect(parse({ watching: [], questions }).success).toBe(true);
|
||||
expectIssue(
|
||||
{ questions: [{ ...questions[0], instruction: " x " }] },
|
||||
["questions", 0, "instruction"],
|
||||
"Use at least three characters for each check",
|
||||
);
|
||||
});
|
||||
|
||||
it("validates budget and repeat interval with field-specific paths", () => {
|
||||
expectIssue({ budget: Infinity }, ["budget"], "Choose a monthly limit greater than zero and up to 100000");
|
||||
expectIssue({ repeat: true, interval: 1.5 }, ["interval"], "Choose a repeat interval between 1 and 10080 minutes");
|
||||
expect(parse({ repeat: false, interval: 0 }).success).toBe(true);
|
||||
expect(parse({ repeat: true, interval: 10080 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("omits blank checks and trims payload text without changing check identity or enabled state", () => {
|
||||
const input = {
|
||||
...defaults,
|
||||
name: " ",
|
||||
context: " Expected behavior \n Keep sources ",
|
||||
watching: ["watch_invented"],
|
||||
questions: [
|
||||
{ id: "custom", instruction: " Find repetitive searches\nInclude retries ", enabled: false },
|
||||
{ id: "blank", instruction: " ", enabled: true },
|
||||
],
|
||||
selection: { ...defaults.selection, filters: [{ key: " swarm ", value: " research=v2 " }] },
|
||||
};
|
||||
const draft = investigationSchema.parse(input);
|
||||
const saved = investigationSettings(draft, undefined, "analysis");
|
||||
const expectedSaved = {
|
||||
name: "Find repetitive searches\nInclude retries",
|
||||
context: "Expected behavior \n Keep sources",
|
||||
model: "analysis",
|
||||
concurrency: 8,
|
||||
filters: [{ key: "swarm", value: "research=v2" }],
|
||||
checks: [
|
||||
...watchChecks(new Set(["watch_invented"])),
|
||||
{ id: "custom", instruction: "Find repetitive searches\nInclude retries", enabled: false },
|
||||
],
|
||||
};
|
||||
expect(saved).toMatchObject(expectedSaved);
|
||||
expect(input.questions[0].instruction).toBe(" Find repetitive searches\nInclude retries ");
|
||||
});
|
||||
|
||||
it("preserves saved configuration and uses the same edit and duplicate defaults", () => {
|
||||
const saved = investigationSettings(investigationSchema.parse(defaults), undefined, "analysis");
|
||||
const initial = { ...saved, enabled: true, concurrency: 3, interval_minutes: 15 };
|
||||
expect(investigationDefaults(initial, "edit", "requests")).toMatchObject({ repeat: true, interval: 15 });
|
||||
const duplicate = investigationDefaults(initial, "duplicate", "requests");
|
||||
expect(duplicate.repeat).toBe(false);
|
||||
expect(investigationSettings(investigationSchema.parse(duplicate), initial, "analysis")).toMatchObject({
|
||||
concurrency: 3,
|
||||
interval_minutes: 15,
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
import { z } from "zod";
|
||||
import type { FieldPath } from "react-hook-form";
|
||||
import type { Settings } from "../model/types";
|
||||
import { normalizeFilters } from "./filters";
|
||||
import { initialWatches, isWatch, watchChecks } from "./watches";
|
||||
|
||||
const selectionFields = {
|
||||
source: z.enum(["traces", "requests", "both"]),
|
||||
service: z.string(),
|
||||
agent_name: z.string(),
|
||||
filters: z.array(z.object({ key: z.string(), value: z.string() })),
|
||||
lookback_hours: z.custom<number>(),
|
||||
sample_size: z.custom<number | null>(),
|
||||
sample_percent: z.custom<number>(),
|
||||
team_id: z.string(),
|
||||
execution_ids: z.array(z.string()),
|
||||
};
|
||||
|
||||
const draftFields = {
|
||||
name: z.string(),
|
||||
selection: z.object(selectionFields),
|
||||
context: z.string(),
|
||||
watching: z.array(z.string()),
|
||||
questions: z.array(z.object({ id: z.string(), instruction: z.string(), enabled: z.boolean() })),
|
||||
selectedModel: z.string().nullable(),
|
||||
budget: z.custom<number>(),
|
||||
repeat: z.boolean(),
|
||||
interval: z.custom<number>(),
|
||||
manualSelection: z.boolean(),
|
||||
};
|
||||
const draftSchema = z.object(draftFields);
|
||||
|
||||
type InvestigationDraft = z.infer<typeof draftSchema>;
|
||||
|
||||
function validateFilters(draft: InvestigationDraft, ctx: z.RefinementCtx) {
|
||||
draft.selection.filters.forEach((filter, index) => {
|
||||
if (!filter.key.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Choose a key and value for every condition, or remove it",
|
||||
path: ["selection", "filters", index, "key"],
|
||||
});
|
||||
}
|
||||
if (!filter.value.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Choose a key and value for every condition, or remove it",
|
||||
path: ["selection", "filters", index, "value"],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateManualSelection(draft: InvestigationDraft, ctx: z.RefinementCtx) {
|
||||
if (draft.manualSelection && !draft.selection.execution_ids.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Choose at least one run or turn off individual selection",
|
||||
path: ["selection", "execution_ids"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateSampleWindow(draft: InvestigationDraft, ctx: z.RefinementCtx) {
|
||||
const selection = draft.selection;
|
||||
const hours = selection.lookback_hours ?? 24;
|
||||
if (!Number.isInteger(hours) || hours < 1 || hours > 8760) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Choose a time range between 1 hour and 365 days",
|
||||
path: ["selection", "lookback_hours"],
|
||||
});
|
||||
}
|
||||
const percent = selection.sample_percent ?? 100;
|
||||
if (!Number.isFinite(percent) || percent <= 0 || percent > 100) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Choose a sampling percentage greater than 0 and up to 100",
|
||||
path: ["selection", "sample_percent"],
|
||||
});
|
||||
}
|
||||
if (selection.sample_size != null && (!Number.isInteger(selection.sample_size) || selection.sample_size < 1)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Choose a positive maximum or leave it blank for no limit",
|
||||
path: ["selection", "sample_size"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateBudgetAndSchedule(draft: InvestigationDraft, ctx: z.RefinementCtx) {
|
||||
if (!Number.isFinite(draft.budget) || draft.budget <= 0 || draft.budget > 100000) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Choose a monthly limit greater than zero and up to 100000",
|
||||
path: ["budget"],
|
||||
});
|
||||
}
|
||||
const intervalOutOfRange = draft.interval < 1 || draft.interval > 10080;
|
||||
const intervalInvalid = !Number.isInteger(draft.interval) || intervalOutOfRange;
|
||||
if (draft.repeat && intervalInvalid) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Choose a repeat interval between 1 and 10080 minutes",
|
||||
path: ["interval"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateExpectations(draft: InvestigationDraft, ctx: z.RefinementCtx) {
|
||||
const checks = draft.questions.filter((check) => check.instruction.trim());
|
||||
if (!draft.context.trim() && !checks.length && !draft.watching.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Describe the expected behavior or pick something to watch for",
|
||||
path: ["context"],
|
||||
});
|
||||
}
|
||||
draft.questions.forEach((check, index) => {
|
||||
if (check.instruction.trim() && check.instruction.trim().length < 3) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Use at least three characters for each check",
|
||||
path: ["questions", index, "instruction"],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const investigationSchema = draftSchema
|
||||
.superRefine((draft, ctx) => {
|
||||
validateFilters(draft, ctx);
|
||||
validateManualSelection(draft, ctx);
|
||||
validateSampleWindow(draft, ctx);
|
||||
validateBudgetAndSchedule(draft, ctx);
|
||||
validateExpectations(draft, ctx);
|
||||
})
|
||||
.transform((draft) => ({
|
||||
...draft,
|
||||
context: draft.context.trim(),
|
||||
selection: { ...draft.selection, filters: normalizeFilters(draft.selection.filters) },
|
||||
questions: draft.questions
|
||||
.filter((check) => check.instruction.trim())
|
||||
.map((check) => ({ ...check, instruction: check.instruction.trim() })),
|
||||
}));
|
||||
|
||||
export type InvestigationInput = z.input<typeof investigationSchema>;
|
||||
export type InvestigationOutput = z.output<typeof investigationSchema>;
|
||||
|
||||
export const investigationStepFields: readonly FieldPath<InvestigationInput>[][] = [
|
||||
["name", "selection.source", "selection.service", "selection.agent_name", "selection.filters", "selection.team_id"],
|
||||
["context", "questions", "watching"],
|
||||
[
|
||||
"selection.execution_ids",
|
||||
"selection.lookback_hours",
|
||||
"selection.sample_size",
|
||||
"selection.sample_percent",
|
||||
"selectedModel",
|
||||
"budget",
|
||||
"interval",
|
||||
"repeat",
|
||||
"manualSelection",
|
||||
],
|
||||
];
|
||||
|
||||
function activitySelectionDefaults(
|
||||
initial: Settings | undefined,
|
||||
defaultSource: Settings["source"],
|
||||
): InvestigationInput["selection"] {
|
||||
return {
|
||||
source: initial?.source ?? defaultSource,
|
||||
service: initial?.service ?? "",
|
||||
agent_name: initial?.agent_name ?? "",
|
||||
filters: initial?.filters ?? [],
|
||||
lookback_hours: initial?.lookback_hours ?? 24,
|
||||
sample_size: initial?.sample_size ?? null,
|
||||
sample_percent: initial?.sample_percent ?? 100,
|
||||
team_id: initial?.team_id ?? "",
|
||||
execution_ids: initial?.execution_ids ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function investigationDefaults(
|
||||
initial: Settings | undefined,
|
||||
mode: "new" | "edit" | "duplicate",
|
||||
defaultSource: Settings["source"],
|
||||
): InvestigationInput {
|
||||
return {
|
||||
name: initial?.name ?? "",
|
||||
selection: activitySelectionDefaults(initial, defaultSource),
|
||||
context: initial?.context ?? "",
|
||||
watching: [...initialWatches(initial?.checks)],
|
||||
questions: (initial?.checks ?? []).filter((check) => !isWatch(check)),
|
||||
selectedModel: initial?.model ?? null,
|
||||
budget: initial?.monthly_budget ?? 100,
|
||||
repeat: mode === "edit" && !!initial?.enabled,
|
||||
interval: initial?.interval_minutes ?? 30,
|
||||
manualSelection: !!initial?.execution_ids?.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function investigationSettings(
|
||||
draft: InvestigationOutput,
|
||||
initial: Settings | undefined,
|
||||
model: string,
|
||||
): Settings {
|
||||
const suggestedName = draft.questions[0]?.instruction || draft.context.split("\n")[0] || "Investigation";
|
||||
return {
|
||||
...initial,
|
||||
...draft.selection,
|
||||
name: draft.name.trim() || suggestedName.slice(0, 100),
|
||||
context: draft.context,
|
||||
model,
|
||||
monthly_budget: draft.budget,
|
||||
enabled: draft.repeat,
|
||||
interval_minutes: draft.interval,
|
||||
concurrency: initial?.concurrency ?? 8,
|
||||
checks: [...watchChecks(new Set(draft.watching)), ...draft.questions],
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 ExpectationsStep() {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = 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?
|
||||
<Textarea
|
||||
{...register("context")}
|
||||
maxLength={6000}
|
||||
rows={4}
|
||||
placeholder="Answer the customer's question using verified sources and explain when information is missing."
|
||||
/>
|
||||
</label>
|
||||
{errors.context?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.context.message}
|
||||
</p>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="watching"
|
||||
render={({ field }) => (
|
||||
<WatchPicker
|
||||
selected={new Set(field.value)}
|
||||
onChange={(values) => field.onChange([...values])}
|
||||
onAddCustom={() => append({ id: crypto.randomUUID(), instruction: "", enabled: true })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" aria-label={`Remove check ${index + 1}`} onClick={() => remove(index)}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
ui/litellm-dashboard/src/components/lens/setup/steps/RunStep.tsx
Normal file
125
ui/litellm-dashboard/src/components/lens/setup/steps/RunStep.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"use client";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import type { InvestigationInput } from "../investigationSchema";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { DurationInput } from "@/components/shared/DurationInput";
|
||||
import { type AnalysisModelInfo } from "../fields/analysisModels";
|
||||
|
||||
import { AnalysisModelField } from "../fields/AnalysisModelField";
|
||||
export function RunStep({
|
||||
modelValid,
|
||||
models,
|
||||
modelDetails,
|
||||
modelsLoading,
|
||||
modelsError,
|
||||
unavailable,
|
||||
unsupported,
|
||||
}: {
|
||||
modelValid: boolean;
|
||||
models: string[];
|
||||
modelDetails: AnalysisModelInfo[];
|
||||
modelsLoading: boolean;
|
||||
modelsError?: string;
|
||||
unavailable: boolean;
|
||||
unsupported: boolean;
|
||||
}) {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useFormContext<InvestigationInput>();
|
||||
const repeat = useWatch({ control, name: "repeat" });
|
||||
return (
|
||||
<>
|
||||
<details open={!modelValid || undefined}>
|
||||
<summary className="cursor-pointer text-sm font-medium">Advanced options</summary>
|
||||
<div className="mt-4 space-y-5">
|
||||
<AnalysisModelField
|
||||
models={models}
|
||||
modelDetails={modelDetails}
|
||||
modelsLoading={modelsLoading}
|
||||
modelsError={modelsError}
|
||||
unavailable={unavailable}
|
||||
unsupported={unsupported}
|
||||
/>
|
||||
<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" max="100000" step="1" />
|
||||
{errors.budget?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.budget.message}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
<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")}
|
||||
/>
|
||||
Repeat this investigation
|
||||
</label>
|
||||
{repeat && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="interval"
|
||||
render={({ field }) => (
|
||||
<DurationInput
|
||||
label="Repeat every"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
base="minutes"
|
||||
max={10080}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{errors.interval?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.interval.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import type { InvestigationInput } from "../investigationSchema";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { MatchingActivity } from "../MatchingActivity";
|
||||
|
||||
export function ScopeStep({
|
||||
step,
|
||||
accessToken,
|
||||
setPreviewReady,
|
||||
}: {
|
||||
step: number;
|
||||
accessToken: string;
|
||||
setPreviewReady: (value: boolean) => void;
|
||||
}) {
|
||||
const { control, register } = useFormContext<InvestigationInput>();
|
||||
const name = useWatch({ control, name: "name" });
|
||||
const manualSelection = useWatch({ control, name: "manualSelection" });
|
||||
return (
|
||||
<MatchingActivity
|
||||
key={step}
|
||||
accessToken={accessToken}
|
||||
nameField={
|
||||
step === 0 ? (
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
Investigation name
|
||||
<Input {...register("name")} placeholder="e.g. Support quality" maxLength={100} />
|
||||
</label>
|
||||
) : undefined
|
||||
}
|
||||
mode={step === 0 ? "scope" : "activity"}
|
||||
onPreviewReady={setPreviewReady}
|
||||
manualSelection={manualSelection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
92
ui/litellm-dashboard/src/components/lens/setup/watches.ts
Normal file
92
ui/litellm-dashboard/src/components/lens/setup/watches.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { Settings } from "../model/types";
|
||||
|
||||
export interface Watch {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
instruction: string;
|
||||
defaultOn: boolean;
|
||||
}
|
||||
|
||||
export const watches: readonly Watch[] = [
|
||||
{
|
||||
id: "watch_unsolved",
|
||||
name: "unsolved",
|
||||
summary: "didn't finish what was asked",
|
||||
instruction:
|
||||
"Find runs where the agent failed to solve what the user asked for: wrong or partial answers, giving up, or stopping mid-task.",
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
id: "watch_blocked",
|
||||
name: "blocked",
|
||||
summary: "missing a tool, data or skill",
|
||||
instruction:
|
||||
"Find runs where the agent could not do a step because it lacked a tool, data or capability, including when it tells the user it cannot help.",
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
id: "watch_permissions",
|
||||
name: "permissions",
|
||||
summary: "denied, unapproved or overstepped",
|
||||
instruction:
|
||||
"Find runs with permission problems: access denied, an approval or confirmation the agent skipped or mishandled, or the agent acting on resources it was not granted.",
|
||||
defaultOn: false,
|
||||
},
|
||||
{
|
||||
id: "watch_unhappy",
|
||||
name: "unhappy",
|
||||
summary: "user annoyed or had to repeat",
|
||||
instruction:
|
||||
"Find runs where the user seems dissatisfied: repeating or rephrasing the same request, correcting the agent, or expressing annoyance.",
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
id: "watch_swallowed",
|
||||
name: "swallowed",
|
||||
summary: "ignored a failed tool call",
|
||||
instruction:
|
||||
"Find runs where a tool call failed or returned an error and the agent continued as if it had succeeded, without retrying or telling the user.",
|
||||
defaultOn: false,
|
||||
},
|
||||
{
|
||||
id: "watch_looping",
|
||||
name: "looping",
|
||||
summary: "repeats steps without progress",
|
||||
instruction:
|
||||
"Find runs where the agent repeats the same tool call, search or step several times without getting new information or making progress.",
|
||||
defaultOn: false,
|
||||
},
|
||||
{
|
||||
id: "watch_invented",
|
||||
name: "invented",
|
||||
summary: "claims no tool ever returned",
|
||||
instruction:
|
||||
"Find runs where the agent states facts, identifiers, numbers or results that do not appear in any tool output or source it had.",
|
||||
defaultOn: false,
|
||||
},
|
||||
{
|
||||
id: "watch_unsafe",
|
||||
name: "unsafe",
|
||||
summary: "harmful, deceptive or rule-bending",
|
||||
instruction:
|
||||
"Find runs with malicious or unsafe behavior from the agent or the user: destructive or irreversible actions, deception, leaking secrets or private data, or attempts to bypass instructions or safeguards.",
|
||||
defaultOn: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function watchChecks(enabled: ReadonlySet<string>): Settings["checks"] {
|
||||
return watches
|
||||
.filter((watch) => enabled.has(watch.id))
|
||||
.map(({ id, instruction }) => ({ id, instruction, enabled: true }));
|
||||
}
|
||||
|
||||
export function initialWatches(checks: Settings["checks"] | undefined): ReadonlySet<string> {
|
||||
if (!checks?.length) return new Set(watches.filter((watch) => watch.defaultOn).map((watch) => watch.id));
|
||||
const ids = new Set(watches.map((watch) => watch.id));
|
||||
return new Set(checks.filter((check) => ids.has(check.id) && check.enabled).map((check) => check.id));
|
||||
}
|
||||
|
||||
export function isWatch(check: Settings["checks"][number]): boolean {
|
||||
return watches.some((watch) => watch.id === check.id);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { lensQueries } from "../../api/queries";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLensApi } from "../../api/useLensApi";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { WorkerFormInput } from "./workerSchema";
|
||||
|
||||
export function AnalysisAccessFields({ accessToken }: { accessToken: string }) {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<WorkerFormInput>();
|
||||
const apiClient = useLensApi();
|
||||
const models = useQuery(lensQueries.models(apiClient, accessToken));
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="analysis-access-model" className="block text-sm font-medium">
|
||||
Analysis model
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="access.model"
|
||||
render={({ field }) => (
|
||||
<SearchSelect
|
||||
inputId="analysis-access-model"
|
||||
options={(models.data?.data ?? []).map(({ id }) => ({ label: id, value: id }))}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={models.isLoading ? "Loading models…" : "Select a model"}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.access?.message && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.access.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="analysis-access-budget" className="block text-sm font-medium">
|
||||
Monthly limit (USD)
|
||||
</label>
|
||||
<Input {...register("access.budget")} id="analysis-access-budget" type="number" min="0.01" step="0.01" />
|
||||
<p className="text-xs text-muted-foreground">Shared across all investigations.</p>
|
||||
</div>
|
||||
{models.error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{models.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { runTime } from "./lensData";
|
||||
import { analysisKeyInfoQuery } from "../../api/queries";
|
||||
|
||||
const keyInfoFields = {
|
||||
key_alias: z.string().nullable().optional(),
|
||||
models: z.array(z.string()),
|
||||
max_budget: z.number().nullable(),
|
||||
budget_duration: z.string().nullable().optional(),
|
||||
rpm_limit: z.number().nullable().optional(),
|
||||
tpm_limit: z.number().nullable().optional(),
|
||||
expires: z.string().nullable().optional(),
|
||||
status: z.string().optional(),
|
||||
};
|
||||
const keyInfoSchema = z.object({ info: z.object(keyInfoFields) });
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLensApi } from "../../api/useLensApi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { runTime } from "../../model/format";
|
||||
|
||||
function budgetLabel(amount: number | null, duration?: string | null): string {
|
||||
if (amount === null) return "No key budget";
|
||||
|
|
@ -37,12 +26,8 @@ function budgetLabel(amount: number | null, duration?: string | null): string {
|
|||
}
|
||||
|
||||
export function useAnalysisKeyInfo(accessToken: string, keyId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["lens-key-info", accessToken, keyId],
|
||||
enabled: !!keyId,
|
||||
queryFn: async () =>
|
||||
keyInfoSchema.parse(await apiClient.get("/key/info", { accessToken, query: { key: keyId } })).info,
|
||||
});
|
||||
const apiClient = useLensApi();
|
||||
return useQuery(analysisKeyInfoQuery(apiClient, accessToken, keyId));
|
||||
}
|
||||
|
||||
export function AnalysisKeyDetails({
|
||||
|
|
@ -1,12 +1,30 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { AnalysisKey } from "./AnalysisKey";
|
||||
import { AnalysisKeyPicker } from "./AnalysisKeyPicker";
|
||||
import type { WorkerFormInput } from "./workerSchema";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ apiClient: { get: vi.fn(), post: vi.fn() } }));
|
||||
|
||||
function AnalysisKeyPickerForm() {
|
||||
const form = useForm<WorkerFormInput>({
|
||||
defaultValues: {
|
||||
useExisting: true,
|
||||
analysisKey: null,
|
||||
access: { model: null, budget: "100" },
|
||||
address: "http://localhost:4000",
|
||||
},
|
||||
});
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<AnalysisKeyPicker accessToken="test" />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Lens billing key", () => {
|
||||
beforeEach(() => {
|
||||
testQueryClient.clear();
|
||||
|
|
@ -15,7 +33,6 @@ describe("Lens billing key", () => {
|
|||
|
||||
it("pages existing keys without dropping the selected billing key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const changed = vi.fn();
|
||||
vi.mocked(apiClient.get).mockImplementation(async (path, options) =>
|
||||
path === "/key/info"
|
||||
? { info: { models: ["restricted-model"], max_budget: 4, budget_duration: "1d" } }
|
||||
|
|
@ -27,11 +44,11 @@ describe("Lens billing key", () => {
|
|||
total_pages: 2,
|
||||
},
|
||||
);
|
||||
renderWithProviders(<AnalysisKey accessToken="test" value={null} onChange={changed} />);
|
||||
renderWithProviders(<AnalysisKeyPickerForm />);
|
||||
await user.click(screen.getByRole("combobox", { name: "Charge analysis to" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Load more keys" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Second page" }));
|
||||
expect(changed).toHaveBeenCalledExactlyOnceWith("c".repeat(64));
|
||||
expect(screen.getByRole("combobox", { name: "Charge analysis to" })).toHaveValue("Second page");
|
||||
expect(await screen.findByText("restricted-model")).toBeInTheDocument();
|
||||
expect(screen.getByText("$4.00 / day")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { analysisKeysQuery, type Key } from "../../api/queries";
|
||||
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import { useState } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLensApi } from "../../api/useLensApi";
|
||||
import { AnalysisKeyDetails } from "./AnalysisKeyDetails";
|
||||
import type { WorkerFormInput } from "./workerSchema";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
|
||||
export function AnalysisKeyPicker({ accessToken }: { accessToken: string }) {
|
||||
const { control } = useFormContext<WorkerFormInput>();
|
||||
const value = useWatch({ control, name: "analysisKey" });
|
||||
const apiClient = useLensApi();
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<Key | null>(value ? { token: value } : null);
|
||||
|
||||
const keyPages = useInfiniteQuery(analysisKeysQuery(apiClient, accessToken, query));
|
||||
const keys = keyPages.data?.pages.flatMap((page) => page.keys) ?? [];
|
||||
const choice = keys.find((key) => key.token === value) ?? (selected?.token === value ? selected : null);
|
||||
const loading = keyPages.isFetching;
|
||||
|
||||
const choices = choice && !keys.some((key) => key.token === choice.token) ? [choice, ...keys] : keys;
|
||||
const items = keyPages.hasNextPage
|
||||
? [...choices, { token: "load-more", key_alias: loading ? "Loading…" : "Load more keys" }]
|
||||
: choices;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">Charge analysis to</p>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="analysisKey"
|
||||
render={({ field }) => {
|
||||
const handleValueChange = (key: Key | null, details: { cancel: () => void }) => {
|
||||
if (key?.token === "load-more") {
|
||||
details.cancel();
|
||||
if (!loading) void keyPages.fetchNextPage();
|
||||
return;
|
||||
}
|
||||
setSelected(key);
|
||||
field.onChange(key?.token ?? null);
|
||||
};
|
||||
return (
|
||||
<Combobox
|
||||
items={items}
|
||||
value={choice}
|
||||
filter={null}
|
||||
itemToStringLabel={(key: Key) => key.key_alias || `${key.token.slice(0, 8)}…`}
|
||||
isItemEqualToValue={(a: Key, b: Key) => a.token === b.token}
|
||||
onInputValueChange={(text, details) => {
|
||||
if (details.reason === "input-change" || details.reason === "input-clear") {
|
||||
setQuery(text);
|
||||
}
|
||||
}}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<ComboboxInput aria-label="Charge analysis to" placeholder="Search existing keys" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>{loading ? "Loading keys…" : "No matching keys"}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(key: Key) => (
|
||||
<ComboboxItem key={key.token} value={key}>
|
||||
{key.key_alias || `${key.token.slice(0, 8)}…`}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{choice && <AnalysisKeyDetails accessToken={accessToken} keyId={choice.token} />}
|
||||
{keyPages.error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{keyPages.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { WorkerSetup } from "./WorkerSetup";
|
||||
import { WorkerDialog } from "./WorkerDialog";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
apiClient: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
|
|
@ -38,7 +38,7 @@ describe("Worker setup", () => {
|
|||
it("generates a complete command using one worker credential and the configured proxy address", async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(created);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<WorkerSetup accessToken="admin" workers={[]} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||
renderWithProviders(<WorkerDialog accessToken="admin" workers={[]} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByText("Advanced options"));
|
||||
await user.click(screen.getByRole("switch", { name: "Use an existing virtual key" }));
|
||||
expect(screen.getByRole("textbox", { name: "LiteLLM proxy URL" })).toHaveValue("https://gateway.example/proxy");
|
||||
|
|
@ -64,7 +64,7 @@ describe("Worker setup", () => {
|
|||
const changed = vi.fn();
|
||||
vi.mocked(apiClient.put).mockResolvedValue(created.worker);
|
||||
renderWithProviders(
|
||||
<WorkerSetup
|
||||
<WorkerDialog
|
||||
accessToken="admin"
|
||||
workers={[{ ...created.worker, analysis_key_id: null }]}
|
||||
onClose={vi.fn()}
|
||||
|
|
@ -88,13 +88,13 @@ describe("Worker setup", () => {
|
|||
const changed = vi.fn();
|
||||
vi.mocked(apiClient.delete).mockResolvedValue(true);
|
||||
const props = { accessToken: "admin", onClose: vi.fn(), onChanged: changed };
|
||||
const view = renderWithProviders(<WorkerSetup {...props} workers={[created.worker]} />);
|
||||
const view = renderWithProviders(<WorkerDialog {...props} workers={[created.worker]} />);
|
||||
expect(screen.queryByRole("button", { name: "Add worker" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Get install command" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Revoke access" }));
|
||||
expect(apiClient.delete).toHaveBeenCalledWith("/lens/workers/worker", { accessToken: "admin" });
|
||||
expect(changed).toHaveBeenCalledOnce();
|
||||
view.rerender(<WorkerSetup {...props} workers={[{ ...created.worker, revoked: true }]} />);
|
||||
view.rerender(<WorkerDialog {...props} workers={[{ ...created.worker, revoked: true }]} />);
|
||||
expect(screen.getByRole("button", { name: "Get install command" })).toBeDisabled();
|
||||
expect(screen.getByRole("combobox", { name: "Analysis model" })).toBeVisible();
|
||||
});
|
||||
|
|
@ -111,7 +111,7 @@ describe("Worker setup", () => {
|
|||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ token_id: "retry-key-id" })
|
||||
.mockResolvedValueOnce(created);
|
||||
renderWithProviders(<WorkerSetup accessToken="admin" workers={[]} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||
renderWithProviders(<WorkerDialog accessToken="admin" workers={[]} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Get install command" })).toBeDisabled();
|
||||
expect(screen.getByRole("textbox", { name: "LiteLLM proxy URL", hidden: true })).not.toBeVisible();
|
||||
await user.click(screen.getByRole("combobox", { name: "Analysis model" }));
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
"use client";
|
||||
import { WorkerList } from "./WorkerList";
|
||||
|
||||
import { initialProxyAddress } from "./workerCommand";
|
||||
import { usePrepareWorker, useRevokeWorker } from "../../api/mutations";
|
||||
import { WorkerInstall } from "./WorkerInstall";
|
||||
import { WorkerForm } from "./WorkerForm";
|
||||
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { workerConnected } from "../../model/status";
|
||||
import { workerFormSchema, type WorkerFormInput } from "./workerSchema";
|
||||
import type { LensList, WorkerCreated } from "../../model/types";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
function defaultWorkerFormValues(): WorkerFormInput {
|
||||
return {
|
||||
useExisting: false,
|
||||
analysisKey: null,
|
||||
access: { model: null, budget: "100" },
|
||||
address: typeof window === "undefined" ? "" : initialProxyAddress(),
|
||||
};
|
||||
}
|
||||
|
||||
export function WorkerDialog({
|
||||
accessToken,
|
||||
workers,
|
||||
onClose,
|
||||
onChanged,
|
||||
onReady,
|
||||
}: {
|
||||
accessToken: string;
|
||||
workers: LensList["workers"];
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
onReady?: () => void;
|
||||
}) {
|
||||
const revokeWorker = useRevokeWorker(accessToken);
|
||||
const now = useNow(2000);
|
||||
const form = useZodForm(workerFormSchema, {
|
||||
defaultValues: defaultWorkerFormValues(),
|
||||
mode: "onChange",
|
||||
});
|
||||
const { formState, reset, setValue } = form;
|
||||
const [editingWorker, setEditingWorker] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [created, setCreated] = useState<WorkerCreated | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const connected = created
|
||||
? workers.some((w) => w.id === created.worker.id && workerConnected(w, now))
|
||||
: workers.some((w) => workerConnected(w, now));
|
||||
const formVisible = !workers.some((w) => !w.revoked) || !!editingWorker;
|
||||
const uninstalledScreen = formVisible ? "form" : "list";
|
||||
const screen = created ? "install" : uninstalledScreen;
|
||||
const createdTitle = connected ? "Worker connected" : "Run the worker";
|
||||
const formTitle = editingWorker ? "Analysis access" : "Connect a worker";
|
||||
const baseTitle = formVisible ? formTitle : "Analysis worker";
|
||||
const dialogTitle = created ? createdTitle : baseTitle;
|
||||
const actionLabel = editingWorker ? "Save analysis access" : "Get install command";
|
||||
const cancelForm = () => {
|
||||
if (!workers.some((w) => !w.revoked)) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setEditingWorker(null);
|
||||
reset(defaultWorkerFormValues());
|
||||
};
|
||||
const editBilling = (worker: LensList["workers"][number]) => {
|
||||
reset({
|
||||
...defaultWorkerFormValues(),
|
||||
useExisting: true,
|
||||
analysisKey: worker.analysis_key_id ?? null,
|
||||
});
|
||||
setCreated(null);
|
||||
setEditingWorker(worker.id);
|
||||
};
|
||||
const onPrepared = (created: WorkerCreated | null) => {
|
||||
if (created) setCreated(created);
|
||||
else {
|
||||
setEditingWorker(null);
|
||||
setValue("analysisKey", null);
|
||||
}
|
||||
};
|
||||
const prepareWorker = usePrepareWorker(accessToken, { onChanged, onPrepared });
|
||||
const createWorker = form.handleSubmit(async (values) => {
|
||||
setError("");
|
||||
try {
|
||||
const registration = {
|
||||
address: values.address,
|
||||
useExisting: values.useExisting,
|
||||
analysisKey: values.analysisKey,
|
||||
access: values.access,
|
||||
editingWorker,
|
||||
};
|
||||
await prepareWorker.mutateAsync(registration);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not create credential");
|
||||
}
|
||||
});
|
||||
const revoke = async (id: string) => {
|
||||
try {
|
||||
await revokeWorker.mutateAsync(id);
|
||||
setValue("analysisKey", null);
|
||||
setValue("useExisting", false);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not revoke worker");
|
||||
}
|
||||
};
|
||||
const setupDescription = created
|
||||
? "Run this command on a server with Docker."
|
||||
: "Deploy the worker on your server to run investigations.";
|
||||
const awaitingConnection = !editingWorker && !connected;
|
||||
const describeSetup = awaitingConnection && (formVisible || !!created);
|
||||
const completed = !!created && connected;
|
||||
const modalSize = cn(
|
||||
created && connected && "sm:max-w-sm p-8",
|
||||
created && !connected && "sm:max-w-lg",
|
||||
!created && "sm:max-w-xl",
|
||||
);
|
||||
const description = describeSetup ? setupDescription : "Worker status and model access";
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !formState.isSubmitting) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className={cn("max-h-[90dvh] overflow-y-auto", modalSize)}>
|
||||
<DialogHeader className={cn(completed && "items-center gap-3 text-center sm:text-center")}>
|
||||
{completed && (
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400">
|
||||
<CheckCircle2 className="size-6" />
|
||||
</div>
|
||||
)}
|
||||
<DialogTitle className="text-xl leading-7">{dialogTitle}</DialogTitle>
|
||||
<DialogDescription className={cn(!(describeSetup || completed) && "sr-only")}>
|
||||
{completed ? "Ready to run investigations." : description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{screen === "form" && <WorkerForm accessToken={accessToken} editingWorker={editingWorker} />}
|
||||
{screen === "install" && created ? (
|
||||
<WorkerInstall
|
||||
connected={connected}
|
||||
address={form.getValues("address")}
|
||||
created={created}
|
||||
copied={copied}
|
||||
setCopied={setCopied}
|
||||
setError={setError}
|
||||
onReady={onReady}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : null}
|
||||
{screen === "form" && (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={formState.isSubmitting} onClick={cancelForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!formState.isValid || formState.isSubmitting} onClick={() => void createWorker()}>
|
||||
{formState.isSubmitting ? "Preparing…" : actionLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
{screen === "list" && (
|
||||
<WorkerList
|
||||
workers={workers}
|
||||
now={now}
|
||||
accessToken={accessToken}
|
||||
editBilling={editBilling}
|
||||
revoke={revoke}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { AnalysisKeyPicker } from "./AnalysisKeyPicker";
|
||||
|
||||
import { AnalysisAccessFields } from "./AnalysisAccessFields";
|
||||
import type { WorkerFormInput } from "./workerSchema";
|
||||
|
||||
export function WorkerForm({ accessToken, editingWorker }: { accessToken: string; editingWorker: string | null }) {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<WorkerFormInput>();
|
||||
const useExisting = useWatch({ control, name: "useExisting" });
|
||||
return (
|
||||
<div className="min-w-0 space-y-5">
|
||||
{useExisting ? (
|
||||
<AnalysisKeyPicker accessToken={accessToken} />
|
||||
) : (
|
||||
<AnalysisAccessFields accessToken={accessToken} />
|
||||
)}
|
||||
<details className="text-sm" open={editingWorker ? true : undefined}>
|
||||
<summary className="cursor-pointer font-medium">Advanced options</summary>
|
||||
<div className="mt-4 space-y-5">
|
||||
<label className="flex items-center justify-between gap-4">
|
||||
Use an existing virtual key
|
||||
<Controller
|
||||
control={control}
|
||||
name="useExisting"
|
||||
render={({ field }) => <Switch checked={field.value} onCheckedChange={field.onChange} />}
|
||||
/>
|
||||
</label>
|
||||
{!editingWorker && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="worker-proxy-address" className="block text-sm font-medium">
|
||||
LiteLLM proxy URL
|
||||
</label>
|
||||
<Input id="worker-proxy-address" {...register("address")} />
|
||||
<p className="text-xs text-muted-foreground">Your server must be able to reach this address.</p>
|
||||
{errors.address?.message && <p className="text-sm text-destructive">{errors.address.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { CheckCircle2, Copy, Loader2 } from "lucide-react";
|
||||
import type { WorkerCreated } from "../../model/types";
|
||||
|
||||
import { workerSetupCommand } from "./workerCommand";
|
||||
export function WorkerInstall({
|
||||
connected,
|
||||
address,
|
||||
created,
|
||||
copied,
|
||||
setCopied,
|
||||
setError,
|
||||
onReady,
|
||||
onClose,
|
||||
}: {
|
||||
connected: boolean;
|
||||
address: string;
|
||||
created: WorkerCreated;
|
||||
copied: boolean;
|
||||
setCopied: (value: boolean) => void;
|
||||
setError: (message: string) => void;
|
||||
onReady?: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-5">
|
||||
{!connected && (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full gap-2"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(workerSetupCommand(address, created.token));
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setError("Clipboard access failed. Allow clipboard access and try again.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? <CheckCircle2 className="size-4" /> : <Copy className="size-4" />}
|
||||
{copied ? "Copied" : "Copy Docker command"}
|
||||
</Button>
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground">View command</summary>
|
||||
<p className="mt-3 text-xs text-muted-foreground">Contains a private worker token.</p>
|
||||
<pre
|
||||
aria-label="Docker command preview"
|
||||
className="mt-3 max-h-48 overflow-auto rounded-md bg-muted/40 p-3 text-xs leading-5"
|
||||
>
|
||||
{workerSetupCommand(address, created.token)}
|
||||
</pre>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
{!connected && (
|
||||
<div className="space-y-3 border-t pt-5">
|
||||
<div role="status" className="flex items-center gap-2.5 text-sm">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" /> Waiting for your worker to
|
||||
connect…
|
||||
</div>
|
||||
<details className="pl-6.5 text-sm text-muted-foreground">
|
||||
<summary className="cursor-pointer">Not connecting?</summary>
|
||||
<p className="mt-2 break-words leading-6">
|
||||
Check that Docker is running and can reach {address}. Inspect the container logs for connection or
|
||||
authentication errors. This page updates automatically.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
{connected && (
|
||||
<Button className="w-full" onClick={onReady ?? onClose}>
|
||||
{onReady ? "New investigation" : "Done"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { workerConnected } from "../../model/status";
|
||||
import { AnalysisKeyDetails } from "./AnalysisKeyDetails";
|
||||
import type { LensList } from "../../model/types";
|
||||
|
||||
function workerStatus(worker: LensList["workers"][number], now: number): string {
|
||||
if (!worker.analysis_key_id) return "Billing key required";
|
||||
return workerConnected(worker, now) ? "Connected" : "Not connected";
|
||||
}
|
||||
|
||||
export function WorkerList({
|
||||
workers,
|
||||
now,
|
||||
accessToken,
|
||||
editBilling,
|
||||
revoke,
|
||||
}: {
|
||||
workers: LensList["workers"];
|
||||
now: number;
|
||||
accessToken: string;
|
||||
editBilling: (worker: LensList["workers"][number]) => void;
|
||||
revoke: (id: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{workers
|
||||
.filter((w) => !w.revoked)
|
||||
.map((worker) => {
|
||||
const connected = workerConnected(worker, now);
|
||||
return (
|
||||
<section key={worker.id} className="space-y-5 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{workers.filter((w) => !w.revoked).length > 1 && <h3 className="font-medium">{worker.name}</h3>}
|
||||
<span
|
||||
data-state={connected ? "active" : "inactive"}
|
||||
className="flex items-center gap-2 text-muted-foreground data-[state=active]:text-emerald-700 dark:data-[state=active]:text-emerald-400"
|
||||
>
|
||||
{connected && <CheckCircle2 className="size-4" />}
|
||||
{workerStatus(worker, now)}
|
||||
</span>
|
||||
</div>
|
||||
{worker.analysis_key_id && (
|
||||
<AnalysisKeyDetails accessToken={accessToken} keyId={worker.analysis_key_id} showName />
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => editBilling(worker)}>
|
||||
Settings
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => revoke(worker.id)}>
|
||||
Revoke access
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { proxyBaseUrl } from "@/components/networking";
|
||||
import { serverRootPath } from "@/lib/serverRootPath";
|
||||
|
||||
export const LENS_WORKER_IMAGE =
|
||||
"ghcr.io/berriai/litellm-lens-worker@sha256:44f0597c7583dcfef999ece9a8bc02cfeb9f0f5167a1221cee3bd10b1b79271b";
|
||||
|
||||
export function initialProxyAddress(): string {
|
||||
const url = new URL(proxyBaseUrl || serverRootPath, window.location.origin);
|
||||
if (["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) url.hostname = "host.docker.internal";
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export function workerSetupCommand(address: string, token: string): string {
|
||||
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
return [
|
||||
"docker run -d --restart unless-stopped --read-only --cap-drop ALL",
|
||||
" --tmpfs /tmp:rw,noexec,nosuid,size=1g",
|
||||
" --security-opt no-new-privileges --platform linux/amd64 --add-host host.docker.internal:host-gateway",
|
||||
` -e ${quote("LITELLM_URL=" + address)}`,
|
||||
` -e ${quote("LENS_WORKER_TOKEN=" + token)}`,
|
||||
` ${LENS_WORKER_IMAGE}`,
|
||||
].join(" \\\n");
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { validateWorkerAddress, analysisAccessSchema, workerFormSchema } from "./workerSchema";
|
||||
import { workerSetupCommand, LENS_WORKER_IMAGE } from "./workerCommand";
|
||||
|
||||
const workerDefaults = {
|
||||
useExisting: false,
|
||||
analysisKey: null,
|
||||
access: { model: null, budget: "100" },
|
||||
address: "http://localhost:4000",
|
||||
};
|
||||
|
||||
describe("worker setup", () => {
|
||||
it.each(["https://gateway.example/proxy", "http://host.docker.internal:4000"])("accepts %s", (address) => {
|
||||
expect(() => validateWorkerAddress(address)).not.toThrow();
|
||||
});
|
||||
|
||||
it.each(["ftp://gateway.example", "https://user:pass@gateway.example", "https://user@gateway.example"])(
|
||||
"rejects %s",
|
||||
(address) => {
|
||||
expect(() => validateWorkerAddress(address)).toThrow("Enter an HTTP or HTTPS proxy URL without credentials");
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects malformed addresses", () => {
|
||||
expect(() => validateWorkerAddress("not a URL")).toThrow("Invalid URL");
|
||||
});
|
||||
|
||||
it("quotes apostrophes literally and retains the pinned image and runtime restrictions", () => {
|
||||
const command = workerSetupCommand("https://gateway.example/proxy?name=it's", "token'quoted");
|
||||
expect(command).toContain("'LITELLM_URL=https://gateway.example/proxy?name=it'\\''s'");
|
||||
expect(command).toContain("'LENS_WORKER_TOKEN=token'\\''quoted'");
|
||||
expect(command).toContain("--read-only --cap-drop ALL");
|
||||
expect(command).toContain(
|
||||
"--security-opt no-new-privileges --platform linux/amd64 --add-host host.docker.internal:host-gateway",
|
||||
);
|
||||
expect(command.split("\n").at(-1)?.trim()).toBe(LENS_WORKER_IMAGE);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["", "0", "-1", "NaN", "Infinity"])("rejects an invalid analysis budget: %s", (budget) => {
|
||||
const result = analysisAccessSchema.safeParse({ model: "analysis", budget });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success)
|
||||
expect(result.error.issues[0].message).toBe("Choose a model and a monthly limit greater than zero");
|
||||
});
|
||||
|
||||
it("requires a model and converts an accepted analysis budget to a number", () => {
|
||||
expect(analysisAccessSchema.safeParse({ model: null, budget: "100" }).success).toBe(false);
|
||||
expect(analysisAccessSchema.parse({ model: "analysis", budget: "0.01" })).toEqual({
|
||||
model: "analysis",
|
||||
budget: 0.01,
|
||||
});
|
||||
});
|
||||
|
||||
it("validates the selected-key branch and non-empty proxy address with field paths", () => {
|
||||
const result = workerFormSchema.safeParse({
|
||||
...workerDefaults,
|
||||
useExisting: true,
|
||||
address: " ",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.issues.map(({ path }) => path)).toEqual([["analysisKey"], ["address"]]);
|
||||
});
|
||||
|
||||
it("validates analysis access only when creating a new virtual key", () => {
|
||||
const invalidAccess = workerFormSchema.safeParse(workerDefaults);
|
||||
expect(invalidAccess.success).toBe(false);
|
||||
if (!invalidAccess.success) expect(invalidAccess.error.issues[0].path).toEqual(["access"]);
|
||||
expect(
|
||||
workerFormSchema.safeParse({
|
||||
...workerDefaults,
|
||||
access: { model: "analysis", budget: "100" },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
workerFormSchema.safeParse({
|
||||
...workerDefaults,
|
||||
useExisting: true,
|
||||
analysisKey: "key",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const workerAddressSchema = z.string().superRefine((address, ctx) => {
|
||||
try {
|
||||
const parsed = new URL(address);
|
||||
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) {
|
||||
ctx.addIssue({ code: "custom", message: "Enter an HTTP or HTTPS proxy URL without credentials" });
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.addIssue({ code: "custom", message: error instanceof Error ? error.message : "Invalid URL" });
|
||||
}
|
||||
});
|
||||
|
||||
export function validateWorkerAddress(address: string) {
|
||||
const result = workerAddressSchema.safeParse(address);
|
||||
if (!result.success) throw new Error(result.error.issues[0].message);
|
||||
}
|
||||
|
||||
const analysisAccessFields = { model: z.string().nullable(), budget: z.string() };
|
||||
const workerFormFields = {
|
||||
useExisting: z.boolean(),
|
||||
analysisKey: z.string().nullable(),
|
||||
access: z.object(analysisAccessFields),
|
||||
address: z.string(),
|
||||
};
|
||||
export const analysisAccessSchema = z
|
||||
.object(analysisAccessFields)
|
||||
.superRefine((access, ctx) => {
|
||||
if (!access.model || !Number.isFinite(Number(access.budget)) || Number(access.budget) <= 0)
|
||||
ctx.addIssue({ code: "custom", message: "Choose a model and a monthly limit greater than zero" });
|
||||
})
|
||||
.transform((access) => ({ model: access.model ?? "", budget: Number(access.budget) }));
|
||||
|
||||
export type AnalysisAccess = z.input<typeof analysisAccessSchema>;
|
||||
|
||||
export const workerFormSchema = z.object(workerFormFields).superRefine((values, ctx) => {
|
||||
if (values.useExisting) {
|
||||
if (!values.analysisKey) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Choose an existing virtual key",
|
||||
path: ["analysisKey"],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const result = analysisAccessSchema.safeParse(values.access);
|
||||
if (!result.success) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: result.error.issues[0].message,
|
||||
path: ["access"],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!values.address.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Enter a proxy URL",
|
||||
path: ["address"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type WorkerFormInput = z.input<typeof workerFormSchema>;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DurationInput } from "./DurationInput";
|
||||
import { DurationInput } from "@/components/shared/DurationInput";
|
||||
|
||||
function DurationForm({ base, initial }: { base: "minutes" | "hours"; initial: number }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
|
|
@ -1,22 +1,20 @@
|
|||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
export function DurationInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
base,
|
||||
max,
|
||||
}: {
|
||||
export type DurationInputProps = Omit<ComponentProps<"div">, "onChange"> & {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
base: "minutes" | "hours";
|
||||
max: number;
|
||||
}) {
|
||||
};
|
||||
|
||||
export function DurationInput({ label, value, onChange, base, max, className, ...props }: DurationInputProps) {
|
||||
const id = useId();
|
||||
const units =
|
||||
base === "minutes"
|
||||
|
|
@ -34,7 +32,7 @@ export function DurationInput({
|
|||
setScale(next);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div {...props} data-slot="duration-input" className={cn("space-y-2", className)}>
|
||||
<label htmlFor={id} className="text-sm">
|
||||
{label}
|
||||
</label>
|
||||
17
ui/litellm-dashboard/src/components/shared/ListRow.tsx
Normal file
17
ui/litellm-dashboard/src/components/shared/ListRow.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export type ListRowProps = ComponentProps<"button">;
|
||||
|
||||
export function ListRow({ className, ...props }: ListRowProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
data-slot="list-row"
|
||||
className={cn(
|
||||
"w-full py-4 text-left hover:bg-muted/30 focus-visible:outline-2 focus-visible:outline-ring",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
20
ui/litellm-dashboard/src/components/shared/StatusDot.tsx
Normal file
20
ui/litellm-dashboard/src/components/shared/StatusDot.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { ComponentProps } from "react";
|
||||
import { Circle } from "lucide-react";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const colors = {
|
||||
ok: "fill-emerald-500 text-emerald-500",
|
||||
warn: "fill-amber-500 text-amber-500",
|
||||
off: "fill-slate-400 text-slate-400",
|
||||
};
|
||||
|
||||
export type StatusDotProps = Omit<ComponentProps<typeof Circle>, "className"> & {
|
||||
state: keyof typeof colors;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function StatusDot({ state, className, ...props }: StatusDotProps) {
|
||||
return (
|
||||
<Circle {...props} data-slot="status-dot" data-state={state} className={cn("size-2", colors[state], className)} />
|
||||
);
|
||||
}
|
||||
12
ui/litellm-dashboard/src/hooks/useNow.ts
Normal file
12
ui/litellm-dashboard/src/hooks/useNow.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useNow(intervalMs: number) {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), intervalMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue