feat(lens): simplify setup and investigation workflow (#44089)

* feat(lens): simplify investigation setup and results

* fix(lens): pin worker with actionable failure diagnostics

* fix(lens): focus worker success on starting an investigation

* feat(lens): simplify investigation setup and worker defaults

* fix(lens): remove setup repetition and label billing access

* fix(lens): finish agent selection and setup readiness

* fix(lens): handle unavailable setup dependencies and restore UI build
This commit is contained in:
moe-berri 2026-10-01 17:21:41 -07:00 • committed by GitHub
parent 0737e26463
commit cc42a352cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 3090 additions and 1343 deletions

View file

@ -1,6 +1,6 @@
services:
lens-worker:
image: ${LENS_WORKER_IMAGE:-ghcr.io/berriai/litellm-lens-worker@sha256:a8e8731d954916594eea462969946b9292fb771681ff515a9fd296b53f856c77}
image: ${LENS_WORKER_IMAGE:-ghcr.io/berriai/litellm-lens-worker@sha256:67eba741c1b97c749975c5c38e2370a603e1105babc908d613c1b79d7b995393}
environment:
LITELLM_URL: ${LITELLM_URL:?Set the URL reachable from this container}
LENS_WORKER_TOKEN: ${LENS_WORKER_TOKEN:?Create a worker credential in the Lens UI}

View file

@ -0,0 +1,6 @@
SELECT DISTINCT AgentName AS agent_name
FROM otel_traces
WHERE AgentName != ''
AND ({all_teams:UInt8}=1 OR TeamId={team:String})
AND ({key_hash:String}='' OR ApiKeyHash={key_hash:String})
ORDER BY agent_name

View file

@ -0,0 +1,8 @@
SELECT
EXISTS(SELECT 1 FROM otel_traces
WHERE ({all_teams:UInt8}=1 OR TeamId={team:String})
AND ({key_hash:String}='' OR ApiKeyHash={key_hash:String})) AS traces,
EXISTS(SELECT 1 FROM spend_logs
WHERE ({all_teams:UInt8}=1 OR team_id={team:String})
AND ({key_hash:String}='' OR api_key={key_hash:String})
AND NOT JSONExtractBool(metadata,'litellm_lens_internal')) AS requests

View file

@ -28,6 +28,7 @@ SELECT *, selection_key FROM (
GROUP BY TeamId,ApiKeyHash,TraceId
HAVING max(EngineReceivedMs) < {end:UInt64}
AND max(toUnixTimestamp64Milli(Timestamp)+toInt64(intDiv(Duration,1000000))) < {end:UInt64}
AND ({agent_name:String}='' OR countIf(AgentName={agent_name:String}) > 0)
AND countIf(arrayAll((k,v) -> ResourceAttributes[k]=v OR SpanAttributes[k]=v,
{filter_keys:Array(String)},{filter_values:Array(String)})
AND ({service:String}='' OR ServiceName={service:String})) > 0
@ -48,6 +49,7 @@ SELECT *, selection_key FROM (
OR JSONExtractString(metadata,'requester_metadata',k)=v OR (k='tag' AND has(request_tags,v)),
{filter_keys:Array(String)},{filter_values:Array(String)})
AND ({service:String}='' OR model_group={service:String})
AND {agent_name:String}=''
AND NOT JSONExtractBool(metadata,'litellm_lens_internal')
AND ({source:String}!='both' OR (team_id,api_key,response_id) NOT IN (
SELECT TeamId,ApiKeyHash,LiteLLMRequestId FROM otel_traces

View file

@ -37,6 +37,8 @@ impl ReadQuery {
#[derive(Clone, Copy)]
pub enum LensQuery {
Availability,
Agents,
Sample,
Content,
Evidence,
@ -45,6 +47,8 @@ pub enum LensQuery {
impl LensQuery {
pub fn parse(name: &str) -> Result<Self, Error> {
match name {
"availability" => Ok(Self::Availability),
"agents" => Ok(Self::Agents),
"sample" => Ok(Self::Sample),
"content" => Ok(Self::Content),
"evidence" => Ok(Self::Evidence),
@ -53,6 +57,8 @@ impl LensQuery {
}
pub fn sql(self) -> &'static str {
match self {
Self::Availability => include_str!("../query/lens_availability.sql"),
Self::Agents => include_str!("../query/lens_agents.sql"),
Self::Sample => include_str!("../query/lens_sample.sql"),
Self::Content => include_str!("../query/lens_content.sql"),
Self::Evidence => include_str!("../query/lens_evidence.sql"),

View file

@ -551,6 +551,7 @@ async fn lens_filters_reads_and_evidence_keep_reused_trace_ids_separate(
"end".into(),
Parameter::Integer(timestamp / 1_000_000 + 1000),
),
("agent_name".into(), Parameter::Text(String::new())),
("service".into(), Parameter::Text("review".into())),
(
"filter_keys".into(),
@ -652,6 +653,7 @@ async fn lens_request_sample_does_not_trust_caller_tags(
("key_hash".into(), Parameter::Text(String::new())),
("start".into(), Parameter::Integer(timestamp - 1000)),
("end".into(), Parameter::Integer(timestamp + 60000)),
("agent_name".into(), Parameter::Text(String::new())),
("service".into(), Parameter::Text(String::new())),
("filter_keys".into(), Parameter::Strings(vec![])),
("filter_values".into(), Parameter::Strings(vec![])),
@ -719,6 +721,7 @@ async fn lens_selection_pages_without_losing_or_repeating_runs(
("key_hash".into(), Parameter::Text(String::new())),
("start".into(), Parameter::Integer(0)),
("end".into(), Parameter::Integer(end)),
("agent_name".into(), Parameter::Text(String::new())),
("service".into(), Parameter::Text(String::new())),
("filter_keys".into(), Parameter::Strings(vec![])),
("filter_values".into(), Parameter::Strings(vec![])),
@ -980,3 +983,108 @@ async fn duplicate_span_preview_matches_diagnostic(
assert_eq!(diagnostic["data"][0]["message"], message);
Ok(())
}
#[rstest]
#[tokio::test]
async fn lens_agent_discovery_and_selection_preserve_scope(
#[future] database: TestResult<ClickHouseDatabase>,
) -> TestResult {
use litellm_traces::LensQuery;
let database = database.await?;
let writer = Connection::writer(&database.url)?;
ensure_schema(&database.client, &writer, "trace_test", 7, 14).await?;
let timestamp = time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64;
for (team, key, trace, agent, span, parent) in [
("alpha", "one", "research", "research_agent", "root", ""),
("alpha", "one", "research", "", "tool", "root"),
("alpha", "one", "support", "support_agent", "root", ""),
("alpha", "two", "hidden-key", "private_agent", "root", ""),
("beta", "one", "hidden-team", "other_agent", "root", ""),
] {
insert_rows(
&database,
"otel_traces",
vec![serde_json::from_value(serde_json::json!({
"Timestamp": timestamp, "TraceId": trace, "SpanId": span, "ParentSpanId": parent,
"ServiceName": "shared-app", "SpanName": "run", "Input": "test",
"SpanAttributes": {"gen_ai.agent.name": agent},
"ResourceAttributes": {"litellm.team_id": team, "litellm.api_key_hash": key}
}))?],
)
.await?;
}
let connection = Connection::configured(&database.url, "trace_test", "default", "")?;
let scope_parameters = BTreeMap::from([
("all_teams".into(), Parameter::Integer(0)),
("team".into(), Parameter::Text("alpha".into())),
("key_hash".into(), Parameter::Text("one".into())),
]);
let agents: serde_json::Value = serde_json::from_str(
&execute_read(
&database.client,
&connection,
LensQuery::Agents.sql(),
&scope_parameters,
)
.await?,
)?;
assert_eq!(
agents["data"],
serde_json::json!([
{"agent_name": "research_agent"}, {"agent_name": "support_agent"}
])
);
let parameters = scope_parameters
.into_iter()
.chain([
("source".into(), Parameter::Text("traces".into())),
(
"start".into(),
Parameter::Integer(timestamp / 1_000_000 - 1000),
),
(
"end".into(),
Parameter::Integer(timestamp / 1_000_000 + 1000),
),
("service".into(), Parameter::Text("shared-app".into())),
(
"agent_name".into(),
Parameter::Text("research_agent".into()),
),
("filter_keys".into(), Parameter::Strings(vec![])),
("filter_values".into(), Parameter::Strings(vec![])),
("limit".into(), Parameter::Integer(100)),
("offset".into(), Parameter::Integer(0)),
("after".into(), Parameter::Text(String::new())),
("sample_percent".into(), Parameter::Text("100".into())),
("sample_cap".into(), Parameter::Integer(0)),
("preview".into(), Parameter::Integer(1)),
("selected_team".into(), Parameter::Text(String::new())),
("execution_ids".into(), Parameter::Strings(vec![])),
])
.collect::<BTreeMap<_, _>>();
let sample: serde_json::Value = serde_json::from_str(
&execute_read(
&database.client,
&connection,
LensQuery::Sample.sql(),
&parameters,
)
.await?,
)?;
assert_eq!(sample["data"].as_array().expect("rows").len(), 1);
assert_eq!(sample["data"][0]["trace_id"], "research");
assert_eq!(sample["data"][0]["span_count"], 2);
let available: serde_json::Value = serde_json::from_str(
&execute_read(
&database.client,
&connection,
LensQuery::Availability.sql(),
&parameters,
)
.await?,
)?;
assert_eq!(available["data"][0]["traces"], 1);
assert_eq!(available["data"][0]["requests"], 0);
Ok(())
}

View file

@ -2,6 +2,7 @@ import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from functools import reduce
from itertools import chain
from types import MappingProxyType
from typing import Annotated, Final, TypeAlias
from uuid import uuid4
@ -35,7 +36,7 @@ from litellm.proxy.lens.models import (
WorkerCreated,
)
from litellm.proxy.lens.repository import LensRepository, WriterDatabase
from litellm.proxy.lens.sources import SourceReader, Storage, parse_execution
from litellm.proxy.lens.sources import ActivityAvailability, SourceReader, Storage, parse_execution
from litellm.proxy.lens.state import (
can_access,
claim_job,
@ -168,6 +169,18 @@ async def create_lens(settings: LensSettings, auth: Auth) -> Lens:
return await repository().create(queue_job(lens, now, str(uuid4())))
@router.get("/activity/available", response_model=ActivityAvailability)
async def activity_available(auth: Auth, storage: StorageDep) -> ActivityAvailability:
scope: Final = user_scope(auth)
return await source_reader(storage).availability(scope) if storage is not None else ActivityAvailability()
@router.get("/agents", response_model=tuple[str, ...])
async def list_agents(auth: Auth, storage: StorageDep) -> tuple[str, ...]:
scope: Final = user_scope(auth)
return await source_reader(storage).agents(scope) if storage is not None else ()
@router.put("/{lens_id}", response_model=Lens)
async def update_lens(lens_id: str, settings: LensSettings, auth: Auth) -> Lens:
await get_lens(lens_id, user_scope(auth, write=True))
@ -264,7 +277,7 @@ class Preview(BaseModel):
as_of: AwareDatetime | None = None
offset: int = Field(default=0, ge=0)
settings: LensSettings
lookback_hours: int = Field(default=24, ge=1, le=720)
lookback_hours: int = Field(default=24, ge=1, le=8760)
@router.post("/preview/sample", response_model=Sample)
@ -326,6 +339,9 @@ async def revoke_worker(worker_id: str, auth: Auth) -> bool:
worker: Final = next((w for w in await repository().workers() if w.id == worker_id), None)
if worker is None or not can_access(scope, worker.scope):
raise HTTPException(404, "Worker not found")
jobs: Final = chain.from_iterable(lens.jobs for lens in await repository().lenses())
if any(job.status == "running" and job.worker_id == worker.id for job in jobs):
raise HTTPException(409, "Wait for this worker's investigation to finish or cancel it before revoking access")
await repository().revoke_worker(worker.id)
return True

View file

@ -29,8 +29,9 @@ class LensSettings(Record):
name: str = Field(min_length=1, max_length=100)
context: str = Field(default="", max_length=6000)
source: Literal["traces", "requests", "both"] = "traces"
lookback_hours: int = Field(default=24, ge=1, le=720)
lookback_hours: int = Field(default=24, ge=1, le=8760)
service: str = Field(default="", max_length=200)
agent_name: str = Field(default="", max_length=200)
filters: tuple[MetadataFilter, ...] = Field(default=(), max_length=8)
checks: tuple[Check, ...] = ()
model: str = Field(min_length=1, max_length=200)
@ -41,7 +42,7 @@ class LensSettings(Record):
concurrency: int = Field(default=8, ge=1)
team_id: str = ""
execution_ids: tuple[str, ...] = ()
monthly_budget: float = Field(default=20, gt=0, le=100000, allow_inf_nan=False)
monthly_budget: float = Field(default=100, gt=0, le=100000, allow_inf_nan=False)
@model_validator(mode="after")
def unique_checks(self) -> "LensSettings":
@ -214,7 +215,7 @@ class LensList(Record):
class RunRequest(Record):
settings: LensSettings | None = None
lookback_hours: int | None = Field(default=None, ge=1, le=720)
lookback_hours: int | None = Field(default=None, ge=1, le=8760)
class FindingUpdate(Record):

View file

@ -18,7 +18,14 @@ from litellm.proxy.lens.models import (
)
class ActivityAvailability(BaseModel):
traces: bool = False
requests: bool = False
class Storage(Protocol):
def lens_availability(self, parameters: Mapping[str, object]) -> Awaitable[object]: ...
def lens_agents(self, parameters: Mapping[str, object]) -> Awaitable[object]: ...
def lens_sample(self, parameters: Mapping[str, object]) -> Awaitable[object]: ...
def lens_content(self, parameters: Mapping[str, object]) -> Awaitable[object]: ...
def lens_evidence(self, parameters: Mapping[str, object]) -> Awaitable[object]: ...
@ -53,6 +60,12 @@ class CountRow(BaseModel):
count: int
class AgentRow(BaseModel):
agent_name: str
_AVAILABILITY: Final = TypeAdapter(tuple[ActivityAvailability, ...])
_AGENTS: Final = TypeAdapter(tuple[AgentRow, ...])
_ROWS: Final = TypeAdapter(tuple[ExecutionRow, ...])
_PARTS: Final = TypeAdapter(tuple[PartRow, ...])
_COUNTS: Final = TypeAdapter(tuple[CountRow, ...])
@ -90,6 +103,14 @@ class SourceReader:
def __init__(self, storage: Storage) -> None:
self.storage: Final = storage
async def availability(self, scope: Scope) -> ActivityAvailability:
rows: Final = _AVAILABILITY.validate_python(await self.storage.lens_availability(parameters(scope, ())))
return rows[0] if rows else ActivityAvailability()
async def agents(self, scope: Scope) -> tuple[str, ...]:
rows: Final = _AGENTS.validate_python(await self.storage.lens_agents(parameters(scope, ())))
return tuple(row.agent_name for row in rows)
async def sample(
self,
scope: Scope,
@ -108,6 +129,7 @@ class SourceReader:
"start": start,
"end": end,
"service": settings.service,
"agent_name": settings.agent_name,
"limit": page_size,
"offset": offset,
"after": cursor,

View file

@ -15,6 +15,40 @@ from .models import Claim, Coverage, ExecutionContent, ModelRequest, ModelResult
logger: Final = logging.getLogger("litellm.lens.worker")
def failure_message(error: Exception) -> str:
if isinstance(error, (OSError, sqlite3.Error)):
return "Worker temporary storage failed. Increase its capacity or reduce analysis parallelism."
if isinstance(error, httpx.TimeoutException):
return "The worker timed out waiting for the proxy. Check proxy availability and model response times."
if isinstance(error, httpx.TransportError):
return "The worker could not connect to the proxy. Check the proxy URL, network access, and TLS configuration."
if isinstance(error, httpx.HTTPStatusError):
path: Final = error.request.url.path
action: Final = (
"Model request"
if path.endswith("/model")
else "Reading trace data"
if path.endswith(("/sample", "/content"))
else "Saving results"
if path.endswith("/result")
else "Worker request"
)
status: Final = error.response.status_code
guidance: Final = MappingProxyType(
{
400: "Check the configured model and whether the worker's billing key is enabled.",
401: "Check the worker credential and its assigned billing key.",
402: "Check the investigation's monthly limit and the worker key's remaining budget.",
403: "Check the worker key's model permissions and access restrictions.",
404: "Check that the proxy and worker versions match and the requested model is configured.",
409: "This worker no longer owns the run. Check whether it was cancelled or claimed again.",
429: "The request was rate limited. Retry later or check the worker key's rate limits.",
}
).get(status, "Check proxy and model availability, then retry the investigation.")
return f"{action} failed (HTTP {status}). {guidance}"
return "The worker could not read an analysis response. Check structured JSON support and matching proxy/worker versions."
class LensWorker:
def __init__(self, client: httpx.AsyncClient, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep) -> None:
self.client: Final = client
@ -82,14 +116,7 @@ class LensWorker:
saved: Final = await self.client.post(prefix + "/result", json=result.model_dump(mode="json"))
saved.raise_for_status()
except (httpx.HTTPError, ValueError, OSError, sqlite3.Error) as exc:
status: Final = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
message: Final = (
"Worker temporary storage failed. Increase its capacity or reduce analysis parallelism."
if isinstance(exc, (OSError, sqlite3.Error))
else "Monthly budget reached"
if status == 402
else "Analysis interrupted. Check worker connectivity, model configuration, and trace storage."
)
message: Final = failure_message(exc)
logger.warning("Analysis %s interrupted (%s)", claim.job.id, type(exc).__name__)
failed: Final = await self.client.post(
prefix + "/result", json=Result(coverage=Coverage(), error=message).model_dump()

View file

@ -108,6 +108,12 @@ class ClickHouseStorage:
async def lens_sample(self, parameters: Mapping[str, object]) -> list[dict[str, JsonValue]]:
return await self._lens_query("sample", parameters)
async def lens_availability(self, parameters: Mapping[str, object]) -> list[dict[str, JsonValue]]:
return await self._lens_query("availability", parameters)
async def lens_agents(self, parameters: Mapping[str, object]) -> list[dict[str, JsonValue]]:
return await self._lens_query("agents", parameters)
async def lens_content(self, parameters: Mapping[str, object]) -> list[dict[str, JsonValue]]:
return await self._lens_query("content", parameters)

View file

@ -124,6 +124,9 @@ def test_lens_bills_selected_key_and_rechecks_its_permissions(gateway: Gateway,
seconds=70,
)
assert second_rows[0]["spend"] == pytest.approx(expected)
active_revoke: Final = gateway.request("DELETE", f"/lens/workers/{worker_id}")
assert active_revoke.status_code == 409, active_revoke.text
gateway.post(f"/lens/{lens_id}/cancel", {})
revoked: Final = gateway.request("DELETE", f"/lens/workers/{worker_id}")
assert revoked.status_code == 200, revoked.text
denied_worker: Final = gateway.request(
@ -134,7 +137,6 @@ def test_lens_bills_selected_key_and_rechecks_its_permissions(gateway: Gateway,
"PUT", f"/lens/workers/{worker_id}/billing-key", {"analysis_key_id": replacement_id}
)
assert forbidden_change.status_code == 409, forbidden_change.text
gateway.post(f"/lens/{lens_id}/cancel", {})
@pytest.mark.parametrize("cancel_on_disconnect", (False, True))

View file

@ -4,7 +4,22 @@ import pytest
from fastapi import HTTPException
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.lens.endpoints import user_scope
from litellm.proxy.lens.endpoints import list_agents, user_scope
@pytest.mark.parametrize("role", (LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY))
@pytest.mark.asyncio
async def test_agent_discovery_without_trace_storage_is_empty(role: LitellmUserRoles) -> None:
auth: Final = UserAPIKeyAuth(user_role=role)
assert await list_agents(auth, None) == ()
@pytest.mark.asyncio
async def test_agent_discovery_without_trace_storage_still_requires_admin_access() -> None:
auth: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER)
with pytest.raises(HTTPException) as error:
await list_agents(auth, None)
assert error.value.status_code == 403
@pytest.mark.parametrize(

View file

@ -61,3 +61,48 @@ async def test_sample_never_returns_authentication_attributes() -> None:
assert sample.executions[0].metadata == (MetadataFilter(key="environment", value="production"),)
assert "opaque-oauth-bearer" not in sample.model_dump_json()
assert sample.eligible == 1
@pytest.mark.asyncio
async def test_agents_use_the_same_team_and_key_scope_as_samples() -> None:
class AgentStorage:
async def lens_agents(self, parameters):
assert parameters["all_teams"] == 0
assert parameters["team"] == "alpha"
assert parameters["key_hash"] == "key-hash"
return [{"agent_name": "research_agent"}, {"agent_name": "support_agent"}]
names: Final = await SourceReader(AgentStorage()).agents(Scope(team_id="alpha", api_key_hash="key-hash"))
assert names == ("research_agent", "support_agent")
@pytest.mark.asyncio
async def test_request_only_storage_is_available_for_investigation() -> None:
class RequestStorage:
async def lens_availability(self, parameters):
assert parameters["team"] == "alpha"
return [{"traces": 0, "requests": 1}]
available: Final = await SourceReader(RequestStorage()).availability(Scope(team_id="alpha"))
assert available.requests
assert not available.traces
@pytest.mark.asyncio
async def test_agent_filter_is_independent_of_service_and_metadata() -> None:
class SampleStorage:
async def lens_sample(self, parameters):
assert parameters["agent_name"] == "research_agent"
assert parameters["service"] == "shared-app"
assert parameters["filter_keys"] == ("enduser.id",)
assert parameters["filter_values"] == ("user-42",)
return []
settings: Final = lens().settings.model_copy(
update={
"agent_name": "research_agent",
"service": "shared-app",
"filters": (MetadataFilter(key="enduser.id", value="user-42"),),
}
)
assert not (await SourceReader(SampleStorage()).sample(Scope(all_teams=True), settings, 1, 2)).executions

View file

@ -86,7 +86,7 @@ def test_behavior_description_is_sufficient_without_separate_checks() -> None:
@pytest.mark.parametrize(
"field,value", (("sample_percent", 0), ("sample_percent", 101), ("sample_size", 0), ("concurrency", 0))
"field,value", (("sample_percent", 0), ("sample_percent", 101), ("sample_size", 0), ("concurrency", 0), ("lookback_hours", 0), ("lookback_hours", 8761))
)
def test_invalid_selection_and_parallelism_are_rejected(field: str, value: int) -> None:
from pydantic import ValidationError
@ -145,11 +145,11 @@ def test_monthly_budget_renews_without_erasing_job_costs() -> None:
assert renew_budget(spent, NOW) is spent
@pytest.mark.parametrize("hours", (24, 168, 720))
@pytest.mark.parametrize("hours", (24, 168, 720, 4800, 8760))
def test_every_scan_uses_the_configured_lookback_window(hours: int) -> None:
original: Final = lens()
configured: Final = original.model_copy(
update={"settings": original.settings.model_copy(update={"lookback_hours": hours})}
update={"settings": LensSettings.model_validate({**original.settings.model_dump(), "lookback_hours": hours})}
)
first: Final = queue_job(configured, NOW, "first")
assert first.jobs[0].start == NOW - timedelta(hours=hours)

View file

@ -15,7 +15,7 @@ from litellm.proxy.lens.models import (
TracePart,
)
from litellm.proxy.lens.state import queue_job
from litellm.proxy.lens.worker import LensWorker
from litellm.proxy.lens.worker import LensWorker, failure_message
from tests.unit.proxy.lens.test_state import NOW, lens
@ -126,6 +126,34 @@ async def test_worker_reads_claimed_activity_and_reports_analysis_or_failure(mod
assert result.coverage.screened == 1
assert result.coverage.unassessable == 0
elif model_status == 402:
assert result.error == "Monthly budget reached"
assert "HTTP 402" in result.error and "remaining budget" in result.error
else:
assert result.error.startswith("Analysis interrupted.")
assert result.error.startswith("Model request failed (HTTP 503).")
@pytest.mark.parametrize("status", (400, 401, 402, 403, 404, 409, 429, 503))
def test_failure_reports_action_and_status_without_private_response_content(status: int) -> None:
request: Final = httpx.Request(
"POST", "https://private-host.test/lens/worker/private-lens/private-run/model?token=secret"
)
response: Final = httpx.Response(status, request=request, text="private trace content and key")
error: Final = httpx.HTTPStatusError("private exception details", request=request, response=response)
message: Final = failure_message(error)
assert message.startswith(f"Model request failed (HTTP {status}).")
assert "private" not in message and "secret" not in message
@pytest.mark.parametrize(
"route,action", (("sample", "Reading trace data"), ("content", "Reading trace data"), ("result", "Saving results"))
)
def test_failure_identifies_the_failing_worker_operation(route: str, action: str) -> None:
request: Final = httpx.Request("GET", f"https://proxy.test/lens/worker/lens/job/{route}")
response: Final = httpx.Response(503, request=request)
error: Final = httpx.HTTPStatusError("private body", request=request, response=response)
assert failure_message(error).startswith(f"{action} failed (HTTP 503).")
def test_connection_timeout_and_invalid_response_have_distinct_private_diagnostics() -> None:
assert "connect to the proxy" in failure_message(httpx.ConnectError("private hostname"))
assert "timed out" in failure_message(httpx.ReadTimeout("private prompt"))
assert "structured JSON" in failure_message(ValueError("private model response"))

View file

@ -1,10 +1,18 @@
"use client";
import { useEffect, useId, useState } from "react";
import { useEffect, useId, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Plus, X, ArrowUpRight } from "lucide-react";
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";
@ -15,7 +23,14 @@ export type ActivitySelection = Pick<Settings, "source"> &
Partial<
Pick<
Settings,
"service" | "filters" | "lookback_hours" | "sample_percent" | "sample_size" | "team_id" | "execution_ids"
| "service"
| "agent_name"
| "filters"
| "lookback_hours"
| "sample_percent"
| "sample_size"
| "team_id"
| "execution_ids"
>
>;
@ -28,10 +43,8 @@ export function RunList({ executions }: { executions: Sample["executions"] }) {
<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} steps` : "LLM request"}
</p>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground" title={run.trace_id}>
{run.trace_id}
{runTime(run.start_time)} ·{" "}
{run.source === "traces" ? `${run.span_count} ${run.span_count === 1 ? "step" : "steps"}` : "LLM request"}
</p>
</div>
))}
@ -43,12 +56,22 @@ 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);
@ -63,7 +86,7 @@ export function ActivityScope({
return () => clearTimeout(timer);
}, [serialized]);
const historyHours = value.lookback_hours ?? 24;
const validWindow = Number.isInteger(historyHours) && historyHours >= 1 && historyHours <= 720;
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);
@ -96,12 +119,19 @@ export function ActivityScope({
lookback_hours: value.lookback_hours,
};
const discoveryOptions = {
queryKey: ["lens-activity-options", value.source, value.lookback_hours, accessToken],
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),
@ -109,18 +139,38 @@ export function ActivityScope({
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: "", filters: [], execution_ids: [] };
const selection = { ...value, source, service: "", agent_name: "", filters: [], execution_ids: [] };
onChange(selection);
};
const windowLabel = validWindow
@ -128,193 +178,201 @@ export function ActivityScope({
: "Choose a valid history window";
const previewTitle = () => {
if (pending) return "Finding matching activity…";
if (!validWindow) return "Choose a history window between 1 and 720 hours";
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";
return `${preview.data.eligible} matching ${value.source === "requests" ? "requests" : "runs"}`;
const noun = value.source === "requests" ? "request" : "run";
return `${preview.data.eligible} matching ${noun}${preview.data.eligible === 1 ? "" : "s"}`;
};
return (
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-4">
<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 runs</option>
<option value="requests">Individual LLM requests</option>
<option value="both">Agent runs and LLM requests</option>
</select>
</label>
<p className="text-xs text-muted-foreground">
{value.source === "requests"
? "Each request is one model call, not an entire agent run."
: "An agent run contains the steps recorded under one trace ID. Separate sessions are not joined automatically."}
</p>
<label className="grid gap-2 text-sm">
{
{
requests: "Model group (optional)",
traces: "Application (optional)",
both: "Application or model group (optional)",
}[value.source ?? "traces"]
}
<Input
list={`${id}-services`}
value={value.service}
placeholder="All activity"
onChange={(e) => onChange({ ...value, service: e.target.value })}
/>
<datalist id={`${id}-services`}>
{services.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</label>
<p className="text-xs text-muted-foreground">
{
{
requests: "The model alias configured on your LiteLLM gateway. Leave blank for all models.",
both: "Matches the application name on agent runs or the model group on requests. Leave blank to include both without a name filter.",
traces:
"The service.name recorded by your agent’s OpenTelemetry instrumentation. Leave blank for all applications.",
}[value.source ?? "traces"]
}
</p>
<div className="space-y-2">
<p className="text-sm font-medium">
Narrow by metadata <span className="font-normal text-muted-foreground">(optional)</span>
</p>
<p className="text-xs text-muted-foreground">
Match a recorded tag, swarm, or environment. Every condition must match exactly.
</p>
{filters.map((f, index) => (
<div key={index} className="flex items-center gap-2">
<Input
aria-label={`Metadata key ${index + 1}`}
list={`${id}-keys`}
placeholder="Choose or enter a key"
value={f.key}
onChange={(e) => edit(index, "key", e.target.value)}
/>
<span className="text-xs text-muted-foreground">is</span>
<Input
aria-label={`Metadata value ${index + 1}`}
list={`${id}-values-${index}`}
placeholder="Choose or enter a value"
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>
<Button
variant="ghost"
size="icon"
aria-label={`Remove condition ${index + 1}`}
onClick={() => onChange({ ...value, filters: filters.filter((_, i) => i !== index) })}
<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 ?? "")}
>
<X className="size-4" />
</Button>
</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>
<p className="text-xs text-muted-foreground">
Suggestions come from up to 100 recent runs. You can also type a recorded key or value.
</p>
</div>
<label className="grid gap-2 text-sm">
Team ID (optional)
<Input
value={value.team_id ?? ""}
placeholder="All teams you can access"
onChange={(e) => onChange({ ...value, team_id: e.target.value })}
/>
</label>
<DurationInput
label="Review the last"
value={value.lookback_hours ?? 24}
base="hours"
max={720}
onChange={(lookback_hours) => onChange({ ...value, lookback_hours })}
/>
<p className="text-xs text-muted-foreground">
Time window used by each scan. Activity becomes eligible two minutes after it finishes.
</p>
<div className="grid grid-cols-2 gap-3">
<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) })}
<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>
<label className="grid gap-2 text-sm">
Maximum runs (optional)
<Input
type="number"
min="1"
placeholder="No limit"
value={value.sample_size ?? ""}
onChange={(e) => onChange({ ...value, sample_size: e.target.value ? Number(e.target.value) : null })}
/>
</label>
</div>
<p className="text-xs text-muted-foreground">100% with no limit selects all matching activity.</p>
{!!value.execution_ids?.length && (
<Button variant="outline" onClick={() => onChange({ ...value, execution_ids: [] })}>
<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>
<MatchingActivity
offset={offset}
onPage={setOffset}
onSelect={(runId, checked) =>
onChange({
...value,
execution_ids: checked
? [...(value.execution_ids ?? []), runId]
: (value.execution_ids ?? []).filter((id) => id !== runId),
})
}
selectedIds={value.execution_ids ?? []}
selectedCount={
value.execution_ids?.length
? Math.min(
Math.ceil((value.execution_ids.length * (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}
onOpen={(run) => setTrace({ id: run.trace_id, ref: run.trace_ref })}
/>
{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
@ -333,6 +391,7 @@ function MatchingActivity({
onPage,
onSelect,
selectedIds,
manualSelection,
selectedCount,
title,
windowLabel,
@ -340,79 +399,112 @@ function MatchingActivity({
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;
return (
<section aria-label="Matching activity" className="self-start rounded-lg border">
<div className="border-b px-4 py-3">
<p className="text-sm font-medium" role="status">
{title}
</p>
<p className="mt-1 text-xs text-muted-foreground">{windowLabel} · Preview only, no analysis cost</p>
<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-80 overflow-y-auto px-4">
<div className="max-h-64 overflow-y-auto px-4">
{ready && error && (
<p role="alert" className="py-3 text-sm text-destructive">
{error.message}
{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. Very recent runs need
two minutes to settle.
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">
<input
type="checkbox"
aria-label={`Select ${run.name}`}
checked={selectedIds.includes(run.id)}
onChange={(e) => onSelect(run.id, e.target.checked)}
/>
{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="sm" aria-label={`Open ${run.name}`} onClick={() => onOpen(run)}>
Open run
<ArrowUpRight className="size-3" />
<Button variant="ghost" size="icon-sm" aria-label={`Open ${run.name}`} onClick={() => onOpen(run)}>
<ChevronRight className="size-4" />
</Button>
)}
</div>
))}
</div>
{ready && data && (
{ready && data && showSelection && (
<div className="border-t px-4 py-3 space-y-2">
<p className="text-xs text-muted-foreground">
{selectedCount} selected for analysis · Showing {offset + (data.executions.length ? 1 : 0)}–
{offset + data.executions.length} of {data.eligible}
{selectedCount} selected for analysis
{paginated && (
<>
{" "}
· Showing {offset + (data.executions.length ? 1 : 0)}–{offset + data.executions.length} of{" "}
{data.eligible}
</>
)}
</p>
<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={data.next_offset == null}
onClick={() => onPage(data.next_offset ?? offset)}
>
Next
</Button>
</div>
{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={data.next_offset == null}
onClick={() => onPage(data.next_offset ?? offset)}
>
Next
</Button>
</div>
)}
</div>
)}
</section>

View file

@ -12,36 +12,27 @@ describe("Lens billing key", () => {
testQueryClient.clear();
vi.clearAllMocks();
});
it("creates a normal key and only passes its ID to worker settings", async () => {
const user = userEvent.setup();
const changed = vi.fn();
vi.mocked(apiClient.get).mockResolvedValue({ keys: [], total_pages: 0 });
vi.mocked(apiClient.post).mockResolvedValue({ token_id: "b".repeat(64), key: "sk-secret-not-for-settings" });
renderWithProviders(<AnalysisKey accessToken="test" value={null} onChange={changed} name="Research" />);
await user.click(screen.getByRole("button", { name: "Create worker key" }));
expect(await screen.findByRole("combobox", { name: "Charge analysis to" })).toHaveValue("Lens: Research");
expect(apiClient.post).toHaveBeenCalledWith("/key/generate", {
accessToken: "test",
body: { key_alias: "Lens: Research", models: [], metadata: { purpose: "lens" } },
});
expect(changed).toHaveBeenCalledExactlyOnceWith("b".repeat(64));
expect(screen.queryByText("sk-secret-not-for-settings")).not.toBeInTheDocument();
});
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) => ({
keys:
options?.query?.page === "2"
? [{ token: "c".repeat(64), key_alias: "Second page" }]
: [{ token: "a".repeat(64), key_alias: "First page" }],
total_pages: 2,
}));
renderWithProviders(<AnalysisKey accessToken="test" value={null} onChange={changed} name="Research" />);
vi.mocked(apiClient.get).mockImplementation(async (path, options) =>
path === "/key/info"
? { info: { models: ["restricted-model"], max_budget: 4, budget_duration: "1d" } }
: {
keys:
options?.query?.page === "2"
? [{ token: "c".repeat(64), key_alias: "Second page" }]
: [{ token: "a".repeat(64), key_alias: "First page" }],
total_pages: 2,
},
);
renderWithProviders(<AnalysisKey accessToken="test" value={null} onChange={changed} />);
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(await screen.findByText("restricted-model")).toBeInTheDocument();
expect(screen.getByText("$4.00 / day")).toBeInTheDocument();
});
});

View file

@ -1,10 +1,12 @@
"use client";
import { useState } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { apiClient } from "@/components/networking";
import { Button } from "@/components/ui/button";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Input } from "@/components/ui/input";
import { AnalysisKeyDetails } from "./AnalysisKeyDetails";
import {
Combobox,
ComboboxContent,
@ -22,17 +24,14 @@ export function AnalysisKey({
accessToken,
value,
onChange,
name,
}: {
accessToken: string;
value: string | null;
onChange: (key: string | null) => void;
name: string;
}) {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Key | null>(value ? { token: value } : null);
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const queryOptions = {
queryKey: ["lens-analysis-keys", accessToken, query],
initialPageParam: 1,
@ -61,28 +60,6 @@ export function AnalysisKey({
const choice = keys.find((key) => key.token === value) ?? selected;
const loading = keyPages.isFetching;
const create = async () => {
setCreating(true);
setError("");
try {
const result = await apiClient.post("/key/generate", {
accessToken,
body: {
key_alias: `Lens: ${name}`,
models: [],
metadata: { purpose: "lens" },
},
});
if (!result.token_id) throw new Error("The proxy did not return the new key's ID");
const key = { token: result.token_id, key_alias: `Lens: ${name}` };
setSelected(key);
onChange(key.token);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Could not create a key");
} finally {
setCreating(false);
}
};
const changeKey = (key: Key | null, details: { cancel: () => void }) => {
if (key?.token === "load-more") {
details.cancel();
@ -99,7 +76,7 @@ export function AnalysisKey({
return (
<div className="space-y-2">
<p className="text-sm">Charge analysis to</p>
<div className="flex items-start gap-2">
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-0 flex-1">
<Combobox
items={items}
@ -127,18 +104,82 @@ export function AnalysisKey({
</ComboboxContent>
</Combobox>
</div>
<Button variant="outline" disabled={creating} onClick={() => void create()}>
{creating ? "Creating…" : "Create worker key"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Spend appears under this key in API Keys. Its permissions and limits apply.
</p>
{(error || keyPages.error) && (
{choice && <AnalysisKeyDetails accessToken={accessToken} keyId={choice.token} />}
{keyPages.error && (
<p role="alert" className="text-sm text-destructive">
{error || keyPages.error?.message}
{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

@ -0,0 +1,99 @@
"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";
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) });
function budgetLabel(amount: number | null, duration?: string | null): string {
if (amount === null) return "No key budget";
const periods: Record<string, string> = {
"1mo": "month",
"30d": "month",
"1d": "day",
"24h": "day",
"7d": "week",
"1h": "hour",
};
const dollars = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
}).format(amount);
return duration ? `${dollars} / ${periods[duration] ?? duration}` : `${dollars} total`;
}
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,
});
}
export function AnalysisKeyDetails({
accessToken,
keyId,
showName = false,
}: {
accessToken: string;
keyId: string;
showName?: boolean;
}) {
const key = useAnalysisKeyInfo(accessToken, keyId);
if (key.isLoading) return <p className="text-xs text-muted-foreground">Loading key permissions…</p>;
if (key.error || !key.data)
return (
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
Could not load key permissions
<Button variant="ghost" size="sm" onClick={() => void key.refetch()}>
Retry
</Button>
</div>
);
const info = key.data;
return (
<div className="space-y-2 text-sm">
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-5 gap-y-2">
{showName && (
<>
<dt className="text-muted-foreground">Billing key</dt>
<dd className="break-words">{info.key_alias || "Assigned virtual key"}</dd>
</>
)}
<dt className="text-muted-foreground">Models</dt>
<dd className="break-words">{info.models.length ? info.models.join(", ") : "All models"}</dd>
<dt className="text-muted-foreground">Key limit</dt>
<dd>{budgetLabel(info.max_budget, info.budget_duration)}</dd>
</dl>
{info.status && info.status !== "active" && (
<p role="alert" className="text-destructive">
This key is {info.status}. Choose an active key.
</p>
)}
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">Other limits</summary>
<div className="mt-2 space-y-1 leading-5">
<p>Requests per minute: {info.rpm_limit ?? "No key limit"}</p>
<p>Tokens per minute: {info.tpm_limit ?? "No key limit"}</p>
<p>Expires: {info.expires ? runTime(info.expires) : "No expiry"}</p>
<p>Team, organization, and model limits still apply.</p>
</div>
</details>
</div>
);
}

View file

@ -2,6 +2,7 @@
import { useId, useState } from "react";
import { Input } from "@/components/ui/input";
import { ChevronDown } from "lucide-react";
export function DurationInput({
label,
@ -47,18 +48,24 @@ export function DurationInput({
value={Number.isFinite(value) ? value / scale : ""}
onChange={(event) => onChange(event.target.value === "" ? NaN : Number(event.target.value) * scale)}
/>
<select
aria-label={`${label} unit`}
value={scale}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
onChange={(event) => changeUnit(Number(event.target.value))}
>
{units.map((unit) => (
<option key={unit.scale} value={unit.scale}>
{unit.label}
</option>
))}
</select>
<div className="relative w-28 shrink-0">
<select
aria-label={`${label} unit`}
value={scale}
className="h-9 w-full appearance-none rounded-md border border-input bg-background pl-3 pr-9 text-sm"
onChange={(event) => changeUnit(Number(event.target.value))}
>
{units.map((unit) => (
<option key={unit.scale} value={unit.scale}>
{unit.label}
</option>
))}
</select>
<ChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
</div>
</div>
</div>
);

View file

@ -0,0 +1,146 @@
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 { evidenceTarget, runTime, type Finding, type Sample } from "./lensData";
export function LensFinding({
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>;
}) {
const evidenceGroups = finding
? [...new Set(finding.evidence.map((e) => e.execution_id))].map((id) => ({
id,
run: sampledRuns.find((r) => r.id === id),
quotes: finding.evidence.filter((e) => e.execution_id === id),
}))
: [];
return (
<Sheet
open={!!finding}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<SheetContent className="overflow-y-auto data-[side=right]:sm:max-w-2xl">
{finding && (
<>
<SheetHeader>
<SheetTitle className="pr-8 text-xl leading-snug">{finding.title}</SheetTitle>
<SheetDescription>
{finding.kind === "issue" ? `${finding.priority} priority` : "Pattern"} ·{" "}
{finding.occurrences?.length ?? 0} linked {finding.occurrences?.length === 1 ? "run" : "runs"}
</SheetDescription>
</SheetHeader>
<div className="space-y-6 p-4">
<div>
<p className="mb-2 text-sm font-medium">What happened</p>
<p className="text-sm leading-6 whitespace-pre-wrap">{finding.description}</p>
</div>
{finding.suggestion && (
<div className="border-y py-4">
<p className="text-sm font-medium">What to do next</p>
<p className="mt-2 text-sm leading-6">{finding.suggestion}</p>
</div>
)}
{finding.limitation && (
<details className="text-sm">
<summary className="cursor-pointer font-medium">Evidence limits</summary>
<p className="mt-3 leading-6 text-muted-foreground">{finding.limitation}</p>
</details>
)}
<div>
<p className="text-sm font-medium">Evidence by run</p>
<p className="mt-1 mb-3 text-xs text-muted-foreground">
Exact quotes from the recorded activity. Counterexamples are labeled separately from supporting
evidence.
</p>
<div className="space-y-2">
{evidenceGroups.map((group) => (
<details key={group.id} className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
{group.run?.name ?? evidenceTarget(group.id)?.id.slice(0, 12) ?? "Recorded run"}
<span className="ml-2 text-xs font-normal text-muted-foreground">
{group.quotes.length} {group.quotes.length === 1 ? "quote" : "quotes"}
{group.run ? ` · ${runTime(group.run.start_time)}` : ""}
</span>
</summary>
<div className="mt-3 space-y-3">
{group.quotes.map((e, i) => (
<div key={`${e.span_id}-${i}`} className="rounded-md bg-muted/40 p-3">
{e.role === "counterexample" && (
<p className="mb-1 text-xs font-medium text-muted-foreground">Counterexample</p>
)}
<blockquote className="text-xs leading-5 whitespace-pre-wrap break-words">
{e.quote}
</blockquote>
<Button
variant="ghost"
size="sm"
className="mt-2"
onClick={() => {
onEvidence({ id: e.execution_id, span: e.span_id });
}}
>
{evidenceTarget(e.execution_id)?.source === "traces"
? "Open original step"
: "Open request"}
<ArrowUpRight className="size-3" />
</Button>
</div>
))}
</div>
</details>
))}
</div>
</div>
{!readOnly && (
<div className="space-y-3 border-t pt-4">
<label className="grid gap-2 text-sm">
What should Lens remember?
<Textarea
value={reason}
onChange={(e) => onReason(e.target.value)}
placeholder="What should Lens know about this finding?"
/>
</label>
<p className="text-xs text-muted-foreground">Your explanation informs future scans of this Lens.</p>
<div className="flex flex-wrap gap-2">
{finding.kind === "issue" && (
<Button
disabled={busy}
onClick={() => changeFinding(finding.status === "resolved" ? "open" : "resolved")}
>
{finding.status === "resolved" ? "Reopen" : "Mark resolved"}
</Button>
)}
<Button disabled={busy} variant="outline" onClick={() => changeFinding("dismissed")}>
This is expected
</Button>
</div>
</div>
)}
</div>
</>
)}
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,270 @@
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 InvestigationExample({ onClose }: { onClose: () => void }) {
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="sm:max-w-2xl max-h-[90dvh] overflow-y-auto gap-0 p-0">
<div className="px-6 py-5 sm:px-8">
<DialogHeader>
<DialogTitle className="text-base">Example investigation</DialogTitle>
<DialogDescription>Does the support agent recover when a tool fails?</DialogDescription>
</DialogHeader>
</div>
<div className="border-y px-6 py-6 sm:px-8">
<div className="flex flex-wrap items-center gap-3 text-xs">
<span className="rounded-full bg-amber-50 px-2.5 py-1 font-medium text-amber-800 dark:bg-amber-950 dark:text-amber-200">
Needs attention
</span>
<span className="text-muted-foreground">3 of 20 conversations</span>
</div>
<h3 className="mt-4 max-w-lg text-2xl font-semibold leading-8 tracking-tight">
Failed lookups leave customers without an answer
</h3>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
The agent repeats the same failed order lookup, then ends the conversation without an answer or a handoff.
</p>
<div className="mt-6 border-l-2 border-border pl-4">
<p className="text-xs text-muted-foreground">After three failed lookups, the agent replies:</p>
<blockquote className="mt-2 text-base leading-6">“I will check that for you.”</blockquote>
<details className="group mt-3 text-sm">
<summary className="flex w-fit cursor-pointer list-none items-center gap-1.5 font-medium [&::-webkit-details-marker]:hidden">
See the trace <ChevronRight className="size-3.5 transition-transform group-open:rotate-90" />
</summary>
<ol className="mt-4 space-y-3 text-sm leading-6 text-muted-foreground">
<li>
<span className="font-medium text-foreground">Customer</span> · Where is my order?
</li>
<li>
<span className="font-medium text-foreground">Order lookup</span> · Service unavailable
</li>
<li>
<span className="font-medium text-foreground">Two retries</span> · Same error, no new information
</li>
<li>
<span className="font-medium text-foreground">Agent</span> · I will check that for you. Conversation
ends.
</li>
</ol>
</details>
</div>
<div className="mt-6">
<h4 className="text-sm font-semibold">What to change</h4>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
After repeated failures, explain the problem and offer a handoff instead of retrying.
</p>
</div>
</div>
<div className="flex items-center justify-between gap-4 px-6 py-4 sm:px-8">
<p className="text-xs text-muted-foreground">Illustrative data, not your agent’s results</p>
<Button variant="outline" onClick={onClose}>
Back to setup
</Button>
</div>
</DialogContent>
</Dialog>
);
}
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

@ -58,7 +58,7 @@ export function LensProgress({ job, onCancel }: { job: Job; onCancel?: () => voi
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<span>You can leave this page. Analysis continues in the background.</span>
{job.status === "running" && <span>You can leave this page while the investigation runs.</span>}
{onCancel && (
<Button variant="ghost" size="sm" onClick={onCancel}>
Cancel analysis

View file

@ -1,8 +1,7 @@
import { useState } from "react";
import { ArrowUpRight } from "lucide-react";
import { ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { RunList } from "./ActivityScope";
import type { Job } from "./lensData";
import { runTime, type Job } from "./lensData";
function assessmentLabel(assessment: Job["assessments"][number] | undefined): string {
if (!assessment) return "Not reviewed";
@ -14,7 +13,8 @@ export function LensRuns({ job, onOpen }: { job?: Job; onOpen: (id: string) => v
const [runOffset, setRunOffset] = useState(0);
const [runFilter, setRunFilter] = useState("all");
const assessments = new Map(job?.assessments?.map((a) => [a.execution_id, a]));
const visibleRuns = (job?.sample?.executions ?? []).filter((run) => {
const runs = job?.sample?.executions ?? [];
const visibleRuns = runs.filter((run) => {
const assessment = assessments.get(run.id);
if (runFilter === "all") return true;
if (runFilter === "unknown") return !assessment || assessment.cannot_assess;
@ -22,24 +22,21 @@ export function LensRuns({ job, onOpen }: { job?: Job; onOpen: (id: string) => v
return assessment?.issue_checks?.includes(runFilter);
});
return (
<>
<p className="text-sm font-medium">Runs in the selected batch</p>
<p className="text-xs text-muted-foreground">
{job?.sample?.executions.length ?? 0} selected from {job?.sample?.eligible ?? 0} matches. Open a run to inspect
its original activity.
</p>
<label className="grid gap-2 text-sm">
Review outcome
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{runs.length} selected from {job?.sample?.eligible ?? 0} matching runs
</p>
<select
aria-label="Filter reviewed runs"
className="rounded-md border bg-background p-2"
className="h-8 max-w-full rounded-md border bg-background px-2 text-xs sm:max-w-64"
value={runFilter}
onChange={(e) => {
setRunFilter(e.target.value);
setRunOffset(0);
}}
>
<option value="all">All selected runs</option>
<option value="all">All outcomes</option>
<option value="clear">No issue observed</option>
<option value="unknown">Insufficient evidence or not reviewed</option>
{job?.settings?.context && <option value="expected_behavior">Expected behavior deviation</option>}
@ -49,42 +46,59 @@ export function LensRuns({ job, onOpen }: { job?: Job; onOpen: (id: string) => v
</option>
))}
</select>
</label>
<p className="text-xs text-muted-foreground">
These are per-run observations. Findings above investigate and group them with original evidence.
</p>
<div className="max-h-[480px] overflow-y-auto rounded-lg border px-4 divide-y">
</div>
<div className="divide-y border-y">
{visibleRuns.slice(runOffset, runOffset + 50).map((run) => (
<div key={run.id} className="flex items-center justify-between gap-3">
<div className="min-w-0">
<RunList executions={[run]} />
<p className="pb-3 text-xs text-muted-foreground">{assessmentLabel(assessments.get(run.id))}</p>
</div>
<Button size="sm" variant="ghost" onClick={() => onOpen(run.id)}>
Open {run.source === "traces" ? "run" : "request"}
<ArrowUpRight className="size-3" />
</Button>
</div>
<button
key={run.id}
title={run.trace_id}
onClick={() => onOpen(run.id)}
className="group grid w-full grid-cols-[minmax(0,1fr)_16px] items-center gap-x-4 gap-y-1 py-4 text-left hover:bg-muted/40 focus-visible:outline-2 focus-visible:outline-ring sm:grid-cols-[minmax(0,1fr)_180px_130px_16px]"
>
<span className="col-start-1 row-start-1 min-w-0 truncate text-sm font-medium">{run.name}</span>
<span className="col-start-1 row-start-2 text-xs text-muted-foreground sm:col-start-2 sm:row-start-1">
{runTime(run.start_time)}
</span>
<span className="col-start-1 row-start-3 text-xs text-muted-foreground sm:col-start-3 sm:row-start-1">
{assessmentLabel(assessments.get(run.id))}
</span>
<ChevronRight
aria-hidden="true"
className="col-start-2 row-start-1 size-4 text-muted-foreground group-hover:text-foreground sm:col-start-4"
/>
</button>
))}
{!job?.sample?.executions.length && (
<p className="py-4 text-sm text-muted-foreground">
The selected runs appear here when an analyzer starts the scan.
{!visibleRuns.length && (
<p className="py-10 text-center text-sm text-muted-foreground">
{runs.length
? "No runs match this outcome."
: "Selected activity appears here when the worker starts reviewing it."}
</p>
)}
</div>
<div className="flex items-center justify-between text-sm">
<Button variant="ghost" disabled={!runOffset} onClick={() => setRunOffset(Math.max(0, runOffset - 50))}>
Previous runs
</Button>
<span>{visibleRuns.length} matching runs</span>
<Button
variant="ghost"
disabled={runOffset + 50 >= visibleRuns.length}
onClick={() => setRunOffset(runOffset + 50)}
>
Next runs
</Button>
</div>
</>
{(runOffset > 0 || visibleRuns.length > 50) && (
<div className="flex items-center justify-between text-xs text-muted-foreground">
<Button
size="sm"
variant="ghost"
disabled={!runOffset}
onClick={() => setRunOffset(Math.max(0, runOffset - 50))}
>
Previous runs
</Button>
<span>
{runOffset + 1}–{Math.min(runOffset + 50, visibleRuns.length)} of {visibleRuns.length}
</span>
<Button
size="sm"
variant="ghost"
disabled={runOffset + 50 >= visibleRuns.length}
onClick={() => setRunOffset(runOffset + 50)}
>
Next runs
</Button>
</div>
)}
</div>
);
}

View file

@ -1,12 +1,13 @@
import { fireEvent, screen } from "@testing-library/react";
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 { apiClient } from "@/components/networking";
import type { Settings } from "./lensData";
vi.mock("@/components/networking", () => ({ apiClient: { post: vi.fn() } }));
vi.mock("@/components/networking", () => ({ apiClient: { post: vi.fn(), get: vi.fn() } }));
const settings: Settings = {
lookback_hours: 24,
@ -24,42 +25,52 @@ const settings: Settings = {
team_id: "",
execution_ids: [],
service: "",
agent_name: "",
checks: [
{ id: "first", instruction: "Find repeated searches", enabled: false },
{ id: "second", instruction: "Find incomplete reports", enabled: true },
],
};
beforeEach(() => {
vi.mocked(apiClient.get).mockReset();
vi.mocked(apiClient.get).mockResolvedValue([]);
vi.mocked(apiClient.post).mockReset();
vi.mocked(apiClient.post).mockResolvedValue({ eligible: 1, selected: 1, executions: [] });
});
describe("Lens setup", () => {
beforeEach(() => {
vi.mocked(apiClient.post).mockReset();
vi.mocked(apiClient.post).mockResolvedValue({ eligible: 0, executions: [] });
});
it("preserves check identity and disabled state when questions are reordered", async () => {
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} />,
);
fireEvent.change(screen.getByRole("textbox", { name: "Specific checks (optional)" }), {
target: { value: "Find incomplete reports\nFind repeated searches" },
await user.click(screen.getByRole("button", { name: "Continue" }));
fireEvent.change(screen.getByRole("textbox", { name: "Check 1" }), {
target: { value: "Find repetitive searches\nInclude retries that add no information" },
});
await user.click(screen.getByRole("button", { name: "Continue" }));
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled());
await user.click(screen.getByRole("button", { name: "Save changes" }));
expect(save).toHaveBeenCalledWith(expect.objectContaining({ checks: [settings.checks[1], settings.checks[0]] }));
expect(save).toHaveBeenCalledWith(
expect.objectContaining({
checks: [
{ ...settings.checks[0], instruction: "Find repetitive searches\nInclude retries that add no information" },
settings.checks[1],
],
}),
);
});
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()} />);
fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "Research" } });
await user.click(screen.getByRole("button", { name: "Continue" }));
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: "Specific checks (optional)" })).not.toBeInTheDocument();
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);
@ -69,6 +80,7 @@ describe("Lens setup", () => {
return body.settings.filters?.some((f) => f.key === "swarm" && f.value === "research")
? {
eligible: 1,
selected: 1,
executions: [
{
id: "run",
@ -82,27 +94,50 @@ describe("Lens setup", () => {
}
: { eligible: 0, executions: [] };
});
renderWithProviders(<LensSetup models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={save} />);
fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "Research" } });
await user.click(screen.getByRole("button", { name: "Continue" }));
renderWithProviders(
<LensSetup models={["analysis"]} defaultModel="analysis" accessToken="test" onClose={vi.fn()} onSave={save} />,
);
fireEvent.change(screen.getByRole("textbox", { name: "Investigation name" }), {
target: { value: "Research follow-up" },
});
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" } });
fireEvent.change(screen.getByRole("combobox", { name: "Metadata value 1" }), { target: { value: "research" } });
expect(await screen.findByText("1 matching runs")).toBeInTheDocument();
expect(screen.getByText("Research report")).toBeInTheDocument();
expect(screen.getByText("request-42")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(screen.getByText("swarm is research")).toBeInTheDocument();
await user.click(screen.getByRole("combobox", { name: "Analysis model" }));
await user.click(await screen.findByRole("option", { name: /analysis/ }));
await user.click(screen.getByRole("button", { name: "Run analysis" }));
expect(save).toHaveBeenCalledWith(
expect.objectContaining({ filters: [{ key: "swarm", value: "research" }], enabled: false }),
);
await user.type(screen.getByRole("textbox", { name: "Check 1" }), "Find incomplete reports");
await user.click(screen.getByRole("button", { name: "Add check" }));
fireEvent.change(screen.getByRole("textbox", { name: "Check 2" }), {
target: { value: "Find repeated searches\nInclude retries that add no information" },
});
await user.click(screen.getByRole("button", { name: "Add check" }));
await user.click(screen.getByRole("button", { name: "Remove check 3" }));
await user.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled());
expect(screen.getByText("1 matching run")).toBeInTheDocument();
expect(screen.getByText("Research report")).toBeInTheDocument();
expect(screen.getByText(/2026-09-30/)).toBeInTheDocument();
expect(screen.getByRole("spinbutton", { name: "Monthly limit (USD)" })).not.toBeVisible();
await user.click(screen.getByRole("button", { name: "Run investigation" }));
const expected = {
name: "Research follow-up",
service: "",
agent_name: "",
filters: [{ key: "swarm", value: "research" }],
enabled: false,
sample_size: null,
model: "analysis",
monthly_budget: 100,
checks: [
expect.objectContaining({ instruction: "Find incomplete reports" }),
expect.objectContaining({ instruction: "Find repeated searches\nInclude retries that add no information" }),
],
};
expect(save).toHaveBeenCalledWith(expect.objectContaining(expected));
});
});
it("searches providers and saves custom history and schedule values", async () => {
it("searches providers and saves custom history while preserving existing schedule values", async () => {
const user = userEvent.setup();
const save = vi.fn().mockResolvedValue(undefined);
renderWithProviders(
@ -119,18 +154,260 @@ it("searches providers and saves custom history and schedule values", async () =
/>,
);
await user.click(screen.getByRole("button", { name: "Continue" }));
await user.click(screen.getByRole("button", { name: "Continue" }));
await user.selectOptions(screen.getByRole("combobox", { name: "Review the last unit" }), "1");
fireEvent.change(screen.getByRole("spinbutton", { name: "Review the last" }), { target: { value: "3" } });
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(screen.getByText(/is no longer available/)).toBeVisible();
await user.clear(screen.getByRole("combobox", { name: "Analysis model" }));
await user.type(screen.getByRole("combobox", { name: "Analysis model" }), "OpenAI");
expect(screen.queryByRole("option", { name: /Anthropic/ })).not.toBeInTheDocument();
await user.click(await screen.findByRole("option", { name: /review.*JSON output supported/ }));
await user.click(screen.getByRole("radio", { name: "Run now and keep monitoring" }));
fireEvent.change(screen.getByRole("spinbutton", { name: "Check every" }), { target: { value: "2" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled());
await user.click(screen.getByRole("button", { name: "Save changes" }));
const expectedSettings = { model: "review", lookback_hours: 3, interval_minutes: 2, enabled: true };
expect(save).toHaveBeenCalledWith(expect.objectContaining(expectedSettings));
fireEvent.change(screen.getByRole("spinbutton", { name: "Check every" }), { target: { value: "0" } });
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
const expected = { model: "review", lookback_hours: 3, interval_minutes: 15, enabled: false };
expect(save).toHaveBeenCalledWith(expect.objectContaining(expected));
});
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()} />);
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();
});
it("keeps the draft when readiness changes and blocks a run until the worker recovers", async () => {
const user = userEvent.setup();
const save = vi.fn();
const props = {
initial: settings,
mode: "duplicate" as const,
models: ["analysis"],
accessToken: "test",
onClose: vi.fn(),
onSave: save,
};
const view = renderWithProviders(<LensSetup {...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} />);
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 />);
await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled());
});
it.each(["empty", "error"])("allows editing saved settings when the preview is %s", async (state) => {
if (state === "error") vi.mocked(apiClient.post).mockRejectedValue(new Error("Storage unavailable"));
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()} />,
);
await user.click(screen.getByRole("button", { name: "Continue" }));
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled();
if (state === "error") expect(await screen.findByRole("button", { name: "Retry preview" })).toBeVisible();
else expect(await screen.findByText(/No matches/)).toBeVisible();
});
it.each(["loading", "error"])("saves edits with the existing model while models are %s", async (state) => {
const user = userEvent.setup();
const save = vi.fn().mockResolvedValue(undefined);
renderWithProviders(
<LensSetup
initial={settings}
models={[]}
modelsLoading={state === "loading"}
modelsError={state === "error" ? "Temporarily unavailable" : undefined}
accessToken="test"
onClose={vi.fn()}
onSave={save}
/>,
);
await user.click(screen.getByRole("button", { name: "Continue" }));
fireEvent.change(screen.getByRole("textbox", { name: "What should the agent be doing?" }), {
target: { value: "Include verified sources" },
});
await user.click(screen.getByRole("button", { name: "Continue" }));
await user.click(screen.getByRole("button", { name: "Save changes" }));
expect(save).toHaveBeenCalledWith(
expect.objectContaining({ model: settings.model, context: "Include verified sources" }),
);
});
it.each(["new", "duplicate"] as const)("blocks a %s investigation until its model is verified", async (mode) => {
const user = userEvent.setup();
const save = vi.fn();
renderWithProviders(
<LensSetup
initial={settings}
mode={mode}
models={[]}
modelsError="Temporarily unavailable"
accessToken="test"
onClose={vi.fn()}
onSave={save}
/>,
);
await user.click(screen.getByRole("button", { name: "Continue" }));
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(await screen.findByText("1 matching run")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Run investigation" })).toBeDisabled();
expect(save).not.toHaveBeenCalled();
});
it("shows optional budget and repeat controls only under advanced options and saves their values", async () => {
const user = userEvent.setup();
const save = vi.fn().mockResolvedValue(undefined);
renderWithProviders(
<LensSetup
initial={settings}
mode="duplicate"
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" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled());
await user.click(screen.getByText("Advanced options"));
expect(screen.getByRole("checkbox", { name: "Repeat this investigation" })).not.toBeChecked();
fireEvent.change(screen.getByRole("spinbutton", { name: "Monthly limit (USD)" }), { target: { value: "8" } });
await user.click(screen.getByRole("checkbox", { name: "Repeat this investigation" }));
fireEvent.change(screen.getByRole("spinbutton", { name: "Repeat every" }), { target: { value: "120" } });
await user.click(screen.getByRole("button", { name: "Run and monitor" }));
expect(save).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true, interval_minutes: 120, monthly_budget: 8 }),
);
});
it("does not silently analyze everything after individual selection is enabled", async () => {
const user = userEvent.setup();
vi.mocked(apiClient.post).mockResolvedValue({
eligible: 1,
selected: 1,
executions: [
{
id: "selected-run",
name: "Example run",
trace_id: "trace",
source: "traces",
start_time: "2026-10-01T12:00:00Z",
span_count: 2,
},
],
});
renderWithProviders(
<LensSetup 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" }));
await user.click(screen.getByText("Advanced options"));
await user.click(screen.getByRole("checkbox", { name: "Choose individual runs" }));
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
await user.click(await screen.findByRole("checkbox", { name: "Select Example run" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled());
});
it("refreshes agent suggestions when the first activity arrives", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
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()} />);
await vi.advanceTimersByTimeAsync(400);
await user.click(screen.getByRole("combobox", { name: "Agent (optional)" }));
expect(await screen.findByText(/No matches. You can enter/)).toBeVisible();
await user.keyboard("{Escape}");
vi.mocked(apiClient.post).mockResolvedValue({
eligible: 1,
selected: 1,
executions: [
{
id: "new-run",
name: "First support run",
service: "support-agent",
trace_id: "new-trace",
source: "traces",
start_time: "2026-10-01T12:00:00Z",
span_count: 2,
},
],
});
vi.mocked(apiClient.get).mockResolvedValue(["support-agent"]);
await vi.advanceTimersByTimeAsync(15000);
await user.click(screen.getByRole("combobox", { name: "Agent (optional)" }));
expect(await screen.findByRole("option", { name: "support-agent" })).toBeVisible();
} finally {
vi.useRealTimers();
}
});
it.each(["empty", "error"])("blocks a new investigation when its preview is %s", async (state) => {
if (state === "error") vi.mocked(apiClient.post).mockRejectedValue(new Error("Storage unavailable"));
else vi.mocked(apiClient.post).mockResolvedValue({ eligible: 0, selected: 0, executions: [] });
const user = userEvent.setup();
renderWithProviders(
<LensSetup
mode="duplicate"
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" }));
expect(screen.getByRole("button", { name: "Run investigation" })).toBeDisabled();
});
it("exposes an unsupported inherited model before allowing a run", async () => {
const user = userEvent.setup();
renderWithProviders(
<LensSetup
mode="duplicate"
initial={settings}
models={["analysis"]}
modelDetails={[{ model_group: "analysis", mode: "embedding", providers: [] }]}
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(screen.getByText("Choose a chat model that supports JSON output.")).toBeVisible();
expect(screen.getByRole("combobox", { name: "Analysis model" })).toBeVisible();
expect(screen.getByRole("button", { name: "Run investigation" })).toBeDisabled();
});
it("saves a discovered agent independently of the application name", async () => {
vi.mocked(apiClient.get).mockResolvedValue(["research_agent", "support_agent"]);
const user = userEvent.setup();
const save = vi.fn();
renderWithProviders(
<LensSetup
initial={{ ...settings, service: "shared-service" }}
models={["analysis"]}
accessToken="test"
onClose={vi.fn()}
onSave={save}
/>,
);
await user.click(screen.getByRole("combobox", { name: "Agent (optional)" }));
await user.click(await screen.findByRole("option", { name: "research_agent" }));
await user.click(screen.getByRole("button", { name: "Continue" }));
await user.click(screen.getByRole("button", { name: "Continue" }));
await user.click(screen.getByRole("button", { name: "Save changes" }));
expect(save).toHaveBeenCalledWith(
expect.objectContaining({ agent_name: "research_agent", service: "shared-service" }),
);
});

View file

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@ -12,18 +13,25 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { ActivityScope, type ActivitySelection } from "./ActivityScope";
import {
analysisModelOptions,
durationLabel,
normalizeFilters,
starterQuestions,
type AnalysisModelInfo,
type Settings,
} from "./lensData";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { DurationInput } from "./DurationInput";
import { ActivityScope, type ActivitySelection } from "./ActivityScope";
import { analysisModelOptions, normalizeFilters, 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,
@ -32,7 +40,10 @@ export function LensSetup({
modelDetails = [],
modelsLoading = false,
modelsError,
defaultModel,
defaultSource = "traces",
accessToken,
ready = true,
onClose,
onSave,
}: {
@ -42,319 +53,361 @@ export function LensSetup({
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 [source, setSource] = useState<Settings["source"]>(initial?.source ?? "traces");
const [lookback, setLookback] = useState(initial?.lookback_hours ?? 24);
const [service, setService] = useState(initial?.service ?? "");
const [filters, setFilters] = useState<NonNullable<Settings["filters"]>>(initial?.filters ?? []);
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 [questions, setQuestions] = useState(
initial?.checks?.map((c) => c.instruction).join("\n") ?? starterQuestions.join("\n"),
);
const [model, setModel] = useState(initial?.model ?? "");
const [enabled, setEnabled] = useState(initial?.enabled ?? false);
const [budget, setBudget] = useState(initial?.monthly_budget ?? 20);
const [sampleSize, setSampleSize] = useState<number | null>(initial?.sample_size ?? null);
const [samplePercent, setSamplePercent] = useState(initial?.sample_percent ?? 100);
const [concurrency, setConcurrency] = useState(initial?.concurrency ?? 8);
const [team, setTeam] = useState(initial?.team_id ?? "");
const [executionIds, setExecutionIds] = useState(initial?.execution_ids ?? []);
const [interval, setInterval] = useState(initial?.interval_minutes ?? 15);
const [questions, setQuestions] = useState(() => (initial?.checks?.length ? initial.checks : [newCheck()]));
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 reviewUnit = { traces: "runs", requests: "requests", both: "runs and requests" }[source];
const settings = (): Settings => ({
name: name.trim(),
source,
lookback_hours: lookback,
service: service.trim(),
context,
filters: normalizeFilters(filters),
model,
enabled,
monthly_budget: budget,
sample_size: sampleSize,
sample_percent: samplePercent,
concurrency,
team_id: team,
execution_ids: executionIds,
interval_minutes: interval,
checks: questions
.split("\n")
.filter((q) => q.trim())
.map((instruction) => {
const previous = initial?.checks?.find((c) => c.instruction === instruction.trim());
return previous ?? { id: crypto.randomUUID(), instruction: instruction.trim(), enabled: true };
}),
});
const execute = async (action: () => Promise<void>) => {
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);
if (step >= 1 && !context.trim() && !filledChecks.length)
throw new Error("Describe the expected behavior or what to look out 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 {
await action();
} catch (e) {
setError(e instanceof Error ? e.message : "Something went wrong");
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: 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 next = () => {
try {
normalizeFilters(filters);
if (!Number.isInteger(lookback) || lookback < 1 || lookback > 720)
throw new Error("Choose a history window between 1 and 720 hours");
if (!Number.isFinite(samplePercent) || samplePercent <= 0 || samplePercent > 100)
throw new Error("Choose a sampling percentage greater than 0 and up to 100");
if (sampleSize != null && (!Number.isInteger(sampleSize) || sampleSize < 1))
throw new Error("Choose a positive maximum or leave it blank for no limit");
if (!name.trim()) throw new Error("Give this lens a name");
if (step === 0 && !questions.trim() && !context.trim())
throw new Error("Describe expected behavior or add a check");
setError("");
setStep(step + 1);
} catch (e) {
setError(e instanceof Error ? e.message : "Check your settings");
}
};
const changeSelection = (selection: ActivitySelection) => {
setSampleSize(selection.sample_size ?? null);
setSamplePercent(selection.sample_percent ?? 100);
setTeam(selection.team_id ?? "");
const previousPool = [source, service, lookback, team, filters];
const nextPool = [
selection.source,
selection.service ?? "",
selection.lookback_hours ?? 24,
selection.team_id ?? "",
selection.filters ?? [],
];
const poolChanged = JSON.stringify(previousPool) !== JSON.stringify(nextPool);
setExecutionIds(poolChanged ? [] : selection.execution_ids ?? []);
setSource(selection.source);
setLookback(selection.lookback_hours ?? 24);
setService(selection.service ?? "");
setFilters(selection.filters ?? []);
};
const saveLabel = () => {
if (busy) return "Saving…";
if (mode === "edit") return "Save changes";
return enabled ? "Start monitoring" : "Run analysis";
};
const validConcurrency = Number.isInteger(concurrency) && concurrency >= 1;
const validInterval = Number.isInteger(interval) && interval >= 1 && interval <= 10080;
const validSchedule = !enabled || validInterval;
const validBudget = Number.isFinite(budget) && budget > 0;
const unsupportedModel = modelDetails.some((item) => item.model_group === model && item.mode && item.mode !== "chat");
const validAnalysis = validBudget && validConcurrency && !!model;
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) onClose();
if (!open && !busy) onClose();
}}
>
<DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent
className={`flex max-h-[90dvh] flex-col gap-6 overflow-hidden ${step === 2 ? "sm:max-w-3xl" : "sm:max-w-xl"}`}
>
<DialogHeader>
<DialogTitle>{{ edit: "Edit lens", duplicate: "Duplicate lens", new: "Set up a lens" }[mode]}</DialogTitle>
<DialogDescription>
<DialogTitle className="text-xl">{headings[step]}</DialogTitle>
<DialogDescription className="sr-only">
{
[
"Describe how your agent should work",
"Choose which activity to analyze",
"Review your selection and start analysis",
"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>
<div className="flex gap-2" aria-label={`Step ${step + 1} of 3`}>
{["Expectations", "Activity", "Review & run"].map((label, i) => (
<div
<nav aria-label="Investigation setup" className="flex gap-2 text-xs">
{["Activity", "Expectations", "Run"].map((label, index) => (
<button
key={label}
className={`flex-1 border-t-2 pt-2 text-xs ${i <= step ? "border-foreground text-foreground" : "border-border text-muted-foreground"}`}
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"}`}
>
{i + 1}. {label}
</div>
{index + 1}. {label}
</button>
))}
</div>
<div className="min-h-0 overflow-y-auto space-y-4 pr-1">
{step === 0 && (
<>
<label className="grid gap-2 text-sm">
Name
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Research quality"
maxLength={100}
/>
</label>
</>
</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 === 0 && (
{step === 1 && (
<>
<label className="grid gap-2 text-sm">
What does a good run look like?
<label className="grid gap-2 text-sm font-medium">
What should the agent be doing?
<Textarea
value={context}
onChange={(e) => setContext(e.target.value)}
rows={3}
placeholder="Our swarm researches a question and produces a cited report that incorporates the fact-checker's corrections."
maxLength={6000}
rows={4}
placeholder="Answer the customer's question using verified sources and explain when information is missing."
/>
</label>
<label className="grid gap-2 text-sm">
Specific checks (optional)
<Textarea value={questions} onChange={(e) => setQuestions(e.target.value)} rows={7} />
</label>
<p className="text-xs text-muted-foreground">
One instruction per line. Ask about usage patterns, successful behavior, or a specific problem. Findings
include evidence from your runs.
</p>
<fieldset className="space-y-3">
<legend className="mb-2 text-sm font-medium">What should we look out for?</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. Repeated searches that add no useful information"
/>
<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>
))}
<Button variant="outline" size="sm" onClick={() => setQuestions([...questions, newCheck()])}>
<Plus className="size-3.5" /> Add check
</Button>
</fieldset>
{(!context.trim() || !filledChecks.length) && (
<Button
variant="link"
className="h-auto px-0"
onClick={() => {
if (!context.trim())
setContext(
"Answer the user's question using verified sources. Explain when information is missing.",
);
if (!filledChecks.length)
setQuestions([
newCheck("Find repeated work that adds no useful information."),
newCheck("Find claims that contradict the available evidence."),
]);
}}
>
Use an example
</Button>
)}
</>
)}
{step === 1 && (
<ActivityScope
accessToken={accessToken}
value={{
source,
service,
filters,
lookback_hours: lookback,
sample_size: sampleSize,
sample_percent: samplePercent,
team_id: team,
execution_ids: executionIds,
}}
onChange={changeSelection}
/>
)}
{step === 2 && (
<>
<div className="rounded-lg border p-4 text-sm space-y-2">
<p className="font-medium">{name}</p>
<p>
{source === "requests" ? "LLM requests" : "Agent runs"} · {service || "All activity"} ·{" "}
{`Last ${durationLabel(lookback, "hours")}`}
</p>
{filters.map((f) => (
<p key={f.key} className="text-muted-foreground">
{f.key} is {f.value}
</p>
))}
<p className="text-muted-foreground">
{samplePercent}% of matching {reviewUnit}
{sampleSize ? `, up to ${sampleSize}` : ", no count limit"} ·{" "}
{questions.split("\n").filter((q) => q.trim()).length} questions
</p>
</div>
<div className="space-y-2">
<p className="text-sm">Analysis model</p>
<SearchSelect
aria-label="Analysis model"
options={analysisModelOptions(models, modelDetails)}
value={model}
onValueChange={(value) => setModel(value ?? "")}
placeholder={modelsLoading ? "Loading models…" : "Search models or providers"}
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>
)}
{modelDetails.some((item) => item.model_group === model && item.mode && item.mode !== "chat") && (
<p role="alert" className="text-sm text-destructive">
Choose a chat model that supports JSON output.
</p>
)}
</div>
<p className="text-xs text-muted-foreground">
Trace content is sent to this model through LiteLLM. Choose a model approved for your data.
</p>
<div className="grid grid-cols-2 gap-4">
<label className="grid gap-2 text-sm">
Monthly limit (USD)
<Input
type="number"
min="0.01"
step="1"
value={budget}
onChange={(e) => setBudget(Number(e.target.value))}
/>
</label>
<label className="grid gap-2 text-sm">
Runs analyzed at once
<Input
type="number"
min="1"
value={concurrency}
onChange={(e) => setConcurrency(Number(e.target.value))}
/>
</label>
</div>
<p className="text-xs text-muted-foreground">
Parallelism controls speed, not how many runs are selected. Your budget applies to all analysis calls.
</p>
<fieldset className="space-y-3">
<legend className="mb-2 text-sm font-medium">When to run</legend>
<label className="flex items-center gap-2 text-sm">
<input type="radio" name="lens-schedule" checked={!enabled} onChange={() => setEnabled(false)} />
Run once, then manually
</label>
<label className="flex items-center gap-2 text-sm">
<input type="radio" name="lens-schedule" checked={enabled} onChange={() => setEnabled(true)} />
Run now and keep monitoring
</label>
{enabled && (
<>
<DurationInput
label="Check every"
value={interval}
onChange={setInterval}
base="minutes"
max={10080}
<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"
/>
<p className="text-xs text-muted-foreground">
Each scan uses the selected lookback window, so windows can overlap. The next interval starts
after completion.
</p>
</>
)}
</fieldset>
<div className="rounded-lg bg-muted/40 p-3 text-sm text-muted-foreground">
{mode === "edit"
? "Changes apply to future scans. You can recheck recent runs from the lens page."
: "The first scan reviews your selected time window. New activity becomes eligible after two minutes. You can leave this page while it runs."}{" "}
Selection and completed coverage are shown with every scan.
</div>
{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>
<Button variant="outline" onClick={() => (step ? setStep(step - 1) : onClose())}>
<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={busy || unsupportedModel || !(validAnalysis && validSchedule)}
onClick={() => execute(() => onSave(settings()))}
>
{saveLabel()}
<Button disabled={!canSave} onClick={() => void save()}>
{busy ? "Saving…" : saveLabel}
</Button>
)}
</DialogFooter>

View file

@ -1,14 +1,26 @@
import { act, screen, within } from "@testing-library/react";
import { act, screen, within, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { renderWithProviders as renderProviders, testQueryClient } from "@/../tests/test-utils";
import { ApiError } from "@/lib/http/client";
import { apiClient } from "@/components/networking";
import { LIVE_TAIL_INTERVAL_MS } from "@/components/view_logs/log_filter_logic";
import { LensView } from "./LensView";
import { nextCheckStatus, type Lens, type Finding } from "./lensData";
import { nextCheckStatus, runTime, type Lens, type Finding } from "./lensData";
vi.mock("@/components/networking", () => ({ apiClient: { get: vi.fn(), post: vi.fn() }, proxyBaseUrl: "" }));
function renderWithProviders(ui: React.ReactElement, options?: Parameters<typeof renderProviders>[1]) {
return renderProviders(ui, { searchParams: window.location.search, ...options });
}
vi.mock("@/components/networking", () => ({
apiClient: { get: vi.fn(), post: vi.fn(), request: vi.fn() },
proxyBaseUrl: "",
}));
beforeEach(() => {
window.history.replaceState({}, "", "/lens/?lens=lens");
vi.mocked(apiClient.post).mockReset();
vi.mocked(apiClient.post).mockResolvedValue({ eligible: 0, selected: 0, executions: [] });
});
const executionId = btoa(JSON.stringify(["traces", "", "trace-42"]));
const pattern: Finding = {
@ -47,6 +59,7 @@ const lens: Lens = {
source: "traces",
lookback_hours: 24,
service: "",
agent_name: "",
filters: [],
interval_minutes: 15,
sample_size: 100,
@ -95,6 +108,7 @@ const lens: Lens = {
source: "traces",
lookback_hours: 24,
service: "",
agent_name: "",
filters: [],
interval_minutes: 15,
sample_size: 100,
@ -162,10 +176,10 @@ 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 />);
await user.click(await screen.findByRole("tab", { name: "Runs" }));
await user.click(await screen.findByRole("tab", { name: "Traces" }));
expect(screen.getByText("Release-42")).toBeInTheDocument();
expect(screen.getByText("trace-42")).toBeInTheDocument();
expect(screen.getByText(/1 selected from 1 matches/)).toBeInTheDocument();
expect(screen.getByTitle("trace-42")).toHaveTextContent("Release-42");
expect(screen.getByText(/1 selected from 1 matching runs/)).toBeInTheDocument();
});
});
@ -206,6 +220,7 @@ it("runs saved settings immediately without opening setup", async () => {
],
};
if (path === "/lens/lens/runs") return lens.jobs;
if (path === "/lens/activity/available") return { traces: true, requests: false };
return { data: [] };
});
vi.mocked(apiClient.post).mockResolvedValue(lens);
@ -217,24 +232,45 @@ it("runs saved settings immediately without opening setup", async () => {
});
it("guides a first-time administrator into worker connection and lens setup", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) =>
path === "/lens" ? { lenses: [], workers: [], tracing_enabled: true } : { data: [{ trace_id: "first-trace" }] },
);
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [], workers: [], tracing_enabled: true };
if (path === "/lens/agents") return [];
return { traces: true, requests: false, data: [] };
});
const user = userEvent.setup();
renderWithProviders(<LensView accessToken="test" />);
const guide = within(await screen.findByRole("region", { name: "Understand what your agents are doing" }));
expect(apiClient.get).toHaveBeenCalledWith("/v1/traces", { accessToken: "test", query: { start_ms: 0 } });
expect(guide.getByRole("link", { name: "View traces" })).toHaveAttribute(
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(
"href",
expect.stringMatching(/^\/ui\/lens\/?\?tab=traces$/),
);
await user.click(guide.getByRole("button", { name: "Connect analyzer" }));
const connection = within(await screen.findByRole("dialog", { name: "Set up Lens analysis" }));
expect(connection.getByRole("button", { name: "Generate setup command" })).toBeVisible();
await user.click(guide.getByRole("button", { name: "Connect worker" }));
const connection = within(await screen.findByRole("dialog", { name: "Connect a worker" }));
expect(connection.getByRole("button", { name: "Get install command" })).toBeVisible();
await user.click(connection.getByRole("button", { name: "Close" }));
await user.click(guide.getByRole("button", { name: "Set up your first lens" }));
expect(await screen.findByRole("dialog", { name: "Set up a lens" })).toBeVisible();
expect(guide.getByRole("button", { name: "New investigation" })).toBeDisabled();
await act(async () => {
testQueryClient.setQueryData(["lenses", "test"], {
lenses: [],
tracing_enabled: true,
workers: [
{
id: "worker",
name: "Worker",
revoked: false,
analysis_key_id: "a".repeat(64),
scope: lens.scope,
last_seen: new Date().toISOString(),
},
],
});
});
await waitFor(() => expect(guide.getByRole("button", { name: "New investigation" })).toBeEnabled());
await user.click(guide.getByRole("button", { name: "New investigation" }));
expect(await screen.findByRole("dialog", { name: "Which activity should we investigate?" })).toBeVisible();
});
it("opens the saved results of an older batch", async () => {
@ -254,16 +290,16 @@ it("opens the saved results of an older batch", async () => {
});
const user = userEvent.setup();
renderWithProviders(<LensView accessToken="test" readOnly />);
await screen.findByRole("option", { name: `${new Date(older.created_at).toLocaleString()} · completed` });
await user.selectOptions(screen.getByRole("combobox", { name: "Investigation batch" }), "older");
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();
expect(screen.queryByText(issue.title)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Batch details" }));
await user.click(screen.getByRole("button", { name: "Run details" }));
expect(screen.getByText(/Took 2m 13s/)).toBeVisible();
expect(screen.getByText("Activity window")).toBeVisible();
await user.keyboard("{Escape}");
await user.click(screen.getByRole("tab", { name: "Scans" }));
expect(within(screen.getByRole("tabpanel", { name: "Scans" })).getByText(/Took 2m 13s/)).toBeVisible();
await user.click(screen.getByRole("tab", { name: "History" }));
expect(within(screen.getByRole("tabpanel", { name: "History" })).getByText(/Took 2m 13s/)).toBeVisible();
});
it("reads request content from the beginning after its abbreviated preview", async () => {
@ -279,6 +315,7 @@ it("reads request content from the beginning after its abbreviated preview", asy
vi.mocked(apiClient.get).mockImplementation(async (path, options) => {
if (path === "/lens") return { lenses: [{ ...lens, jobs: [job] }], workers: [], tracing_enabled: true };
if (path === "/lens/lens/runs") return [job];
if (!path.includes("/executions/")) return { data: [] };
const offset = options?.query?.offset ?? 0;
return {
parts: [
@ -292,8 +329,8 @@ it("reads request content from the beginning after its abbreviated preview", asy
});
const user = userEvent.setup();
renderWithProviders(<LensView accessToken="test" readOnly />);
await user.click(await screen.findByRole("tab", { name: "Runs" }));
await user.click(screen.getByRole("button", { name: "Open request" }));
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();
await user.click(screen.getByRole("button", { name: "Next section" }));
expect(await screen.findByText("Original at 1")).toBeVisible();
@ -308,24 +345,27 @@ it("reads request content from the beginning after its abbreviated preview", asy
it.each([false, true])(
"directs a new user to traces when tracing_enabled=%s and there are no traces",
async (enabled) => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) =>
path === "/lens" ? { lenses: [], workers: [], tracing_enabled: enabled } : { data: [] },
);
renderWithProviders(<LensView accessToken="test" />);
expect(await screen.findByRole("heading", { name: "Set up traces to start running investigations" })).toBeVisible();
expect(await screen.findByRole("heading", { name: "Find what needs attention" })).toBeVisible();
expect(screen.getByRole("link", { name: "Set up traces" })).toHaveAttribute(
"href",
expect.stringMatching(/^\/ui\/lens\/?\?tab=traces$/),
);
expect(screen.queryByRole("button", { name: "Set up your first lens" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "New investigation" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Connect worker" })).toBeDisabled();
expect(screen.queryByRole("button", { name: "Set up analysis" })).not.toBeInTheDocument();
},
);
it("enables first-lens setup when a trace arrives without leaving Investigations", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
const traceCheck = vi.fn().mockResolvedValue({ data: [] });
const traceCheck = vi.fn().mockResolvedValue({ traces: false, requests: false });
vi.mocked(apiClient.get).mockImplementation(async (path) =>
path === "/lens" ? { lenses: [], workers: [], tracing_enabled: true } : traceCheck(),
);
@ -335,14 +375,15 @@ it("enables first-lens setup when a trace arrives without leaving Investigations
await act(async () => vi.advanceTimersByTimeAsync(50));
expect(screen.getByRole("link", { name: "Set up traces" })).toBeVisible();
traceCheck.mockResolvedValue({ data: [{ trace_id: "first-trace" }] });
await act(async () => vi.advanceTimersByTimeAsync(LIVE_TAIL_INTERVAL_MS));
expect(screen.getByRole("button", { name: "Set up your first lens" })).toBeVisible();
traceCheck.mockResolvedValue({ traces: true, requests: false });
await act(async () => vi.advanceTimersByTimeAsync(5000));
expect(screen.getByRole("button", { name: "Connect worker" })).toBeEnabled();
expect(screen.getByRole("button", { name: "New investigation" })).toBeDisabled();
expect(screen.queryByRole("link", { name: "Set up traces" })).not.toBeInTheDocument();
const completedChecks = traceCheck.mock.calls.length;
await act(async () => vi.advanceTimersByTimeAsync(LIVE_TAIL_INTERVAL_MS * 2));
expect(traceCheck).toHaveBeenCalledTimes(completedChecks);
await act(async () => vi.advanceTimersByTimeAsync(5000 * 2));
expect(traceCheck.mock.calls.length).toBeGreaterThan(completedChecks);
view.unmount();
} finally {
vi.useRealTimers();
@ -350,6 +391,7 @@ it("enables first-lens setup when a trace arrives without leaving Investigations
});
it("allows retrying a failed trace readiness check without treating it as an empty account", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
const traceCheck = vi
.fn()
@ -357,13 +399,15 @@ it("allows retrying a failed trace readiness check without treating it as an emp
.mockResolvedValue({ data: [] });
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [], workers: [], tracing_enabled: true };
if (path === "/v1/traces") return traceCheck();
if (path === "/lens/activity/available") return traceCheck();
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<LensView accessToken="test" />);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not check traces. Trace storage unavailable");
expect(screen.queryByRole("link", { name: "Set up traces" })).not.toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not check recorded activity. Trace storage unavailable",
);
expect(screen.getByRole("button", { name: "Connect worker" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByRole("link", { name: "Set up traces" })).toBeVisible();
});
@ -379,3 +423,38 @@ it("keeps saved investigations accessible when tracing is disabled", async () =>
expect(await screen.findByText(issue.title)).toBeVisible();
expect(screen.queryByRole("link", { name: "Set up traces" })).not.toBeInTheDocument();
});
it("allows request-only accounts to connect a worker without requiring agent traces", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [], workers: [], tracing_enabled: true };
if (path === "/lens/activity/available") return { traces: false, requests: true };
return { data: [] };
});
renderWithProviders(<LensView 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();
});
it("closes editing when browser navigation leaves the investigation", async () => {
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens], workers: [], tracing_enabled: true };
if (path === "/lens/lens/runs") return lens.jobs;
if (path === "/lens/agents") return [];
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<LensView 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();
await act(async () => {
window.history.replaceState({}, "", "/lens/");
window.dispatchEvent(new PopStateEvent("popstate"));
});
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(apiClient.request).not.toHaveBeenCalled();
});

View file

@ -1,23 +1,53 @@
"use client";
import type { components } from "@/lib/http/schema";
import { useState } from "react";
import { useEffect, useState } from "react";
import { parseAsString, useQueryState } from "nuqs";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowUpRight, CheckCircle2, Circle, Info, Layers3, Pause, Play, Plus, Settings2 } from "lucide-react";
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 { Textarea } from "@/components/ui/textarea";
import { LensFinding } from "./LensFinding";
import { apiClient } from "@/components/networking";
import { TracePanel } from "./TracePanel";
import { LensSetup } from "./LensSetup";
import { LensRuns } from "./LensRuns";
import { LensProgress, NextCheck, ScanDuration } from "./LensProgress";
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,
InvestigationExample,
MonitoringSetup,
InvestigationSummary,
InvestigationFailure,
} from "./LensOverview";
import { LensWelcome } from "./LensWelcome";
import {
lensStatus,
workerConnected,
scopeLabel,
evidenceTarget,
sortedFindings,
runTime,
@ -30,22 +60,36 @@ import {
const money = (n: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 3 }).format(n);
const when = (value?: string | null) => (value ? new Date(value).toLocaleString() : "Not yet");
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) {
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 }: { accessToken: string; readOnly?: boolean }) {
const client = useQueryClient();
const [workerSetup, setWorkerSetup] = useState(false);
const [showExample, setShowExample] = 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: 10000,
refetchInterval: (current) => {
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],
@ -56,20 +100,8 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
queryFn: () =>
apiClient.get<{ data: import("./lensData").AnalysisModelInfo[] }>("/model_group/info", { accessToken }),
});
const [selected, setSelected] = useState<string | null>(() =>
typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("lens"),
);
const selectLens = (id: string) => {
setSelected(id);
setBatchId("latest");
setHistoryOffset(0);
setFindingId(null);
const url = new URL(window.location.href);
url.searchParams.set("lens", id);
window.history.replaceState(window.history.state, "", url);
};
const [selected, setSelected] = useQueryState("lens", parseAsString.withOptions({ history: "push" }));
const [editing, setEditing] = useState<"new" | "edit" | "duplicate" | null>(null);
const [workerSetup, setWorkerSetup] = useState(false);
const [batchId, setBatchId] = useState("latest");
const [historyOffset, setHistoryOffset] = useState(0);
const [tab, setTab] = useState("findings");
@ -80,13 +112,47 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
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 lens = lenses.find((e) => e.id === selected) ?? lenses[0];
const connected =
query.data?.workers?.some(
(w) => !w.revoked && w.analysis_key_id && query.dataUpdatedAt - Date.parse(w.last_seen) < 120000,
) ?? false;
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: 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,
@ -119,19 +185,11 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
return { ...lens.settings, name: `${lens.settings.name} copy`, enabled: false };
return lens?.settings;
};
const lastCompleted = lens?.jobs?.find((j) => j.status === "completed");
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 evidenceGroups = finding
? [...new Set(finding.evidence.map((e) => e.execution_id))].map((id) => ({
id,
run: sampledRuns.find((r) => r.id === id),
quotes: finding.evidence.filter((e) => e.execution_id === id),
}))
: [];
const target = evidence ? evidenceTarget(evidence.id) : null;
const [requestOffset, setRequestOffset] = useState(0);
const requestEvidence = useQuery({
@ -160,9 +218,13 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
}
};
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",
editing === "edit" ? `/lens/${lens?.id}` : "/lens",
{ accessToken, body: settings },
);
selectLens(saved.id);
@ -176,134 +238,142 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
return (
<section aria-label="Investigations" className="w-full min-w-0 space-y-6">
<header className="flex flex-wrap justify-end gap-4">
{!readOnly && !showEmpty && (
<div className="flex gap-2">
<Button variant="outline" onClick={() => setWorkerSetup(true)}>
<Circle
className={`size-2 ${connected ? "fill-emerald-500 text-emerald-500" : "fill-amber-500 text-amber-500"}`}
/>
{connected ? "Analyzer connected" : "Set up analysis"}
{!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>
{lenses.length > 0 && (
<Button onClick={() => setEditing("new")}>
<Plus className="size-4" />
New lens
) : (
<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>
)}
</div>
)}
</header>
<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">
{error || query.error?.message}
<Button variant="ghost" size="sm" onClick={refresh}>
Retry
{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-20 text-center text-muted-foreground">
Loading lenses…
<p role="status" className="py-8 text-sm text-muted-foreground">
Loading investigations…
</p>
)}
{showEmpty && (
{loaded && showEmpty && (
<LensWelcome
accessToken={accessToken}
tracingEnabled={query.data?.tracing_enabled ?? false}
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")}
onExample={() => setShowExample(true)}
/>
)}
{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 className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]">
<nav aria-label="Lenses" className="flex gap-2 overflow-x-auto lg:flex-col lg:overflow-visible">
{lenses.map((e) => (
<button
key={e.id}
onClick={() => selectLens(e.id)}
aria-current={lens.id === e.id ? "page" : undefined}
className={`min-w-44 rounded-lg px-3 py-3 text-left transition-colors ${lens.id === e.id ? "bg-muted" : "hover:bg-muted/50"}`}
>
<span className="block truncate text-sm font-medium">{e.settings.name}</span>
<span className="mt-1 block text-xs text-muted-foreground">{lensStatus(e, connected)}</span>
</button>
))}
</nav>
<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"]} ·{" "}
{lens.settings.service || "All accessible activity"}
{lens.settings.filters?.length ? ` · ${lens.settings.filters.length} filters` : ""}
{sourceLabels[lens.settings.source ?? "traces"]} · {scopeLabel(lens.settings)}
</p>
</div>
{!readOnly && (
<div className="flex gap-2">
<Button variant="ghost" size="icon" aria-label="Lens settings" onClick={() => setEditing("edit")}>
<Settings2 className="size-4" />
</Button>
<Button variant="outline" onClick={() => setEditing("duplicate")}>
Duplicate
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() =>
update(`/lens/${lens.id}`, { ...lens.settings, enabled: !lens.settings.enabled }, "put")
}
>
{lens.settings.enabled ? <Pause className="size-3" /> : <Play className="size-3" />}
{lens.settings.enabled ? "Pause" : "Resume"}
</Button>
<Button disabled={busy || !!active || !connected} onClick={() => update(`/lens/${lens.id}/runs`, {})}>
<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>
{!query.data?.tracing_enabled && (
<div role="status" className="rounded-lg border border-amber-200 bg-amber-50/30 p-3 text-sm">
Enable agent tracing and ClickHouse on this proxy before running an analysis.
</div>
)}
<div className="grid grid-cols-1 gap-4 rounded-xl border p-4 sm:grid-cols-3">
<div>
<p className="text-xs text-muted-foreground">Status</p>
<p className="mt-1 text-sm font-medium" role="status">
{lensStatus(lens, connected)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{lens.settings.enabled
? `Checks every ${lens.settings.interval_minutes} minutes`
: "Manual analysis available"}
</p>
<NextCheck lens={lens} />
</div>
<div>
<p className="text-xs text-muted-foreground">Last successful scan</p>
<p className="mt-1 text-sm">{when(lastCompleted?.finished_at ?? lens.last_scan_at)}</p>
{lastCompleted && (
<p className="mt-1 text-xs text-muted-foreground">
{lastCompleted.coverage?.screened ?? 0} of {lastCompleted.coverage?.eligible ?? 0} eligible runs
reviewed <ScanDuration job={lastCompleted} />
</p>
)}
</div>
<div>
<p className="text-xs text-muted-foreground">Analysis spend this month</p>
<p className="mt-1 text-sm">
{money(lens.budget_month === new Date().toISOString().slice(0, 7) ? lens.spent ?? 0 : 0)}{" "}
<span className="text-muted-foreground">/ {money(lens.settings.monthly_budget ?? 20)}</span>
</p>
<p className="mt-1 text-xs text-muted-foreground">Includes reservations for pending calls</p>
</div>
</div>
<InvestigationSummary lens={lens} connected={connected} />
{active && (
<LensProgress
key={active.id}
@ -317,23 +387,25 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
}
/>
)}
{job?.error && (
<p role="alert" className="text-sm text-destructive">
{job.error}
</p>
)}
{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">Questions & checks</TabsTrigger>
<TabsTrigger value="runs">Runs</TabsTrigger>
<TabsTrigger value="activity">Scans</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 batch"
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) => {
@ -341,7 +413,7 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
setFindingId(null);
}}
>
<option value="latest">Latest batch</option>
<option value="latest">Latest run</option>
{job && selectedOutsideHistory && (
<option value={batchId}>
{when(job.created_at)} · {job.status}
@ -357,17 +429,21 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
{job && batchId !== "all" && (
<Popover key={job.id}>
<PopoverTrigger
aria-label="Batch details"
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>Batch details</PopoverTitle>
<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>
@ -390,6 +466,14 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
</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
@ -432,7 +516,7 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
? "Problems worth investigating, highest priority first."
: "Useful behavior and trends. These do not necessarily need a fix."}
</p>
<div className="divide-y rounded-xl border">
<div className="divide-y border-y">
{visibleFindings.map((f) => (
<button
key={f.id}
@ -440,7 +524,7 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
setFindingId(f.id);
setReason(f.reason ?? "");
}}
className="flex w-full gap-4 p-4 text-left hover:bg-muted/30"
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"]}`}
@ -450,17 +534,19 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
<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 runs ·{" "}
{f.occurrences?.length ?? 0} linked {f.occurrences?.length === 1 ? "run" : "runs"} ·{" "}
{f.kind === "issue" ? `${f.priority} priority` : "Pattern"}
</p>
</div>
<ArrowUpRight className="size-4 text-muted-foreground" />
<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)}</p>
<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."
@ -472,75 +558,36 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
</TabsContent>
<TabsContent value="checks" className="pt-4 space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Checks used for the selected batch</p>
<p className="text-sm text-muted-foreground">Used for this run</p>
{!readOnly && (
<Button variant="outline" size="sm" onClick={() => setEditing("edit")}>
Edit questions
Edit criteria
</Button>
)}
</div>
{batchSettings?.context && (
<div className="rounded-lg bg-muted/40 p-4">
<p className="text-xs font-medium">Expected behavior</p>
<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>
)}
{batchSettings?.checks.map((c) => (
<div key={c.id} className="flex items-start gap-3 rounded-lg border p-4">
<Layers3 className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<p className="text-sm flex-1">{c.instruction}</p>
{!readOnly && batchId === "latest" && (
<Button
size="sm"
variant="ghost"
onClick={() =>
update(
`/lens/${lens.id}`,
{
...lens.settings,
checks: lens.settings.checks.map((q) =>
q.id === c.id ? { ...q, enabled: !q.enabled } : q,
),
},
"put",
)
}
>
{c.enabled ? "Disable" : "Enable"}
</Button>
)}
<div className="pt-2">
<h3 className="text-base font-semibold">What should we look out 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>
<p className="text-sm leading-6 flex-1">{c.instruction}</p>
{!c.enabled && <span className="text-xs text-muted-foreground">Disabled</span>}
</div>
))}
{!readOnly && (
<Button
variant="outline"
disabled={!!active || !connected}
onClick={() => update(`/lens/${lens.id}/runs`, {})}
>
Run saved settings now
</Button>
)}
<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">
<div className="rounded-lg border p-4 text-sm space-y-2">
<p className="font-medium">Activity this lens reviews</p>
<p>
{sourceLabels[batchSettings?.source ?? "traces"]} · {batchSettings?.service || "All services"}
</p>
{batchSettings?.filters?.map((f) => (
<p key={f.key} className="text-muted-foreground">
{f.key} is {f.value}
</p>
))}
{!readOnly && (
<Button variant="outline" size="sm" onClick={() => setEditing("edit")}>
Change selection
</Button>
)}
</div>
<LensRuns
key={job?.id ?? batchId}
job={job}
@ -550,49 +597,59 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
}}
/>
</TabsContent>
<TabsContent value="activity" className="pt-4 space-y-3">
<div className="flex justify-between">
<Button
variant="outline"
disabled={!historyOffset}
onClick={() => setHistoryOffset(Math.max(0, historyOffset - 50))}
>
Newer batches
</Button>
<Button
variant="outline"
disabled={(history.data?.length ?? 0) < 50}
onClick={() => setHistoryOffset(historyOffset + 50)}
>
Older batches
</Button>
</div>
{history.error && <p role="alert">{history.error.message}</p>}
{(history.data ?? lens.jobs)?.map((j) => (
<div key={j.id} className="rounded-lg border p-4">
<div className="flex justify-between gap-3 text-sm">
<span className="font-medium">{j.stage}</span>
<span>{money(j.cost ?? 0)}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{when(j.created_at)} · Settings version {j.revision}
<ScanDuration job={j} />
</p>
<p className="mt-3 text-sm">
{j.coverage?.screened ?? 0} reviewed / {j.coverage?.eligible ?? 0} eligible ·{" "}
{j.coverage?.investigated ?? 0} patterns investigated · {j.coverage?.inconclusive ?? 0}{" "}
inconclusive
</p>
<p className="mt-1 text-xs text-muted-foreground">
{j.coverage?.partial ?? 0} partial executions · {j.coverage?.unassessable ?? 0} could not be
assessed
</p>
<Button size="sm" variant="outline" className="mt-3" onClick={() => openBatch(j.id)}>
View results
<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>
{j.error && <p className="mt-2 text-sm text-destructive">{j.error}</p>}
</div>
))}
)}
</TabsContent>
</Tabs>
</section>
@ -600,8 +657,11 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
)}
{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}
@ -617,117 +677,42 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
workers={query.data?.workers ?? []}
onClose={() => setWorkerSetup(false)}
onChanged={refresh}
onReady={
ready && showEmpty
? () => {
setWorkerSetup(false);
setEditing("new");
}
: undefined
}
/>
)}
<Sheet
open={!!finding}
onOpenChange={(open) => {
if (!open) setFindingId(null);
{showExample && <InvestigationExample onClose={() => setShowExample(false)} />}
{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);
}}
>
<SheetContent className="overflow-y-auto data-[side=right]:sm:max-w-2xl">
{finding && (
<>
<SheetHeader>
<SheetTitle className="pr-8 text-xl leading-snug">{finding.title}</SheetTitle>
<SheetDescription>
{finding.kind === "issue" ? `${finding.priority} priority` : "Pattern"} ·{" "}
{finding.occurrences?.length ?? 0} linked runs
</SheetDescription>
</SheetHeader>
<div className="space-y-6 p-4">
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">What happened</p>
<p className="text-sm leading-6 whitespace-pre-wrap">{finding.description}</p>
</div>
{finding.suggestion && (
<div className="rounded-lg bg-muted/40 p-4">
<p className="text-sm font-medium">What to do next</p>
<p className="mt-2 text-sm leading-6">{finding.suggestion}</p>
</div>
)}
{finding.limitation && (
<details className="rounded-lg border p-3 text-sm">
<summary className="cursor-pointer font-medium">What this does and doesn’t tell us</summary>
<p className="mt-3 leading-6 text-muted-foreground">{finding.limitation}</p>
</details>
)}
<div>
<p className="text-sm font-medium">Evidence by run</p>
<p className="mt-1 mb-3 text-xs text-muted-foreground">
Exact quotes from the recorded activity. Counterexamples are labeled separately from supporting
evidence.
</p>
<div className="space-y-2">
{evidenceGroups.map((group) => (
<details key={group.id} className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
{group.run?.name ?? evidenceTarget(group.id)?.id.slice(0, 12) ?? "Recorded run"}
<span className="ml-2 text-xs font-normal text-muted-foreground">
{group.quotes.length} quotes{group.run ? ` · ${runTime(group.run.start_time)}` : ""}
</span>
</summary>
<div className="mt-3 space-y-3">
{group.quotes.map((e, i) => (
<div key={`${e.span_id}-${i}`} className="rounded-md bg-muted/40 p-3">
{e.role === "counterexample" && (
<p className="mb-1 text-xs font-medium text-muted-foreground">Counterexample</p>
)}
<blockquote className="text-xs leading-5 whitespace-pre-wrap break-words">
{e.quote}
</blockquote>
<Button
variant="ghost"
size="sm"
className="mt-2"
onClick={() => {
setRequestOffset(0);
setEvidence({ id: e.execution_id, span: e.span_id });
}}
>
{evidenceTarget(e.execution_id)?.source === "traces"
? "Open original step"
: "Open request"}
<ArrowUpRight className="size-3" />
</Button>
</div>
))}
</div>
</details>
))}
</div>
</div>
{!readOnly && (
<div className="space-y-3 border-t pt-4">
<label className="grid gap-2 text-sm">
What should Lens remember?
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="What should Lens know about this finding?"
/>
</label>
<p className="text-xs text-muted-foreground">Your explanation informs future scans of this Lens.</p>
<div className="flex flex-wrap gap-2">
{finding.kind === "issue" && (
<Button
disabled={busy}
onClick={() => changeFinding(finding.status === "resolved" ? "open" : "resolved")}
>
{finding.status === "resolved" ? "Reopen" : "Mark resolved"}
</Button>
)}
<Button disabled={busy} variant="outline" onClick={() => changeFinding("dismissed")}>
This is expected
</Button>
</div>
</div>
)}
</div>
</>
)}
</SheetContent>
</Sheet>
/>
{lens && target?.source === "traces" && (
<TracePanel
open={!!evidence}
@ -758,7 +743,7 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin
</pre>
))}
{requestEvidence.data?.parts.length === 0 && <p>Request was not found or is past retention</p>}
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{requestOffset > 0 && (
<Button variant="outline" onClick={() => setRequestOffset(Math.max(0, requestOffset - 8000))}>
Previous section

View file

@ -1,134 +1,141 @@
import Link from "next/link";
import { isTracingNotEnabled, useTraceAvailability } from "@/components/view_logs/TraceView/useAgentTraces";
import { Aperture, ArrowUpRight, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Check, ArrowRight, Loader2 } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { uiHref } from "@/utils/uiHref";
export function LensWelcome({
accessToken,
tracingEnabled,
tracesReady,
requestsReady = false,
checking,
traceError,
connected,
readOnly,
onConnect,
onCreate,
onRetry,
onExample,
}: {
accessToken: string;
tracingEnabled: boolean;
tracesReady: boolean;
requestsReady?: boolean;
checking: boolean;
traceError?: string;
connected: boolean;
readOnly: boolean;
onConnect: () => void;
onCreate: () => void;
onRetry: () => void;
onExample: () => void;
}) {
const traces = useTraceAvailability(accessToken, tracingEnabled);
if (tracingEnabled && traces.isPending) {
return (
<p role="status" className="py-8 text-sm text-muted-foreground">
Checking for traces…
</p>
);
}
if (traces.error && !isTracingNotEnabled(traces.error)) {
return (
<div role="alert" className="py-8 text-sm">
<p>Could not check traces. {traces.error.message}</p>
<Button variant="outline" className="mt-4" onClick={() => void traces.refetch()}>
Retry
</Button>
</div>
);
}
if (!tracingEnabled || !traces.data || isTracingNotEnabled(traces.error)) {
return (
<section className="py-8">
<h2 className="text-lg font-semibold">Set up traces to start running investigations</h2>
{tracingEnabled && !traces.error && (
<p className="mt-2 text-sm text-muted-foreground">No agent traces received yet.</p>
)}
<Link
href={uiHref("lens/?tab=traces")}
className="mt-4 inline-flex items-center gap-1 text-sm font-medium underline underline-offset-4"
>
Set up traces <ArrowUpRight aria-hidden="true" className="size-4" />
</Link>
</section>
);
}
const activityReady = tracesReady || requestsReady;
const workerReady = activityReady && connected;
const canCreate = workerReady && !readOnly;
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",
className: "col-start-2 w-fit sm:col-start-auto",
});
return (
<section aria-labelledby="lens-welcome" className="overflow-hidden rounded-xl border bg-card">
<div className="border-b bg-muted/20 px-6 py-10 sm:px-10">
<Aperture aria-hidden="true" className="mb-5 size-8" strokeWidth={1.5} />
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-muted-foreground">Getting started</p>
<h2 id="lens-welcome" className="text-2xl font-semibold tracking-tight">
Understand what your agents are doing
</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
Tell Lens how your agent should behave. It reviews recorded runs, finds recurring problems, and links each
finding to the evidence behind it.
</p>
</div>
<ol className="grid divide-y lg:grid-cols-3 lg:divide-x lg:divide-y-0">
<li className="flex flex-col gap-3 p-6 sm:p-8">
<span className="flex size-7 items-center justify-center rounded-full border text-xs font-medium">1</span>
<h3 className="font-medium">Start with recorded activity</h3>
<p className="text-sm leading-6 text-muted-foreground">
Use the agent traces or LLM requests already in LiteLLM. Lens needs their inputs and outputs to understand
what happened.
</p>
<Link
href={uiHref("lens/?tab=traces")}
className="mt-auto inline-flex items-center gap-1 pt-3 text-sm font-medium underline-offset-4 hover:underline"
>
View traces <ArrowUpRight aria-hidden="true" className="size-4" />
<section aria-labelledby="lens-welcome" className="max-w-3xl py-4 sm:py-6">
<h2 id="lens-welcome" className="text-xl font-semibold tracking-tight">
Find what needs attention
</h2>
<p className="mt-2 max-w-lg text-sm leading-6 text-muted-foreground">
Check how your agents behave. Get findings you can trace back to what happened.
</p>
<Button variant="link" className="mt-3 h-auto px-0 text-sm" onClick={onExample}>
View an example <ArrowRight className="size-3.5" />
</Button>
<ol className="mt-8 divide-y border-y">
<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]">
<Step number={1} complete={activityReady} checking={checking} active={!activityReady} />
<div>
<h3 className="text-sm font-medium">{firstStepTitle}</h3>
<p className="mt-1 text-sm text-muted-foreground">{traceStatus}</p>
</div>
<Link href={uiHref("lens/?tab=traces")} className={traceButtonClass}>
{tracesReady ? "View traces" : "Set up traces"}
</Link>
</li>
<li className="flex flex-col gap-3 p-6 sm:p-8">
<span className="flex size-7 items-center justify-center rounded-full border text-xs font-medium">
{connected ? <CheckCircle2 aria-hidden="true" className="size-4 text-emerald-600" /> : "2"}
</span>
<h3 className="font-medium">Connect the analyzer</h3>
<p className="text-sm leading-6 text-muted-foreground">
Run one Docker command on your server. The analyzer connects to LiteLLM and runs scans in the background for
all your lenses.
</p>
<div className="mt-auto pt-3">
{connected && (
<p role="status" className="text-sm font-medium text-emerald-700 dark:text-emerald-400">
Analyzer connected
</p>
)}
{!connected && !readOnly && (
<Button variant="outline" onClick={onConnect}>
Connect analyzer
</Button>
)}
{!connected && readOnly && (
<p className="text-sm text-muted-foreground">An administrator can connect the analyzer.</p>
)}
<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}`}
>
<Step number={2} complete={workerReady} active={waitingForWorker} />
<div>
<h3 className="text-sm font-medium">Connect a worker</h3>
<p className="mt-1 text-sm text-muted-foreground">
{workerReady ? "Worker connected" : "Runs your investigations in the background."}
</p>
</div>
<Button
className="col-start-2 w-fit sm:col-start-auto"
variant={waitingForWorker ? "default" : "ghost"}
disabled={!canConnect}
onClick={onConnect}
>
{connected ? "Manage worker" : "Connect worker"}
</Button>
</li>
<li className="flex flex-col gap-3 p-6 sm:p-8">
<span className="flex size-7 items-center justify-center rounded-full border text-xs font-medium">3</span>
<h3 className="font-medium">Create your first lens</h3>
<p className="text-sm leading-6 text-muted-foreground">
Describe expected behavior, choose the runs to review, and start a scan. Run it once or repeat on a
schedule.
</p>
<div className="mt-auto pt-3">
{!readOnly ? (
<Button onClick={onCreate}>
Set up your first lens <ArrowUpRight aria-hidden="true" className="size-4" />
</Button>
) : (
<p className="text-sm text-muted-foreground">
Ask an administrator to create a lens. Findings will appear here.
</p>
)}
<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" : ""}`}
>
<Step number={3} complete={false} active={workerReady} />
<div>
<h3 className="text-sm font-medium">Run an investigation</h3>
</div>
<Button
className="col-start-2 w-fit sm:col-start-auto"
variant={workerReady ? "default" : "ghost"}
disabled={!canCreate}
onClick={onCreate}
>
New investigation
</Button>
</li>
</ol>
<div className="border-t bg-muted/20 px-6 py-4 text-sm text-muted-foreground sm:px-10">
Try questions like “Did the agent finish the task?”, “Are handoffs working?”, or “Where is it repeating work?”
</div>
{traceError && (
<div role="alert" className="mt-6 text-sm text-destructive">
Could not check recorded activity. {traceError}{" "}
<Button variant="link" onClick={onRetry}>
Retry
</Button>
</div>
)}
{readOnly && (
<p className="mt-6 text-sm text-muted-foreground">An administrator can finish setup and run investigations.</p>
)}
</section>
);
}
function Step({
number,
complete,
checking = false,
active = false,
}: {
number: number;
complete: boolean;
checking?: boolean;
active?: boolean;
}) {
const incomplete = checking ? <Loader2 className="size-4 animate-spin" /> : number;
const unfinishedStyle = active ? "bg-foreground text-background" : "border text-muted-foreground";
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}`}
aria-label={complete ? `Step ${number} complete` : `Step ${number}`}
>
{complete ? <Check className="size-4" /> : incomplete}
</span>
);
}
function activityStatus(traces: boolean, requests: boolean, checking: boolean) {
if (traces) return "Traces received";
if (requests) return "Request logs received";
return checking ? "Checking for activity…" : "Connect your agent and send a trace.";
}

View file

@ -25,7 +25,7 @@ export function TracePanel({
>
<SheetContent className="w-full overflow-y-auto data-[side=right]:sm:max-w-[90vw]">
<SheetHeader>
<SheetTitle>Original run</SheetTitle>
<SheetTitle className="text-xl">Original run</SheetTitle>
<SheetDescription>Recorded agent steps and evidence</SheetDescription>
</SheetHeader>
{open && (

View file

@ -6,7 +6,7 @@ import { apiClient } from "@/components/networking";
import { WorkerSetup } from "./WorkerSetup";
vi.mock("@/components/networking", () => ({
apiClient: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
apiClient: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() },
proxyBaseUrl: "https://gateway.example/proxy",
}));
@ -26,27 +26,32 @@ describe("Worker setup", () => {
beforeEach(() => {
testQueryClient.clear();
vi.clearAllMocks();
vi.mocked(apiClient.get).mockResolvedValue({
keys: [{ token: "b".repeat(64), key_alias: "Analysis" }],
total_pages: 1,
});
vi.mocked(apiClient.get).mockImplementation(async (path) =>
path === "/key/info"
? { info: { models: ["analysis-model"], max_budget: 15, budget_duration: "1mo" } }
: {
keys: [{ token: "b".repeat(64), key_alias: "Analysis" }],
total_pages: 1,
},
);
});
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()} />);
expect(screen.getByRole("textbox", { name: "Your LiteLLM deployment URL" })).toHaveValue(
"https://gateway.example/proxy",
);
expect(screen.getByRole("button", { name: "Generate setup command" })).toBeDisabled();
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");
expect(screen.getByRole("button", { name: "Get install command" })).toBeDisabled();
await user.click(screen.getByRole("combobox", { name: "Charge analysis to" }));
await user.click(await screen.findByRole("option", { name: "Analysis" }));
await user.click(screen.getByRole("button", { name: "Generate setup command" }));
await user.click(screen.getByRole("button", { name: "Get install command" }));
expect(apiClient.post).toHaveBeenCalledWith("/lens/workers/register", {
accessToken: "admin",
body: { name: "Lens analyzer", analysis_key_id: "b".repeat(64) },
body: { name: "Lens worker", analysis_key_id: "b".repeat(64) },
});
expect(screen.getByRole("status")).toHaveTextContent("Waiting for your analyzer to connect");
expect(screen.getByRole("status")).toHaveTextContent("Waiting for your worker to connect");
expect(screen.getByLabelText("Docker command preview")).not.toBeVisible();
await user.click(screen.getByRole("button", { name: "Copy Docker command" }));
const command = await navigator.clipboard.readText();
expect(command).toContain("LITELLM_URL=https://gateway.example/proxy");
@ -67,10 +72,10 @@ describe("Worker setup", () => {
/>,
);
expect(screen.getByText("Billing key required")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Billing key" }));
await user.click(screen.getByRole("button", { name: "Settings" }));
await user.click(screen.getByRole("combobox", { name: "Charge analysis to" }));
await user.click(await screen.findByRole("option", { name: "Analysis" }));
await user.click(screen.getByRole("button", { name: "Save billing key" }));
await user.click(screen.getByRole("button", { name: "Save analysis access" }));
expect(apiClient.put).toHaveBeenCalledWith("/lens/workers/worker/billing-key", {
accessToken: "admin",
body: { analysis_key_id: "b".repeat(64) },
@ -78,4 +83,64 @@ describe("Worker setup", () => {
expect(changed).toHaveBeenCalledOnce();
expect(apiClient.post).not.toHaveBeenCalled();
});
it("requires revoking the current worker before setting up a replacement", async () => {
const user = userEvent.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]} />);
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 }]} />);
expect(screen.getByRole("button", { name: "Get install command" })).toBeDisabled();
expect(screen.getByRole("combobox", { name: "Analysis model" })).toBeVisible();
});
it("cleans up a newly created key when registration fails before retrying", async () => {
const user = userEvent.setup();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/models") return { data: [{ id: "analysis-model" }] };
if (path === "/lens") return { workers: [] };
return { keys: [], total_pages: 0 };
});
vi.mocked(apiClient.post)
.mockResolvedValueOnce({ token_id: "limited-key-id", key: "sk-secret-not-displayed" })
.mockRejectedValueOnce(new Error("Registration unavailable"))
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ token_id: "retry-key-id" })
.mockResolvedValueOnce(created);
renderWithProviders(<WorkerSetup 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" }));
await user.click(await screen.findByRole("option", { name: "analysis-model" }));
await user.clear(screen.getByLabelText("Monthly limit (USD)"));
await user.type(screen.getByLabelText("Monthly limit (USD)"), "12");
await user.click(screen.getByRole("button", { name: "Get install command" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Registration unavailable");
expect(apiClient.post).toHaveBeenNthCalledWith(1, "/key/generate", {
accessToken: "admin",
body: {
key_alias: "Lens analysis",
models: ["analysis-model"],
max_budget: 12,
budget_duration: "1mo",
metadata: { purpose: "lens" },
},
});
await user.click(screen.getByRole("button", { name: "Get install command" }));
expect(await screen.findByRole("status")).toHaveTextContent("Waiting for your worker");
expect(apiClient.post).toHaveBeenCalledWith("/key/delete", {
accessToken: "admin",
body: { keys: ["limited-key-id"] },
});
expect(apiClient.post).toHaveBeenCalledTimes(5);
expect(apiClient.post).toHaveBeenLastCalledWith("/lens/workers/register", {
accessToken: "admin",
body: { name: "Lens worker", analysis_key_id: "retry-key-id" },
});
expect(screen.queryByText("sk-secret-not-displayed")).not.toBeInTheDocument();
});
});

View file

@ -2,15 +2,26 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
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 { AnalysisKey } from "./AnalysisKey";
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:a8e8731d954916594eea462969946b9292fb771681ff515a9fd296b53f856c77";
"ghcr.io/berriai/litellm-lens-worker@sha256:67eba741c1b97c749975c5c38e2370a603e1105babc908d613c1b79d7b995393";
function initialProxyAddress(): string {
const url = new URL(proxyBaseUrl || serverRootPath, window.location.origin);
@ -32,7 +43,7 @@ export function workerSetupCommand(address: string, token: string): string {
function workerStatus(worker: LensList["workers"][number], now: number): string {
if (!worker.analysis_key_id) return "Billing key required";
return now - Date.parse(worker.last_seen) < 120000 ? "Connected · ready to analyze" : "Not connected";
return workerConnected(worker, now) ? "Connected" : "Not connected";
}
export function WorkerSetup({
@ -40,17 +51,21 @@ export function WorkerSetup({
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()), 15000);
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);
@ -58,8 +73,28 @@ export function WorkerSetup({
const [created, setCreated] = useState<WorkerCreated | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const actionLabel = editingWorker ? "Save billing key" : "Generate setup command";
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);
@ -67,11 +102,17 @@ export function WorkerSetup({
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: analysisKey },
body: { analysis_key_id: keyId },
});
setEditingWorker(null);
setAnalysisKey(null);
@ -81,125 +122,195 @@ export function WorkerSetup({
setCreated(
await apiClient.post<WorkerCreated>("/lens/workers/register", {
accessToken,
body: { name: "Lens analyzer", analysis_key_id: analysisKey },
body: { name: "Lens worker", analysis_key_id: keyId },
}),
);
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "Could not create credential");
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) onClose();
if (!open && !busy) onClose();
}}
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Set up Lens analysis</DialogTitle>
<DialogDescription>
Lens reads your agents’ logs and finds issues in the background. Run its analyzer once with Docker.
<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>
{!editingWorker && (
<>
<label className="grid gap-2 text-sm">
Your LiteLLM deployment URL
<Input value={address} onChange={(event) => setAddress(event.target.value)} />
</label>
<p className="text-xs text-muted-foreground">
The analyzer connects to this deployment to read logs and save findings.
</p>
</>
)}
{editingWorker && (
<p className="text-sm font-medium">
Billing for {workers.find((worker) => worker.id === editingWorker)?.name}
</p>
)}
{!created && (
<AnalysisKey
key={editingWorker ?? "new"}
accessToken={accessToken}
value={analysisKey}
onChange={setAnalysisKey}
name="worker"
/>
{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="space-y-3">
<p className="text-sm font-medium">Run this command on your server</p>
<textarea
readOnly
aria-label="Docker setup command"
rows={7}
className="w-full rounded-md border p-3 font-mono text-xs"
value={workerSetupCommand(address, created.token)}
/>
<Button
onClick={async () => {
await navigator.clipboard.writeText(workerSetupCommand(address, created.token));
setCopied(true);
}}
>
{copied ? "Copied" : "Copy Docker command"}
</Button>
<p className="text-xs text-muted-foreground">
Keep this command private. It includes the analyzer’s access token.
</p>
<p className="text-sm" role="status">
{workers.some((worker) => worker.id === created.worker.id && now - Date.parse(worker.last_seen) < 120000)
? "Analyzer connected. You can start a scan."
: "Waiting for your analyzer to connect…"}
</p>
<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>
) : (
<Button disabled={busy || !address.trim() || !analysisKey} onClick={createWorker}>
{busy ? "Saving…" : actionLabel}
</Button>
) : 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>
)}
{editingWorker && (
<Button
variant="ghost"
onClick={() => {
setEditingWorker(null);
setAnalysisKey(null);
}}
>
Cancel
</Button>
)}
{workers
?.filter((w) => !w.revoked)
.map((worker) => (
<div key={worker.id} className="flex justify-between items-center border-t pt-3 text-sm">
<span>
{worker.name}
<span className="block text-xs text-muted-foreground">{workerStatus(worker, now)}</span>
</span>
<Button variant="ghost" size="sm" onClick={() => editBilling(worker)}>
Billing key
</Button>
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
await apiClient.delete(`/lens/workers/${worker.id}`, { accessToken });
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "Could not revoke worker");
}
}}
>
Revoke access
</Button>
</div>
))}
{!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}

View file

@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
analysisElapsed,
workerConnected,
type LensList,
analysisProgress,
normalizeFilters,
sortedFindings,
@ -39,6 +41,7 @@ const job: Job = {
source: "traces",
lookback_hours: 24,
service: "",
agent_name: "",
filters: [],
enabled: false,
interval_minutes: 15,
@ -101,7 +104,7 @@ describe("Analysis progress", () => {
expect(analysisProgress({ ...job, status: "queued" })).toMatchObject({
step: -1,
total: 0,
title: "Waiting for an analyzer",
title: "Queued for your worker",
});
});
@ -142,3 +145,22 @@ describe("Lens selection and findings", () => {
expect(sortedFindings([base, high]).map((f) => f.id)).toEqual(["high", "low"]);
});
});
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

@ -1,3 +1,4 @@
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
import type { components } from "@/lib/http/schema";
export type Lens = components["schemas"]["Lens"];
@ -7,6 +8,18 @@ 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 const starterQuestions = [
"Find repeated work or tool calls that add no useful information.",
"Find tool failures or retries that the agent does not recover from.",
@ -20,10 +33,7 @@ export function normalizeFilters(filters: NonNullable<Settings["filters"]>): Set
});
}
export function runTime(value: string): string {
const date = new Date(value.includes("T") ? value : value.replace(" ", "T").slice(0, 23) + "Z");
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
export { formatActivityTimestamp as runTime } from "@/utils/activityTimestamp";
export function sortedFindings(findings: Finding[]): Finding[] {
const rank = { high: 0, medium: 1, low: 2 };
@ -37,9 +47,12 @@ 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 ?? 20)) return "Budget reached";
if (!lens.settings.enabled) return "Paused";
return connected ? "Monitoring" : "Analyzer disconnected";
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 {
@ -67,10 +80,10 @@ export function analysisProgress(job: Job) {
if (job.status === "queued") {
return {
step: -1,
title: "Waiting for an analyzer",
title: "Queued for your worker",
done: 0,
total: 0,
detail: "Analysis will start when an analyzer is available.",
detail: "The worker picks up queued investigations automatically.",
};
}
if (job.stage === "Grouping observations") {
@ -145,13 +158,6 @@ export function durationLabel(value: number, base: "minutes" | "hours" = "minute
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
}
const nextCheckTimeFormat: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
};
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");
@ -162,6 +168,6 @@ export function nextCheckStatus(lens: Lens, now: number): string | null {
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 = next.toLocaleString(undefined, nextCheckTimeFormat);
const time = formatActivityTimestamp(lens.next_run_at);
return `Next check ${time} · ${relative}`;
}

View file

@ -18,11 +18,12 @@ vi.mock("./_components/LensView", () => ({
describe("Lens navigation", () => {
beforeEach(() => auth.mockReturnValue({ accessToken: "test-token", userRole: "Admin", isViewOnly: false }));
it("opens traces and pauses trace polling when navigating to investigations", async () => {
it("opens investigations and pauses trace polling when returning from traces", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<LensPage />, { onUrlUpdate });
expect(screen.getByRole("tab", { name: "Traces" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "Investigations" })).toHaveAttribute("aria-selected", "true");
await user.click(screen.getByRole("tab", { name: "Traces" }));
expect(screen.getByText("Trace polling active")).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(screen.getByText("Manage investigations")).toBeVisible();

View file

@ -15,7 +15,8 @@ export default function LensPage() {
parseAsStringLiteral(["traces", "investigations"]).withOptions({ history: "push" }),
);
const [lensId] = useQueryState("lens", parseAsString);
const activeTab = tab ?? (lensId ? "investigations" : "traces");
const defaultTab = isProxyAdminTierRole(userRole ?? "") || lensId ? "investigations" : "traces";
const activeTab = tab ?? defaultTab;
if (!accessToken) return null;
return (
<main className="flex w-full min-w-0 flex-1 flex-col gap-5 p-6 md:p-8">

View file

@ -61,6 +61,7 @@ const bucketRunCounts = () =>
describe("AgentTracesSection", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@ -81,6 +82,31 @@ describe("AgentTracesSection", () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
});
it.each([
[401, "Your session is no longer valid. Sign out and sign in again."],
[403, "Your account does not have access to these traces."],
])("stops live polling after HTTP %s and explains how to recover", async (status, message) => {
vi.useFakeTimers();
vi.mocked(agentTraceListCall).mockRejectedValue(new ApiError("Private token details", Number(status), {}));
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail
/>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
expect(screen.getByText(`Could not load runs: ${message}`)).toBeVisible();
expect(screen.queryByText(/Private token details/)).not.toBeInTheDocument();
expect(agentTraceListCall).toHaveBeenCalledOnce();
expect(screen.getByTestId("runs-footer")).toHaveTextContent("Update failed");
});
it("renders the setup snippet when the proxy answers 501", async () => {
vi.mocked(agentTraceListCall).mockRejectedValue(
new ApiError("Agent tracing is not enabled", 501, { detail: "Agent tracing is not enabled" }),

View file

@ -226,23 +226,49 @@ export function AgentTracesSection({
onOpenTrace={toggleRun}
selectedKey={openTrace === null ? null : runKey(openTrace)}
/>
<footer
data-testid="runs-footer"
className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-[11px] text-muted-foreground"
>
{runs.length} {runs.length === 1 ? "run" : "runs"}
{zoom && (
<button
type="button"
onClick={() => setZoom(null)}
aria-label="Clear time zoom"
className="ml-3 rounded border border-info/40 bg-info/10 px-1.5 text-info hover:bg-info/20"
>
{moment(zoom.startMs).format("MMM DD, HH:mm")} to {moment(zoom.endMs).format("MMM DD, HH:mm")} ×
</button>
)}
<span className="ml-auto">{traces.isFetching ? "Updating…" : "Updated just now"}</span>
</footer>
<RunsFooter
count={runs.length}
zoom={zoom}
isFetching={traces.isFetching}
failed={!!traces.error}
onResetZoom={() => setZoom(null)}
/>
</div>
);
}
function RunsFooter({
count,
zoom,
isFetching,
failed,
onResetZoom,
}: {
count: number;
zoom: TimeWindow | null;
isFetching: boolean;
failed: boolean;
onResetZoom: () => void;
}) {
const settled = failed ? "Update failed" : "Updated just now";
const status = isFetching ? "Updating…" : settled;
return (
<footer
data-testid="runs-footer"
className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-[11px] text-muted-foreground"
>
{count} {count === 1 ? "run" : "runs"}
{zoom && (
<button
type="button"
onClick={() => onResetZoom()}
aria-label="Clear time zoom"
className="ml-3 rounded border border-info/40 bg-info/10 px-1.5 text-info hover:bg-info/20"
>
{moment(zoom.startMs).format("MMM DD, HH:mm")} to {moment(zoom.endMs).format("MMM DD, HH:mm")} ×
</button>
)}
<span className="ml-auto">{status}</span>
</footer>
);
}

View file

@ -3,6 +3,7 @@
import { ArrowDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
import { cn } from "@/lib/cva.config";
import { StatusMark } from "./StatusMark";
@ -19,22 +20,6 @@ interface AgentTracesTableProps {
selectedKey?: string | null;
}
const SECOND_MS = 1000;
const MINUTE_S = 60;
const HOUR_M = 60;
const DAY_H = 24;
export function relativeTime(iso: string, now: number = Date.now()): string {
const diffS = Math.round((now - new Date(iso).getTime()) / SECOND_MS);
if (diffS < 5) return "just now";
if (diffS < MINUTE_S) return `${diffS}s ago`;
const diffM = Math.round(diffS / MINUTE_S);
if (diffM < HOUR_M) return `${diffM}m ago`;
const diffH = Math.round(diffM / HOUR_M);
if (diffH < DAY_H) return `${diffH}h ago`;
return `${Math.round(diffH / DAY_H)}d ago`;
}
export const formatCost = (cost: number): string => {
if (cost === 0) return "$0.00";
if (cost < 0.01) return `$${cost.toFixed(4)}`;
@ -63,7 +48,7 @@ export function AgentTracesTable({
<table aria-label="Agent runs" className="w-full min-w-[900px] table-fixed border-collapse text-left">
<thead className="sticky top-0 z-sticky bg-muted/40 backdrop-blur">
<tr className="h-8 border-b border-border text-[10px] tracking-[0.08em] text-muted-foreground uppercase">
<th className={`w-[96px] ${TH}`}>
<th className={`w-[190px] ${TH}`}>
<span className="inline-flex items-center gap-1">
Time <ArrowDown className="size-2.5" />
</span>
@ -94,9 +79,9 @@ export function AgentTracesTable({
>
<td
className="px-3 font-mono text-[11px] tabular-nums text-muted-foreground"
title={new Date(run.start_time).toLocaleString()}
title={formatActivityTimestamp(run.start_time)}
>
{relativeTime(run.start_time)}
{formatActivityTimestamp(run.start_time)}
</td>
<td className="truncate px-3 text-muted-foreground" title={run.service}>
{run.service}

View file

@ -12,10 +12,22 @@ export const TRACING_NOT_ENABLED_STATUS = 501;
/** A proxy without the tracing routes at all answers 404; treat it like tracing being off. */
const TRACING_ROUTE_MISSING_STATUS = 404;
export const isTracingNotEnabled = (error: unknown): error is ApiError =>
export const isTracingNotEnabled = (error: unknown): boolean =>
error instanceof ApiError &&
(error.status === TRACING_NOT_ENABLED_STATUS || error.status === TRACING_ROUTE_MISSING_STATUS);
const requiresUserAction = (error: unknown): boolean => {
if (!(error instanceof ApiError)) return false;
return isTracingNotEnabled(error) || error.status === 401 || error.status === 403;
};
const displayError = (error: Error | null): Error | null => {
if (!(error instanceof ApiError)) return error;
if (error.status === 401) return new Error("Your session is no longer valid. Sign out and sign in again.");
if (error.status === 403) return new Error("Your account does not have access to these traces.");
return error;
};
interface UseAgentTracesOptions {
accessToken: string;
startTime: string;
@ -69,8 +81,11 @@ export function useAgentTraces({
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
enabled,
retry: (failureCount, error) => !isTracingNotEnabled(error) && failureCount < 1,
refetchInterval: (q) => (isLiveTail && !isTracingNotEnabled(q.state.error) ? LIVE_TAIL_INTERVAL_MS : false),
staleTime: LIVE_TAIL_INTERVAL_MS,
retry: (failureCount, error) => !requiresUserAction(error) && failureCount < 1,
refetchInterval: (q) => (isLiveTail && !requiresUserAction(q.state.error) ? LIVE_TAIL_INTERVAL_MS : false),
refetchOnWindowFocus: (q) => !requiresUserAction(q.state.error),
refetchOnReconnect: (q) => !requiresUserAction(q.state.error),
refetchIntervalInBackground: false,
};
const query = useInfiniteQuery<TracePage, Error>(queryOptions);
@ -83,7 +98,7 @@ export function useAgentTraces({
isLoading: query.isLoading,
isFetching: query.isFetching,
notEnabledDetail: notEnabled ? query.error?.message || "Agent tracing is not enabled" : null,
error: notEnabled ? null : query.error,
error: notEnabled ? null : displayError(query.error),
hasMore: query.hasNextPage,
loadMore: () => void query.fetchNextPage(),
refetch: () => void query.refetch(),
@ -97,7 +112,10 @@ export function useTraceAvailability(accessToken: string, enabled: boolean) {
select: (page: TracePage) => page.data.length > 0,
enabled,
retry: false,
refetchInterval: (query) => (query.state.data?.data.length ? false : LIVE_TAIL_INTERVAL_MS),
refetchInterval: (query) =>
query.state.data?.data.length || requiresUserAction(query.state.error) ? false : LIVE_TAIL_INTERVAL_MS,
refetchOnWindowFocus: (query) => !requiresUserAction(query.state.error),
refetchOnReconnect: (query) => !requiresUserAction(query.state.error),
refetchIntervalInBackground: false,
};
return useQuery(options);

View file

@ -8865,6 +8865,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/lens/activity/available": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Activity Available */
get: operations["activity_available_lens_activity_available_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/agents": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Agents */
get: operations["list_agents_lens_agents_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/preview/sample": {
parameters: {
query?: never;
@ -25201,6 +25235,19 @@ export interface components {
/** Results */
results: components["schemas"]["TagActiveUsersResponse"][];
};
/** ActivityAvailability */
ActivityAvailability: {
/**
* Requests
* @default false
*/
requests: boolean;
/**
* Traces
* @default false
*/
traces: boolean;
};
/** AdaptiveRouterWeights */
AdaptiveRouterWeights: {
/**
@ -33511,6 +33558,11 @@ export interface components {
};
/** LensSettings */
LensSettings: {
/**
* Agent Name
* @default
*/
agent_name: string;
/**
* Checks
* @default []
@ -33555,7 +33607,7 @@ export interface components {
model: string;
/**
* Monthly Budget
* @default 20
* @default 100
*/
monthly_budget: number;
/** Name */
@ -61434,6 +61486,46 @@ export interface operations {
};
};
};
activity_available_lens_activity_available_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ActivityAvailability"];
};
};
};
};
list_agents_lens_agents_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": string[];
};
};
};
};
preview_sample_lens_preview_sample_post: {
parameters: {
query?: never;

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { formatActivityTimestamp } from "./activityTimestamp";
describe("Activity timestamps", () => {
it("shows the date, seconds and timezone for the same instant across storage formats", () => {
const value = "2026-10-01T20:42:17.123Z";
const formatted = formatActivityTimestamp(value);
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
};
const parts = new Intl.DateTimeFormat(undefined, options).formatToParts(new Date(value));
for (const part of parts.filter((part) => ["year", "month", "day", "second", "timeZoneName"].includes(part.type)))
expect(formatted).toContain(part.value);
expect(formatActivityTimestamp("2026-10-01 20:42:17.123")).toBe(formatted);
expect(formatActivityTimestamp("2026-10-01T13:42:17.123-07:00")).toBe(formatted);
});
it("preserves an invalid timestamp rather than inventing a date", () => {
expect(formatActivityTimestamp("unavailable")).toBe("unavailable");
});
});

View file

@ -0,0 +1,15 @@
export function formatActivityTimestamp(value: string): string {
const normalized = value.includes("T") ? value : value.replace(" ", "T").slice(0, 23) + "Z";
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return value;
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
};
return date.toLocaleString(undefined, options);
}