mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
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:
parent
0737e26463
commit
cc42a352cb
44 changed files with 3090 additions and 1343 deletions
|
|
@ -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}
|
||||
|
|
|
|||
6
litellm-rust/crates/traces/query/lens_agents.sql
Normal file
6
litellm-rust/crates/traces/query/lens_agents.sql
Normal 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
|
||||
8
litellm-rust/crates/traces/query/lens_availability.sql
Normal file
8
litellm-rust/crates/traces/query/lens_availability.sql
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
¶meters,
|
||||
)
|
||||
.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(),
|
||||
¶meters,
|
||||
)
|
||||
.await?,
|
||||
)?;
|
||||
assert_eq!(available["data"][0]["traces"], 1);
|
||||
assert_eq!(available["data"][0]["requests"], 0);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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's model permissions and
|
||||
budget before retrying.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
94
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
94
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
26
ui/litellm-dashboard/src/utils/activityTimestamp.test.ts
Normal file
26
ui/litellm-dashboard/src/utils/activityTimestamp.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
15
ui/litellm-dashboard/src/utils/activityTimestamp.ts
Normal file
15
ui/litellm-dashboard/src/utils/activityTimestamp.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue