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:
devin-ai-integration[bot] 2026-10-03 08:29:16 +00:00 • committed by GitHub
parent cb00dbecd7
commit 09313bcf1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 5166 additions and 3201 deletions

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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&apos;s model permissions and
budget before retrying.
</p>
</details>
</div>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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");
}

View file

@ -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,
});
});
});

View file

@ -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();

View file

@ -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();
});
});

View file

@ -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();

View file

@ -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}

View 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);
}
},
});
}

View 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);
}

View file

@ -0,0 +1,8 @@
"use client";
import { apiClient } from "@/components/networking";
import { useLensDemo } from "../LensDemoContext";
export function useLensApi() {
return useLensDemo()?.client ?? apiClient;
}

View file

@ -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", () => {

View file

@ -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();

View 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.",
},
];

View file

@ -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");
});

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>;

View file

@ -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>
);
}

View file

@ -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();

View file

@ -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>
);
}

View file

@ -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";

View file

@ -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) => {

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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,

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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&apos;s model permissions and
budget before retrying.
</p>
</details>
</div>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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">

View file

@ -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 };
}

View file

@ -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]);
});

View 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;
});
}

View file

@ -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");
});
});

View 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" };

View file

@ -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);
});
});

View 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)));
}

View 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();
});

View 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 };
}

View 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];

View file

@ -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",
);
});
});

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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"

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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(" · "),
};
});
}

View file

@ -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");
});
});

View file

@ -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() };
});
}

View file

@ -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,
});
});
});

View file

@ -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],
};
}

View file

@ -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>
);
}

View 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>
</>
);
}

View file

@ -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}
/>
);
}

View 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);
}

View file

@ -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>
);
}

View file

@ -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({

View file

@ -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();
});

View file

@ -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>
);
}

View file

@ -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" }));

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
})}
</>
);
}

View file

@ -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");
}

View file

@ -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);
});

View file

@ -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>;

View file

@ -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);

View file

@ -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>

View 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,
)}
/>
);
}

View 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)} />
);
}

View 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;
}