Merge remote-tracking branch 'origin/main' into litellm_shared_rust_pagination

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yujong Lee 2026-10-04 07:31:53 +00:00
commit c6c605ee25
224 changed files with 12391 additions and 5919 deletions

View file

@ -2,62 +2,74 @@
Lens reviews recorded activity and saves evidence-linked findings in the LiteLLM dashboard under Observability, Lens (`/ui/lens/`)
## Install the release stack
## Install
Each stable, RC, and dev release containing Lens publishes the worker at the same version on GHCR and Docker Hub. Use the [LiteLLM releases page](https://github.com/BerriAI/litellm/releases) to select a version that includes the coordinated worker release
Build LiteLLM and its worker from the same source commit with the same release identity. The worker runs separately and connects to your gateway using a limited worker token
For a new local installation, install Docker with Compose, download the two release files, and create a private environment file. Replace `X.Y.Z` with the release version, without `v` (RCs use `X.Y.Z-rc.N`)
### New local installation
Install Docker with Compose and Git. This builds LiteLLM and its worker from the same checkout and starts the existing local tracing stack:
```bash
mkdir litellm-lens
cd litellm-lens
LENS_RELEASE=X.Y.Z
curl -fSLo compose.yaml "https://raw.githubusercontent.com/BerriAI/litellm/v${LENS_RELEASE}/deploy/lens/stack.yaml"
curl -fSLo config.yaml "https://raw.githubusercontent.com/BerriAI/litellm/v${LENS_RELEASE}/deploy/lens/config.yaml"
umask 077
printf 'LITELLM_VERSION=%s\nLITELLM_MASTER_KEY=sk-%s\nLITELLM_SALT_KEY=sk-%s\n' \
"$LENS_RELEASE" "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env
printf 'POSTGRES_PASSWORD=%s\nCLICKHOUSE_PASSWORD=%s\n' \
"$(openssl rand -hex 32)" "$(openssl rand -hex 32)" >> .env
docker compose up -d
git clone https://github.com/BerriAI/litellm.git
cd litellm
export LITELLM_RELEASE_TAG="sha-$(git rev-parse HEAD)"
export LENS_WORKER_IMAGE="litellm-lens-worker:${LITELLM_RELEASE_TAG}"
export OPENAI_API_KEY='sk-...'
docker build --build-arg LITELLM_RELEASE_TAG="$LITELLM_RELEASE_TAG" \
-f deploy/lens/Dockerfile -t "$LENS_WORKER_IMAGE" .
docker compose -f docker/docker-compose.tracing.yml up -d --build
```
Open `http://localhost:4000/ui/`, log in as `admin` with `LITELLM_MASTER_KEY` from `.env`, and add a model in the dashboard. In Lens, select **Connect worker**, choose that model and a monthly budget, then **Get install command**. Expand **Using Docker Compose or Helm?**, copy the worker token, and add `LENS_WORKER_TOKEN=<token>` to `.env`
Open `http://localhost:4002/ui/` and sign in as `admin` with password `sk-1234`. Go to **Lens > Investigations > Connect worker**, choose a model and monthly budget, then **Get install command**. Expand **Using Docker Compose or Helm?** and copy the worker token. In the same terminal, run:
```bash
docker compose --profile lens up -d
export LITELLM_URL=http://litellm:4000
export LENS_WORKER_TOKEN='<paste-your-worker-token>'
docker compose -f docker/docker-compose.tracing.yml -f deploy/lens/compose.yaml up -d
```
The stack starts LiteLLM, PostgreSQL, ClickHouse, and the worker from published images. The dashboard shows **Worker connected**. The worker has a limited token, no database credentials, and no provider keys. The stack exposes only the dashboard on localhost; use your normal ingress and managed databases for a public production deployment
The worker joins the gateway's Docker network, and the dashboard shows **Worker connected**. Save the token privately for restarts and upgrades
Keep `.env` private and preserve its salt key. Keep both named database volumes. To upgrade, wait for active investigations to finish, stop the worker, change only `LITELLM_VERSION`, then pull and recreate the stack:
This stack is for local evaluation: it binds to localhost and uses development database credentials. For a hosted deployment, keep your normal database, keys, networking, and deployment process. Build both images from one source revision with the same `LITELLM_RELEASE_TAG`, publish the worker to your registry, and set `LENS_WORKER_IMAGE` on LiteLLM to that image
### Existing LiteLLM installation
Keep your deployment and PostgreSQL database. A working gateway/worker pair can stay as it is until you upgrade both. For a gateway built from source, use its exact commit and `LITELLM_RELEASE_TAG`; a release version or the latest commit on `main` is not a substitute for that source identity
The public development package is `ghcr.io/berriai/litellm-lens-worker-dev:sha-<full-commit>`. It publishes amd64 images on Lens-related changes, so an arbitrary source commit may have no image. Check the exact image exists before using it. If it is unavailable, your gateway uses a different release identity, or you need native arm64, build the worker from the gateway's checkout:
```bash
docker compose --profile lens stop lens-worker
# Update LITELLM_VERSION in .env to the new release
docker compose --profile lens pull
docker compose --profile lens up -d
export LITELLM_RELEASE_TAG='<gateway-release-identity>'
export LENS_WORKER_IMAGE='<your-registry>/litellm-lens-worker:<your-image-tag>'
docker build --build-arg LITELLM_RELEASE_TAG="$LITELLM_RELEASE_TAG" \
-f deploy/lens/Dockerfile -t "$LENS_WORKER_IMAGE" .
```
This preserves your investigations, findings, model credentials, and worker token. Never use `down -v` during an upgrade. If moving from an existing installation, keep its databases and add the standalone worker instead of creating an empty replacement stack
For a remote worker host, publish that image to a registry the host can pull from. Set the gateway's `LENS_WORKER_IMAGE` to the resulting image reference, restart the gateway using its normal deployment process, then copy its install command. Prefer the published image digest for hosted installations. Do not change the gateway's release identity just to accept another worker
For Kubernetes or Render, run the standalone worker using `LITELLM_URL` and `LENS_WORKER_TOKEN` from setup. Keep existing databases and secrets. The worker needs no inbound port.
## Helm
The componentized `helm/litellm` chart includes an optional Lens worker. Configure PostgreSQL and ClickHouse as usual, install the chart, then obtain a limited worker token from Lens setup. Store it in a Kubernetes Secret and enable the worker in your values:
The componentized source chart at `helm/litellm` includes an optional Lens worker. Use the chart from the same checkout as your gateway and keep your component image overrides in your values. Configure PostgreSQL and ClickHouse as usual, install the chart, then obtain a limited worker token from Lens setup. Store it in a Kubernetes Secret and enable the worker in your values:
```yaml
lensWorker:
enabled: true
image:
repository: <your-worker-image-repository>
digest: sha256:<matching-worker-image-digest>
tokenSecret:
name: litellm-lens-worker
key: token
```
Published release charts pin the worker's approved image digest. Source charts without a digest default to the chart's application version. The chart connects the worker to the backend service. Keep these values and the Secret when upgrading the chart so the gateway and worker upgrade together. `lensWorker.replicaCount` controls simultaneous investigations. To use a private registry or external proxy, set `lensWorker.image.repository`, `lensWorker.image.digest` (or `tag` for a source build), and `lensWorker.url`. A digest takes precedence over the tag. The dashboard uses the chart's worker image for standalone install commands too
Set the worker repository and digest explicitly to an image built from the gateway's source commit and release identity. The chart connects the worker to the backend service. Keep these values and the Secret when upgrading the chart and update the gateway and worker image overrides together. `lensWorker.replicaCount` controls simultaneous investigations. To use a private registry or external proxy, set `lensWorker.image.repository`, `lensWorker.image.digest` (or `tag` for a source build), and `lensWorker.url`. A digest takes precedence over the tag. The dashboard uses the chart's worker image for standalone install commands too
## Standalone worker
Upgrade your existing LiteLLM proxy to a release that includes Lens with PostgreSQL and agent tracing. Configure one ClickHouse URL for trace writes, bounded reads, and Lens queries:
Start with a source deployment that includes Lens, PostgreSQL, and agent tracing, and prepare its matching worker as described above. Configure one ClickHouse URL for trace writes, bounded reads, and Lens queries:
```yaml
general_settings:
@ -74,13 +86,13 @@ Retention changes require a proxy restart. ClickHouse removes expired rows durin
In **Lens > Investigations**, click **Connect worker**, choose an analysis model and monthly limit, then **Get install command**. Use **Advanced options** to select an existing virtual key or change the proxy URL if the server running Docker needs a different network address. Copy the command and run it on your server. The dashboard shows **Worker connected** when the container checks in
The command already contains the compatible worker image and one worker token. The selected virtual key stays on the proxy; its secret is never sent to the worker. No source checkout, environment file, or second LiteLLM deployment is needed. Keep the command private because it includes the token. The LiteLLM release provides the dashboard and APIs; the container only runs background analysis
The command already contains the compatible worker image and one worker token. The selected virtual key stays on the proxy; its secret is never sent to the worker. Once the matching image is available on the worker host, no second LiteLLM deployment is needed. Keep the command private because it includes the token. The LiteLLM release provides the dashboard and APIs; the container only runs background analysis
The dashboard selects the worker image matching the running gateway release. Release images support Linux amd64 and arm64. CI also publishes `:sha-<commit>` development images; use those only with a gateway built from the same commit and release tag
The dashboard uses the gateway's `LENS_WORKER_IMAGE` override when set. Public `:sha-<commit>` development images must match both the gateway commit and release identity. Build from source for the worker host's native architecture
After upgrading the gateway, update the worker image and redeploy it while keeping its proxy URL and token. Existing containers do not update automatically. If an investigation reports a worker compatibility error, update the image before retrying
For deployments managed with Compose, download `compose.yaml` and provide `LITELLM_URL`, `LENS_WORKER_TOKEN`, and `LITELLM_VERSION` (without `v`) in a private environment file. To use another registry, set `LENS_WORKER_IMAGE` to the compatible image instead of setting a version:
For deployments managed with Compose, download `compose.yaml` and provide `LITELLM_URL`, `LENS_WORKER_TOKEN`, and an explicit `LENS_WORKER_IMAGE` in a private environment file:
```bash
docker compose --env-file /path/to/lens.env -f compose.yaml up -d
@ -219,9 +231,9 @@ Upgrades using `--use_prisma_db_push` stop before schema changes if any legacy L
## Release compatibility
Released gateway and worker images carry `LITELLM_RELEASE_TAG`. A worker announces its release and protocol before claiming an investigation. A mismatch returns HTTP 409 with the required image, leaving queued investigations untouched. During a rolling upgrade, workers wait for a gateway from their release
Gateway and worker builds carry the same `LITELLM_RELEASE_TAG`. A worker announces its release and protocol before claiming an investigation. A mismatch returns HTTP 409 with the required image, leaving queued investigations untouched. During a rolling upgrade, workers wait for a gateway from their release
The dashboard reads its image from the running gateway. `LENS_WORKER_IMAGE` overrides the registry/image for private deployments. Worker-only Compose accepts `LITELLM_VERSION` (without `v`) or an explicit `LENS_WORKER_IMAGE`. Release workers are available as `ghcr.io/berriai/litellm-lens-worker:vX.Y.Z` and `docker.io/litellm/litellm-lens-worker:vX.Y.Z`, including matching RC/dev suffixes, on amd64 and arm64
The dashboard reads its image from the running gateway. `LENS_WORKER_IMAGE` overrides the registry/image for private deployments. Set an explicit `LENS_WORKER_IMAGE` for worker-only Compose. Verify that the image exists and matches the gateway before deploying it
For source development, use `make lens-dev`, which gives the proxy and source worker the same commit identity. For custom containers, build both from the same checkout with `--build-arg LITELLM_RELEASE_TAG=sha-$(git rev-parse HEAD)` and set the proxy's `LENS_WORKER_IMAGE` to the worker image you built. An unlabelled custom build refuses worker setup and claims instead of guessing from the Python package version. Normal package-index installations use their installed release version

View file

@ -1,6 +1,6 @@
services:
lens-worker:
image: ${LENS_WORKER_IMAGE:-ghcr.io/berriai/litellm-lens-worker:v${LITELLM_VERSION:?Set LITELLM_VERSION to the gateway release, without the v prefix}}
image: ${LENS_WORKER_IMAGE:-${LITELLM_VERSION:+ghcr.io/berriai/litellm-lens-worker:v}${LITELLM_VERSION:-}}
environment:
LITELLM_URL: ${LITELLM_URL:?Set the URL reachable from this container}
LENS_WORKER_TOKEN: ${LENS_WORKER_TOKEN:?Create a worker credential in the Lens UI}

View file

@ -5,6 +5,8 @@ services:
build:
context: ..
target: runtime
args:
LITELLM_RELEASE_TAG: ${LITELLM_RELEASE_TAG:-}
command: ["--config", "/app/tracing-config.yaml", "--port", "4000"]
environment:
LITELLM_MASTER_KEY: sk-1234
@ -15,6 +17,7 @@ services:
CLICKHOUSE_URL: http://default:local-tracing@clickhouse:8123
CLICKHOUSE_DATABASE: litellm
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
LENS_WORKER_IMAGE: ${LENS_WORKER_IMAGE:-}
volumes:
- ./tracing-config.yaml:/app/tracing-config.yaml:ro
ports:

View file

@ -1,3 +1,4 @@
import functools
import glob
import os
import random
@ -10,7 +11,8 @@ import time
from collections.abc import Callable
from dataclasses import dataclass, replace
from pathlib import Path
from typing import TYPE_CHECKING, Final, Optional
from typing import TYPE_CHECKING, Final, Optional, Union
from urllib.parse import unquote, urlsplit
from litellm_proxy_extras import prisma_toolchain
from litellm_proxy_extras._logging import logger
@ -202,6 +204,66 @@ def _max_migration_timestamp(names) -> int:
return max(_migration_timestamp(n) for n in names)
_REDACTED: Final = "REDACTED"
_PASSWORD_QUERY_KEYS: Final = frozenset(("password", "sslpassword"))
@functools.cache
def _secret_shape_redactor() -> Callable[[str], str]:
try:
from litellm._logging import redact_secrets
except ImportError:
return lambda text: text
return redact_secrets
def _url_passwords(url: str) -> frozenset[str]:
try:
parts: Final = urlsplit(url)
except ValueError:
return frozenset()
query_pairs: Final = tuple(pair.partition("=") for pair in parts.query.split("&"))
raw_query_passwords: Final = tuple(
value for key, separator, value in query_pairs if separator and key.lower() in _PASSWORD_QUERY_KEYS
)
raw_passwords: Final = ((parts.password,) if parts.password else ()) + raw_query_passwords
return frozenset(password for password in raw_passwords + tuple(map(unquote, raw_passwords)) if password)
def _configured_database_passwords() -> frozenset[str]:
database_url: Final = os.getenv("DATABASE_URL")
direct_url: Final = os.getenv("DIRECT_URL")
database_passwords: Final = _url_passwords(database_url) if database_url else frozenset()
direct_passwords: Final = _url_passwords(direct_url) if direct_url else frozenset()
return database_passwords | direct_passwords
def _redact_credentials(text: str) -> str:
"""Mask configured database passwords before passing the text to LiteLLM redaction."""
passwords: Final = sorted(_configured_database_passwords(), key=len, reverse=True)
alternation: Final = "|".join(re.escape(password) for password in passwords)
password_pattern: Final = (
re.compile(rf"(?P<lead>:|password=)(?:{alternation})(?=@|&|$|[\s'\"\]),])", re.IGNORECASE)
if passwords
else None
)
result: Final = password_pattern.sub(rf"\g<lead>{_REDACTED}", text) if password_pattern is not None else text
return _secret_shape_redactor()(result)
def _redacted_command(command: object) -> Union[str, tuple[str, ...], list[str]]:
if isinstance(command, tuple):
return tuple(_redact_credentials(str(argument)) for argument in command)
if isinstance(command, list):
return [_redact_credentials(str(argument)) for argument in command]
return _redact_credentials(str(command))
def _redact_command_error(error: subprocess.CalledProcessError) -> str:
redacted_command: Final = _redacted_command(error.cmd)
return str(subprocess.CalledProcessError(error.returncode, redacted_command))
def _get_prisma_command() -> str:
"""Get the Prisma command to use, bypassing Python wrapper in offline mode."""
if str_to_bool(os.getenv("PRISMA_OFFLINE_MODE")):
@ -315,7 +377,8 @@ class ProxyExtrasDBManager:
return False
except subprocess.CalledProcessError as e:
logger.warning(
f"Error creating baseline migration: {e}, {e.stderr}, {e.stdout}"
f"Error creating baseline migration: {_redact_command_error(e)}, "
f"{_redact_credentials(str(e.stderr))}, {_redact_credentials(str(e.stdout))}"
)
raise e
@ -1572,6 +1635,11 @@ class ProxyExtrasDBManager:
f"Error: {stderr}"
)
raise
else:
logger.error(
"prisma migrate deploy failed with an error the resolver does not handle: "
f"{_redact_credentials(stderr)}"
)
else:
if ProxyExtrasDBManager.spend_logs_is_partitioned():
raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR)
@ -1586,7 +1654,7 @@ class ProxyExtrasDBManager:
)
return True
except subprocess.TimeoutExpired:
logger.warning(
logger.error(
"Attempt %s timed out. Raise %s if this database needs longer to apply its schema.",
attempt + 1,
PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR if use_migrate else PRISMA_COMMAND_TIMEOUT_ENV_VAR,
@ -1599,7 +1667,12 @@ class ProxyExtrasDBManager:
if attempts_left > 0
else ""
)
logger.info(f"The process failed to execute. Details: {e}.{retry_msg}")
stderr_detail: Final = (
f" stderr: {_redact_credentials(str(e.stderr))}" if e.stderr else ""
)
logger.error(
f"The process failed to execute. Details: {_redact_command_error(e)}.{stderr_detail}{retry_msg}"
)
time.sleep(random.randrange(5, 15))
finally:
os.chdir(original_dir)

View file

@ -1474,6 +1474,7 @@ from .rust_bridge import rust
from .rag.main import *
from .sandbox.main import *
from .decisions.main import *
from .tool_loop import ToolLoopMaxRoundsExceeded, arun_tool_loop, run_tool_loop
from .search.main import *
from .realtime_api.main import (
_arealtime,

View file

@ -2235,3 +2235,5 @@ HARNESS_SNAPSHOT_SKIP_DIRS: Final = frozenset(
".ruff_cache",
}
)
DEFAULT_TOOL_LOOP_MAX_ROUNDS: Final = 20

172
litellm/tool_loop.py Normal file
View file

@ -0,0 +1,172 @@
"""Client-side tool-calling loop helpers for litellm.completion."""
from collections.abc import Awaitable, Callable, Sequence
from typing import TypeAlias, cast
from typing_extensions import TypedDict, Unpack
from litellm.constants import DEFAULT_TOOL_LOOP_MAX_ROUNDS
from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionAssistantMessage,
ChatCompletionAssistantToolCall,
ChatCompletionToolCallFunctionChunk,
ChatCompletionToolMessage,
ChatCompletionToolParam,
)
from litellm.types.utils import ChatCompletionMessageToolCall, Message, ModelResponse
ToolExecutor: TypeAlias = Callable[[ChatCompletionMessageToolCall], ChatCompletionToolMessage]
AsyncToolExecutor: TypeAlias = Callable[[ChatCompletionMessageToolCall], Awaitable[ChatCompletionToolMessage]]
class _ToolLoopCompletionKwargs(TypedDict, total=False, extra_items=object):
"""Extra keywords forwarded verbatim to ``litellm.completion``, which owns their contract."""
class ToolLoopMaxRoundsExceeded(RuntimeError):
max_rounds: int
def __init__(self, max_rounds: int) -> None:
self.max_rounds = max_rounds
super().__init__(f"model still requested tool calls on round {max_rounds} of {max_rounds}")
def _validate_tool_loop_args(max_rounds: int, completion_kwargs: _ToolLoopCompletionKwargs) -> None:
if max_rounds < 1:
raise ValueError(f"max_rounds must be >= 1, got {max_rounds}")
if completion_kwargs.get("stream"):
raise ValueError("run_tool_loop requires whole responses; stream=True is not supported")
def _expect_model_response(response: object) -> ModelResponse:
if not isinstance(response, ModelResponse):
raise TypeError(f"run_tool_loop requires completion to return a ModelResponse, got {type(response).__name__}")
return response
def _assistant_tool_call(tool_call: ChatCompletionMessageToolCall) -> ChatCompletionAssistantToolCall:
return ChatCompletionAssistantToolCall(
id=tool_call.id,
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=tool_call.function.name, arguments=tool_call.function.arguments
),
)
def _assistant_message(
message: Message, tool_calls: tuple[ChatCompletionMessageToolCall, ...]
) -> ChatCompletionAssistantMessage:
return cast( # cast-ok: dict literal with thinking_blocks and reasoning_items spread in only when set
"ChatCompletionAssistantMessage",
{
"role": "assistant",
"content": message.content,
"tool_calls": [_assistant_tool_call(tc) for tc in tool_calls],
**{
key: value
for key, value in (
("thinking_blocks", getattr(message, "thinking_blocks", None)),
("reasoning_items", getattr(message, "reasoning_items", None)),
)
if value is not None
},
},
)
def _function_tool_call(tool_call: object) -> ChatCompletionMessageToolCall:
if not isinstance(tool_call, ChatCompletionMessageToolCall):
raise TypeError(
f"run_tool_loop only executes function tool calls, got custom tool call {getattr(tool_call, 'id', None)}"
)
return tool_call
def _function_tool_calls(message: Message) -> tuple[ChatCompletionMessageToolCall, ...]:
return tuple(_function_tool_call(tool_call) for tool_call in message.tool_calls or ())
def run_tool_loop(
*,
model: str,
messages: Sequence[AllMessageValues],
tools: Sequence[ChatCompletionToolParam],
execute_tool: ToolExecutor,
max_rounds: int = DEFAULT_TOOL_LOOP_MAX_ROUNDS,
**completion_kwargs: Unpack[_ToolLoopCompletionKwargs], # kwargs-ok: forwarded verbatim to litellm.completion
) -> str | None:
"""Call completion, execute each requested tool, and repeat until the model answers.
Returns the final assistant message content. Raises ToolLoopMaxRoundsExceeded when the
model is still requesting tools after max_rounds completions.
Example:
execute = functools.partial(run_repo_tool, repository="litellm", revision="main")
answer = litellm.run_tool_loop(
model="anthropic/claude-sonnet-5-5", messages=messages, tools=tools, execute_tool=execute
)
"""
import litellm
_validate_tool_loop_args(max_rounds, completion_kwargs)
history: tuple[AllMessageValues, ...] = tuple(messages) # rebind-ok: rounds append new turns
for round_number in range(1, max_rounds + 1):
message = (
_expect_model_response(
litellm.completion(model=model, messages=list(history), tools=list(tools), **completion_kwargs)
)
.choices[0]
.message
)
if not message.tool_calls:
return message.content
tool_calls = _function_tool_calls(message)
if round_number == max_rounds:
break
tool_results = tuple(execute_tool(tool_call) for tool_call in tool_calls)
history = (*history, _assistant_message(message, tool_calls), *tool_results)
raise ToolLoopMaxRoundsExceeded(max_rounds)
async def arun_tool_loop(
*,
model: str,
messages: Sequence[AllMessageValues],
tools: Sequence[ChatCompletionToolParam],
execute_tool: AsyncToolExecutor,
max_rounds: int = DEFAULT_TOOL_LOOP_MAX_ROUNDS,
**completion_kwargs: Unpack[_ToolLoopCompletionKwargs], # kwargs-ok: forwarded verbatim to litellm.acompletion
) -> str | None:
"""Async version of run_tool_loop, awaiting each execute_tool call in order.
Returns the final assistant message content. Raises ToolLoopMaxRoundsExceeded when the
model is still requesting tools after max_rounds completions.
Example:
execute = functools.partial(arun_repo_tool, repository="litellm", revision="main")
answer = await litellm.arun_tool_loop(
model="anthropic/claude-sonnet-5-5", messages=messages, tools=tools, execute_tool=execute
)
"""
import litellm
_validate_tool_loop_args(max_rounds, completion_kwargs)
history: tuple[AllMessageValues, ...] = tuple(messages) # rebind-ok: rounds append new turns
for round_number in range(1, max_rounds + 1):
message = (
_expect_model_response(
await litellm.acompletion(model=model, messages=list(history), tools=list(tools), **completion_kwargs)
)
.choices[0]
.message
)
if not message.tool_calls:
return message.content
tool_calls = _function_tool_calls(message)
if round_number == max_rounds:
break
tool_results = tuple([await execute_tool(tool_call) for tool_call in tool_calls])
history = (*history, _assistant_message(message, tool_calls), *tool_results)
raise ToolLoopMaxRoundsExceeded(max_rounds)

View file

@ -34,6 +34,7 @@ dependencies = [
"pydantic-settings>=2.14.1,<3.0",
"jsonschema>=4.0.0,<5.0",
"boto3>=1.43.1,<2.0",
"typing-extensions>=4.13.0,<5.0",
]
[project.urls]

View file

@ -177,3 +177,4 @@ hypothesis: >=6.165.10 # MPL 2.0 license
pytest-rerunfailures: >=15.1 # MPL 2.0 license
pytest-recording: >=0.13.4 # MIT license
expression: >=5.6.0 # MIT License - https://github.com/cognitedata/Expression/blob/main/LICENSE
typing-extensions: >=4.13.0 # PSF-2.0 license - https://github.com/python/typing_extensions/blob/main/LICENSE

View file

@ -1,6 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
worker_image() {
env -u LENS_WORKER_IMAGE -u LITELLM_VERSION \
LITELLM_URL=http://litellm:4000 LENS_WORKER_TOKEN=config-test "$@" \
docker compose --env-file /dev/null -f deploy/lens/compose.yaml config --images
}
[[ "$(worker_image LENS_WORKER_IMAGE=registry.example/lens:source)" == registry.example/lens:source ]]
[[ "$(worker_image LITELLM_VERSION=1.2.3)" == ghcr.io/berriai/litellm-lens-worker:v1.2.3 ]]
[[ "$(worker_image LENS_WORKER_IMAGE=registry.example/lens:source LITELLM_VERSION=1.2.3)" == registry.example/lens:source ]]
if worker_image > /dev/null 2>&1; then
printf 'Worker Compose accepted neither an image nor a release version\n' >&2
exit 1
fi
qa_dir=$(mktemp -d)
master_key="sk-$(openssl rand -hex 32)"
compose=(docker compose -p lens-compose-ci --env-file "$qa_dir/env" -f deploy/lens/stack.yaml)

View file

@ -99,7 +99,7 @@ export const MIGRATED_E2E_PAGES: Readonly<Record<string, MigratedPage>> = {
group: "Settings",
content: { role: "heading", name: "UI Theme Customization" },
},
logs: { segment: "logs", linkName: "Logs", content: { role: "heading", name: "Request Logs" } },
logs: { segment: "logs", linkName: "Logs", content: { role: "tab", name: "Request Logs" } },
"admin-panel": {
segment: "admin-panel",
linkName: "Admin Settings",

View file

@ -1,3 +1,5 @@
from __future__ import annotations
import json
import re
import threading

View file

@ -0,0 +1,558 @@
from __future__ import annotations
import json
import os
import re
import signal
import socket
import socketserver
import subprocess
import sys
import threading
import uuid
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager, suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Literal, cast
from urllib.parse import quote, urlsplit, urlunsplit
import psycopg
import pytest
from psycopg import sql
from psycopg.types.json import Jsonb
from tests.integration._support.client import eventually
from tests.integration._support.process import _free_port as free_port
REPO_ROOT: Final = Path(__file__).resolve().parents[3]
MIGRATIONS_DIR: Final = REPO_ROOT / "litellm-proxy-extras" / "litellm_proxy_extras" / "migrations"
MIGRATION_NAME: Final = "20260921190000_agent_identity"
pytestmark: Final = pytest.mark.timeout(300)
PASSWORD: Final = "wr ong'pw9"
FRAGMENTS: Final = ("wr ong", "wr+ong", "ong'pw9", "wr%20ong", "ong%27pw9", "pw9")
SHIPPED_MIGRATIONS: Final = tuple(
sorted(path.name for path in MIGRATIONS_DIR.iterdir() if path.is_dir() and path.name != "0_init")
)
LOG_PREFIX: Final = r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} - [^\n]+ - "
LOG_RECORD_START: Final = rf"{LOG_PREFIX}(?:DEBUG|INFO|WARNING|ERROR|CRITICAL) - "
ERROR_RECORD: Final = re.compile(rf"(?ms)^({LOG_PREFIX}ERROR - .*?)(?={LOG_RECORD_START}|\Z)")
RETRY_COUNT: Final = re.compile(r"Retrying\.\.\. \((\d+) attempts left\)")
FRAGMENT_PATTERN: Final = re.compile(
"|".join(re.escape(fragment) for fragment in sorted(FRAGMENTS, key=len, reverse=True))
)
@dataclass(frozen=True, slots=True)
class MigrationResult:
returncode: int
output: str
def _migration_log_path(test_name: str, tmp_path: Path) -> Path:
log_directory: Final = (
Path(os.environ["INTEGRATION_RESULTS_DIR"]) if "INTEGRATION_RESULTS_DIR" in os.environ else tmp_path
)
log_path: Final = log_directory / f"v1-migration-{test_name}-{uuid.uuid4().hex[:8]}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
return log_path
def _database_url(
admin_url: str,
role: str,
password: str,
database: str,
*,
encode_password: bool = True,
) -> str:
parsed: Final = urlsplit(admin_url)
authority: Final = parsed.netloc.rsplit("@", 1)[-1]
encoded_password: Final = quote(password, safe="") if encode_password else password
netloc: Final = f"{quote(role, safe='')}:{encoded_password}@{authority}"
return urlunsplit(parsed._replace(netloc=netloc, path=f"/{database}"))
def _replace_port(database_url: str, port: int, hostname: str | None = None) -> str:
parsed: Final = urlsplit(database_url)
target_host: Final = hostname or parsed.hostname
assert target_host is not None
host: Final = f"[{target_host}]" if ":" in target_host else target_host
userinfo: Final = parsed.netloc.rsplit("@", 1)[0]
return urlunsplit(parsed._replace(netloc=f"{userinfo}@{host}:{port}"))
def _unreachable_database_url(database_url: str) -> str:
parsed: Final = urlsplit(database_url)
url: Final = _database_url(
database_url,
parsed.username or "",
PASSWORD,
parsed.path.lstrip("/"),
encode_password=False,
)
return _replace_port(url, free_port())
@contextmanager
def owned_database(password: str) -> Iterator[str]:
admin_url: Final = os.environ["DATABASE_URL"]
role: Final = f"v1_migration_{uuid.uuid4().hex}"
database: Final = f"v1_migration_{uuid.uuid4().hex}"
database_url: Final = _database_url(admin_url, role, password, database)
try:
with psycopg.connect(admin_url, autocommit=True) as admin:
admin.execute(
sql.SQL("CREATE ROLE {} WITH LOGIN PASSWORD {}").format(sql.Identifier(role), sql.Literal(password))
)
admin.execute(sql.SQL("CREATE DATABASE {} OWNER {}").format(sql.Identifier(database), sql.Identifier(role)))
yield database_url
finally:
with psycopg.connect(admin_url, autocommit=True) as admin:
admin.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(database)))
admin.execute(sql.SQL("DROP ROLE IF EXISTS {}").format(sql.Identifier(role)))
def _migration_environment(database_url: str | None, extra_env: Mapping[str, str]) -> dict[str, str]:
excluded_variables: Final = (
("DIRECT_URL", "USE_V2_MIGRATION_RESOLVER")
if database_url is not None
else ("DATABASE_URL", "DIRECT_URL", "USE_V2_MIGRATION_RESOLVER")
)
inherited_environment: Final = {key: value for key, value in os.environ.items() if key not in excluded_variables}
database_environment: Final = {"DATABASE_URL": database_url} if database_url is not None else {}
return {
**inherited_environment,
**database_environment,
"LITELLM_LOG": "ERROR",
**extra_env,
}
def _migration_invocation(
database_url: str | None,
tmp_path: Path,
extra_env: Mapping[str, str],
resolver: Literal["legacy", "v2"] = "legacy",
) -> tuple[tuple[str, ...], dict[str, str]]:
config_path: Final = tmp_path / "config.yaml"
config_path.write_text(
"model_list:\n - model_name: integration-fake\n litellm_params:\n model: openai/integration-fake\n"
)
resolver_flag: Final = "--use_legacy_migration_resolver" if resolver == "legacy" else "--use_v2_migration_resolver"
command: Final = (
sys.executable,
"-I",
"-m",
"litellm.proxy.proxy_cli",
"--config",
str(config_path),
resolver_flag,
"--skip_server_startup",
)
environment: Final = _migration_environment(database_url, extra_env)
return command, environment
def run_v1_migrations(
database_url: str | None,
tmp_path: Path,
extra_env: Mapping[str, str],
test_name: str,
resolver: Literal["legacy", "v2"] = "legacy",
) -> MigrationResult:
command, environment = _migration_invocation(database_url, tmp_path, extra_env, resolver)
output_path: Final = _migration_log_path(test_name, tmp_path)
with output_path.open("w") as output_file:
completed: Final = subprocess.run(
command,
cwd=REPO_ROOT,
env=environment,
stdout=output_file,
stderr=subprocess.STDOUT,
text=True,
timeout=240,
)
output: Final = output_path.read_text()
return MigrationResult(completed.returncode, output)
def error_lines(output: str) -> tuple[str, ...]:
return tuple(match.group(1) for match in ERROR_RECORD.finditer(output))
def _json_error_records(output: str) -> tuple[dict[str, object], ...]:
records: Final = tuple(_parse_json_record(line, output) for line in output.splitlines() if line.startswith("{"))
return tuple(record for record in records if record.get("level") == "ERROR")
def _parse_json_record(line: str, output: str) -> dict[str, object]:
try:
record: Final = json.loads(line)
except json.JSONDecodeError:
pytest.fail(_safe_output(output))
assert isinstance(record, dict), _safe_output(output)
return cast(dict[str, object], record)
def _retry_count_texts(lines: tuple[str, ...]) -> tuple[str, ...]:
return tuple(value for value in (_retry_count_text(line) for line in lines) if value is not None)
def _retry_count_text(line: str) -> str | None:
match: Final = RETRY_COUNT.search(line)
return match.group(1) if match is not None else None
def _safe_output(output: str) -> str:
return FRAGMENT_PATTERN.sub("[REDACTED]", output)
def _assert_no_password_fragments(output: str) -> None:
assert [fragment for fragment in FRAGMENTS if fragment in output] == [], _safe_output(output)
def _applied_migrations(database_url: str) -> tuple[str, ...]:
with psycopg.connect(database_url) as connection:
rows: Final = connection.execute(
'SELECT migration_name FROM "_prisma_migrations" '
"WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL ORDER BY migration_name"
).fetchall()
return tuple(str(row[0]) for row in rows)
def _duplicate_migrations(database_url: str) -> tuple[tuple[str, int], ...]:
with psycopg.connect(database_url) as connection:
rows: Final = connection.execute(
'SELECT migration_name, COUNT(*) FROM "_prisma_migrations" '
"GROUP BY migration_name HAVING COUNT(*) > 1 ORDER BY migration_name"
).fetchall()
return tuple((str(row[0]), int(row[1])) for row in rows)
def _retry_p3018_migration(database_url: str) -> None:
agent_ids: Final = (uuid.uuid4().hex, uuid.uuid4().hex)
with psycopg.connect(database_url) as connection:
connection.execute('DROP INDEX "LiteLLM_AgentIdentity_provider_tenant_id_client_id_key"')
for agent_id in agent_ids:
connection.execute(
'INSERT INTO "LiteLLM_AgentsTable" '
'("agent_id", "agent_name", "agent_card_params", "created_by", "updated_by") '
"VALUES (%s, %s, %s, %s, %s)",
(agent_id, f"audit-agent-{agent_id}", Jsonb({}), "integration", "integration"),
)
connection.execute(
'INSERT INTO "LiteLLM_AgentIdentity" '
'("agent_id", "provider", "issuer", "tenant_id", "client_id", "revision") '
"VALUES (%s, %s, %s, %s, %s, %s)",
(agent_id, "entra", f"https://audit.invalid/{agent_id}", "tenant-1", "client-1", uuid.uuid4().hex),
)
connection.execute('DELETE FROM "_prisma_migrations" WHERE migration_name = %s', (MIGRATION_NAME,))
def _relay(source: socket.socket, destination: socket.socket) -> None:
try:
while data := source.recv(65536):
destination.sendall(data)
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
return
class _PostgresForwardingServer(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
request_queue_size = 64
target: tuple[str, int]
def __init__(self, port: int, target: tuple[str, int]) -> None:
self.target = target
super().__init__(("127.0.0.1", port), _PostgresForwardingHandler)
class _PostgresForwardingHandler(socketserver.BaseRequestHandler):
def handle(self) -> None:
server: Final = cast(_PostgresForwardingServer, self.server)
with socket.create_connection(server.target, timeout=10) as upstream:
reply: Final = threading.Thread(target=_relay, args=(upstream, self.request), daemon=True)
reply.start()
try:
_relay(self.request, upstream)
finally:
with suppress(OSError):
self.request.shutdown(socket.SHUT_WR)
with suppress(OSError):
upstream.shutdown(socket.SHUT_WR)
reply.join(timeout=10)
@contextmanager
def _gated_postgres_forwarder(port: int, target: tuple[str, int]) -> Iterator[Callable[[], None]]:
server: Final = _PostgresForwardingServer(port, target)
thread: Final = threading.Thread(target=server.serve_forever, daemon=True)
try:
yield thread.start
finally:
if thread.is_alive():
server.shutdown()
server.server_close()
if thread.ident is not None:
thread.join(timeout=10)
def _stop_process(process: subprocess.Popen[str]) -> None:
if process.poll() is None:
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=30)
except subprocess.TimeoutExpired:
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=30)
def _migration_log_has_p1001_or_process_exited(output_path: Path, process: subprocess.Popen[str]) -> tuple[bool, bool]:
p1001_logged: Final = any("P1001" in line for line in error_lines(output_path.read_text()))
process_exited: Final = process.poll() is not None
return p1001_logged, process_exited
def test_unreachable_database_emits_four_p1001_errors_without_password_fragments(
tmp_path: Path, request: pytest.FixtureRequest
) -> None:
with owned_database(PASSWORD) as database_url:
unreachable_url: Final = _unreachable_database_url(database_url)
completed: Final = run_v1_migrations(unreachable_url, tmp_path, {}, request.node.name)
output: Final = completed.output
errors: Final = error_lines(output)
p1001_errors: Final = tuple(line for line in errors if "P1001" in line)
assert completed.returncode == 1, _safe_output(output)
assert len(errors) == 4, _safe_output(output)
assert len(p1001_errors) == 4, _safe_output(output)
assert _retry_count_texts(errors) == (), _safe_output(output)
_assert_no_password_fragments(output)
def test_wrong_password_emits_four_p1000_errors_without_password_fragments(
tmp_path: Path, request: pytest.FixtureRequest
) -> None:
with owned_database(f"correct-{uuid.uuid4().hex}") as database_url:
parsed: Final = urlsplit(database_url)
wrong_url: Final = _database_url(
database_url,
parsed.username or "",
PASSWORD,
parsed.path.lstrip("/"),
)
completed: Final = run_v1_migrations(wrong_url, tmp_path, {}, request.node.name)
output: Final = completed.output
errors: Final = error_lines(output)
p1000_errors: Final = tuple(line for line in errors if "P1000" in line)
assert completed.returncode == 1, _safe_output(output)
assert len(errors) == 4, _safe_output(output)
assert len(p1000_errors) == 4, _safe_output(output)
assert _retry_count_texts(errors) == (), _safe_output(output)
_assert_no_password_fragments(output)
def test_duplicate_agent_identity_logs_the_p3018_migration_error(
tmp_path: Path, request: pytest.FixtureRequest
) -> None:
with owned_database(PASSWORD) as database_url:
setup: Final = run_v1_migrations(database_url, tmp_path, {}, request.node.name)
setup_output: Final = setup.output
assert setup.returncode == 0, _safe_output(setup_output)
_retry_p3018_migration(database_url)
completed: Final = run_v1_migrations(database_url, tmp_path, {}, request.node.name)
output: Final = completed.output
errors: Final = error_lines(output)
p3018_errors: Final = tuple(line for line in errors if "P3018" in line)
expected_markers: Final = ((True, True), (True, True))
assert completed.returncode == 1, _safe_output(output)
assert len(errors) == 2, _safe_output(output)
assert tuple((MIGRATION_NAME in line, "P3018" in line) for line in p3018_errors) == expected_markers, (
_safe_output(output)
)
assert _retry_count_texts(errors) == ("3", "1"), _safe_output(output)
_assert_no_password_fragments(output)
def test_clean_database_applies_exactly_the_shipped_migrations(tmp_path: Path, request: pytest.FixtureRequest) -> None:
with owned_database(PASSWORD) as database_url:
completed: Final = run_v1_migrations(database_url, tmp_path, {}, request.node.name)
output: Final = completed.output
assert completed.returncode == 0, _safe_output(output)
assert error_lines(output) == (), _safe_output(output)
assert _applied_migrations(database_url) == SHIPPED_MIGRATIONS, _safe_output(output)
_assert_no_password_fragments(output)
def test_unreachable_database_recovers_after_postgres_forwarder_starts(
tmp_path: Path, request: pytest.FixtureRequest
) -> None:
with owned_database(PASSWORD) as database_url:
admin_url: Final = os.environ["DATABASE_URL"]
admin: Final = urlsplit(admin_url)
hostname: Final = admin.hostname
port: Final = admin.port
assert hostname is not None and port is not None
target_host: Final = "127.0.0.1" if hostname == "localhost" else hostname
forwarding_port: Final = free_port()
forwarded_url: Final = _replace_port(database_url, forwarding_port, "127.0.0.1")
command, environment = _migration_invocation(forwarded_url, tmp_path, {})
output_path: Final = _migration_log_path(request.node.name, tmp_path)
with _gated_postgres_forwarder(forwarding_port, (target_host, port)) as open_forwarder:
with output_path.open("w") as output_file:
process: Final = subprocess.Popen(
command,
cwd=REPO_ROOT,
env=environment,
stdout=output_file,
stderr=subprocess.STDOUT,
text=True,
start_new_session=True,
)
try:
observation: Final = eventually(
lambda: _migration_log_has_p1001_or_process_exited(output_path, process),
lambda state: state[0] or state[1],
seconds=240,
)
assert observation[0], _safe_output(output_path.read_text())
open_forwarder()
completed_returncode: Final = process.wait(timeout=240)
finally:
_stop_process(process)
output: Final = output_path.read_text()
errors: Final = error_lines(output)
p1001_errors: Final = tuple(line for line in errors if "P1001" in line)
assert completed_returncode == 0, _safe_output(output)
assert len(p1001_errors) == 1, _safe_output(output)
assert _retry_count_texts(errors) == (), _safe_output(output)
assert _duplicate_migrations(database_url) == (), _safe_output(output)
assert _applied_migrations(database_url) == SHIPPED_MIGRATIONS, _safe_output(output)
_assert_no_password_fragments(output)
def test_unreachable_database_keeps_password_masked_when_shape_redaction_is_disabled(
tmp_path: Path, request: pytest.FixtureRequest
) -> None:
with owned_database(PASSWORD) as database_url:
unreachable_url: Final = _unreachable_database_url(database_url)
completed: Final = run_v1_migrations(
unreachable_url,
tmp_path,
{"LITELLM_DISABLE_REDACT_SECRETS": "true"},
request.node.name,
)
output: Final = completed.output
errors: Final = error_lines(output)
p1001_errors: Final = tuple(line for line in errors if "P1001" in line)
assert completed.returncode == 1, _safe_output(output)
assert len(errors) == 4, _safe_output(output)
assert len(p1001_errors) == 4, _safe_output(output)
assert _retry_count_texts(errors) == (), _safe_output(output)
_assert_no_password_fragments(output)
def test_component_database_env_vars_with_wrong_password_emit_four_p1000_errors_without_password_fragments(
tmp_path: Path, request: pytest.FixtureRequest
) -> None:
correct_password: Final = f"correct-{uuid.uuid4().hex}"
with owned_database(correct_password) as database_url:
parsed: Final = urlsplit(database_url)
host: Final = parsed.hostname
port: Final = parsed.port
username: Final = parsed.username
assert host is not None and port is not None and username is not None
extra_env: Final = {
"DATABASE_HOST": f"{host}:{port}",
"DATABASE_USERNAME": username,
"DATABASE_PASSWORD": PASSWORD,
"DATABASE_NAME": parsed.path.lstrip("/"),
}
completed: Final = run_v1_migrations(None, tmp_path, extra_env, request.node.name)
output: Final = completed.output
errors: Final = error_lines(output)
p1000_errors: Final = tuple(line for line in errors if "P1000" in line)
assert completed.returncode == 1, _safe_output(output)
assert len(errors) == 4, _safe_output(output)
assert len(p1000_errors) == 4, _safe_output(output)
assert _retry_count_texts(errors) == (), _safe_output(output)
_assert_no_password_fragments(output)
def test_json_logs_emit_four_valid_json_p1001_error_records_without_password_fragments(
tmp_path: Path, request: pytest.FixtureRequest
) -> None:
with owned_database(PASSWORD) as database_url:
unreachable_url: Final = _unreachable_database_url(database_url)
completed: Final = run_v1_migrations(unreachable_url, tmp_path, {"JSON_LOGS": "true"}, request.node.name)
output: Final = completed.output
errors: Final = _json_error_records(output)
messages: Final = tuple(record.get("message") for record in errors)
assert completed.returncode == 1, _safe_output(output)
assert len(errors) == 4, _safe_output(output)
assert tuple(isinstance(message, str) and "P1001" in message for message in messages) == (
True,
True,
True,
True,
), _safe_output(output)
assert error_lines(output) == (), _safe_output(output)
_assert_no_password_fragments(output)
def test_migration_job_entrypoint_emits_four_p1001_errors_without_password_fragments(
tmp_path: Path, request: pytest.FixtureRequest
) -> None:
with owned_database(PASSWORD) as database_url:
unreachable_url: Final = _unreachable_database_url(database_url)
command: Final = (sys.executable, "-I", "-m", "litellm.proxy.prisma_migration")
environment: Final = _migration_environment(
unreachable_url,
{"USE_V2_MIGRATION_RESOLVER": "false"},
)
output_path: Final = _migration_log_path(request.node.name, tmp_path)
with output_path.open("w") as output_file:
completed: Final = subprocess.run(
command,
cwd=REPO_ROOT,
env=environment,
stdout=output_file,
stderr=subprocess.STDOUT,
text=True,
timeout=240,
)
output: Final = output_path.read_text()
errors: Final = error_lines(output)
p1001_errors: Final = tuple(line for line in errors if "P1001" in line)
assert completed.returncode == 1, _safe_output(output)
assert len(errors) == 4, _safe_output(output)
assert len(p1001_errors) == 4, _safe_output(output)
_assert_no_password_fragments(output)
def test_v2_resolver_unreachable_database_exits_2_and_names_p1001(
tmp_path: Path, request: pytest.FixtureRequest
) -> None:
with owned_database(PASSWORD) as database_url:
unreachable_url: Final = _unreachable_database_url(database_url)
completed: Final = run_v1_migrations(unreachable_url, tmp_path, {}, request.node.name, resolver="v2")
output: Final = completed.output
assert completed.returncode == 2, _safe_output(output)
assert "P1001" in output, _safe_output(output)
assert error_lines(output) == (), _safe_output(output)
_assert_no_password_fragments(output)
def test_v2_resolver_clean_database_applies_exactly_the_shipped_migrations(
tmp_path: Path, request: pytest.FixtureRequest
) -> None:
with owned_database(PASSWORD) as database_url:
completed: Final = run_v1_migrations(database_url, tmp_path, {}, request.node.name, resolver="v2")
output: Final = completed.output
assert completed.returncode == 0, _safe_output(output)
assert error_lines(output) == (), _safe_output(output)
assert _applied_migrations(database_url) == SHIPPED_MIGRATIONS, _safe_output(output)
_assert_no_password_fragments(output)

View file

@ -1,3 +1,5 @@
from __future__ import annotations
import asyncio
import base64
import binascii

View file

@ -1,6 +1,6 @@
import sys
from datetime import datetime
from typing import List, Optional
from typing import Final, List, Optional
import pytest
from litellm._uuid import uuid
import os
@ -391,6 +391,7 @@ async def test_create_mcp_server_invalid_alias():
@_SKIP_NO_MCP
@pytest.mark.asyncio
async def test_edit_mcp_server_redacts_credentials():
mock_get_server: Final = mock.AsyncMock()
with (
mock.patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.MCP_AVAILABLE",
@ -399,6 +400,10 @@ async def test_edit_mcp_server_redacts_credentials():
mock.patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw"
) as mock_get_prisma,
mock.patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
new=mock_get_server,
),
mock.patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.update_mcp_server",
new_callable=mock.AsyncMock,
@ -422,6 +427,18 @@ async def test_edit_mcp_server_redacts_credentials():
mock_manager.reload_servers_from_database = mock.AsyncMock()
server_id = str(uuid.uuid4())
stored_server: Final = LiteLLM_MCPServerTable(
server_id=server_id,
alias="Updated Server",
url="https://updated.example.com/mcp",
transport=MCPTransport.http,
created_at=datetime.now(),
updated_at=datetime.now(),
credentials={"auth_value": "secret"},
teams=[],
)
mock_get_server.return_value = stored_server
updated_server = LiteLLM_MCPServerTable(
server_id=server_id,
alias="Updated Server",
@ -458,6 +475,7 @@ async def test_edit_mcp_server_redacts_credentials():
mock_update.assert_awaited_once()
mock_manager.update_server.assert_called_once_with(updated_server)
mock_manager.reload_servers_from_database.assert_awaited_once()
mock_get_server.assert_awaited_once_with(mock_prisma, server_id)
def test_validate_mcp_server_name_direct():

View file

@ -1,3 +1,4 @@
import importlib
import os
import socket
import sys
@ -12,10 +13,10 @@ import pytest
from uvicorn.importer import import_from_string
from uvicorn.main import main as uvicorn_main
import gateway.main
from gateway.launch import GATEWAY_APP, main, pool_database_url, uvicorn_argv
from litellm.proxy.db.db_url_settings import DatabaseURLSettings
from litellm.proxy.db.pgbouncer import PGBOUNCER_POOLED_ENV_VAR, PgBouncerError, PgBouncerSettings
from litellm.proxy.proxy_server import app as proxy_app
DB_ENV: Final = {
"DATABASE_HOST": "db.internal",
@ -107,8 +108,22 @@ class TestUvicornArgv:
argv: Final = uvicorn_argv(("--timeout-keep-alive", "30"), {"KEEPALIVE_TIMEOUT": "75"})
assert _uvicorn_params(argv)["timeout_keep_alive"] == 30
def test_the_app_uvicorn_is_told_to_serve_is_the_trimmed_gateway(self):
assert import_from_string(cast(str, _uvicorn_params(uvicorn_argv((), {}))["app"])) is gateway.main.app
def test_the_app_uvicorn_is_told_to_serve_is_the_trimmed_gateway(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_app.router, "lifespan_context", proxy_app.router.lifespan_context)
for key in (
"DATABASE_URL",
"DIRECT_URL",
"DATABASE_URL_READ_REPLICA",
"DATABASE_HOST",
"DATABASE_HOST_READ_REPLICA",
"DATABASE_PASSWORD",
"IAM_TOKEN_DB_AUTH",
"AZURE_POSTGRESQL_AUTH",
):
monkeypatch.delenv(key, raising=False)
served: Final = import_from_string(cast(str, _uvicorn_params(uvicorn_argv((), {}))["app"]))
assert served is importlib.import_module("gateway.main").app
class TestPoolDatabaseUrl:

View file

@ -1,11 +1,13 @@
import glob
import logging
import os
import re
import subprocess
import sys
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Final
from typing import Final, NoReturn, Optional
import pytest
@ -19,6 +21,8 @@ sys.path.insert(
from litellm_proxy_extras.utils import (
PARTITIONED_SPEND_LOGS_PUSH_ERROR,
ProxyExtrasDBManager,
_redact_command_error,
_redact_credentials,
filter_partitioned_spend_logs_diff,
)
@ -1412,3 +1416,411 @@ class TestMigrationJobOwnedDrift:
assert 'PRIMARY KEY ("request_id")' not in filtered
assert "LiteLLM_SpendLogs_legacy" not in filtered
assert 'ALTER TABLE "LiteLLM_BudgetTable" ADD COLUMN "updated_by" TEXT;' in filtered
_P3018_UNCLASSIFIED_STDERR: Final = (
"Error: P3018\n\n"
"A migration failed to apply. New migrations cannot be applied before the error is "
"recovered from.\n\n"
"Migration name: 20260921190000_agent_identity\n\n"
"Database error code: 23505\n\n"
"Database error:\n"
'ERROR: could not create unique index "agent_identity_key"\n'
"DETAIL: Key (agent_id)=(agent-1) is duplicated.\n"
)
_FAKE_PRISMA_PID: Final = 424242
class TestV1MigrationFailuresLogAtError:
@staticmethod
def _run_v1_migrations(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
*,
deploy_stderr: Optional[str] = None,
deploy_timeout: bool = False,
diff_stderr: Optional[str] = None,
database_url: Optional[str] = None,
) -> tuple[bool, list[list[str]], list[int]]:
import litellm_proxy_extras.utils as utils_module
calls: Final[list[list[str]]] = []
killed_pids: Final[list[int]] = []
class _FakePrismaPopen:
def __init__(
self,
argv: tuple[str, ...],
*,
env: Optional[dict[str, str]] = None,
stdout: object = None,
stderr: object = None,
text: object = None,
start_new_session: object = None,
) -> None:
self.args: Final = argv
self.argv: Final = argv
self.pid: Final = _FAKE_PRISMA_PID
self.returncode: Optional[int] = None
calls.append(list(argv))
def __enter__(self) -> "_FakePrismaPopen":
return self
def __exit__(self, *args: object) -> None:
return None
def _subcommand(self) -> tuple[str, str]:
known: Final = (
("migrate", "deploy"),
("migrate", "diff"),
("migrate", "resolve"),
("db", "execute"),
)
for index in range(len(self.argv) - 1):
pair: Final = tuple(self.argv[index : index + 2])
if pair in known:
return pair
return ("", "")
def communicate(self, timeout: Optional[float] = None) -> tuple[str, str]:
subcommand: Final = self._subcommand()
if subcommand == ("migrate", "deploy"):
if deploy_timeout:
raise subprocess.TimeoutExpired(self.argv, timeout)
if deploy_stderr is not None:
self.returncode = 1
return "", deploy_stderr
self.returncode = 0
return "No pending migrations to apply", ""
if subcommand == ("migrate", "diff") and diff_stderr is not None:
self.returncode = 1
return "", diff_stderr
self.returncode = 0
return "", ""
migration_dir: Final = tmp_path / "migration_dir"
migration_dir.mkdir()
if database_url is None:
monkeypatch.delenv("DATABASE_URL", raising=False)
else:
monkeypatch.setenv("DATABASE_URL", database_url)
monkeypatch.setenv("LITELLM_MIGRATION_DIR", str(migration_dir))
monkeypatch.setattr(
utils_module.prisma_toolchain.subprocess, "Popen", _FakePrismaPopen
)
monkeypatch.setattr(
utils_module.prisma_toolchain.os, "killpg", lambda pid, sig: killed_pids.append(pid)
)
monkeypatch.setattr(utils_module.time, "sleep", lambda seconds: None)
succeeded: Final = ProxyExtrasDBManager._run_migrations(use_migrate=True, use_v2_resolver=False)
return succeeded, calls, killed_pids
@staticmethod
def _deploy_call_count(calls: list[list[str]]) -> int:
return sum(1 for call in calls if tuple(call[-2:]) == ("migrate", "deploy"))
@staticmethod
def _error_messages(caplog: pytest.LogCaptureFixture) -> list[str]:
return [
record.getMessage()
for record in caplog.records
if record.levelno >= logging.ERROR and record.name.startswith("litellm_proxy_extras")
]
def test_an_unrecognized_prisma_error_logs_its_stderr_at_error(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
stderr: Final = "Error: P1001: Can't reach database server at db:5432"
with caplog.at_level(logging.ERROR, logger="litellm_proxy_extras"):
succeeded, calls, _ = self._run_v1_migrations(
monkeypatch, tmp_path, deploy_stderr=stderr
)
assert succeeded is False
assert self._deploy_call_count(calls) == 4
assert any(stderr in message for message in self._error_messages(caplog))
def test_an_unclassified_p3018_logs_its_stderr_and_retry_failure_at_error(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.ERROR, logger="litellm_proxy_extras"):
succeeded, calls, _ = self._run_v1_migrations(
monkeypatch, tmp_path, deploy_stderr=_P3018_UNCLASSIFIED_STDERR
)
assert succeeded is False
assert self._deploy_call_count(calls) == 4
messages: Final = self._error_messages(caplog)
assert any(
"20260921190000_agent_identity" in message and "is duplicated" in message
for message in messages
)
assert any(
"The process failed to execute" in message and "Retrying... (3 attempts left)" in message
for message in messages
)
def test_called_process_error_with_no_command_retries_all_v1_attempts(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
import litellm_proxy_extras.utils as utils_module
migration_dir: Final = tmp_path / "migration_dir"
migration_dir.mkdir()
monkeypatch.setenv("LITELLM_MIGRATION_DIR", str(migration_dir))
monkeypatch.delenv("DATABASE_URL", raising=False)
monkeypatch.delenv("DIRECT_URL", raising=False)
monkeypatch.setenv("PRISMA_OFFLINE_MODE", "true")
monkeypatch.setenv("PRISMA_CLI_PATH", sys.executable)
monkeypatch.setattr(utils_module.time, "sleep", lambda seconds: None)
calls: Final[list[None]] = []
class _FakePrismaPopen:
def __init__(
self,
argv: tuple[str, ...],
*,
env: Optional[dict[str, str]] = None,
stdout: object = None,
stderr: object = None,
text: object = None,
start_new_session: object = None,
) -> None:
self.args: Final = None
self.returncode: Final = 1
calls.append(None)
def __enter__(self) -> "_FakePrismaPopen":
return self
def __exit__(self, *args: object) -> None:
return None
def communicate(self, timeout: Optional[float] = None) -> tuple[str, str]:
return "", "Error: P3018 unclassified"
monkeypatch.setattr(
utils_module.prisma_toolchain.subprocess, "Popen", _FakePrismaPopen
)
try:
succeeded: Final = ProxyExtrasDBManager._run_migrations(
use_migrate=True, use_v2_resolver=False
)
except TypeError as error:
pytest.fail(
f"_run_migrations raised TypeError after {len(calls)} Popen calls: {error}",
pytrace=False,
)
assert succeeded is False
assert len(calls) == 4
def test_a_timeout_logs_at_error_naming_the_migrate_deploy_timeout_env_var(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
from litellm_proxy_extras.prisma_toolchain import PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR
with caplog.at_level(logging.ERROR, logger="litellm_proxy_extras"):
succeeded, calls, killed_pids = self._run_v1_migrations(
monkeypatch, tmp_path, deploy_timeout=True
)
assert succeeded is False
assert self._deploy_call_count(calls) == 4
assert killed_pids == [_FAKE_PRISMA_PID] * 4
assert any(
"timed out" in message and PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR in message
for message in self._error_messages(caplog)
)
def test_a_recovered_baseline_logs_nothing_at_error(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.ERROR, logger="litellm_proxy_extras"):
succeeded, calls, _ = self._run_v1_migrations(
monkeypatch,
tmp_path,
deploy_stderr=_P3005_STDERR,
database_url="postgresql://user:pass@db:5432/litellm",
)
assert succeeded is True
assert tuple(calls[0][-2:]) == ("migrate", "deploy")
assert self._error_messages(caplog) == []
def test_a_failed_baseline_recovery_logs_its_stderr_at_error(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
database_url: Final = "postgresql://llmproxy:s3cr3t 'p\"w@db:5432/litellm"
monkeypatch.delenv("DIRECT_URL", raising=False)
with caplog.at_level(logging.DEBUG, logger="litellm_proxy_extras"):
succeeded, calls, _ = self._run_v1_migrations(
monkeypatch,
tmp_path,
deploy_stderr=_P3005_STDERR,
diff_stderr=f"baseline diff failed: XYZ-7731 for {database_url}",
database_url=database_url,
)
assert succeeded is False
assert self._deploy_call_count(calls) == 4
assert [
record.getMessage()
for record in caplog.records
if "s3cr3t" in record.getMessage() or 'p"w' in record.getMessage()
] == []
messages: Final = self._error_messages(caplog)
assert any("postgresql://REDACTED@db:5432/litellm" in message for message in messages)
assert any("XYZ-7731" in message for message in messages)
@pytest.mark.parametrize(
"database_url,direct_url,text,expected",
(
(
"postgresql://u:pa ss@db:5432/litellm",
None,
'Error: P1000: Authentication failed against database server at "postgresql://u:pa ss@db:5432/litellm"',
'Error: P1000: Authentication failed against database server at "postgresql://REDACTED@db:5432/litellm"',
),
(
"postgresql://u:pa'ss@db:5432/litellm",
None,
"postgresql://u:pa'ss@db:5432/litellm",
"postgresql://REDACTED@db:5432/litellm",
),
(
'postgresql://u:pa"ss@db:5432/litellm',
None,
'postgresql://u:pa"ss@db:5432/litellm',
"postgresql://REDACTED@db:5432/litellm",
),
(
"postgresql://u:p@ss@db:5432/litellm",
None,
"postgresql://u:p@ss@db:5432/litellm",
"postgresql://REDACTED@db:5432/litellm",
),
(
"postgresql://u:p%20ss@db:5432/litellm",
None,
"postgresql://u:p ss@db:5432/litellm",
"postgresql://REDACTED@db:5432/litellm",
),
(
"postgresql://db/litellm?password=a b&sslmode=require",
None,
"postgresql://db/litellm?password=a b&sslmode=require",
"postgresql://db/litellm?REDACTED&sslmode=require",
),
(
"postgresql://db/litellm?sslpassword=zq'7x",
None,
"postgresql://db/litellm?sslpassword=zq'7x",
"postgresql://db/litellm?REDACTED",
),
(
None,
"postgresql://u:pa ss@db:5432/litellm",
"postgresql://u:pa ss@db:5432/litellm",
"postgresql://REDACTED@db:5432/litellm",
),
(
None,
None,
"postgresql://u:pw@db/x",
"postgresql://REDACTED@db/x",
),
(
"postgresql://u:p@db:5432/litellm",
None,
"Error: P1001: Can't reach database server at db:5432",
"Error: P1001: Can't reach database server at db:5432",
),
(
"postgresql://u:p@db:5432/litellm",
None,
"Error:P1001: Can't reach database server at db:5432",
"Error:P1001: Can't reach database server at db:5432",
),
(
None,
None,
"plain text with no URL",
"plain text with no URL",
),
),
)
def test_redact_credentials_masks_passwords_in_embedded_urls(
database_url: str | None,
direct_url: str | None,
text: str,
expected: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
if database_url is None:
monkeypatch.delenv("DATABASE_URL", raising=False)
else:
monkeypatch.setenv("DATABASE_URL", database_url)
if direct_url is None:
monkeypatch.delenv("DIRECT_URL", raising=False)
else:
monkeypatch.setenv("DIRECT_URL", direct_url)
assert _redact_credentials(text) == expected
@pytest.mark.parametrize("password", ("zq'7x", 'zq"7x', "zq'\"7x", "zq 7x", "zq@7x"))
def test_redact_command_error_masks_url_arguments(password: str, monkeypatch: pytest.MonkeyPatch) -> None:
database_url: Final = f"postgresql://u:{password}@db:5432/litellm"
monkeypatch.setenv("DATABASE_URL", database_url)
monkeypatch.delenv("DIRECT_URL", raising=False)
error: Final = subprocess.CalledProcessError(1, ["prisma", "migrate", "diff", "--to-url", database_url])
message: Final = _redact_command_error(error)
assert "zq" not in message
assert "7x" not in message
assert "postgresql://REDACTED@db:5432/litellm" in message
assert "returned non-zero exit status 1" in message
@pytest.mark.parametrize(
"command",
(
None,
Path("/usr/bin/prisma"),
7,
("prisma", "migrate", "deploy"),
["prisma", "migrate", "deploy"],
"prisma migrate deploy",
),
)
def test_redact_command_error_preserves_unredacted_command_format(
command: object, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("DATABASE_URL", raising=False)
monkeypatch.delenv("DIRECT_URL", raising=False)
error: Final = subprocess.CalledProcessError(1, command)
assert _redact_command_error(error) == str(error)
def test_redact_command_error_masks_password_in_tuple_url_argument(
monkeypatch: pytest.MonkeyPatch,
) -> None:
password: Final = "zq 7x"
database_url: Final = f"postgresql://u:{password}@db:5432/litellm"
monkeypatch.setenv("DATABASE_URL", database_url)
monkeypatch.delenv("DIRECT_URL", raising=False)
error: Final = subprocess.CalledProcessError(1, ("prisma", "migrate", "deploy", "--to-url", database_url))
message: Final = _redact_command_error(error)
assert message.startswith("Command '('")
assert password not in message
assert "postgresql://REDACTED@db:5432/litellm" in message

View file

@ -109,25 +109,6 @@ def set_salt_key(monkeypatch):
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-1234")
@pytest.fixture(autouse=True)
def reset_constants_module():
"""Reset constants module to ensure clean state before each test"""
import importlib
from litellm import constants
from litellm.proxy.auth import auth_checks
# Reload modules before test
importlib.reload(constants)
importlib.reload(auth_checks)
yield
# Reload modules after test to clean up
importlib.reload(constants)
importlib.reload(auth_checks)
@pytest.fixture
def valid_sso_user_defined_values():
return LiteLLM_UserTable(
@ -875,19 +856,10 @@ def test_get_cli_jwt_auth_token_default_expiration(valid_sso_user_defined_values
def test_get_cli_jwt_auth_token_custom_expiration(valid_sso_user_defined_values, monkeypatch):
"""Test generating CLI JWT token with custom expiration via environment variable"""
import importlib
from litellm import constants
"""Test generating a CLI JWT token with custom expiration via the configured constant"""
from litellm.proxy.auth import auth_checks
# Set custom expiration to 48 hours
monkeypatch.setenv("LITELLM_CLI_JWT_EXPIRATION_HOURS", "48")
# Reload the constants module to pick up the new env var
importlib.reload(constants)
# Also reload auth_checks to pick up the new constant value
importlib.reload(auth_checks)
monkeypatch.setattr(auth_checks, "CLI_JWT_EXPIRATION_HOURS", 48)
token = auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token(valid_sso_user_defined_values)

View file

@ -7321,15 +7321,10 @@ async def test_expired_cli_session_token_is_rejected(monkeypatch):
on the shared validation path, not only for DB-backed keys."""
monkeypatch.delenv("EXPERIMENTAL_UI_LOGIN", raising=False)
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-cli-test")
monkeypatch.setenv("LITELLM_CLI_JWT_EXPIRATION_HOURS", "-1")
import importlib
from litellm import constants
from litellm.proxy.auth import auth_checks
importlib.reload(constants)
importlib.reload(auth_checks)
monkeypatch.setattr(auth_checks, "CLI_JWT_EXPIRATION_HOURS", -1)
user_info = LiteLLM_UserTable(
user_id="cli-admin",
@ -7346,22 +7341,17 @@ async def test_expired_cli_session_token_is_rejected(monkeypatch):
mock_request.headers = {"authorization": f"Bearer {cli_token}"}
mock_request.query_params = {}
try:
with (
patch("litellm.proxy.proxy_server.master_key", "sk-master"),
patch("litellm.proxy.proxy_server.prisma_client", None),
):
with pytest.raises(ProxyException) as exc_info:
await user_api_key_auth(
request=mock_request,
api_key=f"Bearer {cli_token}",
)
with (
patch("litellm.proxy.proxy_server.master_key", "sk-master"),
patch("litellm.proxy.proxy_server.prisma_client", None),
):
with pytest.raises(ProxyException) as exc_info:
await user_api_key_auth(
request=mock_request,
api_key=f"Bearer {cli_token}",
)
assert exc_info.value.type == ProxyErrorTypes.expired_key
finally:
monkeypatch.delenv("LITELLM_CLI_JWT_EXPIRATION_HOURS", raising=False)
importlib.reload(constants)
importlib.reload(auth_checks)
assert exc_info.value.type == ProxyErrorTypes.expired_key
@pytest.mark.asyncio

View file

@ -1,52 +1,45 @@
import os
from typing import Final
import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI, Request
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
load_dotenv()
# Set the SERVER_ROOT_PATH environment variable to match the custom mount path
os.environ["SERVER_ROOT_PATH"] = "/my-custom-path"
from litellm.proxy.proxy_server import app as litellm_app
from litellm.proxy.proxy_server import proxy_startup_event
# Create main FastAPI app
app = FastAPI(title="Custom LiteLLM Server", lifespan=proxy_startup_event)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
custom_path = "/my-custom-path"
# Mount LiteLLM app at /litellm
app.mount(custom_path, litellm_app)
# Default route at /
@app.get("/")
async def root():
return {
"message": "Welcome to the API Gateway",
"litellm_endpoint": f"{custom_path}",
}
def build_app() -> FastAPI:
load_dotenv()
os.environ["SERVER_ROOT_PATH"] = "/my-custom-path"
from litellm.proxy.proxy_server import app as litellm_app
from litellm.proxy.proxy_server import proxy_startup_event
# Health check endpoint
@app.get("/health")
async def health_check():
return {"status": "healthy"}
app: Final = FastAPI(title="Custom LiteLLM Server", lifespan=proxy_startup_event)
custom_path: Final = "/my-custom-path"
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount(custom_path, litellm_app)
@app.get("/")
async def root() -> dict[str, str]:
return {
"message": "Welcome to the API Gateway",
"litellm_endpoint": custom_path,
}
@app.get("/health")
async def health_check() -> dict[str, str]:
return {"status": "healthy"}
return app
if __name__ == "__main__":
# Run the server on port 8000
uvicorn.run(app, host="0.0.0.0", port=4000, log_level="info")
uvicorn.run(build_app(), host="0.0.0.0", port=4000, log_level="info")

View file

@ -68,3 +68,36 @@ def _build_constant_env_var_map() -> dict[str, str]:
env_var_map[constant_name] = env_var_name
return env_var_map
@pytest.mark.parametrize(
("cli_value", "litellm_cli_value", "expected"),
[
("48", None, 48),
(None, "48", 48),
(None, None, 24),
("48", "72", 48),
],
ids=("canonical-only", "alias-only", "default", "canonical-wins"),
)
def test_cli_jwt_expiration_hours_from_environment(
monkeypatch: pytest.MonkeyPatch,
cli_value: str | None,
litellm_cli_value: str | None,
expected: int,
) -> None:
monkeypatch.delenv("CLI_JWT_EXPIRATION_HOURS", raising=False)
monkeypatch.delenv("LITELLM_CLI_JWT_EXPIRATION_HOURS", raising=False)
try:
if cli_value is not None:
monkeypatch.setenv("CLI_JWT_EXPIRATION_HOURS", cli_value)
if litellm_cli_value is not None:
monkeypatch.setenv("LITELLM_CLI_JWT_EXPIRATION_HOURS", litellm_cli_value)
importlib.reload(litellm.constants)
assert litellm.constants.CLI_JWT_EXPIRATION_HOURS == expected
finally:
monkeypatch.delenv("CLI_JWT_EXPIRATION_HOURS", raising=False)
monkeypatch.delenv("LITELLM_CLI_JWT_EXPIRATION_HOURS", raising=False)
importlib.reload(litellm.constants)

View file

@ -0,0 +1,420 @@
import json
from typing import Final
import httpx
import pytest
import respx
import litellm
from litellm.tool_loop import ToolLoopMaxRoundsExceeded
from litellm.types.llms.openai import ChatCompletionToolMessage
from litellm.types.utils import ChatCompletionMessageToolCall
OPENAI_CHAT_COMPLETIONS_URL: Final = "https://api.openai.com/v1/chat/completions"
WEATHER_TOOLS: Final = (
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
},
)
def _openai_response(content: str | None, tool_calls: list | None = None) -> dict:
return {
"id": "chatcmpl-tool-loop",
"object": "chat.completion",
"created": 1739462947,
"model": "gpt-5-mini",
"choices": [
{
"index": 0,
"finish_reason": "tool_calls" if tool_calls else "stop",
"message": {
"role": "assistant",
"content": content,
"tool_calls": tool_calls,
},
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
def _tool_call(call_id: str, name: str, arguments: dict) -> dict:
return {
"id": call_id,
"type": "function",
"function": {"name": name, "arguments": json.dumps(arguments)},
}
def _tool_result(tool_call: ChatCompletionMessageToolCall) -> ChatCompletionToolMessage:
return ChatCompletionToolMessage(role="tool", content='{"temp": "72F"}', tool_call_id=tool_call.id or "")
def _request_bodies(respx_mock: respx.MockRouter) -> list[dict]:
return [json.loads(call.request.content) for call in respx_mock.calls]
def test_final_answer_without_tool_calls_returns_content(respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(OPENAI_CHAT_COMPLETIONS_URL).mock(
return_value=httpx.Response(200, json=_openai_response("done"))
)
executor_called: Final = []
def executor(tc: ChatCompletionMessageToolCall) -> ChatCompletionToolMessage:
executor_called.append(tc)
return _tool_result(tc)
answer: Final = litellm.run_tool_loop(
model="openai/gpt-5-mini",
messages=[{"role": "user", "content": "hi"}],
tools=WEATHER_TOOLS,
execute_tool=executor,
api_key="sk-test",
)
assert answer == "done"
assert executor_called == []
assert route.call_count == 1
def test_two_rounds_appends_assistant_and_tool_messages_in_order(respx_mock: respx.MockRouter) -> None:
tool_calls: Final = [
_tool_call("call_1", "get_weather", {"city": "Paris"}),
_tool_call("call_2", "get_weather", {"city": "Tokyo"}),
]
route: Final = respx_mock.post(OPENAI_CHAT_COMPLETIONS_URL).mock(
side_effect=[
httpx.Response(200, json=_openai_response(None, tool_calls)),
httpx.Response(200, json=_openai_response("Paris 72F, Tokyo 60F")),
]
)
executed: Final = []
def executor(tc: ChatCompletionMessageToolCall) -> ChatCompletionToolMessage:
executed.append(tc)
return _tool_result(tc)
messages: Final = [{"role": "user", "content": "weather in Paris and Tokyo?"}]
messages_snapshot: Final = [dict(message) for message in messages]
answer: Final = litellm.run_tool_loop(
model="openai/gpt-5-mini",
messages=messages,
tools=WEATHER_TOOLS,
execute_tool=executor,
api_key="sk-test",
)
assert answer == "Paris 72F, Tokyo 60F"
assert route.call_count == 2
assert [tc.id for tc in executed] == ["call_1", "call_2"]
assert [tc.function.name for tc in executed] == ["get_weather", "get_weather"]
assert [tc.function.arguments for tc in executed] == [
'{"city": "Paris"}',
'{"city": "Tokyo"}',
]
second_body: Final = _request_bodies(respx_mock)[1]
assert second_body["messages"] == [
{"role": "user", "content": "weather in Paris and Tokyo?"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city": "Paris"}'},
},
{
"id": "call_2",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city": "Tokyo"}'},
},
],
},
{"role": "tool", "content": '{"temp": "72F"}', "tool_call_id": "call_1"},
{"role": "tool", "content": '{"temp": "72F"}', "tool_call_id": "call_2"},
]
assert len(messages) == len(messages_snapshot)
assert messages == messages_snapshot
def test_response_format_and_tools_forwarded_every_round(respx_mock: respx.MockRouter) -> None:
respx_mock.post(OPENAI_CHAT_COMPLETIONS_URL).mock(
side_effect=[
httpx.Response(200, json=_openai_response(None, [_tool_call("call_1", "get_weather", {"city": "Paris"})])),
httpx.Response(200, json=_openai_response('{"summary": "sunny"}')),
]
)
response_format: Final = {
"type": "json_schema",
"json_schema": {
"name": "weather_report",
"schema": {
"type": "object",
"properties": {"summary": {"type": "string"}},
"required": ["summary"],
},
},
}
litellm.run_tool_loop(
model="openai/gpt-5-mini",
messages=[{"role": "user", "content": "weather?"}],
tools=WEATHER_TOOLS,
execute_tool=_tool_result,
response_format=response_format,
api_key="sk-test",
)
bodies: Final = _request_bodies(respx_mock)
assert len(bodies) == 2
for body in bodies:
assert body["response_format"] == response_format
assert body["tools"] == list(WEATHER_TOOLS)
def test_max_rounds_exceeded_raises_without_executing_last_round(respx_mock: respx.MockRouter) -> None:
tool_call: Final = _tool_call("call_1", "get_weather", {"city": "Paris"})
route: Final = respx_mock.post(OPENAI_CHAT_COMPLETIONS_URL).mock(
return_value=httpx.Response(200, json=_openai_response(None, [tool_call]))
)
executed: Final = []
def executor(tc: ChatCompletionMessageToolCall) -> ChatCompletionToolMessage:
executed.append(tc)
return _tool_result(tc)
with pytest.raises(ToolLoopMaxRoundsExceeded) as exc_info:
litellm.run_tool_loop(
model="openai/gpt-5-mini",
messages=[{"role": "user", "content": "weather?"}],
tools=WEATHER_TOOLS,
execute_tool=executor,
max_rounds=2,
api_key="sk-test",
)
assert exc_info.value.max_rounds == 2
assert route.call_count == 2
assert [tc.id for tc in executed] == ["call_1"]
def test_max_rounds_below_one_rejected_before_any_request(respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(OPENAI_CHAT_COMPLETIONS_URL).mock(
return_value=httpx.Response(200, json=_openai_response("done"))
)
with pytest.raises(ValueError, match="max_rounds must be >= 1"):
litellm.run_tool_loop(
model="openai/gpt-5-mini",
messages=[{"role": "user", "content": "hi"}],
tools=WEATHER_TOOLS,
execute_tool=_tool_result,
max_rounds=0,
api_key="sk-test",
)
assert route.call_count == 0
def test_stream_rejected_before_any_request(respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post(OPENAI_CHAT_COMPLETIONS_URL).mock(
return_value=httpx.Response(200, json=_openai_response("done"))
)
with pytest.raises(ValueError, match="stream=True is not supported"):
litellm.run_tool_loop(
model="openai/gpt-5-mini",
messages=[{"role": "user", "content": "hi"}],
tools=WEATHER_TOOLS,
execute_tool=_tool_result,
stream=True,
api_key="sk-test",
)
assert route.call_count == 0
def test_custom_tool_call_raises_type_error_without_executing(respx_mock: respx.MockRouter) -> None:
custom_response: Final = _openai_response(
None,
[{"id": "call_custom", "type": "custom", "custom": {"name": "apply_patch", "input": "*** patch"}}],
)
route: Final = respx_mock.post(OPENAI_CHAT_COMPLETIONS_URL).mock(
return_value=httpx.Response(200, json=custom_response)
)
executor_called: Final = []
def executor(tc: ChatCompletionMessageToolCall) -> ChatCompletionToolMessage:
executor_called.append(tc)
return _tool_result(tc)
with pytest.raises(TypeError, match="custom tool call call_custom"):
litellm.run_tool_loop(
model="openai/gpt-5-mini",
messages=[{"role": "user", "content": "hi"}],
tools=WEATHER_TOOLS,
execute_tool=executor,
api_key="sk-test",
)
assert executor_called == []
assert route.call_count == 1
def _responses_payload(response_id: str, output: list) -> dict:
return {
"id": response_id,
"object": "response",
"created_at": 1734366691,
"status": "completed",
"model": "gpt-5.5",
"output": output,
"parallel_tool_calls": True,
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
"error": None,
"incomplete_details": None,
"instructions": None,
"metadata": None,
"temperature": None,
"tool_choice": "auto",
"tools": [],
"top_p": None,
"max_output_tokens": None,
"previous_response_id": None,
"reasoning": None,
"truncation": None,
"user": None,
}
def test_responses_bridge_replays_reasoning_items_across_rounds(respx_mock: respx.MockRouter) -> None:
round_one: Final = _responses_payload(
"resp_1",
[
{"type": "reasoning", "id": "rs_abc123", "summary": [], "encrypted_content": "enc_xyz"},
{
"type": "function_call",
"id": "fc_1",
"call_id": "call_1",
"name": "get_weather",
"arguments": '{"city": "Paris"}',
"status": "completed",
},
],
)
round_two: Final = _responses_payload(
"resp_2",
[
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "Paris is 72F", "annotations": []}],
}
],
)
route: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
side_effect=[httpx.Response(200, json=round_one), httpx.Response(200, json=round_two)]
)
executed: Final = []
def executor(tc: ChatCompletionMessageToolCall) -> ChatCompletionToolMessage:
executed.append(tc)
return _tool_result(tc)
answer: Final = litellm.run_tool_loop(
model="openai/responses/gpt-5.5",
messages=[{"role": "user", "content": "weather in Paris?"}],
tools=WEATHER_TOOLS,
execute_tool=executor,
api_key="sk-test",
)
assert answer == "Paris is 72F"
assert route.call_count == 2
assert [tc.id for tc in executed] == ["fc_1"]
second_input: Final = _request_bodies(respx_mock)[1]["input"]
item_types: Final = [item.get("type") for item in second_input]
reasoning_index: Final = next(i for i, item in enumerate(second_input) if item.get("type") == "reasoning")
function_call_index: Final = next(
i for i, item in enumerate(second_input) if item.get("type") == "function_call"
)
reasoning_item: Final = second_input[reasoning_index]
assert reasoning_item["id"] == "rs_abc123"
assert reasoning_item["encrypted_content"] == "enc_xyz"
assert reasoning_index < function_call_index, f"reasoning item must precede function_call: {item_types}"
async def test_arun_tool_loop_two_rounds(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
monkeypatch.setattr(litellm, "module_level_aclient", AsyncHTTPHandler())
tool_calls: Final = [
_tool_call("call_1", "get_weather", {"city": "Paris"}),
_tool_call("call_2", "get_weather", {"city": "Tokyo"}),
]
route: Final = respx_mock.post(OPENAI_CHAT_COMPLETIONS_URL).mock(
side_effect=[
httpx.Response(200, json=_openai_response(None, tool_calls)),
httpx.Response(200, json=_openai_response("Paris 72F, Tokyo 60F")),
]
)
executed: Final = []
async def executor(tc: ChatCompletionMessageToolCall) -> ChatCompletionToolMessage:
executed.append(tc)
return _tool_result(tc)
messages: Final = [{"role": "user", "content": "weather in Paris and Tokyo?"}]
messages_snapshot: Final = [dict(message) for message in messages]
answer: Final = await litellm.arun_tool_loop(
model="openai/gpt-5-mini",
messages=messages,
tools=WEATHER_TOOLS,
execute_tool=executor,
api_key="sk-test",
)
assert answer == "Paris 72F, Tokyo 60F"
assert route.call_count == 2
assert [tc.id for tc in executed] == ["call_1", "call_2"]
second_body: Final = _request_bodies(respx_mock)[1]
assert second_body["messages"] == [
{"role": "user", "content": "weather in Paris and Tokyo?"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city": "Paris"}'},
},
{
"id": "call_2",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city": "Tokyo"}'},
},
],
},
{"role": "tool", "content": '{"temp": "72F"}', "tool_call_id": "call_1"},
{"role": "tool", "content": '{"temp": "72F"}', "tool_call_id": "call_2"},
]
assert messages == messages_snapshot

View file

@ -42,6 +42,7 @@
"react-dom": "19.2.8",
"react-error-boundary": "6.1.6",
"react-hook-form": "7.82.0",
"react-hotkeys-hook": "5.3.3",
"react-intersection-observer": "11.0.1",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
@ -10537,6 +10538,19 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-hotkeys-hook": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-5.3.3.tgz",
"integrity": "sha512-aswgyWUnE25hmhzHTfKDmKzsaSE5DJ4LKaU/o6rQSXkDd/1Bh9TfAFQbHkf6fLy11HvlYkp+cDDarGdhmCDhoQ==",
"license": "MIT",
"workspaces": [
"packages/*"
],
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-intersection-observer": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-11.0.1.tgz",

View file

@ -59,6 +59,7 @@
"react-dom": "19.2.8",
"react-error-boundary": "6.1.6",
"react-hook-form": "7.82.0",
"react-hotkeys-hook": "5.3.3",
"react-intersection-observer": "11.0.1",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",

View file

@ -7,6 +7,7 @@ import { ThemeProvider } from "next-themes";
import { AuthProvider } from "@/contexts/AuthContext";
import ReactQueryProvider from "@/contexts/ReactQueryProvider";
import HotkeysProvider from "@/contexts/HotkeysProvider";
import { Toaster } from "@/components/ui/sonner";
const inter = Inter({ subsets: ["latin"] });
@ -30,7 +31,9 @@ export default function RootLayout({
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
<NuqsAdapter>
<ReactQueryProvider>
<AuthProvider>{children}</AuthProvider>
<HotkeysProvider>
<AuthProvider>{children}</AuthProvider>
</HotkeysProvider>
<Toaster />
</ReactQueryProvider>
</NuqsAdapter>

View file

@ -1,13 +0,0 @@
"use client";
import { useContext, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { LensPreviewContext } from "./LensPreviewButton";
export function HeaderActions({ children }: { children: ReactNode }) {
const preview = useContext(LensPreviewContext);
const actions = <div className="flex flex-wrap items-center gap-3">{children}</div>;
if (preview === undefined) return actions;
return preview.target ? createPortal(actions, preview.target) : null;
}

View file

@ -0,0 +1,101 @@
"use client";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { Activity, ScanSearch, Settings } from "lucide-react";
import { StatusDot } from "@/components/shared/StatusDot";
import { cn } from "@/lib/cva.config";
import type { InvestigationActivity } from "./model/status";
import { useWorkerConnected } from "./hooks/useWorkerConnected";
import type { LensList } from "./model/types";
import { LENS_TABS, type LensTab } from "./route";
import { frameCorner, frameTab } from "./ui/frame";
const MODE_ICONS = { traces: Activity, investigations: ScanSearch, settings: Settings } as const;
const ACTIVITY_DOT: Record<Exclude<InvestigationActivity, "idle">, { className: string; label: string }> = {
running: { className: "bg-info motion-safe:animate-pulse", label: "An investigation is running" },
queued: { className: "bg-muted-foreground/60", label: "An investigation is queued" },
};
function ActivityDot({ activity }: { activity: InvestigationActivity }) {
if (activity === "idle") return null;
return (
<span
aria-hidden="true"
className={cn(
"size-1.5 rounded-full animate-in fade-in-0 zoom-in-50 duration-300",
ACTIVITY_DOT[activity].className,
)}
/>
);
}
/** Inverted corner joining the tab's side border to the card's top border; plain CSS borders so both snap to the same pixels. */
function NotchCorner({ side, demo }: { side: "left" | "right"; demo: boolean }) {
return (
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute bottom-0 size-3 overflow-hidden",
side === "left" ? "-left-3" : "-right-3",
)}
>
<span className={frameCorner({ session: demo ? "demo" : "live", side })} />
</span>
);
}
export function LensModeSwitch({
activity,
demo,
workers,
setup,
}: {
activity: InvestigationActivity;
demo: boolean;
workers: LensList["workers"] | null;
setup?: string;
}) {
const connected = useWorkerConnected(workers);
const settingsTitle = connected ? "Worker connected" : "Connect worker";
const tabs = Object.entries(LENS_TABS).filter(([view]) => view !== "settings" || workers);
return (
<div className={frameTab({ session: demo ? "demo" : "live" })}>
<NotchCorner side="left" demo={demo} />
<NotchCorner side="right" demo={demo} />
<TabsPrimitive.List aria-label="Lens" className="relative inline-flex h-9 items-center p-1">
<TabsPrimitive.Indicator className="absolute top-1 bottom-1 left-(--active-tab-left) w-(--active-tab-width) rounded-full bg-muted transition-[left,width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none" />
{tabs.map(([view, label]) => {
const Icon = MODE_ICONS[view as LensTab];
const workerDisconnected = view === "settings" && workers !== null && !connected;
return (
<TabsPrimitive.Tab
key={view}
value={view}
title={view === "settings" ? settingsTitle : undefined}
aria-description={
view === "investigations" && activity !== "idle" ? ACTIVITY_DOT[activity].label : undefined
}
className={cn(
"relative z-raised inline-flex h-full items-center gap-2 rounded-full text-sm font-medium text-muted-foreground outline-none transition-colors duration-200 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-active:text-foreground",
view === "settings" ? "px-2.5" : "px-4",
)}
>
<span className="relative inline-flex">
<Icon aria-hidden="true" className="size-4" />
{workerDisconnected && <StatusDot state="error" className="absolute -top-0.5 -right-0.5 size-1.5" />}
</span>
<span className={cn(view === "settings" && "sr-only")}>{label}</span>
{view === "investigations" && setup && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none font-medium text-muted-foreground animate-in fade-in-0 duration-200">
{setup}
</span>
)}
{view === "investigations" && <ActivityDot activity={activity} />}
</TabsPrimitive.Tab>
);
})}
</TabsPrimitive.List>
</div>
);
}

View file

@ -1,7 +1,8 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { dismissLensIntro } from "@/../tests/lens-test-utils";
import LensPage from "@/app/(dashboard)/lens/page";
const { auth } = vi.hoisted(() => ({ auth: vi.fn() }));
@ -16,32 +17,52 @@ vi.mock("./investigations/InvestigationsView", () => ({
}));
describe("Lens navigation", () => {
beforeEach(() => auth.mockReturnValue({ accessToken: "test-token", userRole: "Admin", isViewOnly: false }));
beforeEach(() => {
testQueryClient.clear();
window.localStorage.clear();
window.sessionStorage.clear();
dismissLensIntro();
auth.mockReturnValue({ accessToken: "test-token", userRole: "Admin", isViewOnly: false });
vi.stubGlobal(
"fetch",
vi.fn(async (input) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/v1/traces")
return Response.json({
items: [{}],
next_cursor: null,
traversal: { id: "demo", published_at: "2026-01-01T00:00:00Z", expires_at: "2026-01-01T00:30:00Z" },
});
if (path === "/lens") return Response.json({ lenses: [], workers: [], tracing_enabled: true });
return Response.json({ traces: true, requests: false, data: [] });
}),
);
});
it("opens traces by default and pauses polling while viewing investigations", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<LensPage />, { onUrlUpdate });
expect(screen.getByRole("tab", { name: "Traces" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Trace polling active")).toBeVisible();
expect(await screen.findByText("Trace polling active")).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(screen.getByText("Manage investigations")).toBeVisible();
expect(await screen.findByText("Manage investigations")).toBeVisible();
expect(screen.getByText("Trace polling paused")).not.toBeVisible();
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("tab")).toBe("investigations");
await user.click(screen.getByRole("tab", { name: "Traces" }));
expect(screen.getByText("Trace polling active")).toBeVisible();
expect(await screen.findByText("Trace polling active")).toBeVisible();
});
it("opens existing lens links on investigations", () => {
it("opens existing lens links on investigations", async () => {
renderWithProviders(<LensPage />, { searchParams: "?lens=saved-lens" });
expect(screen.getByRole("tab", { name: "Investigations" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Manage investigations")).toBeVisible();
expect(await screen.findByText("Manage investigations")).toBeVisible();
});
it("honors an explicit traces tab even when a saved investigation is in the URL", () => {
it("honors an explicit traces tab even when a saved investigation is in the URL", async () => {
renderWithProviders(<LensPage />, { searchParams: "?tab=traces&lens=saved-lens" });
expect(screen.getByRole("tab", { name: "Traces" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Trace polling active")).toBeVisible();
expect(await screen.findByText("Trace polling active")).toBeVisible();
});
it.each(["Internal User", "Internal Viewer", "Org Admin"])(
@ -50,7 +71,7 @@ describe("Lens navigation", () => {
auth.mockReturnValue({ accessToken: "test-token", userRole, isViewOnly: false });
const user = userEvent.setup();
renderWithProviders(<LensPage />);
expect(screen.getByText("Trace polling active")).toBeVisible();
expect(await screen.findByText("Trace polling active")).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(screen.getByText(/Investigations require proxy administrator access/)).toBeVisible();
expect(screen.queryByText("Manage investigations")).not.toBeInTheDocument();
@ -60,10 +81,10 @@ describe("Lens navigation", () => {
it.each([
{ userRole: "Admin Viewer", isViewOnly: false },
{ userRole: "Admin", isViewOnly: true },
])("preserves read-only investigation access for $userRole with isViewOnly=$isViewOnly", (session) => {
])("preserves read-only investigation access for $userRole with isViewOnly=$isViewOnly", async (session) => {
auth.mockReturnValue({ accessToken: "test-token", ...session });
renderWithProviders(<LensPage />, { searchParams: "?tab=investigations" });
expect(screen.getByText("Read-only investigations")).toBeVisible();
expect(await screen.findByText("Read-only investigations")).toBeVisible();
expect(screen.queryByText("Manage investigations")).not.toBeInTheDocument();
});
});

View file

@ -1,13 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { TracesApiContext } from "@/components/view_logs/TraceView/tracesApi";
import { LensApiContext, type LensServices } from "./services";
export function LensServicesProvider({ services, children }: { services: LensServices; children: ReactNode }) {
return (
<LensApiContext.Provider value={services.lens}>
<TracesApiContext.Provider value={services.traces}>{children}</TracesApiContext.Provider>
</LensApiContext.Provider>
);
}

View file

@ -0,0 +1,344 @@
import { act, fireEvent, 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 { dismissLensIntro } from "@/../tests/lens-test-utils";
import { readStorage } from "@/lib/storage";
import { LENS_INTRO_DISMISSED, LENS_INTRO_SEEN } from "./storage";
import { LensWorkspace } from "./LensWorkspace";
import { createLensDemoData } from "./data/demo/fixtures";
import type { LensList } from "./model/types";
const network = vi.fn<typeof fetch>();
const list = vi.fn<() => Promise<LensList>>();
const data = createLensDemoData();
const worker = () => ({
id: "setup-worker",
analysis_key_id: "a".repeat(64),
revoked: false,
last_seen: new Date().toISOString(),
scope: data.lenses[0].scope,
});
function serve({ enabled = false, traces = false, requests = false, connected = false } = {}) {
list.mockResolvedValue({ lenses: [], workers: connected ? [worker()] : [], tracing_enabled: enabled });
network.mockImplementation(async (input, init) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/v1/traces")
return enabled
? Response.json({
items: traces ? [data.runs[0].trace.summary] : [],
next_cursor: null,
traversal: { id: "demo", published_at: "2026-01-01T00:00:00Z", expires_at: "2026-01-01T00:30:00Z" },
})
: Response.json({ detail: "Tracing is not enabled" }, { status: 501 });
if (path === "/lens/activity/available") return Response.json({ traces, requests });
if (path === "/lens" && init?.method === "POST") {
const saved = { ...data.lenses[0], settings: { ...data.lenses[0].settings, ...JSON.parse(String(init.body)) } };
list.mockResolvedValue({ lenses: [saved], workers: [worker()], tracing_enabled: true });
return Response.json(saved);
}
if (path === "/lens") return Response.json(await list());
if (path === "/key/generate") return Response.json({ token_id: worker().analysis_key_id });
if (path === "/lens/workers/register") {
list.mockResolvedValue({ lenses: [], workers: [worker()], tracing_enabled: true });
return Response.json({ worker: worker(), token: "test-worker-token", image: "test-worker-image" });
}
if (path === "/models") return Response.json({ data: [{ id: "analysis" }] });
if (path === "/model_group/info")
return Response.json({ data: [{ model_group: "analysis", providers: ["OpenAI"], mode: "chat" }] });
if (path === "/key/info") return Response.json({ info: { models: ["analysis"], max_budget: 100 } });
if (path === "/lens/agents") return Response.json(["support_agent"]);
if (path === "/lens/preview/sample") return Response.json({ eligible: 1, selected: 1, executions: [] });
if (path.endsWith("/runs")) return Response.json(data.lenses[0].jobs);
return Response.json({ data: [] });
});
}
beforeEach(() => {
testQueryClient.clear();
window.localStorage.clear();
window.sessionStorage.clear();
network.mockReset();
list.mockReset();
vi.stubGlobal("fetch", network);
Element.prototype.scrollIntoView = vi.fn();
serve();
});
const renderWorkspace = (options?: Parameters<typeof renderWithProviders>[1], userRole = "Admin") =>
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole={userRole} readOnly={false} />, options);
const setupParam = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? "")).get("setup");
async function connectWorkerFromSettings(user: ReturnType<typeof userEvent.setup>) {
const settings = within(await screen.findByRole("region", { name: "Settings" }));
expect(settings.getByRole("heading", { name: "Connect a worker" })).toBeVisible();
await user.click(settings.getByRole("combobox", { name: "Analysis model" }));
await user.click(await screen.findByRole("option", { name: "analysis" }));
await user.click(settings.getByRole("button", { name: "Get install command" }));
expect(await settings.findByRole("heading", { name: "Worker connected" })).toBeVisible();
await user.click(settings.getByRole("button", { name: "New investigation" }));
expect(await screen.findByRole("region", { name: "New investigation" })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
}
describe("Lens introduction", () => {
it("opens on the first visit, rests for the session on close, and stays away after don't show again", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
const first = renderWorkspace({ onUrlUpdate });
const intro = within(await screen.findByRole("dialog"));
expect(await intro.findByRole("heading", { name: "The gateway that helps your agents improve" })).toBeVisible();
expect(intro.getByRole("heading", { name: "Before you start" })).toBeVisible();
await user.click(intro.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(readStorage(LENS_INTRO_SEEN)).toBe(true);
expect(readStorage(LENS_INTRO_DISMISSED)).toBe(false);
first.unmount();
const second = renderWorkspace();
expect(await screen.findByRole("tablist", { name: "Lens" })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
second.unmount();
window.sessionStorage.clear();
const third = renderWorkspace();
const reopened = within(await screen.findByRole("dialog"));
await user.click(reopened.getByRole("checkbox", { name: "Don’t show this again" }));
await user.click(reopened.getByRole("button", { name: "Close" }));
expect(readStorage(LENS_INTRO_DISMISSED)).toBe(true);
third.unmount();
window.sessionStorage.clear();
const fourth = renderWorkspace();
expect(await screen.findByRole("tablist", { name: "Lens" })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
fourth.unmount();
renderWorkspace({ searchParams: "?setup=lens" });
expect(await screen.findByRole("dialog")).toBeVisible();
});
it("lets settings bring back an introduction hidden with don't show again", async () => {
const user = userEvent.setup();
const first = renderWorkspace();
const intro = within(await screen.findByRole("dialog"));
await user.click(intro.getByRole("checkbox", { name: "Don’t show this again" }));
await user.click(intro.getByRole("button", { name: "Close" }));
first.unmount();
const settings = renderWorkspace({ searchParams: "?tab=settings" });
const toggle = within(await screen.findByRole("region", { name: "Introduction" })).getByRole("switch", {
name: "Show the introduction on each new session",
});
expect(toggle).not.toBeChecked();
await user.click(toggle);
expect(toggle).toBeChecked();
settings.unmount();
window.sessionStorage.clear();
renderWorkspace();
expect(await screen.findByRole("dialog")).toBeVisible();
});
it("never opens on its own inside the sample session", async () => {
renderWorkspace({ searchParams: "?demo=true" });
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
describe("Lens setup journey", () => {
it.each(["/lens", "/lens/activity/available"])(
"keeps recorded traces visible while %s is pending",
async (pendingPath) => {
dismissLensIntro();
serve({ enabled: true, traces: true });
const normal = network.getMockImplementation()!;
network.mockImplementation((input, init) =>
new URL(String(input), "http://localhost").pathname === pendingPath
? new Promise<Response>(() => {})
: normal(input, init),
);
renderWorkspace();
expect(await screen.findByRole("table", { name: "Agent runs" })).toBeVisible();
},
);
it.each(["/v1/traces", "/lens/activity/available"])(
"opens a saved investigation while %s is pending",
async (pendingPath) => {
dismissLensIntro();
serve();
list.mockResolvedValue({ lenses: data.lenses, workers: [worker()], tracing_enabled: false });
const normal = network.getMockImplementation()!;
network.mockImplementation((input, init) =>
new URL(String(input), "http://localhost").pathname === pendingPath
? new Promise<Response>(() => {})
: normal(input, init),
);
renderWorkspace({ searchParams: `?lens=${data.lenses[0].id}` });
expect(await screen.findByRole("heading", { name: data.lenses[0].settings.name })).toBeVisible();
},
);
it("walks a first visit from the introduction through tracing into the Settings tab and the first investigation", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWorkspace({ onUrlUpdate });
const intro = within(await screen.findByRole("dialog"));
await user.click(await intro.findByRole("button", { name: "Set up Lens" }));
await waitFor(() => expect(setupParam(onUrlUpdate)).toBe("lens"));
serve({ enabled: true });
await user.click(intro.getByRole("button", { name: "Check setup" }));
expect(await intro.findByText("Trace storage is connected")).toBeVisible();
await user.click(intro.getByRole("button", { name: "Continue to your agent" }));
expect(intro.getByRole("button", { name: "Check for traces" })).toBeVisible();
serve({ enabled: true, traces: true });
await user.click(intro.getByRole("button", { name: "Check for traces" }));
expect(await intro.findByText(/Your first trace is ready/)).toBeVisible();
expect(screen.getByRole("dialog")).toBeVisible();
await user.click(intro.getByRole("button", { name: "Continue to worker" }));
await waitFor(() => expect(setupParam(onUrlUpdate)).toBeNull());
await connectWorkerFromSettings(user);
});
it("resumes setup from the URL and leaves only when the user chooses traces", async () => {
serve({ enabled: true, traces: true });
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWorkspace({ searchParams: "?tab=investigations&setup=lens", onUrlUpdate });
const intro = within(await screen.findByRole("dialog"));
expect(await intro.findByRole("button", { name: "Connect worker" })).toBeVisible();
expect(intro.getByRole("heading", { name: "Get Lens running" })).toBeVisible();
await user.click(intro.getByRole("button", { name: "View traces" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(await screen.findByRole("table", { name: "Agent runs" })).toBeVisible();
await waitFor(() => expect(setupParam(onUrlUpdate)).toBeNull());
await user.click(screen.getByRole("tab", { name: "Investigations" }));
const guide = within(await screen.findByRole("region", { name: "Get Lens running" }));
expect(guide.getByRole("button", { name: /Connect a worker/ })).toHaveAttribute("aria-expanded", "true");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("allows request-only investigations without forcing agent instrumentation", async () => {
dismissLensIntro();
serve({ requests: true });
const user = userEvent.setup();
const welcome = renderWorkspace({ searchParams: "?tab=investigations" });
expect(await screen.findByRole("region", { name: "Get Lens running" })).toBeVisible();
expect(screen.getByRole("button", { name: "Connect worker" })).toBeEnabled();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
welcome.unmount();
renderWorkspace({ searchParams: "?tab=investigations&setup=lens" });
const intro = within(await screen.findByRole("dialog"));
expect(await intro.findByRole("heading", { name: "Before you start" })).toBeVisible();
expect(intro.getByRole("button", { name: "Connect worker" })).toBeEnabled();
await user.click(intro.getByRole("button", { name: /Enable tracing on the gateway/ }));
expect(intro.getByRole("button", { name: "Continue with request logs" })).toBeEnabled();
await user.click(intro.getByRole("button", { name: /Send your first trace/ }));
await user.click(intro.getByRole("button", { name: "Continue with request logs" }));
await connectWorkerFromSettings(user);
});
it("keeps setup recoverable when checking for a first trace fails", async () => {
serve({ enabled: true });
const user = userEvent.setup();
renderWorkspace({ searchParams: "?setup=lens" });
const intro = within(await screen.findByRole("dialog"));
await intro.findByRole("button", { name: "Check for traces" });
const normal = network.getMockImplementation()!;
network.mockImplementation(async (input, init) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/v1/traces") return Response.json({ detail: "Trace storage unavailable" }, { status: 503 });
return normal(input, init);
});
await user.click(intro.getByRole("button", { name: "Check for traces" }));
expect(await intro.findByRole("alert")).toHaveTextContent("Could not check setup");
expect(intro.queryByRole("button", { name: "Continue to worker" })).not.toBeInTheDocument();
serve({ enabled: true, traces: true });
await user.click(intro.getByRole("button", { name: "Retry" }));
expect(await intro.findByRole("button", { name: "Continue to worker" })).toBeEnabled();
expect(intro.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps request-only users in investigations when an activity refresh fails", async () => {
dismissLensIntro();
serve({ requests: true, connected: true });
const user = userEvent.setup();
renderWorkspace({ searchParams: "?tab=investigations" });
expect(await screen.findByRole("button", { name: "New investigation" })).toBeEnabled();
const normal = network.getMockImplementation()!;
network.mockImplementation((input, init) =>
new URL(String(input), "http://localhost").pathname === "/lens/activity/available"
? Promise.resolve(Response.json({ detail: "Activity unavailable" }, { status: 503 }))
: normal(input, init),
);
await act(() => testQueryClient.refetchQueries());
expect(await screen.findByRole("alert")).toHaveTextContent("Could not check setup. Activity unavailable");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "New investigation" })).toBeDisabled();
network.mockImplementation(normal);
await user.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(screen.getByRole("button", { name: "New investigation" })).toBeEnabled());
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps administrator-only setup unavailable to trace viewers", async () => {
serve({ enabled: true, traces: true });
renderWorkspace({ searchParams: "?setup=lens" }, "Internal User");
const intro = within(await screen.findByRole("dialog"));
expect(await intro.findByText(/A gateway administrator can connect a worker/)).toBeVisible();
expect(intro.getByRole("button", { name: "Connect worker" })).toBeDisabled();
expect(network.mock.calls.some(([input]) => new URL(String(input), "http://localhost").pathname === "/lens")).toBe(
false,
);
});
it.each(["traces", "requests with trace errors", "requests with pending traces", "traces with activity errors"])(
"finishes guided setup with %s and opens the saved investigation",
async (scenario) => {
const source = scenario.startsWith("requests") ? "requests" : "traces";
const activity = { enabled: true, traces: source === "traces", requests: source === "requests", connected: true };
serve(activity);
const normal = network.getMockImplementation()!;
const failingPath = scenario === "requests with trace errors" ? "/v1/traces" : "/lens/activity/available";
network.mockImplementation((input, init) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/v1/traces" && scenario === "requests with pending traces")
return new Promise<Response>(() => {});
if (scenario.endsWith("errors") && path === failingPath)
return Promise.resolve(Response.json({ detail: "Activity unavailable" }, { status: 503 }));
return normal(input, init);
});
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWorkspace({ searchParams: "?setup=lens", onUrlUpdate });
const intro = within(await screen.findByRole("dialog"));
await user.click(await intro.findByRole("button", { name: "New investigation" }));
const editor = within(await screen.findByRole("region", { name: "New investigation" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
fireEvent.change(editor.getByRole("textbox", { name: "Investigation name" }), {
target: { value: "My first review" },
});
await user.click(editor.getByRole("button", { name: "Continue" }));
await user.click(editor.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(editor.getByRole("button", { name: "Run and monitor" })).toBeEnabled());
await user.click(editor.getByRole("button", { name: "Run and monitor" }));
expect(await screen.findByRole("heading", { name: "My first review" })).toBeVisible();
expect(
within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Investigations" }),
).toHaveAttribute("aria-selected", "true");
await waitFor(() => expect(setupParam(onUrlUpdate)).toBeNull());
const create = network.mock.calls.find(
([input, init]) => new URL(String(input), "http://localhost").pathname === "/lens" && init?.method === "POST",
);
expect(create).toBeDefined();
expect(JSON.parse(String(create?.[1]?.body))).toEqual(
expect.objectContaining({ name: "My first review", source }),
);
},
);
});

View file

@ -2,8 +2,10 @@ import { screen, waitFor, within } 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 { dismissLensIntro } from "@/../tests/lens-test-utils";
import { LensWorkspace } from "./LensWorkspace";
import { createLensDemoData } from "./demo/createLensDemo";
import { lensKeys } from "./data/queries";
import { createLensDemoData } from "./data/demo/fixtures";
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
@ -13,6 +15,9 @@ const expectUrl = (onUrlUpdate: ReturnType<typeof vi.fn>, check: (params: URLSea
const network = vi.fn<typeof fetch>();
beforeEach(() => {
testQueryClient.clear();
window.localStorage.clear();
window.sessionStorage.clear();
dismissLensIntro();
vi.stubGlobal("fetch", network);
network.mockReset();
network.mockImplementation(async (input) => {
@ -27,17 +32,18 @@ describe("Lens interactive demo", () => {
it("opens without tracing, keeps the sample session in the URL, and restores the live view without mixing data", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
window.localStorage.clear();
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Internal User" readOnly={false} />, {
onUrlUpdate,
});
expect(await screen.findByText("Enable tracing")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Preview sample" }));
expect(await screen.findByRole("heading", { name: "The gateway that helps your agents improve" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Explore with sample data" }));
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
expect(screen.getByRole("switch", { name: "Demo data" })).toBeChecked();
await expectUrl(onUrlUpdate, (url) => expect(url.get("demo")).toBe("true"));
expect(screen.queryByRole("button", { name: "Set up tracing" })).not.toBeInTheDocument();
network.mockClear();
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(screen.getByText("Where is order #1042?")).toBeVisible();
const search = screen.getByRole("combobox", { name: "Search runs" });
await user.type(search, "headphones");
@ -84,7 +90,8 @@ describe("Lens interactive demo", () => {
await expectUrl(onUrlUpdate, (url) => expect(url.get("view")).toBe("conversation"));
expect(network).not.toHaveBeenCalled();
await user.click(screen.getByRole("switch", { name: "Demo data" }));
expect(await screen.findByText("Enable tracing")).toBeVisible();
expect(await screen.findByRole("heading", { name: "Enable tracing" })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await expectUrl(onUrlUpdate, (url) => expect([...url.keys()]).toEqual([]));
});
@ -93,10 +100,10 @@ describe("Lens interactive demo", () => {
const onUrlUpdate = vi.fn();
network.mockResolvedValue(Response.json({ detail: "Tracing is not enabled" }, { status: 501 }));
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Internal User" readOnly={false} />, {
searchParams: "?tab=traces&trace=live-trace&span=live-span&lens=live-lens",
searchParams: "?tab=traces&setup=lens&trace=live-trace&span=live-span&lens=live-lens",
onUrlUpdate,
});
await user.click(await screen.findByRole("switch", { name: "Demo data" }));
await user.click(await screen.findByRole("button", { name: "Explore with sample data" }));
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
await expectUrl(onUrlUpdate, (url) =>
expect([...url.entries()]).toEqual([
@ -125,15 +132,15 @@ describe("Lens interactive demo", () => {
it("connects findings and history to their original trace without live requests", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
window.localStorage.clear();
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />, {
searchParams: "?tab=investigations",
onUrlUpdate,
});
await screen.findByRole("button", { name: "Preview sample" });
await user.click(screen.getByRole("button", { name: "Preview sample" }));
await user.click(await screen.findByRole("button", { name: "Explore with sample data" }));
network.mockClear();
await user.click(await screen.findByRole("row", { name: /Repeated lookups leave customers without an answer/ }));
const finding = screen.getByRole("dialog");
const finding = screen.getByRole("complementary", { name: "Finding details" });
expect(within(finding).getByText(/The support agent retries/)).toBeVisible();
const summaries = within(finding).getAllByText("support_agent", { exact: true });
await user.click(summaries[0]);
@ -146,16 +153,19 @@ describe("Lens interactive demo", () => {
expect(await navigator.clipboard.readText()).not.toContain("Authorization");
await user.click(screen.getByRole("tab", { name: "Attributes" }));
expect(await screen.findByText("gen_ai.agent.name")).toBeVisible();
await user.click(
within(screen.getByRole("dialog", { name: "Original run" })).getByRole("button", { name: "Close" }),
);
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Close" }));
expect(within(finding).getByRole("button", { name: "Back to finding" })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(within(finding).getByRole("button", { name: "Back to finding" }));
expect(within(finding).getByText(/The support agent retries/)).toBeVisible();
await user.click(within(finding).getByRole("button", { name: "Close finding (Esc)" }));
expect(await screen.findByRole("table", { name: "Investigations" })).toBeVisible();
expect(network).not.toHaveBeenCalled();
await expectUrl(onUrlUpdate, (url) => expect(url.get("demo")).toBe("true"));
await expectUrl(onUrlUpdate, (url) => expect(url.has("span")).toBe(false));
await user.click(screen.getByRole("switch", { name: "Demo data" }));
expect(await screen.findByRole("heading", { name: "Find what needs attention" })).toBeVisible();
await expectUrl(onUrlUpdate, (url) => expect(url.get("demo")).toBeNull());
expect(screen.getByRole("switch", { name: "Demo data" })).not.toBeChecked();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("has no demo entry for existing investigations, populated traces, or connecting another agent", async () => {
@ -225,7 +235,245 @@ describe("Lens interactive demo", () => {
const tab = within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Investigations" });
await waitFor(() => expect(tab).toHaveAccessibleDescription("An investigation is running"));
lenses.mockReturnValue([withJob("completed")]);
await testQueryClient.refetchQueries({ queryKey: ["lens", "list"] });
await testQueryClient.refetchQueries({ queryKey: lensKeys.lists() });
await waitFor(() => expect(tab).toHaveAccessibleDescription(""));
});
it("marks the Investigations tab while the inline editor is open and clears it on back", async () => {
const user = userEvent.setup();
const saved = createLensDemoData().lenses[0];
network.mockImplementation(async (input) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/lens") return Response.json({ lenses: [saved], workers: [], tracing_enabled: true });
if (path.endsWith("/runs")) return Response.json(saved.jobs);
if (path === "/lens/agents") return Response.json([]);
if (path.startsWith("/lens/preview")) return Response.json({ eligible: 0, selected: 0, executions: [] });
if (path === "/v1/traces") return Response.json({ detail: "Tracing is not enabled" }, { status: 501 });
return Response.json({ data: [], traces: true, requests: false });
});
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />, {
searchParams: "?tab=investigations&dialog=new",
});
const tabs = within(screen.getByRole("tablist", { name: "Lens" }));
expect(await screen.findByRole("region", { name: "New investigation" })).toBeVisible();
const tab = tabs.getByRole("tab", { name: /^Investigations/ });
expect(tab).toHaveAttribute("aria-selected", "true");
expect(within(tab).getByText("New")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Back to investigations" }));
expect(await screen.findByRole("row", { name: new RegExp(saved.settings.name) })).toBeVisible();
expect(within(tab).queryByText("New")).not.toBeInTheDocument();
});
it("adds a quiet Settings tab that manages the worker inline and reflects its health", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
const saved = createLensDemoData().lenses[0];
const worker = {
id: "worker",
name: "Worker",
revoked: false,
analysis_key_id: "a".repeat(64),
scope: saved.scope,
last_seen: new Date().toISOString(),
};
const workers = vi.fn(() => [worker]);
network.mockImplementation(async (input) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/lens") return Response.json({ lenses: [saved], workers: workers(), tracing_enabled: true });
if (path.endsWith("/runs")) return Response.json(saved.jobs);
if (path === "/v1/traces") return Response.json({ detail: "Tracing is not enabled" }, { status: 501 });
return Response.json({ data: [], traces: true, requests: false });
});
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />, { onUrlUpdate });
const tabs = within(screen.getByRole("tablist", { name: "Lens" }));
const settings = await tabs.findByRole("tab", { name: "Settings" });
expect(settings).toHaveAttribute("title", "Worker connected");
await user.click(settings);
await expectUrl(onUrlUpdate, (url) => expect(url.get("tab")).toBe("settings"));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
const panel = within(screen.getByRole("region", { name: "Settings" }));
expect(panel.getByRole("status")).toHaveTextContent("Tracing enabled");
expect(panel.getByRole("heading", { name: "Analysis worker" })).toBeVisible();
expect(panel.getByRole("heading", { name: worker.name })).toBeVisible();
expect(panel.getByText("Connected")).toBeVisible();
await user.click(panel.getByRole("button", { name: "Edit access" }));
expect(panel.getByRole("heading", { name: "Analysis access" })).toBeVisible();
await user.click(panel.getByRole("button", { name: "Cancel" }));
expect(panel.getByRole("heading", { name: "Analysis worker" })).toBeVisible();
workers.mockReturnValue([{ ...worker, revoked: true }]);
await testQueryClient.refetchQueries({ queryKey: lensKeys.lists() });
await waitFor(() => expect(settings).toHaveAttribute("title", "Connect worker"));
expect(panel.getByRole("heading", { name: "Connect a worker" })).toBeVisible();
expect(panel.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
await user.click(panel.getByRole("button", { name: "Connect an agent" }));
await expectUrl(onUrlUpdate, (url) => expect(url.get("tab")).toBe("traces"));
expect(tabs.getByRole("tab", { name: "Traces" })).toHaveAttribute("aria-selected", "true");
});
it("sends the first-time guide's Connect worker into the Settings tab", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
network.mockImplementation(async (input) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/lens") return Response.json({ lenses: [], workers: [], tracing_enabled: true });
if (path === "/v1/traces")
return Response.json({
items: [{}],
next_cursor: null,
traversal: { id: "demo", published_at: "2026-01-01T00:00:00Z", expires_at: "2026-01-01T00:30:00Z" },
});
return Response.json({ data: [], traces: true, requests: false });
});
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />, {
searchParams: "?tab=investigations",
onUrlUpdate,
});
const guide = within(await screen.findByRole("region", { name: "Get Lens running" }));
await user.click(await guide.findByRole("button", { name: "Connect worker" }));
await expectUrl(onUrlUpdate, (url) => expect(url.get("tab")).toBe("settings"));
const panel = within(await screen.findByRole("region", { name: "Settings" }));
expect(panel.getByRole("heading", { name: "Connect a worker" })).toBeVisible();
expect(panel.getByRole("button", { name: "Get install command" })).toBeVisible();
});
it("keeps a pending worker install across tab switches and offers the first investigation once it connects", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
const token = "b".repeat(64);
const worker = {
id: "worker",
name: "Lens worker",
revoked: false,
analysis_key_id: token,
scope: { all_teams: true, api_key_hash: "", team_id: "" },
last_seen: "1970-01-01T00:00:00Z",
};
const workers = vi.fn((): (typeof worker)[] => []);
network.mockImplementation(async (input, init) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/lens") return Response.json({ lenses: [], workers: workers(), tracing_enabled: true });
if (path === "/lens/workers/register" && init?.method === "POST") {
workers.mockReturnValue([worker]);
return Response.json({ token: "lens-test-token", image: "lens-worker:v1", worker });
}
if (path === "/key/list") return Response.json({ keys: [{ token, key_alias: "Analysis" }], total_pages: 1 });
if (path === "/key/info") return Response.json({ info: { models: [], max_budget: null } });
if (path === "/lens/agents") return Response.json([]);
if (path.startsWith("/lens/preview")) return Response.json({ eligible: 0, selected: 0, executions: [] });
if (path === "/v1/traces")
return Response.json({
items: [createLensDemoData().runs[0].trace.summary],
next_cursor: null,
traversal: { id: "demo", published_at: "2026-01-01T00:00:00Z", expires_at: "2026-01-01T00:30:00Z" },
});
return Response.json({ data: [], traces: true, requests: false });
});
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />, {
searchParams: "?tab=settings",
onUrlUpdate,
});
const panel = within(await screen.findByRole("region", { name: "Settings" }));
await user.click(panel.getByText("Advanced options"));
await user.click(panel.getByRole("switch", { name: "Use an existing virtual key" }));
await user.click(panel.getByRole("combobox", { name: "Charge analysis to" }));
await user.click(await screen.findByRole("option", { name: "Analysis" }));
await user.click(panel.getByRole("button", { name: "Get install command" }));
expect(await panel.findByText("Waiting for your worker to connect…")).toBeInTheDocument();
const tabs = within(screen.getByRole("tablist", { name: "Lens" }));
await user.click(tabs.getByRole("tab", { name: "Traces" }));
await waitFor(() => expect(panel.getByText("Waiting for your worker to connect…")).not.toBeVisible());
await user.click(tabs.getByRole("tab", { name: "Settings" }));
expect(panel.getByText("Waiting for your worker to connect…")).toBeVisible();
expect(panel.getByLabelText("Docker command preview")).toHaveTextContent("LENS_WORKER_TOKEN=lens-test-token");
workers.mockReturnValue([{ ...worker, last_seen: new Date().toISOString() }]);
await testQueryClient.refetchQueries({ queryKey: lensKeys.lists() });
expect(await panel.findByRole("heading", { name: "Worker connected" })).toBeVisible();
await user.click(panel.getByRole("button", { name: "New investigation" }));
await expectUrl(onUrlUpdate, (url) => expect(url.get("tab")).toBe("investigations"));
expect(lastUrl(onUrlUpdate).get("dialog")).toBe("new");
expect(await screen.findByRole("region", { name: "New investigation" })).toBeVisible();
});
it("hides the Settings tab for read-only sessions", async () => {
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly />);
expect(await screen.findByRole("tablist", { name: "Lens" })).toBeVisible();
await waitFor(() => expect(network).toHaveBeenCalled());
expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument();
});
it("sends read-only sessions following a Settings link to the default tab", async () => {
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly />, {
searchParams: "?tab=settings",
});
expect(await screen.findByRole("tab", { name: "Traces", selected: true })).toBeVisible();
});
it("turns the worker health dot off once heartbeats expire even when polling returns unchanged data", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const saved = createLensDemoData().lenses[0];
const worker = {
id: "worker",
name: "Worker",
revoked: false,
analysis_key_id: "a".repeat(64),
scope: saved.scope,
last_seen: new Date().toISOString(),
};
network.mockImplementation(async (input) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/lens") return Response.json({ lenses: [saved], workers: [worker], tracing_enabled: true });
if (path === "/v1/traces") return Response.json({ detail: "Tracing is not enabled" }, { status: 501 });
return Response.json({ data: [], traces: true, requests: false });
});
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />);
const tabs = within(screen.getByRole("tablist", { name: "Lens" }));
const settings = await tabs.findByRole("tab", { name: "Settings" });
expect(settings).toHaveAttribute("title", "Worker connected");
await vi.advanceTimersByTimeAsync(130000);
await waitFor(() => expect(settings).toHaveAttribute("title", "Connect worker"));
} finally {
vi.useRealTimers();
}
});
it("polls /lens every 2s while Settings waits for a worker, then drops to the 10s cadence once it connects", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const saved = createLensDemoData().lenses[0];
const worker = {
id: "worker",
name: "Worker",
revoked: false,
analysis_key_id: "a".repeat(64),
scope: saved.scope,
last_seen: new Date(Date.now() - 600_000).toISOString(),
};
const workers = vi.fn(() => [worker]);
const listCalls = () =>
network.mock.calls.filter(([input]) => new URL(String(input), "http://localhost").pathname === "/lens").length;
network.mockImplementation(async (input) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/lens") return Response.json({ lenses: [saved], workers: workers(), tracing_enabled: true });
if (path === "/v1/traces") return Response.json({ detail: "Tracing is not enabled" }, { status: 501 });
return Response.json({ data: [], traces: true, requests: false });
});
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />, {
searchParams: "?tab=settings",
});
expect(await screen.findByRole("region", { name: "Settings" })).toBeVisible();
const initial = listCalls();
await vi.advanceTimersByTimeAsync(2000);
await waitFor(() => expect(listCalls()).toBe(initial + 1));
workers.mockReturnValue([{ ...worker, last_seen: new Date().toISOString() }]);
await vi.advanceTimersByTimeAsync(2000);
await waitFor(() => expect(listCalls()).toBe(initial + 2));
await vi.advanceTimersByTimeAsync(2000);
expect(listCalls()).toBe(initial + 2);
await vi.advanceTimersByTimeAsync(8000);
await waitFor(() => expect(listCalls()).toBe(initial + 3));
} finally {
vi.useRealTimers();
}
});
});

View file

@ -2,34 +2,49 @@
import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { Activity, Aperture, ArrowUpRight, ScanSearch } from "lucide-react";
import { Aperture, ArrowUpRight } from "lucide-react";
import AgentTracesPage from "@/components/view_logs/TraceView/AgentTracesPage";
import { Button } from "@/components/ui/button";
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { LensServicesProvider } from "./LensServicesProvider";
import { LensPreviewContext } from "./LensPreviewButton";
import { LensServicesProvider, useLensAccessToken, useLensApi, useLiveLensServices } from "./data/LensServices";
import { LensPreviewContext } from "@/components/view_logs/TraceView/LensPreviewButton";
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
import { InvestigationsView } from "./investigations/InvestigationsView";
import { createLensDemo } from "./demo/createLensDemo";
import { lensQueries } from "./api/queries";
import { useLensApi } from "./services";
import { investigationActivity, type InvestigationActivity } from "./model/status";
import { LensSettings } from "./settings/LensSettings";
import { createLensDemo } from "./data/demo/createLensDemo";
import { lensQueries } from "./data/queries";
import { LensModeSwitch } from "./LensModeSwitch";
import { frameCard } from "./ui/frame";
import { investigationActivity, listPollInterval } from "./model/status";
import { cn } from "@/lib/cva.config";
import { LENS_TABS, useLensRoute, type LensTab } from "./route";
import { useDialogRoute, useLensRoute, type LensDialog, type LensTab } from "./route";
import { LensIntroDialog, useLensIntro } from "./onboarding/LensIntroDialog";
import { OnboardingProvider, type Onboarding } from "./onboarding/OnboardingContext";
import { traceRefOf, useOpenTraceRouting } from "@/components/view_logs/TraceView/traceRouting";
type WorkspaceProps = { accessToken: string; userRole: string; readOnly: boolean };
export function LensWorkspace(props: WorkspaceProps) {
const { demo } = useLensRoute();
return demo ? <SampleSession /> : <LensContent {...props} />;
return demo ? <SampleSession /> : <LiveSession {...props} />;
}
function LiveSession(props: WorkspaceProps) {
const services = useLiveLensServices(props.accessToken);
return (
<LensServicesProvider services={services}>
<LensContent userRole={props.userRole} readOnly={props.readOnly} />
</LensServicesProvider>
);
}
function SampleSession() {
const [services] = useState(() => createLensDemo());
return (
<LensServicesProvider services={services}>
<LensContent accessToken="lens-demo" userRole="proxy_admin_viewer" readOnly />
<LensContent userRole="proxy_admin_viewer" readOnly />
</LensServicesProvider>
);
}
@ -44,171 +59,164 @@ function DemoToggle({ demo, onChange }: { demo: boolean; onChange: (demo: boolea
);
}
const MODE_ICONS = { traces: Activity, investigations: ScanSearch } as const;
/** The inline investigation editor marks the tab so the notch says where you are, not just which tab is open. */
const SETUP_LABELS: Partial<Record<LensDialog, string>> = { new: "New", edit: "Editing", duplicate: "Duplicate" };
const ACTIVITY_DOT: Record<Exclude<InvestigationActivity, "idle">, { className: string; label: string }> = {
running: { className: "bg-info motion-safe:animate-pulse", label: "An investigation is running" },
queued: { className: "bg-muted-foreground/60", label: "An investigation is queued" },
};
function ActivityDot({ activity }: { activity: InvestigationActivity }) {
if (activity === "idle") return null;
return (
<span
aria-hidden="true"
className={cn(
"size-1.5 rounded-full animate-in fade-in-0 zoom-in-50 duration-300",
ACTIVITY_DOT[activity].className,
)}
/>
);
}
const FRAME = {
live: {
card: "border border-foreground/15",
tab: "-mb-px border-x border-t border-foreground/15",
corner: { left: "border-r border-b", right: "border-l border-b", tone: "border-foreground/15" },
},
demo: {
card: "border-2 border-info",
tab: "-mb-0.5 border-x-2 border-t-2 border-info",
corner: { left: "border-r-2 border-b-2", right: "border-l-2 border-b-2", tone: "border-info" },
},
} as const;
const frameOf = (demo: boolean) => FRAME[demo ? "demo" : "live"];
/** Inverted corner joining the tab's side border to the card's top border; plain CSS borders so both snap to the same pixels. */
function NotchCorner({ side, demo }: { side: "left" | "right"; demo: boolean }) {
const { corner } = frameOf(demo);
return (
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute bottom-0 size-3 overflow-hidden",
side === "left" ? "-left-3" : "-right-3",
)}
>
<span
className={cn(
"block size-full shadow-[0_0_0_12px_var(--card)]",
side === "left" ? "rounded-br-xl" : "rounded-bl-xl",
corner[side],
corner.tone,
)}
/>
</span>
);
}
function LensModeSwitch({ activity, demo }: { activity: InvestigationActivity; demo: boolean }) {
return (
<div className={cn("relative z-raised rounded-t-2xl bg-card px-1.5 pt-1.5 pb-[7px]", frameOf(demo).tab)}>
<NotchCorner side="left" demo={demo} />
<NotchCorner side="right" demo={demo} />
<TabsPrimitive.List
aria-label="Lens"
className="relative inline-flex h-9 items-center rounded-full bg-muted/70 p-1"
>
<TabsPrimitive.Indicator className="absolute top-1 bottom-1 left-(--active-tab-left) w-(--active-tab-width) rounded-full bg-background shadow-sm ring-1 ring-border transition-[left,width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none" />
{Object.entries(LENS_TABS).map(([view, label]) => {
const Icon = MODE_ICONS[view as LensTab];
return (
<TabsPrimitive.Tab
key={view}
value={view}
aria-description={
view === "investigations" && activity !== "idle" ? ACTIVITY_DOT[activity].label : undefined
}
className="relative z-raised inline-flex h-full items-center gap-2 rounded-full px-4 text-sm font-medium text-muted-foreground outline-none transition-colors duration-200 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-active:text-foreground"
>
<Icon aria-hidden="true" className="size-4" />
{label}
{view === "investigations" && <ActivityDot activity={activity} />}
</TabsPrimitive.Tab>
);
})}
</TabsPrimitive.List>
</div>
);
}
function useInvestigationActivity(accessToken: string, enabled: boolean): InvestigationActivity {
const api = useLensApi(accessToken);
const { data } = useQuery({ ...lensQueries.list(api, false), enabled });
return investigationActivity(data?.lenses ?? []);
/** The one always-mounted `/lens` observer; every other reader is a plain cache subscriber. */
function useLensOverview(enabled: boolean, settingsOpen: boolean) {
const api = useLensApi();
const { data } = useQuery({
...lensQueries.list(api),
enabled,
refetchInterval: (query) => listPollInterval(query.state.data, settingsOpen, Date.now()),
});
return { activity: investigationActivity(data?.lenses ?? []), list: data };
}
const PANEL =
"flex min-h-0 flex-1 flex-col overflow-y-auto animate-in fade-in-0 duration-300 motion-reduce:animate-none";
function LensContent({ accessToken, userRole, readOnly }: WorkspaceProps) {
const { tab, lensId, demo, setTab, setDemo } = useLensRoute();
function LensContent({ userRole, readOnly }: Omit<WorkspaceProps, "accessToken">) {
const accessToken = useLensAccessToken();
const { tab, lensId, demo, settingUp, setTab, setDemo, setSetup } = useLensRoute();
const { dialog, openDialog } = useDialogRoute();
const { openTrace } = useOpenTraceRouting();
const [previewTarget, setPreviewTarget] = useState<HTMLDivElement | null>(null);
const activeTab = tab ?? (lensId ? "investigations" : "traces");
const canInvestigate = isProxyAdminTierRole(userRole);
const activity = useInvestigationActivity(accessToken, canInvestigate);
const canViewInvestigations = isProxyAdminTierRole(userRole);
const isAdmin = isProxyAdminRole(userRole);
const canConfigure = canViewInvestigations && !readOnly;
const defaultTab = lensId ? "investigations" : "traces";
const activeTab = tab === "settings" && !canConfigure ? defaultTab : tab ?? defaultTab;
const intro = useLensIntro({ demo, settingUp });
const { activity, list } = useLensOverview(
canViewInvestigations,
(canConfigure && activeTab === "settings") || intro.open,
);
const workers = canConfigure && list ? list.workers : null;
const leaveIntro = (forever = false) => {
if (!intro.open) return;
intro.close(forever);
setSetup(false);
};
const startSetup = () => {
if (!settingUp) setSetup(true);
};
const exitIntro = (to: LensTab) => {
leaveIntro();
setTab(to);
};
const showSettings = () => {
leaveIntro();
setTab("settings");
};
const startFirstInvestigation = () => {
leaveIntro();
setTab("investigations");
openDialog("new");
};
const showSentTrace = (trace: TraceSummary) => {
leaveIntro();
setTab("traces");
openTrace(traceRefOf(trace));
};
const enterDemo = () => {
leaveIntro();
setDemo(true);
};
const onboarding: Onboarding = {
readOnly,
canViewInvestigations,
canInvestigate: isAdmin,
canMintTracingKey: isAdmin,
connect: showSettings,
create: startFirstInvestigation,
openTrace: showSentTrace,
};
const preview = (view: LensTab) => ({
target: previewTarget,
open: !demo && activeTab === view ? () => setDemo(true) : undefined,
open: !demo && !intro.open && activeTab === view ? enterDemo : undefined,
});
return (
<main className="flex h-full w-full min-w-0 flex-1 flex-col px-4 pt-3 pb-4">
<Tabs value={activeTab} onValueChange={(value) => setTab(value as LensTab)} className="min-h-0 flex-1 gap-0">
<div className="grid grid-cols-[1fr_auto_1fr] items-end gap-3">
<div className="flex min-w-0 flex-col gap-1 pb-3">
<h1 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
<Aperture aria-hidden="true" className="size-5" strokeWidth={2} />
Lens
</h1>
<p className="truncate text-xs text-muted-foreground">
Trace your agents and investigate what goes wrong.{" "}
<a
href="https://docs.litellm.ai/docs/proxy/lens"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
>
Docs
<ArrowUpRight aria-hidden="true" className="size-3" />
</a>
</p>
<OnboardingProvider value={onboarding}>
<main className="flex h-full w-full min-w-0 flex-1 flex-col px-4 pt-3 pb-4">
<Tabs value={activeTab} onValueChange={(value) => setTab(value as LensTab)} className="min-h-0 flex-1 gap-0">
<div className="grid grid-cols-[1fr_auto_1fr] items-end gap-3">
<div className="flex min-w-0 flex-col gap-1 pb-3">
<h1 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
<Aperture aria-hidden="true" className="size-5" strokeWidth={2} />
Lens
</h1>
<p className="truncate text-xs text-muted-foreground">
Trace your agents and investigate what goes wrong.{" "}
<a
href="https://docs.litellm.ai/docs/proxy/lens"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
>
Docs
<ArrowUpRight aria-hidden="true" className="size-3" />
</a>
</p>
</div>
<LensModeSwitch
activity={activity}
demo={demo}
workers={workers}
setup={activeTab === "investigations" && dialog ? SETUP_LABELS[dialog] : undefined}
/>
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 pb-3">
<div ref={setPreviewTarget} />
<DemoToggle demo={demo} onChange={(next) => (next ? enterDemo() : setDemo(false))} />
</div>
</div>
<LensModeSwitch activity={activity} demo={demo} />
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 pb-3">
<div ref={setPreviewTarget} />
<DemoToggle demo={demo} onChange={setDemo} />
</div>
</div>
<div className={cn("flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-card", frameOf(demo).card)}>
<TabsContent value="traces" keepMounted className={PANEL}>
<LensPreviewContext.Provider value={preview("traces")}>
<AgentTracesPage
accessToken={accessToken}
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={isProxyAdminRole(userRole)}
/>
</LensPreviewContext.Provider>
</TabsContent>
<TabsContent value="investigations" className={cn(PANEL, "p-4")}>
<LensPreviewContext.Provider value={preview("investigations")}>
{canInvestigate ? (
<InvestigationsView
active={activeTab === "investigations"}
<div className={frameCard({ session: demo ? "demo" : "live" })}>
<TabsContent value="traces" keepMounted className={PANEL}>
<LensPreviewContext.Provider value={preview("traces")}>
<AgentTracesPage
accessToken={accessToken}
readOnly={readOnly || !isProxyAdminRole(userRole)}
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={isAdmin}
/>
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</LensPreviewContext.Provider>
</TabsContent>
</div>
</Tabs>
</main>
</LensPreviewContext.Provider>
</TabsContent>
<TabsContent value="investigations" className={PANEL}>
<LensPreviewContext.Provider value={preview("investigations")}>
{canViewInvestigations ? (
<InvestigationsView readOnly={readOnly || !isAdmin} />
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</LensPreviewContext.Provider>
</TabsContent>
{workers && list && (
<TabsContent value="settings" keepMounted className={cn(PANEL, "p-6")}>
<LensSettings
list={list}
workerReadyAction={
list.lenses.length === 0 ? (
<Button className="w-full" onClick={startFirstInvestigation}>
New investigation
</Button>
) : undefined
}
onOpenTraces={() => setTab("traces")}
/>
</TabsContent>
)}
</div>
</Tabs>
<LensIntroDialog
open={intro.open}
onClose={leaveIntro}
onStart={startSetup}
onExit={exitIntro}
onDemo={enterDemo}
/>
</main>
</OnboardingProvider>
);
}

View file

@ -1,93 +0,0 @@
"use client";
import { validateWorkerAddress, analysisAccessSchema, type AnalysisAccess } from "../setup/worker/workerSchema";
import { useMutation } from "@tanstack/react-query";
import type { Lens, Settings, WorkerCreated } from "../model/types";
import type { LensApi } from "./service";
import { useLensApi } from "../services";
export type LensWrite = (api: LensApi) => Promise<unknown>;
export function useLensUpdate(accessToken: string) {
const api = useLensApi(accessToken);
return useMutation({ retry: false, mutationFn: (write: LensWrite) => write(api) });
}
export function useSaveLens(accessToken: string) {
const api = useLensApi(accessToken);
return useMutation({
retry: false,
mutationFn: ({ id, settings }: { id?: string; settings: Settings }): Promise<Lens> => api.saveLens(id, settings),
});
}
export function useRevokeWorker(accessToken: string) {
const api = useLensApi(accessToken);
return useMutation({ retry: false, mutationFn: (id: string) => api.revokeWorker(id) });
}
export async function createAnalysisKey(api: LensApi, access: AnalysisAccess): Promise<string> {
const parsed = analysisAccessSchema.safeParse(access);
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
const result = await api.generateAnalysisKey(parsed.data);
if (!result.token_id) throw new Error("The proxy did not return the new key's ID");
return result.token_id;
}
export function usePrepareWorker(
accessToken: string,
{ onChanged, onPrepared }: { onChanged: () => void; onPrepared: (created: WorkerCreated | null) => void },
) {
const api = useLensApi(accessToken);
return useMutation({
retry: false,
mutationFn: async ({
address,
useExisting,
analysisKey,
access,
editingWorker,
}: {
address: string;
useExisting: boolean;
analysisKey: string | null;
access: AnalysisAccess;
editingWorker: string | null;
}) => {
let newKey: string | null = null;
try {
validateWorkerAddress(address);
const keyId = useExisting ? analysisKey : await createAnalysisKey(api, access);
if (!useExisting) newKey = keyId;
if (editingWorker) {
await api.setWorkerBillingKey(editingWorker, keyId);
onPrepared(null);
onChanged();
return null;
}
const created = await api.registerWorker(keyId);
onPrepared(created);
onChanged();
return created;
} catch (e) {
const message = e instanceof Error ? e.message : "Could not create credential";
if (newKey) {
try {
const current = await api.lenses();
if (!current.workers.some((worker) => !worker.revoked && worker.analysis_key_id === newKey)) {
await api.deleteKeys([newKey]);
}
onChanged();
} catch {
throw new Error(
`${message}. Could not confirm cleanup. Check the Lens analysis key in Virtual Keys before retrying.`,
);
}
}
throw new Error(message);
}
},
});
}

View file

@ -0,0 +1,40 @@
"use client";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { apiClient } from "@/components/networking";
import { liveTracesApi, TracesApiContext, type TracesApi } from "@/components/view_logs/TraceView/tracesApi";
import { liveLensApi, type LensApi } from "./service";
export interface LensServices {
readonly accessToken: string;
readonly lens: LensApi;
readonly traces: TracesApi;
}
const LensServicesContext = createContext<LensServices | null>(null);
export function liveLensServices(accessToken: string): LensServices {
return { accessToken, lens: liveLensApi(apiClient, accessToken), traces: liveTracesApi(accessToken) };
}
function useLensServices(): LensServices {
const provided = useContext(LensServicesContext);
if (!provided) throw new Error("Lens services need a LensServicesProvider above them");
return provided;
}
export const useLensApi = (): LensApi => useLensServices().lens;
export const useLensAccessToken = (): string => useLensServices().accessToken;
export function useLiveLensServices(accessToken: string): LensServices {
return useMemo(() => liveLensServices(accessToken), [accessToken]);
}
export function LensServicesProvider({ services, children }: { services: LensServices; children: ReactNode }) {
return (
<LensServicesContext.Provider value={services}>
<TracesApiContext.Provider value={services.traces}>{children}</TracesApiContext.Provider>
</LensServicesContext.Provider>
);
}

View file

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { createLensDemo, createLensDemoData } from "./createLensDemo";
import { evidenceTarget } from "../model/findings";
import { createLensDemo } from "./createLensDemo";
import { createLensDemoData } from "./fixtures";
import { evidenceTarget } from "../../model/findings";
describe("Lens demo data", () => {
it("links every finding to the quoted original step and assessed run", () => {

View file

@ -0,0 +1,77 @@
import { ApiError } from "@/lib/http/client";
import type { TracesApi } from "@/components/view_logs/TraceView/tracesApi";
import type { LensServices } from "../LensServices";
import type { LensApi } from "../service";
import { createLensDemoData, type LensDemoData } from "./fixtures";
const notInDemo = (): Promise<never> =>
Promise.reject(new ApiError("This item is not in the demo", 404, { detail: "This item is not in the demo" }));
const readOnly = (): Promise<never> =>
Promise.reject(new ApiError("Demo data is read-only", 403, { detail: "Demo data is read-only" }));
const found = <T>(value: T | undefined): Promise<T> => (value === undefined ? notInDemo() : Promise.resolve(value));
function demoLensApi(data: LensDemoData): LensApi {
const jobs = (lensId: string) => data.lenses.find((lens) => lens.id === lensId)?.jobs;
return {
scope: "demo",
lenses: async () => ({ lenses: data.lenses, workers: [], tracing_enabled: true }),
activity: async () => ({ traces: true, requests: false }),
runs: (lensId, offset) => found(jobs(lensId)?.slice(offset)),
run: (lensId, jobId) => found(jobs(lensId)?.find((job) => job.id === jobId)),
execution: notInDemo,
sample: notInDemo,
agents: notInDemo,
models: async () => ({ data: [] }),
modelDetails: async () => ({ data: [] }),
keys: notInDemo,
keyInfo: notInDemo,
saveLens: readOnly,
startRun: readOnly,
watchAll: async () => ({ watching: [], skipped: [] }),
cancelRun: readOnly,
reviewFinding: readOnly,
registerWorker: readOnly,
setWorkerBillingKey: readOnly,
revokeWorker: readOnly,
generateAnalysisKey: readOnly,
deleteKeys: readOnly,
};
}
function demoTracesApi(data: LensDemoData): TracesApi {
const run = (traceId: string) => data.runs.find(({ trace }) => trace.summary.trace_id === traceId);
return {
live: false,
handoff: (traceId, spanId) => {
const found = run(traceId);
const step = spanId ? found?.details.find((span) => span.span_id === spanId) : found;
return { text: JSON.stringify(step, null, 2), copied: spanId ? "Step copied" : "Trace copied" };
},
list: async ({ startMs, endMs }) => ({
items: data.runs
.map((item) => item.trace.summary)
.filter((trace) => Date.parse(trace.start_time) >= startMs && Date.parse(trace.start_time) <= endMs),
next_cursor: null,
traversal: { id: "demo", published_at: new Date(endMs).toISOString(), expires_at: new Date(endMs).toISOString() },
}),
anyRecorded: async () => data.runs.length > 0,
trace: (traceId) => found(run(traceId)?.trace),
span: (traceId, spanId) => found(run(traceId)?.details.find((span) => span.span_id === spanId)),
spanError: async (traceId, spanId) => {
const span = run(traceId)?.trace.spans.find((item) => item.span_id === spanId);
return found(
span && {
span_id: span.span_id,
message: span.error ?? "",
total_chars: span.error?.length ?? 0,
next_cursor: null,
},
);
},
};
}
export function createLensDemo(now = Date.now()): LensServices {
const data = createLensDemoData(now);
return { accessToken: "lens-demo", lens: demoLensApi(data), traces: demoTracesApi(data) };
}

View file

@ -1,11 +1,6 @@
import { ApiError } from "@/lib/http/client";
import type { TracesApi } from "@/components/view_logs/TraceView/tracesApi";
import type { LensServices } from "../services";
import type { LensApi } from "../api/service";
import type { Trace, Span, SpanDetail } from "@/components/view_logs/TraceView/traceTypes";
import type { Lens, Finding, Job, Settings } from "../model/types";
import type { Lens, Finding, Job, Settings } from "../../model/types";
import { withReleaseCases } from "./lensDemoLongTrace";
import { scenarios, type Scenario } from "./scenarios";
const executionId = (traceId: string) => btoa(JSON.stringify(["traces", "", traceId]));
@ -336,75 +331,3 @@ export function createLensDemoData(now = Date.now()) {
});
return { runs, lenses };
}
const notInDemo = (): Promise<never> =>
Promise.reject(new ApiError("This item is not in the demo", 404, { detail: "This item is not in the demo" }));
const readOnly = (): Promise<never> =>
Promise.reject(new ApiError("Demo data is read-only", 403, { detail: "Demo data is read-only" }));
const found = <T>(value: T | undefined): Promise<T> => (value === undefined ? notInDemo() : Promise.resolve(value));
function demoLensApi(data: LensDemoData): LensApi {
const jobs = (lensId: string) => data.lenses.find((lens) => lens.id === lensId)?.jobs;
return {
scope: "demo",
lenses: async () => ({ lenses: data.lenses, workers: [], tracing_enabled: true }),
activity: async () => ({ traces: true, requests: false }),
runs: (lensId, offset) => found(jobs(lensId)?.slice(offset)),
run: (lensId, jobId) => found(jobs(lensId)?.find((job) => job.id === jobId)),
execution: notInDemo,
sample: notInDemo,
agents: notInDemo,
models: async () => ({ data: [] }),
modelDetails: async () => ({ data: [] }),
keys: notInDemo,
keyInfo: notInDemo,
saveLens: readOnly,
startRun: readOnly,
watchAll: async () => ({ watching: [], skipped: [] }),
cancelRun: readOnly,
reviewFinding: readOnly,
registerWorker: readOnly,
setWorkerBillingKey: readOnly,
revokeWorker: readOnly,
generateAnalysisKey: readOnly,
deleteKeys: readOnly,
};
}
function demoTracesApi(data: LensDemoData): TracesApi {
const run = (traceId: string) => data.runs.find(({ trace }) => trace.summary.trace_id === traceId);
return {
live: false,
handoff: (traceId, spanId) => {
const found = run(traceId);
const step = spanId ? found?.details.find((span) => span.span_id === spanId) : found;
return { text: JSON.stringify(step, null, 2), copied: spanId ? "Step copied" : "Trace copied" };
},
list: async ({ startMs, endMs }) => ({
items: data.runs
.map((item) => item.trace.summary)
.filter((trace) => Date.parse(trace.start_time) >= startMs && Date.parse(trace.start_time) <= endMs),
next_cursor: null,
traversal: { id: "demo", published_at: new Date(endMs).toISOString(), expires_at: new Date(endMs).toISOString() },
}),
anyRecorded: async () => data.runs.length > 0,
trace: (traceId) => found(run(traceId)?.trace),
span: (traceId, spanId) => found(run(traceId)?.details.find((span) => span.span_id === spanId)),
spanError: async (traceId, spanId) => {
const span = run(traceId)?.trace.spans.find((item) => item.span_id === spanId);
return found(
span && {
span_id: span.span_id,
message: span.error ?? "",
total_chars: span.error?.length ?? 0,
next_cursor: null,
},
);
},
};
}
export function createLensDemo(now = Date.now()): LensServices {
const data = createLensDemoData(now);
return { lens: demoLensApi(data), traces: demoTracesApi(data) };
}

View file

@ -0,0 +1,56 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Lens, LensList, Settings } from "../model/types";
import type { LensApi } from "./service";
import { lensKeys } from "./queries";
import { useLensApi } from "./LensServices";
export type LensWrite = (api: LensApi) => Promise<unknown>;
export function useInvalidateLenses() {
const api = useLensApi();
const client = useQueryClient();
return () =>
Promise.all([
client.invalidateQueries({ queryKey: lensKeys.list(api.scope) }),
client.invalidateQueries({ queryKey: lensKeys.histories() }),
]);
}
export function useLensUpdate() {
const api = useLensApi();
const invalidate = useInvalidateLenses();
return useMutation({ retry: false, mutationFn: (write: LensWrite) => write(api), onSettled: invalidate });
}
function upsertLens(list: LensList | undefined, saved: Lens): LensList | undefined {
if (!list) return list;
const known = list.lenses.some((lens) => lens.id === saved.id);
const lenses = known ? list.lenses.map((lens) => (lens.id === saved.id ? saved : lens)) : [...list.lenses, saved];
return { ...list, lenses };
}
export function useSaveLens() {
const api = useLensApi();
const client = useQueryClient();
return useMutation({
retry: false,
mutationFn: ({ id, settings }: { id?: string; settings: Settings }): Promise<Lens> => api.saveLens(id, settings),
onSuccess: (saved) => {
client.setQueryData<LensList>(lensKeys.list(api.scope), (current) => upsertLens(current, saved));
return client.invalidateQueries({ queryKey: lensKeys.list(api.scope) });
},
});
}
export function useRevokeWorker() {
const api = useLensApi();
const client = useQueryClient();
return useMutation({
retry: false,
mutationFn: (id: string) => api.revokeWorker(id),
onSettled: () => client.invalidateQueries({ queryKey: lensKeys.list(api.scope) }),
});
}

View file

@ -1,9 +1,12 @@
import { infiniteQueryOptions, queryOptions, type Query } from "@tanstack/react-query";
import type { LensList, Settings, ActivitySelection } from "../model/types";
import { infiniteQueryOptions, keepPreviousData, queryOptions } from "@tanstack/react-query";
import { hasActiveJob } from "../model/status";
import type { Sample, Settings, ActivitySelection, Job } from "../model/types";
import type { KeyPage, LensApi } from "./service";
export type { Key } from "./service";
type Activity = Awaited<ReturnType<LensApi["activity"]>>;
export const lensKeys = {
all: ["lens"] as const,
lists: () => [...lensKeys.all, "list"] as const,
@ -19,29 +22,20 @@ export const lensKeys = {
models: (scope: string) => [...lensKeys.all, "models", { scope }] as const,
modelDetails: (scope: string) => [...lensKeys.all, "model-details", { scope }] as const,
activity: (scope: string) => [...lensKeys.all, "activity-available", { scope }] as const,
discovery: (scope: string, source: Settings["source"], hours: number | undefined, asOf: string) =>
[...lensKeys.all, "discovery", { scope, source, hours, asOf }] as const,
preview: (scope: string, selection: ActivitySelection, offset: number, asOf: string) =>
[...lensKeys.all, "preview", { scope, selection, offset, asOf }] as const,
agents: (scope: string, asOf: string) => [...lensKeys.all, "agents", { scope, asOf }] as const,
discoveries: () => [...lensKeys.all, "discovery"] as const,
discovery: (scope: string, source: Settings["source"], hours: number | undefined) =>
[...lensKeys.discoveries(), { scope, source, hours }] as const,
preview: (scope: string, selection: ActivitySelection, asOf: string) =>
[...lensKeys.all, "preview", { scope, selection, asOf }] as const,
agents: (scope: string) => [...lensKeys.all, "agents", { scope }] as const,
analysisKeys: (scope: string, query: string) => [...lensKeys.all, "analysis-keys", { scope, query }] as const,
analysisKeyInfo: (scope: string, keyId: string | undefined) =>
[...lensKeys.all, "analysis-key-info", { scope, keyId }] as const,
};
export const lensQueries = {
list(api: LensApi, workerSetup: boolean) {
const options = {
queryKey: lensKeys.list(api.scope),
queryFn: () => api.lenses(),
refetchInterval: (current: Query<LensList>): number | false => {
const running = current.state.data?.lenses.some((item) =>
item.jobs.some((job) => ["queued", "running"].includes(job.status)),
);
return workerSetup || running ? 2000 : 10000;
},
};
return queryOptions(options);
list(api: LensApi) {
return queryOptions({ queryKey: lensKeys.list(api.scope), queryFn: () => api.lenses(), staleTime: 5000 });
},
models(api: LensApi) {
return queryOptions({ queryKey: lensKeys.models(api.scope), queryFn: () => api.models() });
@ -54,7 +48,8 @@ export const lensQueries = {
queryKey: lensKeys.activity(api.scope),
queryFn: () => api.activity(),
enabled: loaded,
refetchInterval: 5000,
refetchInterval: ({ state }: { state: { data?: Activity } }) =>
state.data?.traces && state.data.requests ? false : 5000,
};
return queryOptions(options);
},
@ -63,7 +58,8 @@ export const lensQueries = {
queryKey: lensKeys.history(api.scope, lensId, historyOffset),
enabled: !!lensId,
queryFn: () => api.runs(lensId as string, historyOffset),
refetchInterval: 10000,
refetchInterval: ({ state }: { state: { data?: Job[] } }) =>
state.data && hasActiveJob(state.data) ? 10000 : false,
};
return queryOptions(options);
},
@ -96,31 +92,32 @@ export const lensQueries = {
};
return queryOptions(options);
},
discovery(api: LensApi, { value, asOf, enabled }: { value: ActivitySelection; asOf: string; enabled: boolean }) {
discovery(api: LensApi, { value, enabled }: { value: ActivitySelection; enabled: boolean }) {
const unfiltered = { source: value.source, service: "", filters: [], lookback_hours: value.lookback_hours };
const options = {
queryKey: lensKeys.discovery(api.scope, value.source, value.lookback_hours, asOf),
queryFn: () => api.sample(unfiltered, 0, asOf),
queryKey: lensKeys.discovery(api.scope, value.source, value.lookback_hours),
queryFn: () => api.sample(unfiltered, 0, new Date().toISOString()),
staleTime: 60000,
enabled,
};
return queryOptions(options);
},
preview(
api: LensApi,
{ scope, offset, asOf, enabled }: { scope: ActivitySelection; offset: number; asOf: string; enabled: boolean },
) {
preview(api: LensApi, { scope, asOf, enabled }: { scope: ActivitySelection; asOf: string; enabled: boolean }) {
const options = {
queryKey: lensKeys.preview(api.scope, scope, offset, asOf),
queryFn: () => api.sample(scope, offset, asOf),
queryKey: lensKeys.preview(api.scope, scope, asOf),
initialPageParam: 0,
queryFn: ({ pageParam }: { pageParam: number }) => api.sample(scope, pageParam, asOf),
getNextPageParam: (lastPage: Sample) => lastPage.next_offset ?? undefined,
enabled,
staleTime: 30000,
gcTime: 60_000,
placeholderData: keepPreviousData,
};
return queryOptions(options);
return infiniteQueryOptions(options);
},
agents(api: LensApi, asOf: string, source: Settings["source"]) {
agents(api: LensApi, source: Settings["source"]) {
const options = {
queryKey: lensKeys.agents(api.scope, asOf),
queryKey: lensKeys.agents(api.scope),
queryFn: () => api.agents(),
enabled: source !== "requests",
staleTime: 60000,

View file

@ -1,9 +1,17 @@
import { z } from "zod";
import type { RunWindow } from "../investigations/detail/RunNowDialog";
import type { ApiClient } from "@/lib/http/client";
import type { components } from "@/lib/http/schema";
import type { ActivitySelection, Job, Lens, LensList, Sample, Settings, WorkerCreated } from "../model/types";
import type { AnalysisModelInfo } from "../setup/fields/analysisModels";
import type {
ActivitySelection,
AnalysisModelInfo,
Job,
Lens,
LensList,
RunWindow,
Sample,
Settings,
WorkerCreated,
} from "../model/types";
export type ExecutionContent = components["schemas"]["ExecutionContent"];
export type FindingStatus = components["schemas"]["FindingUpdate"]["status"];

View file

@ -0,0 +1,58 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { isTracingNotEnabled, useTraceAvailability } from "@/components/view_logs/TraceView/useAgentTraces";
import { useLensAccessToken, useLensApi } from "../data/LensServices";
import { lensQueries } from "../data/queries";
import { readiness, type Readiness, type ReadinessInput } from "../model/readiness";
import { useWorkerConnected } from "./useWorkerConnected";
export interface LensReadiness extends Readiness {
readonly loading: boolean;
readonly checking: boolean;
readonly error: string | undefined;
refresh(): void;
}
export function useLensReadiness(canInvestigate: boolean): LensReadiness {
const api = useLensApi();
const accessToken = useLensAccessToken();
const traces = useTraceAvailability(accessToken, true);
const list = useQuery({ ...lensQueries.list(api), enabled: canInvestigate });
const activity = useQuery(lensQueries.activity(api, canInvestigate && list.isSuccess));
const connected = useWorkerConnected(list.data?.workers);
const tracesDisabled = isTracingNotEnabled(traces.error);
const input: ReadinessInput = {
traces: {
recorded: traces.data,
failed: traces.isError,
disabled: tracesDisabled,
checked: traces.isSuccess,
},
tracingConfigured: list.data?.tracing_enabled ?? false,
activity: activity.data,
activityError: activity.error,
connected,
listError: list.error,
investigations: list.data?.lenses.length ?? 0,
};
const state = readiness(input);
const activityError = state.activityReady ? null : (tracesDisabled ? null : traces.error) || activity.error;
const error = list.error || activityError;
const loadingTraces = !state.activityReady && traces.isPending;
const loadingActivity = !state.activityReady && list.isSuccess && activity.isPending;
const loadingInvestigations = canInvestigate && (list.isPending || loadingActivity);
const refresh = () => {
void traces.refetch();
if (!canInvestigate) return;
void list.refetch();
void activity.refetch();
};
return {
...state,
loading: loadingTraces || loadingInvestigations,
checking: traces.isFetching || list.isFetching || activity.isFetching,
error: error?.message,
refresh,
};
}

View file

@ -0,0 +1,12 @@
"use client";
import { useNow } from "@/hooks/useNow";
import { workerConnected } from "../model/status";
import type { LensList } from "../model/types";
const HEARTBEAT_TICK_MS = 5000;
export function useWorkerConnected(workers: LensList["workers"] | null | undefined): boolean {
const now = useNow(HEARTBEAT_TICK_MS);
return workers?.some((worker) => workerConnected(worker, now)) ?? false;
}

View file

@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Inspector } from "@/components/shared/Inspector";
import { Button } from "@/components/ui/button";
import { RunView } from "@/components/view_logs/TraceView/TraceDrawer";
import { useLocalRunSelection } from "@/components/view_logs/TraceView/traceRouting";
import { lensQueries } from "../data/queries";
import { useLensAccessToken, useLensApi } from "../data/LensServices";
import { evidenceTarget, type EvidenceTarget } from "../model/findings";
import type { EvidenceRef } from "../route";
const SECTION = 8000;
export interface EvidenceViewProps {
readonly lensId: string;
readonly evidence: EvidenceRef;
/** Shown as a back link above the evidence when it is stacked over something else. */
readonly backLabel?: string;
readonly onBack?: () => void;
}
/** The original trace step or logged request a piece of evidence points at, filling the panel. */
export function EvidenceView({ lensId, evidence, backLabel, onBack }: EvidenceViewProps) {
const target = evidenceTarget(evidence.id);
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="evidence-view">
{onBack && backLabel && (
<div className="flex h-9 shrink-0 items-center border-b px-3">
<Inspector.BackLink label={backLabel} onClick={onBack} />
</div>
)}
<EvidenceBody
key={`${evidence.id}:${evidence.span}`}
lensId={lensId}
target={target}
evidence={evidence}
onBack={onBack}
/>
</div>
);
}
function EvidenceBody({
lensId,
target,
evidence,
onBack = () => {},
}: {
lensId: string;
target: EvidenceTarget | null;
evidence: EvidenceRef;
onBack?: () => void;
}) {
if (target?.source === "traces")
return (
<TraceEvidence
traceId={target.id}
traceRef={target.traceRef}
initialSpanId={evidence.span || null}
onBack={onBack}
/>
);
if (target?.source === "requests") return <RequestEvidence lensId={lensId} evidenceId={evidence.id} />;
return <p className="p-4 text-sm text-muted-foreground">This evidence is no longer available.</p>;
}
export function TraceEvidence({
traceId,
traceRef,
initialSpanId,
onBack,
}: {
traceId: string;
traceRef?: string;
initialSpanId: string | null;
onBack: () => void;
}) {
const selection = useLocalRunSelection(initialSpanId);
const accessToken = useLensAccessToken();
return (
<RunView
traceId={traceId}
traceRef={traceRef}
selection={selection}
accessToken={accessToken}
onBack={onBack}
embedded
/>
);
}
function RequestEvidence({ lensId, evidenceId }: { lensId: string; evidenceId: string }) {
const api = useLensApi();
const [offset, setOffset] = useState(0);
const request = { lensId, evidenceId, requestOffset: offset, source: "requests" };
const evidence = useQuery(lensQueries.evidence(api, request));
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<header className="border-b px-4 py-4">
<h2 className="text-sm font-semibold">Request evidence</h2>
<p className="text-xs text-muted-foreground">Original logged input and output</p>
</header>
<div className="space-y-3 p-4">
{evidence.isLoading && <p role="status">Loading request…</p>}
{evidence.error && <p role="alert">{evidence.error.message}</p>}
{evidence.data?.parts.map((p) => (
<pre className="text-xs break-words whitespace-pre-wrap" key={p.span_id}>
{p.content}
</pre>
))}
{evidence.data?.parts.length === 0 && <p>Request was not found or is past retention</p>}
<div className="flex flex-wrap gap-2">
{offset > 0 && (
<Button variant="outline" onClick={() => setOffset(Math.max(0, offset - SECTION))}>
Previous section
</Button>
)}
{evidence.data?.parts.some((p) => p.truncated) && (
<Button variant="outline" onClick={() => setOffset(offset === 0 ? 1 : offset + SECTION)}>
Next section
</Button>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,135 @@
import { fireEvent, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import type { RunSelection } from "@/components/view_logs/TraceView/traceRouting";
import { renderWithLens } from "@/../tests/lens-test-utils";
import { Inspector } from "@/components/shared/Inspector";
import type { OwnedFinding } from "../model/inbox";
import type { Finding, Lens } from "../model/types";
import { FindingPanel, ownedFindingKey } from "./FindingDetails";
vi.mock("@/components/view_logs/TraceView/TraceDrawer", () => ({
RunView: ({ traceId, selection }: { traceId: string; selection: RunSelection }) => (
<div data-testid="run-view">
{traceId} at {selection.spanId}
</div>
),
}));
const lens = {
id: "lens",
settings: { name: "Support reviews", agent_name: "support_agent" },
jobs: [],
findings: [],
} as unknown as Lens;
const finding: Finding = {
id: "finding",
check_id: "check",
title: "Repeated retries",
description: "The tool kept failing",
kind: "issue",
priority: "high",
status: "open",
revision: 1,
first_seen: "2026-09-30T10:00:00Z",
last_seen: "2026-09-30T10:00:00Z",
reason: "Saved feedback",
suggestion: "",
limitation: "",
occurrences: [],
evidence: [],
};
const owned = (current: Finding | null): OwnedFinding | null => (current ? { lens, finding: current } : null);
function Harness({ current, onReview }: { current: Finding | null; onReview: FindingPanelReview }) {
return (
<Inspector.Root
items={[]}
itemKey={ownedFindingKey}
selected={owned(current)}
onSelectedChange={vi.fn()}
noun="finding"
storageKey="test.finding"
>
<FindingPanel readOnly={false} busy={false} onReview={onReview} />
</Inspector.Root>
);
}
type FindingPanelReview = Parameters<typeof FindingPanel>[0]["onReview"];
afterEach(() => vi.restoreAllMocks());
it("keeps a feedback draft during refreshes, sends it with status changes, and restores saved feedback on reopening", async () => {
vi.spyOn(window, "matchMedia").mockImplementation(
(query: string) =>
({
matches: query.includes("reduce"),
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as unknown as MediaQueryList,
);
const user = userEvent.setup();
const onReview = vi.fn();
const view = renderWithLens(<Harness current={finding} onReview={onReview} />);
const reason = () => screen.getByRole("textbox", { name: "What should Lens remember?" });
expect(screen.getByRole("complementary", { name: "Finding details" })).toHaveTextContent("support_agent");
expect(reason()).toHaveValue("Saved feedback");
fireEvent.change(reason(), { target: { value: "Draft feedback" } });
await user.click(screen.getByRole("button", { name: "Mark resolved" }));
expect(onReview).toHaveBeenLastCalledWith({ lens, finding }, "resolved", "Draft feedback");
const refreshed: Finding = { ...finding, reason: "Server feedback", status: "resolved" };
view.rerender(<Harness current={refreshed} onReview={onReview} />);
expect(reason()).toHaveValue("Draft feedback");
await user.click(screen.getByRole("button", { name: "Reopen" }));
expect(onReview).toHaveBeenLastCalledWith({ lens, finding: refreshed }, "open", "Draft feedback");
await user.click(screen.getByRole("button", { name: "This is expected" }));
expect(onReview).toHaveBeenLastCalledWith({ lens, finding: refreshed }, "dismissed", "Draft feedback");
view.rerender(<Harness current={null} onReview={onReview} />);
expect(screen.queryByRole("complementary")).not.toBeInTheDocument();
view.rerender(<Harness current={refreshed} onReview={onReview} />);
expect(reason()).toHaveValue("Server feedback");
const other: Finding = { ...finding, id: "other", reason: "Other feedback" };
view.rerender(<Harness current={other} onReview={onReview} />);
expect(reason()).toHaveValue("Other feedback");
});
it("stacks a quote's original step over the finding and keeps the feedback draft on the way back", async () => {
const user = userEvent.setup();
const traceOf = (id: string) => btoa(JSON.stringify(["traces", "", id]));
const quoted: Finding = {
...finding,
evidence: [
{ execution_id: traceOf("trace-1"), span_id: "step-a", quote: "first quote", role: "support" },
{ execution_id: traceOf("trace-2"), span_id: "step-b", quote: "second quote", role: "support" },
],
};
const onUrlUpdate = vi.fn();
renderWithLens(<Harness current={quoted} onReview={vi.fn()} />, { onUrlUpdate });
const panel = screen.getByRole("complementary", { name: "Finding details" });
const reason = () => within(panel).getByRole("textbox", { name: "What should Lens remember?", hidden: true });
fireEvent.change(reason(), { target: { value: "Draft feedback" } });
for (const summary of within(panel).getAllByText(/quote$/)) await user.click(summary);
await user.click(within(panel).getAllByRole("button", { name: "Open original step" })[0]);
expect(await within(panel).findByTestId("run-view")).toHaveTextContent("trace-1 at step-a");
expect(reason()).not.toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(within(panel).getByRole("button", { name: "Back to finding" }));
expect(within(panel).queryByTestId("run-view")).not.toBeInTheDocument();
expect(reason()).toBeVisible();
expect(reason()).toHaveValue("Draft feedback");
await user.click(within(panel).getAllByRole("button", { name: "Open original step" })[1]);
expect(await within(panel).findByTestId("run-view")).toHaveTextContent("trace-2 at step-b");
const url = new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
expect(url.get("evidence")).toBe(traceOf("trace-2"));
expect(url.get("evidence_span")).toBe("step-b");
});

View file

@ -0,0 +1,200 @@
"use client";
import { useState } from "react";
import { ArrowUpRight } from "lucide-react";
import { Inspector } from "@/components/shared/Inspector";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { evidenceTarget } from "../model/findings";
import { runTime } from "../model/format";
import { findingAgents, findingKey, type OwnedFinding, sampledExecutions } from "../model/inbox";
import type { Finding, Sample } from "../model/types";
import { EvidenceView } from "./Evidence";
import { IssueBrief } from "./IssueBrief";
import { type EvidenceRef, useEvidenceRoute } from "../route";
export const ownedFindingKey = (owned: OwnedFinding): string => findingKey(owned.lens, owned.finding);
export interface FindingDetailsProps {
readonly finding: Finding;
readonly agents?: readonly string[];
readonly sampledRuns: Sample["executions"];
readonly readOnly: boolean;
readonly busy: boolean;
readonly onOpenEvidence: (evidence: EvidenceRef) => void;
readonly onReview: (status: Finding["status"], reason: string) => void;
}
export function FindingDetails({
finding,
agents = [],
sampledRuns,
readOnly,
busy,
onOpenEvidence,
onReview,
}: FindingDetailsProps) {
const [reason, setReason] = useState(finding.reason ?? "");
const evidenceGroups = [...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 (
<div className="min-h-0 flex-1 overflow-y-auto">
<header className="flex flex-col gap-1.5 border-b px-4 py-4">
<h2 className="text-xl leading-snug font-semibold">{finding.title}</h2>
<p className="text-sm text-muted-foreground">
{agents.length > 0 && <span className="font-medium text-foreground">{agents.join(", ")} · </span>}
{finding.kind === "issue" ? `${finding.priority} priority` : "Pattern"} · {finding.occurrences?.length ?? 0}{" "}
linked {finding.occurrences?.length === 1 ? "run" : "runs"}
</p>
</header>
<div className="space-y-6 p-4">
{finding.brief ? (
<IssueBrief title={finding.title} brief={finding.brief} />
) : (
<>
<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={() => onOpenEvidence({ 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={() => onReview(finding.status === "resolved" ? "open" : "resolved", reason)}
>
{finding.status === "resolved" ? "Reopen" : "Mark resolved"}
</Button>
)}
<Button disabled={busy} variant="outline" onClick={() => onReview("dismissed", reason)}>
This is expected
</Button>
</div>
</div>
)}
</div>
</div>
);
}
export interface FindingPanelProps {
readonly readOnly: boolean;
readonly busy: boolean;
/** Runs to name evidence by; defaults to every run the owning investigation has sampled. */
readonly sampledRuns?: Sample["executions"];
readonly onReview: (owned: OwnedFinding, status: Finding["status"], reason: string) => void;
}
/**
* A finding in the side panel. Opening a quote's original step or request stacks it over the finding, which
* stays mounted so a feedback draft survives the round trip.
*/
export function FindingPanelBody({
owned,
readOnly,
busy,
sampledRuns,
onReview,
}: FindingPanelProps & { readonly owned: OwnedFinding }) {
const { evidence, setEvidence } = useEvidenceRoute();
return (
<>
<div hidden={evidence !== null} className={evidence ? undefined : "flex min-h-0 flex-1 flex-col"}>
<FindingDetails
key={ownedFindingKey(owned)}
finding={owned.finding}
agents={findingAgents(owned.lens, owned.finding)}
sampledRuns={sampledRuns ?? sampledExecutions(owned.lens)}
readOnly={readOnly}
busy={busy}
onOpenEvidence={setEvidence}
onReview={(status, reason) => onReview(owned, status, reason)}
/>
</div>
{evidence && (
<EvidenceView
lensId={owned.lens.id}
evidence={evidence}
backLabel="Back to finding"
onBack={() => setEvidence(null)}
/>
)}
</>
);
}
/** The side panel for whichever finding the surrounding Inspector has open. */
export function FindingPanel(props: FindingPanelProps) {
return (
<Inspector.Panel label="Finding details" testId="finding-panel">
{(owned: OwnedFinding) => <FindingPanelBody {...props} owned={owned} />}
</Inspector.Panel>
);
}

View file

@ -1,51 +0,0 @@
import { fireEvent, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it, vi } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import type { Finding } from "../model/types";
import { FindingSheet } from "./FindingSheet";
const finding: Finding = {
id: "finding",
check_id: "check",
title: "Repeated retries",
description: "The tool kept failing",
kind: "issue",
priority: "high",
status: "open",
revision: 1,
first_seen: "2026-09-30T10:00:00Z",
last_seen: "2026-09-30T10:00:00Z",
reason: "Saved feedback",
suggestion: "",
limitation: "",
occurrences: [],
evidence: [],
};
it("keeps a feedback draft during refreshes, sends it with status changes, and restores saved feedback on reopening", async () => {
const user = userEvent.setup();
const changeFinding = vi.fn().mockResolvedValue(undefined);
const props = { sampledRuns: [], readOnly: false, busy: false, onClose: vi.fn(), onEvidence: vi.fn(), changeFinding };
const view = renderWithProviders(<FindingSheet {...props} finding={finding} />);
const reason = () => screen.getByRole("textbox", { name: "What should Lens remember?" });
expect(reason()).toHaveValue("Saved feedback");
fireEvent.change(reason(), { target: { value: "Draft feedback" } });
await user.click(screen.getByRole("button", { name: "Mark resolved" }));
expect(changeFinding).toHaveBeenLastCalledWith("resolved", "Draft feedback");
const refreshed: Finding = { ...finding, reason: "Server feedback", status: "resolved" };
view.rerender(<FindingSheet {...props} finding={refreshed} />);
expect(reason()).toHaveValue("Draft feedback");
await user.click(screen.getByRole("button", { name: "Reopen" }));
expect(changeFinding).toHaveBeenLastCalledWith("open", "Draft feedback");
await user.click(screen.getByRole("button", { name: "This is expected" }));
expect(changeFinding).toHaveBeenLastCalledWith("dismissed", "Draft feedback");
view.rerender(<FindingSheet {...props} />);
view.rerender(<FindingSheet {...props} finding={refreshed} />);
expect(reason()).toHaveValue("Server feedback");
const other: Finding = { ...finding, id: "other", reason: "Other feedback" };
view.rerender(<FindingSheet {...props} finding={other} />);
expect(reason()).toHaveValue("Other feedback");
});

View file

@ -1,164 +0,0 @@
import { useState } from "react";
import { ArrowUpRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
import { IssueBrief } from "./IssueBrief";
import { evidenceTarget } from "../model/findings";
import { runTime } from "../model/format";
import { type Finding, type Sample } from "../model/types";
export function FindingSheet({
finding,
agents = [],
sampledRuns,
readOnly,
busy,
onClose,
onEvidence,
changeFinding,
}: {
finding?: Finding;
agents?: readonly string[];
sampledRuns: Sample["executions"];
readOnly: boolean;
busy: boolean;
onClose: () => void;
onEvidence: (evidence: { id: string; span: string }) => void;
changeFinding: (status: Finding["status"], reason: string) => Promise<void>;
}) {
const [reason, setReason] = useFindingReason(finding);
const evidenceGroups = finding
? [...new Set(finding.evidence.map((e) => e.execution_id))].map((id) => ({
id,
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>
{agents.length > 0 && <span className="font-medium text-foreground">{agents.join(", ")} · </span>}
{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">
{finding.brief ? (
<IssueBrief title={finding.title} brief={finding.brief} />
) : (
<>
<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) => 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", reason)}
>
{finding.status === "resolved" ? "Reopen" : "Mark resolved"}
</Button>
)}
<Button disabled={busy} variant="outline" onClick={() => changeFinding("dismissed", reason)}>
This is expected
</Button>
</div>
</div>
)}
</div>
</>
)}
</SheetContent>
</Sheet>
);
}
function useFindingReason(finding: Finding | undefined) {
const [draft, setDraft] = useState({ id: finding?.id, reason: finding?.reason ?? "" });
if (draft.id !== finding?.id) setDraft({ id: finding?.id, reason: finding?.reason ?? "" });
const reason = draft.reason;
const setReason = (reason: string) => setDraft({ ...draft, reason });
return [reason, setReason] as const;
}

View file

@ -1,6 +1,14 @@
"use client";
import { Fragment, useState, type KeyboardEvent, type ReactNode } from "react";
import {
getCoreRowModel,
getExpandedRowModel,
useReactTable,
type CellContext,
type ColumnDef,
type TableOptions,
} from "@tanstack/react-table";
import { createContext, useContext, type ReactNode } from "react";
import {
ChevronRight,
Circle,
@ -11,32 +19,64 @@ import {
CircleX,
Pencil,
Play,
Search,
} from "lucide-react";
import { useNow } from "@/hooks/useNow";
import { Input } from "@/components/ui/input";
import { Inspector } from "@/components/shared/Inspector";
import { InspectorTable } from "@/components/shared/InspectorTable";
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
import { cn } from "@/lib/cva.config";
import { agoLabel } from "@/components/view_logs/TraceView/lensField";
import { agoLabel, scopeLabel } from "../model/format";
import { findingAgents, openFindings, scheduleLabel } from "../model/inbox";
import { findingAgents, findingKey, openFindings, scheduleLabel } from "../model/inbox";
import { lensStatus } from "../model/status";
import { scopeLabel } from "../model/format";
import { SearchBox } from "@/components/shared/search/SearchBox";
import { itemValues } from "@/components/shared/search/valueSource";
import { type Finding, type Lens } from "../model/types";
import { useListSearchRoute } from "../route";
import { FINDING_PANEL_WIDTH_KEY } from "../storage";
import { filterInvestigations, INVESTIGATION_INDEX, INVESTIGATION_QUERY } from "./investigationQuery";
const PRIORITY_COLOR = { high: "text-destructive", medium: "text-amber-500", low: "text-muted-foreground" } as const;
const ROW =
"border-b border-border/60 transition-colors duration-150 hover:bg-trace-row-hover motion-reduce:transition-none";
const PRIORITY_COLOR = { high: "text-destructive", medium: "text-warning", low: "text-muted-foreground" } as const;
const META = "truncate text-xs text-muted-foreground";
const ACTION =
"inline-flex size-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground";
const INVESTIGATION_HEIGHT = 56;
const FINDING_HEIGHT = 48;
function openOnRowKeyDown(event: KeyboardEvent<HTMLTableRowElement>, open: () => void) {
if (event.target !== event.currentTarget || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
open();
/** One row of the list: an investigation, or an open finding shown under the investigation that owns it. */
export type InvestigationRow =
| { readonly kind: "investigation"; readonly lens: Lens }
| { readonly kind: "finding"; readonly lens: Lens; readonly finding: Finding };
export const investigationRowKey = (row: InvestigationRow): string =>
row.kind === "investigation" ? `investigation:${row.lens.id}` : `finding:${findingKey(row.lens, row.finding)}`;
const findingRows = (row: InvestigationRow): InvestigationRow[] | undefined =>
row.kind === "investigation"
? openFindings(row.lens).map((finding) => ({ kind: "finding", lens: row.lens, finding }))
: undefined;
interface ListContextValue {
readonly now: number;
readonly connected: boolean;
readonly readOnly: boolean;
readonly onEdit: (id: string) => void;
readonly onRunNow: (id: string) => void;
}
const ListContext = createContext<ListContextValue | null>(null);
function useList(): ListContextValue {
const value = useContext(ListContext);
if (value === null) throw new Error("Investigation cells must be rendered inside InvestigationList");
return value;
}
type Cell = CellContext<InvestigationRow, unknown>;
const ROW_LABEL = { investigation: "Investigation details", finding: "Finding details" } as const;
function JobIcon({ lens }: { lens: Lens }) {
const status = lens.jobs[0]?.status;
const className = "size-4 shrink-0";
@ -45,7 +85,7 @@ function JobIcon({ lens }: { lens: Lens }) {
if (status === "failed") return <CircleX aria-hidden="true" className={cn(className, "text-destructive")} />;
if (status === "cancelled")
return <CircleSlash aria-hidden="true" className={cn(className, "text-muted-foreground")} />;
if (status === "completed") return <CircleCheck aria-hidden="true" className={cn(className, "text-emerald-600")} />;
if (status === "completed") return <CircleCheck aria-hidden="true" className={cn(className, "text-success")} />;
return <Circle aria-hidden="true" className={cn(className, "text-muted-foreground")} />;
}
@ -58,222 +98,237 @@ function TwoLine({ meta, title, className }: { meta: ReactNode; title: ReactNode
);
}
function NameCell({ row }: Cell) {
const { now } = useList();
const item = row.original;
if (item.kind === "investigation")
return (
<span className="flex min-w-0 items-center gap-2">
<InspectorTable.Indent
row={row}
toggleLabel={(expanded) => `${expanded ? "Hide" : "Show"} findings for ${item.lens.settings.name}`}
/>
<JobIcon lens={item.lens} />
<TwoLine
meta={`${scopeLabel(item.lens.settings)} · ${scheduleLabel(item.lens, now)}`}
title={item.lens.settings.name}
className="font-semibold"
/>
</span>
);
const priority = item.finding.priority ?? "medium";
return (
<span
className="flex min-w-0 items-center gap-2"
title={item.finding.suggestion ? `Fix: ${item.finding.suggestion}` : undefined}
>
<InspectorTable.Indent row={row} className="h-12" />
<span aria-hidden="true" className="w-4 shrink-0" />
<CircleDot aria-hidden="true" className={cn("size-4 shrink-0", PRIORITY_COLOR[priority])} />
<TwoLine
meta={`${priority} priority · ${findingAgents(item.lens, item.finding).join(", ")}`}
title={item.finding.title}
/>
</span>
);
}
function StatusCell({ row: { original: item } }: Cell) {
const { connected } = useList();
if (item.kind === "finding") {
const runs = item.finding.occurrences.length;
return (
<span className="text-muted-foreground">
{runs} {runs === 1 ? "run" : "runs"}
</span>
);
}
const failed = item.lens.jobs[0]?.status === "failed";
return (
<span className={cn("block truncate", failed ? "text-destructive" : "text-muted-foreground")}>
{lensStatus(item.lens, connected)}
</span>
);
}
function FindingCount({ row: { original: item } }: Cell) {
if (item.kind === "finding") return null;
const count = openFindings(item.lens).length;
if (count === 0) return null;
return (
<span
title={`${count} open ${count === 1 ? "finding" : "findings"}`}
className="inline-flex min-w-5 justify-center rounded-full bg-muted px-1.5 font-mono text-xs tabular-nums text-foreground"
>
{count}
</span>
);
}
function ActivityCell({ row: { original: item } }: Cell) {
const { now } = useList();
if (item.kind === "finding")
return (
<span title={formatActivityTimestamp(item.finding.last_seen)}>
{agoLabel(Date.parse(item.finding.last_seen), now)}
</span>
);
const latest = item.lens.jobs[0];
return (
<span title={latest ? formatActivityTimestamp(latest.created_at) : undefined}>
{latest ? agoLabel(Date.parse(latest.created_at), now) : "never run"}
</span>
);
}
function ActionsCell({ row: { original: item } }: Cell) {
const { readOnly, onEdit, onRunNow } = useList();
if (item.kind === "finding")
return <ChevronRight aria-hidden="true" className="mr-2 ml-auto size-3.5 text-muted-foreground/60" />;
if (readOnly) return null;
const { id, settings } = item.lens;
return (
<span className="flex items-center justify-end gap-0.5">
<button
type="button"
aria-label={`Run ${settings.name} now`}
title="Run now"
onClick={(event) => {
event.stopPropagation();
onRunNow(id);
}}
className={ACTION}
>
<Play className="size-3.5" />
</button>
<button
type="button"
aria-label={`Edit ${settings.name}`}
title="Edit"
onClick={(event) => {
event.stopPropagation();
onEdit(id);
}}
className={cn(ACTION, "text-muted-foreground/60 group-hover:text-muted-foreground")}
>
<Pencil className="size-3.5" />
</button>
</span>
);
}
const COLUMNS: ColumnDef<InvestigationRow>[] = [
{ id: "name", header: "Investigation", cell: NameCell, meta: { className: "pl-2 pr-0" } },
{ id: "status", size: 180, header: "Status", cell: StatusCell, meta: { className: "text-right text-xs" } },
{ id: "findings", size: 72, header: "Open findings", cell: FindingCount, meta: { className: "text-right" } },
{
id: "activity",
size: 120,
header: "Last activity",
cell: ActivityCell,
meta: { className: "text-right text-xs text-muted-foreground" },
},
{ id: "actions", size: 76, header: "Actions", cell: ActionsCell, meta: { className: "pl-0 pr-3" } },
];
export interface InvestigationListProps {
readonly lenses: readonly Lens[];
readonly connected: boolean;
readonly readOnly?: boolean;
readonly selected: InvestigationRow | null;
readonly onSelect: (row: InvestigationRow | null) => void;
readonly onEdit: (id: string) => void;
readonly onRunNow: (id: string) => void;
readonly actions?: ReactNode;
/** Body of the side panel for the selected row. */
readonly children: (row: InvestigationRow) => ReactNode;
}
/** Investigations with their open findings; any row opens in the side panel and J/K walk the visible rows. */
export function InvestigationList({
lenses,
connected,
readOnly = false,
onOpen,
selected,
onSelect,
onEdit,
onRunNow,
onOpenFinding,
}: {
lenses: Lens[];
connected: boolean;
readOnly?: boolean;
onOpen: (id: string) => void;
onEdit: (id: string) => void;
onRunNow: (id: string) => void;
onOpenFinding: (lens: Lens, finding: Finding) => void;
}) {
actions,
children,
}: InvestigationListProps) {
const [search, setSearch] = useListSearchRoute();
const now = useNow(15000);
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
const toggle = (id: string) =>
setCollapsed((current) => {
const next = new Set(current);
if (!next.delete(id)) next.add(id);
return next;
});
const shown = lenses.filter((lens) =>
`${lens.settings.name} ${scopeLabel(lens.settings)}`.toLowerCase().includes(search.toLowerCase()),
);
const shown = filterInvestigations([...lenses], search);
const tableOptions: TableOptions<InvestigationRow> = {
data: shown.map((lens): InvestigationRow => ({ kind: "investigation", lens })),
columns: COLUMNS,
getRowId: investigationRowKey,
getSubRows: findingRows,
initialState: { expanded: true },
autoResetAll: false,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
};
const table = useReactTable(tableOptions);
const rows = table.getRowModel().rows.map((row) => row.original);
const noun = selected?.kind ?? "investigation";
return (
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card">
<div className="flex min-h-11 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-muted/40 px-3 py-2">
<div className="relative w-full max-w-[380px]">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Search investigations"
placeholder="Search investigations"
className="h-7 bg-background pl-8 text-xs"
<Inspector.Root
items={rows}
itemKey={investigationRowKey}
selected={selected}
onSelectedChange={onSelect}
noun={noun}
storageKey={FINDING_PANEL_WIDTH_KEY}
>
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden bg-card">
<div className="flex min-h-11 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-card p-2">
<SearchBox.Root
language={INVESTIGATION_QUERY}
values={itemValues(INVESTIGATION_INDEX, lenses)}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
onValueChange={setSearch}
label="Search investigations"
>
<SearchBox.Input
className="rounded-lg"
placeholder="Search investigations, or filter like status:failed schedule:watching"
/>
<SearchBox.Suggestions />
</SearchBox.Root>
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table aria-label="Investigations" className="w-full min-w-[720px] table-fixed border-collapse text-left">
<thead className="sr-only">
<tr>
<th>Investigation</th>
<th>Status</th>
<th>Open findings</th>
<th>Last activity</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{shown.map((lens) => {
const latest = lens.jobs[0];
const findings = openFindings(lens);
const expanded = findings.length > 0 && !collapsed.has(lens.id);
return (
<Fragment key={lens.id}>
<tr
onClick={() => onOpen(lens.id)}
onKeyDown={(event) => openOnRowKeyDown(event, () => onOpen(lens.id))}
<ListContext.Provider value={{ now, connected, readOnly, onEdit, onRunNow }}>
<InspectorTable.Root table={table}>
<InspectorTable.Grid aria-label="Investigations" className="min-w-[720px]">
<InspectorTable.Header hidden />
<InspectorTable.Body<InvestigationRow>
rowHeight={(row) => (row.depth ? FINDING_HEIGHT : INVESTIGATION_HEIGHT)}
>
{(row) => (
<InspectorTable.Row
row={row}
item={row.original}
tabIndex={0}
aria-label={lens.settings.name}
className={cn(ROW, "group h-14 cursor-pointer focus-visible:outline-2 focus-visible:outline-ring")}
>
<td className="pl-2">
<span className="flex min-w-0 items-center gap-2">
{findings.length > 0 ? (
<button
type="button"
aria-expanded={expanded}
aria-label={`${expanded ? "Hide" : "Show"} findings for ${lens.settings.name}`}
onClick={(event) => {
event.stopPropagation();
toggle(lens.id);
}}
className="inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ChevronRight
className={cn(
"size-3.5 transition-transform duration-150 motion-reduce:transition-none",
expanded && "rotate-90",
)}
/>
</button>
) : (
<span aria-hidden="true" className="size-6 shrink-0" />
)}
<JobIcon lens={lens} />
<TwoLine
meta={`${scopeLabel(lens.settings)} · ${scheduleLabel(lens, now)}`}
title={lens.settings.name}
className="font-semibold"
/>
</span>
</td>
<td
className={cn(
"w-[180px] truncate px-3 text-right text-xs",
latest?.status === "failed" ? "text-destructive" : "text-muted-foreground",
)}
>
{lensStatus(lens, connected)}
</td>
<td className="w-[72px] px-3 text-right">
{findings.length > 0 && (
<span
title={`${findings.length} open ${findings.length === 1 ? "finding" : "findings"}`}
className="inline-flex min-w-5 justify-center rounded-full bg-muted px-1.5 font-mono text-xs tabular-nums text-foreground"
>
{findings.length}
</span>
)}
</td>
<td
className="w-[120px] px-3 text-right text-xs text-muted-foreground"
title={latest ? formatActivityTimestamp(latest.created_at) : undefined}
>
{latest ? agoLabel(Date.parse(latest.created_at), now) : "never run"}
</td>
<td className="w-[76px] pr-3">
{!readOnly && (
<span className="flex items-center justify-end gap-0.5">
<button
type="button"
aria-label={`Run ${lens.settings.name} now`}
title="Run now"
onClick={(event) => {
event.stopPropagation();
onRunNow(lens.id);
}}
className="inline-flex size-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Play className="size-3.5" />
</button>
<button
type="button"
aria-label={`Edit ${lens.settings.name}`}
title="Edit"
onClick={(event) => {
event.stopPropagation();
onEdit(lens.id);
}}
className="inline-flex size-7 items-center justify-center rounded text-muted-foreground/60 hover:bg-muted hover:text-foreground group-hover:text-muted-foreground"
>
<Pencil className="size-3.5" />
</button>
</span>
)}
</td>
</tr>
{expanded &&
findings.map((finding, index) => {
const priority = finding.priority ?? "medium";
const last = index === findings.length - 1;
const runs = finding.occurrences.length;
return (
<tr
key={finding.id}
onClick={() => onOpenFinding(lens, finding)}
onKeyDown={(event) => openOnRowKeyDown(event, () => onOpenFinding(lens, finding))}
tabIndex={0}
aria-label={finding.title}
className={cn(ROW, "h-12 cursor-pointer focus-visible:outline-2 focus-visible:outline-ring")}
>
<td className="pl-2" title={finding.suggestion ? `Fix: ${finding.suggestion}` : undefined}>
<span className="flex min-w-0 items-center gap-2">
<span aria-hidden="true" className="relative h-12 w-6 shrink-0">
<span
className={cn("absolute left-3 top-0 w-px bg-border", last ? "h-1/2" : "h-full")}
/>
<span className="absolute top-1/2 left-3 h-px w-3 bg-border" />
</span>
<span aria-hidden="true" className="w-4 shrink-0" />
<CircleDot
aria-hidden="true"
className={cn("size-4 shrink-0", PRIORITY_COLOR[priority])}
/>
<TwoLine
meta={`${priority} priority · ${findingAgents(lens, finding).join(", ")}`}
title={finding.title}
/>
</span>
</td>
<td className="px-3 text-right text-xs text-muted-foreground">
{runs} {runs === 1 ? "run" : "runs"}
</td>
<td />
<td
className="px-3 text-right text-xs text-muted-foreground"
title={formatActivityTimestamp(finding.last_seen)}
>
{agoLabel(Date.parse(finding.last_seen), now)}
</td>
<td className="pr-5">
<ChevronRight aria-hidden="true" className="ml-auto size-3.5 text-muted-foreground/60" />
</td>
</tr>
);
})}
</Fragment>
);
})}
</tbody>
</table>
{!shown.length && (
<div className="py-16 text-center text-xs text-muted-foreground">No investigations match your search.</div>
)}
aria-label={
row.original.kind === "finding" ? row.original.finding.title : row.original.lens.settings.name
}
className={row.depth ? "h-12" : "group h-14"}
/>
)}
</InspectorTable.Body>
</InspectorTable.Grid>
{!shown.length && (
<div className="py-16 text-center text-xs text-muted-foreground">
No investigations match your search.
</div>
)}
</InspectorTable.Root>
</ListContext.Provider>
</div>
<footer className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-xs text-muted-foreground">
{shown.length} {shown.length === 1 ? "investigation" : "investigations"} ·{" "}
{lenses.filter((l) => l.settings.enabled).length} watching
</footer>
</div>
<Inspector.Panel label={ROW_LABEL[noun]} testId="investigation-panel">
{children}
</Inspector.Panel>
</Inspector.Root>
);
}

View file

@ -1,54 +0,0 @@
"use client";
import { StatusDot } from "@/components/shared/StatusDot";
import { ArrowLeft, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Lens } from "../model/types";
export function InvestigationNavigation({
lens,
showActions,
activityReady,
connected,
ready,
selectLens,
setWorkerSetup,
setEditing,
}: {
lens: Lens | undefined;
showActions: boolean;
activityReady: boolean;
connected: boolean;
ready: boolean;
selectLens: (id: string | null) => void;
setWorkerSetup: (open: boolean) => void;
setEditing: (mode: "new") => void;
}) {
return (
<header className={lens ? "flex flex-wrap items-center justify-between gap-2" : "flex items-center gap-2"}>
{lens ? (
<Button variant="ghost" size="sm" className="-ml-3" onClick={() => selectLens(null)}>
<ArrowLeft className="size-4" /> Back
</Button>
) : null}
{showActions && (
<div className="flex flex-wrap gap-2">
<Button
variant="ghost"
size="sm"
disabled={!activityReady}
onClick={() => setWorkerSetup(true)}
title={connected ? "Worker connected" : "Connect worker"}
className="text-muted-foreground"
>
<StatusDot state={connected ? "ok" : "warn"} />
{connected ? "Worker" : "Connect worker"}
</Button>
<Button size="sm" variant={lens ? "outline" : "default"} disabled={!ready} onClick={() => setEditing("new")}>
<Plus className="size-4" /> New investigation
</Button>
</div>
)}
</header>
);
}

View file

@ -2,7 +2,6 @@
import { useNow } from "@/hooks/useNow";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
analysisElapsed,
@ -12,11 +11,10 @@ import {
analysisStages,
remainingLabel,
stageDurations,
type ProgressSample,
} from "../model/progress";
import { useProgressSamples } from "./useProgressSamples";
import { durationText } from "../model/format";
import { nextCheckStatus } from "../model/status";
import { type Lens, type Job } from "../model/types";
import { type Job } from "../model/types";
import { cn } from "@/lib/cva.config";
const steps = ["review runs", "find patterns", "check evidence"];
@ -32,11 +30,7 @@ export function InvestigationProgress({ job, onCancel }: { job: Job; onCancel?:
const now = useNow(1000);
const progress = analysisProgress(job);
const fraction = analysisFraction(progress);
const [samples, setSamples] = useState<ProgressSample[]>([]);
const latest = samples.at(-1);
if (!latest || latest.step !== progress.step || latest.done !== progress.done) {
setSamples([...samples, { at: now, step: progress.step, done: progress.done, fraction }].slice(-120));
}
const samples = useProgressSamples(progress);
const pace = analysisPace(samples, now);
const percent = Math.round(fraction * 100);
const queued = progress.step < 0;
@ -130,20 +124,3 @@ export function InvestigationProgress({ job, onCancel }: { job: Job; onCancel?:
</section>
);
}
export function NextCheck({ lens }: { lens: Lens }) {
const now = useNow(15000);
const label = nextCheckStatus(lens, now);
if (!label) return null;
return <p className="mt-1 text-xs text-muted-foreground">{label}</p>;
}
export function ScanDuration({ job }: { job: Job }) {
if (!job.finished_at) return null;
return (
<span title="Total time, including any wait for an analyzer">
{" · Took "}
{analysisElapsed(job.created_at, Date.parse(job.finished_at))}
</span>
);
}

View file

@ -1,52 +1,13 @@
"use client";
import type { ReactNode } from "react";
import { ArrowUpRight, Loader2, SearchX, TriangleAlert } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { StateMessage } from "../ui/StateMessage";
import { ApiError } from "@/lib/http/client";
const DOCS_URL = "https://docs.litellm.ai/docs/proxy/lens";
function CenteredState({
role,
icon,
tone = "muted",
title,
description,
children,
}: {
role: "status" | "alert";
icon: ReactNode;
tone?: "muted" | "destructive";
title: string;
description: ReactNode;
children?: ReactNode;
}) {
return (
<div
role={role}
className="m-auto flex max-w-sm flex-col items-center gap-3 py-16 text-center animate-in fade-in-0 duration-300 motion-reduce:animate-none"
>
<span
aria-hidden="true"
className={cn(
"flex size-10 items-center justify-center rounded-full",
tone === "destructive" ? "bg-destructive/10 text-destructive" : "bg-muted text-muted-foreground",
)}
>
{icon}
</span>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-foreground">{title}</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children && <div className="mt-1 flex items-center gap-2">{children}</div>}
</div>
);
}
function DocsLink() {
return (
<a
@ -71,7 +32,7 @@ function loadFailureMessage(queryError: unknown, unavailable: boolean): string {
export function InvestigationsLoadFailed({ queryError, refresh }: { queryError: unknown; refresh: () => void }) {
const unavailable = queryError instanceof ApiError && queryError.status === 404;
return (
<CenteredState
<StateMessage
role="alert"
tone="destructive"
icon={<TriangleAlert className="size-5" />}
@ -82,7 +43,7 @@ export function InvestigationsLoadFailed({ queryError, refresh }: { queryError:
{unavailable ? "Reload page" : "Try again"}
</Button>
<DocsLink />
</CenteredState>
</StateMessage>
);
}
@ -102,7 +63,7 @@ export function InvestigationError({ message, refresh }: { message: string; refr
export function InvestigationsLoading() {
return (
<CenteredState
<StateMessage
role="status"
icon={<Loader2 className="size-5 animate-spin motion-reduce:animate-none" />}
title="Loading investigations…"
@ -113,7 +74,7 @@ export function InvestigationsLoading() {
export function InvestigationMissing({ selectLens }: { selectLens: (id: string | null) => void }) {
return (
<CenteredState
<StateMessage
role="alert"
icon={<SearchX className="size-5" />}
title="Investigation not found"
@ -122,6 +83,6 @@ export function InvestigationMissing({ selectLens }: { selectLens: (id: string |
<Button size="sm" onClick={() => selectLens(null)}>
View all investigations
</Button>
</CenteredState>
</StateMessage>
);
}

View file

@ -1,12 +1,13 @@
import { act, fireEvent, screen, within, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders as renderProviders, testQueryClient } from "@/../tests/test-utils";
import { testQueryClient } from "@/../tests/test-utils";
import { renderWithLens } from "@/../tests/lens-test-utils";
import { ApiError } from "@/lib/http/client";
import { apiClient } from "@/components/networking";
import { lensKeys } from "../api/queries";
import { lensKeys } from "../data/queries";
import { InvestigationsView } from "./InvestigationsView";
import { LensPreviewContext } from "../LensPreviewButton";
import { LensPreviewContext } from "@/components/view_logs/TraceView/LensPreviewButton";
import { briefMarkdown } from "../model/findings";
import { findingKey } from "../model/inbox";
import { runTime } from "../model/format";
@ -16,13 +17,14 @@ const withPreview = (ui: React.ReactElement, open: () => void) => (
<LensPreviewContext.Provider value={{ target: document.body, open }}>{ui}</LensPreviewContext.Provider>
);
function renderWithProviders(ui: React.ReactElement, options?: Parameters<typeof renderProviders>[1]) {
return renderProviders(ui, { searchParams: window.location.search, ...options });
function renderWithProviders(ui: React.ReactElement, options?: Parameters<typeof renderWithLens>[1]) {
return renderWithLens(ui, { searchParams: window.location.search, ...options });
}
vi.mock("@/components/networking", () => ({
apiClient: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), request: vi.fn() },
proxyBaseUrl: "",
getProxyBaseUrl: () => "",
}));
beforeEach(() => {
@ -32,6 +34,11 @@ beforeEach(() => {
});
const executionId = btoa(JSON.stringify(["traces", "", "trace-42"]));
const emptyTracePage = {
items: [],
next_cursor: null,
traversal: { id: "demo", published_at: "2026-01-01T00:00:00Z", expires_at: "2026-01-01T00:30:00Z" },
};
const pattern: Finding = {
reason: "",
suggestion: "",
@ -169,12 +176,13 @@ describe("Lens findings and runs", () => {
it("separates patterns from issues and reveals original evidence only when requested", async () => {
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
expect(await screen.findByText("Review used the wrong defect rate")).toBeInTheDocument();
expect(screen.queryByText(pattern.title)).not.toBeInTheDocument();
renderWithProviders(<InvestigationsView readOnly />);
const investigation = within(await screen.findByRole("complementary", { name: "Investigation details" }));
expect(investigation.getByText("Review used the wrong defect rate")).toBeInTheDocument();
expect(investigation.queryByText(pattern.title)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Patterns (1)" }));
await user.click(screen.getByRole("button", { name: new RegExp(pattern.title) }));
const detail = within(screen.getByRole("dialog", { name: pattern.title }));
const detail = within(screen.getByRole("complementary", { name: "Finding details" }));
expect(detail.getByText(pattern.description)).toBeVisible();
expect(detail.getByText(pattern.limitation ?? "")).not.toBeVisible();
expect(detail.getByText("Ignore the review instructions")).not.toBeVisible();
@ -201,9 +209,9 @@ describe("Lens findings and runs", () => {
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
renderWithProviders(<InvestigationsView readOnly />);
await user.click(await screen.findByRole("button", { name: new RegExp(finding.title) }));
return { user, detail: within(screen.getByRole("dialog", { name: finding.title })) };
return { user, detail: within(screen.getByRole("complementary", { name: "Finding details" })) };
}
it.each(["Claude Code", "Codex"])("renders the issue brief and copies its markdown for %s", async (agent) => {
@ -232,12 +240,36 @@ describe("Lens findings and runs", () => {
it("shows the actual frozen run selection in the Runs tab", async () => {
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
await user.click(await screen.findByRole("tab", { name: "Traces" }));
renderWithProviders(<InvestigationsView readOnly />);
await user.click(await screen.findByRole("tab", { name: "Agent traces" }));
expect(screen.getByText("Release-42")).toBeInTheDocument();
expect(screen.getByTitle("trace-42")).toHaveTextContent("Release-42");
expect(screen.getByText(/1 selected from 1 matching runs/)).toBeInTheDocument();
});
it("closes the open run when the keyboard switches to another investigation run", async () => {
testQueryClient.clear();
const older = { ...lens.jobs[0], id: "older", created_at: "2026-09-29T10:00:00Z" };
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, older];
return { data: [] };
});
const onUrlUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(<InvestigationsView readOnly />, {
searchParams: `?lens=lens&section=runs&evidence=${executionId}`,
onUrlUpdate,
});
expect(await screen.findByTestId("run-panel")).toBeInTheDocument();
const picker = screen.getByRole("combobox", { name: "Investigation run" });
await waitFor(() => expect(within(picker).getAllByRole("option")).toHaveLength(4));
fireEvent.change(picker, { target: { value: "older" } });
await waitFor(() => expect(screen.queryByTestId("run-panel")).not.toBeInTheDocument());
const url = new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
expect(url.get("run")).toBe("older");
expect(url.has("evidence")).toBe(false);
});
});
it("runs with saved settings from Run now without opening setup, then accepts an agent and window", async () => {
@ -264,7 +296,7 @@ it("runs with saved settings from Run now without opening setup, then accepts an
});
vi.mocked(apiClient.post).mockResolvedValue(lens);
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
renderWithProviders(<InvestigationsView />);
await user.click(await screen.findByRole("button", { name: "Run now" }));
const choices = await screen.findByRole("dialog", { name: "Run now" });
expect(within(choices).getByRole("button", { name: "Since last run" })).toHaveAttribute("aria-pressed", "true");
@ -293,7 +325,7 @@ it("offers the interactive demo without starting an investigation", async () =>
});
const onPreview = vi.fn();
const user = userEvent.setup();
renderWithProviders(withPreview(<InvestigationsView accessToken="test" />, onPreview));
renderWithProviders(withPreview(<InvestigationsView />, onPreview));
await user.click(await screen.findByRole("button", { name: "Preview sample" }));
expect(onPreview).toHaveBeenCalledOnce();
expect(apiClient.post).not.toHaveBeenCalled();
@ -308,19 +340,21 @@ it("guides a first-time administrator into worker connection and lens setup", as
return { traces: true, requests: false, data: [] };
});
const user = userEvent.setup();
renderWithProviders(withPreview(<InvestigationsView accessToken="test" />, vi.fn()));
const guide = within(await screen.findByRole("region", { name: "Find what needs attention" }));
const connect = vi.fn();
const create = vi.fn();
renderWithProviders(withPreview(<InvestigationsView />, vi.fn()), {
onboarding: { connect, create },
});
const guide = within(await screen.findByRole("region", { name: "Get Lens running" }));
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$/),
expect(guide.getByRole("button", { name: /Send your first trace/ })).toContainElement(
guide.getByLabelText("Step 2 complete"),
);
expect(guide.queryByRole("button", { name: "View traces" })).not.toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Preview sample" })).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" }));
expect(guide.getByRole("button", { name: "New investigation" })).toBeDisabled();
expect(connect).toHaveBeenCalledOnce();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await act(async () => {
testQueryClient.setQueryData(lensKeys.list("test"), {
lenses: [],
@ -337,10 +371,11 @@ it("guides a first-time administrator into worker connection and lens setup", as
],
});
});
await waitFor(() => expect(guide.getByRole("button", { name: "New investigation" })).toBeEnabled());
expect(guide.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
await user.click(guide.getByRole("button", { name: "New investigation" }));
expect(await screen.findByRole("dialog", { name: "Which activity should we investigate?" })).toBeVisible();
await user.click(await guide.findByRole("button", { name: "Continue to investigation" }));
expect(create).toHaveBeenCalledOnce();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
await user.click(guide.getByRole("button", { name: /Run your first investigation/ }));
expect(guide.getByRole("button", { name: "New investigation" })).toBeEnabled();
});
it("opens the saved results of an older batch", async () => {
@ -359,11 +394,12 @@ it("opens the saved results of an older batch", async () => {
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
renderWithProviders(<InvestigationsView readOnly />);
await screen.findByRole("option", { name: `${runTime(older.created_at)} · completed` });
await user.selectOptions(screen.getByRole("combobox", { name: "Investigation run" }), "older");
expect(await screen.findByText("Earlier batch finding")).toBeVisible();
expect(screen.queryByText(issue.title)).not.toBeInTheDocument();
const investigation = within(screen.getByRole("complementary", { name: "Investigation details" }));
expect(await investigation.findByText("Earlier batch finding")).toBeVisible();
expect(investigation.queryByText(issue.title)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Run details" }));
expect(screen.getByText(/Took 2m 13s/)).toBeVisible();
expect(screen.getByText("Activity window")).toBeVisible();
@ -398,9 +434,14 @@ it("reads request content from the beginning after its abbreviated preview", asy
};
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
await user.click(await screen.findByRole("tab", { name: "Traces" }));
await user.click(screen.getByRole("button", { name: /Release-42/ }));
renderWithProviders(<InvestigationsView readOnly />);
await user.click(await screen.findByRole("tab", { name: "Agent traces" }));
const row = screen.getByRole("button", { name: /Release-42/ });
await user.click(row);
const panel = await screen.findByRole("complementary", { name: "Run details" });
expect(row).toHaveAttribute("aria-selected", "true");
expect(panel).toHaveTextContent("1 / 1");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(await screen.findByText("Abbreviated preview")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Next section" }));
expect(await screen.findByText("Original at 1")).toBeVisible();
@ -417,18 +458,19 @@ it.each([false, true])(
async (enabled) => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) =>
path === "/lens" ? { lenses: [], workers: [], tracing_enabled: enabled } : { data: [] },
);
renderWithProviders(<InvestigationsView accessToken="test" />);
expect(await screen.findByRole("heading", { name: "Find what needs attention" })).toBeVisible();
expect(screen.getByRole("link", { name: "Set up traces" })).toHaveAttribute(
"href",
expect.stringMatching(/^\/ui\/lens\/?\?tab=traces$/),
);
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();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [], workers: [], tracing_enabled: enabled };
return path === "/v1/traces" ? emptyTracePage : { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView />);
const guide = within(await screen.findByRole("region", { name: "Get Lens running" }));
expect(guide.getByRole("button", { name: /Send your first trace/ })).toHaveAttribute("aria-expanded", "true");
expect(guide.getByRole("button", { name: "Check for traces" })).toBeVisible();
await user.click(guide.getByRole("button", { name: /Connect a worker/ }));
expect(guide.getByRole("button", { name: "Connect worker" })).toBeDisabled();
await user.click(guide.getByRole("button", { name: /Run your first investigation/ }));
expect(guide.getByRole("button", { name: "New investigation" })).toBeDisabled();
},
);
@ -441,15 +483,14 @@ it("enables first-lens setup when a trace arrives without leaving Investigations
);
vi.useFakeTimers();
try {
const view = renderWithProviders(<InvestigationsView accessToken="test" />);
const view = renderWithProviders(<InvestigationsView />);
await act(async () => vi.advanceTimersByTimeAsync(50));
expect(screen.getByRole("link", { name: "Set up traces" })).toBeVisible();
expect(screen.queryByText(/Your first trace is ready/)).not.toBeInTheDocument();
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();
expect(screen.getByText(/Your first trace is ready/)).toBeVisible();
expect(screen.getByRole("button", { name: "Continue to worker" })).toBeEnabled();
const completedChecks = traceCheck.mock.calls.length;
await act(async () => vi.advanceTimersByTimeAsync(5000 * 2));
@ -470,16 +511,15 @@ it("allows retrying a failed trace readiness check without treating it as an emp
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [], workers: [], tracing_enabled: true };
if (path === "/lens/activity/available") return traceCheck();
return { data: [] };
return path === "/v1/traces" ? emptyTracePage : { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not check recorded activity. Trace storage unavailable",
);
expect(screen.getByRole("button", { name: "Connect worker" })).toBeDisabled();
renderWithProviders(<InvestigationsView />);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not check setup. Trace storage unavailable");
expect(screen.getByRole("region", { name: "Get Lens running" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByRole("link", { name: "Set up traces" })).toBeVisible();
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(traceCheck).toHaveBeenCalledTimes(2);
});
it("shows a centered failure with a retry when investigations cannot load, then recovers", async () => {
@ -489,14 +529,17 @@ it("shows a centered failure with a retry when investigations cannot load, then
.fn()
.mockRejectedValueOnce(new ApiError("Proxy timed out", 504, {}))
.mockResolvedValue({ lenses: [], workers: [], tracing_enabled: true });
vi.mocked(apiClient.get).mockImplementation(async (path) => (path === "/lens" ? list() : { data: [] }));
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return list();
return path === "/v1/traces" ? emptyTracePage : { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
renderWithProviders(<InvestigationsView />);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Couldn't load investigations");
expect(alert).toHaveTextContent("Proxy timed out");
await user.click(within(alert).getByRole("button", { name: "Try again" }));
expect(await screen.findByRole("heading", { name: "Find what needs attention" })).toBeVisible();
expect(await screen.findByRole("region", { name: "Get Lens running" })).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
@ -507,9 +550,9 @@ it("keeps saved investigations accessible when tracing is disabled", async () =>
if (path === "/lens/lens/runs") return lens.jobs;
return { data: [] };
});
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
expect(await screen.findByText(issue.title)).toBeVisible();
expect(screen.queryByRole("link", { name: "Set up traces" })).not.toBeInTheDocument();
renderWithProviders(<InvestigationsView readOnly />);
expect(await screen.findByRole("row", { name: issue.title })).toBeVisible();
expect(screen.queryByRole("region", { name: "Get Lens running" })).not.toBeInTheDocument();
});
it("allows request-only accounts to connect a worker without requiring agent traces", async () => {
@ -520,13 +563,16 @@ it("allows request-only accounts to connect a worker without requiring agent tra
if (path === "/lens/activity/available") return { traces: false, requests: true };
return { data: [] };
});
renderWithProviders(<InvestigationsView accessToken="test" />);
expect(await screen.findByText("Request logs received")).toBeVisible();
expect(screen.getByRole("button", { name: "Connect worker" })).toBeEnabled();
expect(screen.getByRole("button", { name: "New investigation" })).toBeDisabled();
const user = userEvent.setup();
renderWithProviders(<InvestigationsView />);
expect(await screen.findByRole("button", { name: "Connect worker" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: /Send your first trace/ }));
const panel = within(screen.getByRole("region", { name: /Send your first trace/ }));
expect(panel.getByText(/Request logs are already available/)).toBeVisible();
expect(panel.getByRole("button", { name: "Continue with request logs" })).toBeEnabled();
});
it("reopens the edit dialog from a shared link and drops it from the URL on cancel", async () => {
it("reopens the inline editor from a shared link and drops it from the URL on cancel", async () => {
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens], workers: [], tracing_enabled: true };
@ -536,14 +582,17 @@ it("reopens the edit dialog from a shared link and drops it from the URL on canc
});
const onUrlUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />, {
renderWithProviders(<InvestigationsView />, {
searchParams: `?lens=${lens.id}&dialog=edit`,
onUrlUpdate,
});
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByDisplayValue(lens.settings.name)).toBeVisible();
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
const editor = await screen.findByRole("region", { name: "Edit investigation" });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(within(editor).getByDisplayValue(lens.settings.name)).toBeVisible();
expect(screen.queryByRole("heading", { level: 2, name: lens.settings.name })).not.toBeInTheDocument();
await user.click(within(editor).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("region", { name: "Edit investigation" })).not.toBeInTheDocument());
expect(await screen.findByRole("heading", { level: 2, name: lens.settings.name })).toBeVisible();
const url = new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
expect(url.has("dialog")).toBe(false);
expect(url.get("lens")).toBe(lens.id);
@ -559,21 +608,101 @@ it("reopens a finding and a results section from shared links", async () => {
});
const onUrlUpdate = vi.fn();
const user = userEvent.setup();
const { unmount } = renderWithProviders(<InvestigationsView accessToken="test" />, {
const { unmount } = renderWithProviders(<InvestigationsView />, {
searchParams: `?issue=${encodeURIComponent(findingKey(lens, issue))}`,
onUrlUpdate,
});
const sheet = await screen.findByRole("dialog");
expect(within(sheet).getAllByText(issue.title)[0]).toBeVisible();
await user.click(within(sheet).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString)).has("issue")).toBe(false);
const panel = await screen.findByRole("complementary", { name: "Finding details" });
expect(within(panel).getByRole("heading", { name: issue.title })).toBeVisible();
expect(screen.getByRole("row", { name: issue.title })).toHaveAttribute("aria-selected", "true");
await user.click(within(panel).getByRole("button", { name: "Close finding (Esc)" }));
await waitFor(() =>
expect(new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString)).has("issue")).toBe(false),
);
expect(screen.getByRole("row", { name: issue.title })).toHaveAttribute("aria-selected", "false");
unmount();
renderWithProviders(<InvestigationsView accessToken="test" />, { searchParams: `?lens=${lens.id}&section=checks` });
renderWithProviders(<InvestigationsView />, { searchParams: `?lens=${lens.id}&section=checks` });
expect(await screen.findByRole("tab", { name: "Criteria", selected: true })).toBeVisible();
});
it("steps across findings and investigations with J and K, skipping hidden findings, and closes with Escape", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
const twinIssue: Finding = { ...issue, id: "twin-issue", title: "Twin review used the wrong defect rate" };
const twin: Lens = {
...lens,
id: "twin",
settings: { ...lens.settings, name: "Twin reviews" },
findings: [twinIssue],
jobs: lens.jobs.map((job) => ({ ...job, findings: [twinIssue] })),
};
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens, twin], tracing_enabled: true, workers: [] };
if (path === "/lens/activity/available") return { traces: true, requests: false };
if (path.endsWith("/runs")) return [];
return { data: [] };
});
const onUrlUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(<InvestigationsView />, { onUrlUpdate });
const lastIssue = () => new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? "")).get("issue");
await user.click(await screen.findByRole("button", { name: `Hide findings for ${twin.settings.name}` }));
expect(screen.queryByRole("row", { name: twinIssue.title })).not.toBeInTheDocument();
await user.click(screen.getByRole("row", { name: issue.title }));
const panel = await screen.findByRole("complementary", { name: "Finding details" });
expect(panel).toHaveTextContent("2 / 3");
await waitFor(() => expect(lastIssue()).toBe(findingKey(lens, issue)));
await user.keyboard("j");
await waitFor(() => expect(lastIssue()).toBeNull());
expect(screen.getByRole("row", { name: twin.settings.name })).toHaveAttribute("aria-selected", "true");
expect(screen.queryByRole("row", { name: twinIssue.title })).not.toBeInTheDocument();
const twinPanel = screen.getByRole("complementary", { name: "Investigation details" });
expect(within(twinPanel).getByRole("heading", { level: 2, name: twin.settings.name })).toBeVisible();
expect(twinPanel).toHaveTextContent("3 / 3");
expect(screen.getByRole("button", { name: "Next investigation (J)" })).toBeDisabled();
await user.keyboard("k");
await waitFor(() => expect(lastIssue()).toBe(findingKey(lens, issue)));
await user.keyboard("{Escape}");
await waitFor(() => expect(lastIssue()).toBeNull());
expect(screen.getByRole("row", { name: issue.title })).toHaveAttribute("aria-selected", "false");
});
it("opens an investigation beside the list and walks from it into its findings with J and K", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens], tracing_enabled: true, workers: [] };
if (path === "/lens/activity/available") return { traces: true, requests: false };
if (path.endsWith("/runs")) return [];
return { data: [] };
});
const onUrlUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(<InvestigationsView />, { onUrlUpdate });
const lastUrl = () => new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
await user.click(await screen.findByRole("row", { name: lens.settings.name }));
const panel = await screen.findByRole("complementary", { name: "Investigation details" });
expect(within(panel).getByRole("heading", { level: 2, name: lens.settings.name })).toBeVisible();
expect(screen.getByRole("table", { name: "Investigations" })).toBeVisible();
expect(panel).toHaveTextContent("1 / 2");
await waitFor(() => expect(lastUrl().get("lens")).toBe(lens.id));
await user.keyboard("j");
await waitFor(() => expect(lastUrl().get("issue")).toBe(findingKey(lens, issue)));
expect(lastUrl().has("lens")).toBe(false);
expect(screen.getByRole("complementary", { name: "Finding details" })).toHaveTextContent("2 / 2");
expect(screen.getByRole("row", { name: issue.title })).toHaveAttribute("aria-selected", "true");
await user.keyboard("k");
await waitFor(() => expect(lastUrl().get("lens")).toBe(lens.id));
expect(lastUrl().has("issue")).toBe(false);
expect(screen.getByRole("complementary", { name: "Investigation details" })).toHaveTextContent("1 / 2");
});
it("lists each finding under the investigation that owns it and resolves only that copy", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
@ -581,11 +710,12 @@ it("lists each finding under the investigation that owns it and resolves only th
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens, twin], tracing_enabled: true, workers: [] };
if (path === "/lens/activity/available") return { traces: true, requests: false };
if (path.endsWith("/runs")) return [];
return { data: [] };
});
vi.mocked(apiClient.patch).mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
renderWithProviders(<InvestigationsView />);
const rows = await screen.findAllByRole("row", { name: issue.title });
expect(rows).toHaveLength(2);
await user.click(await screen.findByRole("button", { name: `Hide findings for ${lens.settings.name}` }));
@ -608,7 +738,7 @@ it("lists investigations without edit or run controls for read-only viewers", as
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
renderWithProviders(<InvestigationsView readOnly />);
const row = await screen.findByRole("row", { name: lens.settings.name });
expect(within(row).queryByRole("button", { name: /now/ })).not.toBeInTheDocument();
await user.click(row);
@ -627,7 +757,7 @@ it("opens investigations from the keyboard without treating nested edit keys as
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
renderWithProviders(<InvestigationsView />);
const row = await screen.findByRole("row", { name: lens.settings.name });
row.focus();
expect(row).toHaveFocus();
@ -635,14 +765,16 @@ it("opens investigations from the keyboard without treating nested edit keys as
expect(await screen.findByRole("heading", { level: 2, name: lens.settings.name })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Back" }));
expect(screen.getByRole("row", { name: lens.settings.name })).toHaveAttribute("aria-selected", "true");
await user.click(screen.getByRole("button", { name: "Close investigation (Esc)" }));
const editButton = await screen.findByRole("button", { name: `Edit ${lens.settings.name}` });
editButton.focus();
expect(editButton).toHaveFocus();
await user.keyboard("{Enter}");
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByDisplayValue(lens.settings.name)).toBeVisible();
const editor = await screen.findByRole("region", { name: "Edit investigation" });
expect(within(editor).getByDisplayValue(lens.settings.name)).toBeVisible();
expect(screen.queryByRole("heading", { level: 2, name: lens.settings.name })).not.toBeInTheDocument();
expect(screen.queryByRole("row", { name: lens.settings.name })).not.toBeInTheDocument();
});
it("opens a failed investigation's details from its row and edits only from the pencil", async () => {
@ -665,13 +797,12 @@ it("opens a failed investigation's details from its row and edits only from the
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
renderWithProviders(<InvestigationsView />);
await user.click(await screen.findByRole("button", { name: `Edit ${lens.settings.name}` }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByDisplayValue(lens.settings.name)).toBeVisible();
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
await user.click(screen.getByRole("row", { name: lens.settings.name }));
const editor = await screen.findByRole("region", { name: "Edit investigation" });
expect(within(editor).getByDisplayValue(lens.settings.name)).toBeVisible();
await user.click(within(editor).getByRole("button", { name: "Cancel" }));
await user.click(await screen.findByRole("row", { name: lens.settings.name }));
expect(within(await screen.findByRole("alert")).getByLabelText("Investigation error")).toHaveTextContent("boom");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
@ -688,7 +819,7 @@ it("shows the actual saved failure and run context without opening backend logs"
if (path === "/lens/lens/runs/failed-run") return job;
return { data: [] };
});
renderWithProviders(<InvestigationsView accessToken="test" readOnly />);
renderWithProviders(<InvestigationsView readOnly />);
const failure = within(await screen.findByRole("alert"));
expect(failure.getByLabelText("Investigation error")).toHaveTextContent(error.replaceAll("\n", " "));
expect(failure.getByText("failed-run")).toBeVisible();
@ -703,6 +834,7 @@ it("keeps a finding open to retry when its update fails", async () => {
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens, twin], tracing_enabled: true, workers: [] };
if (path === "/lens/activity/available") return { traces: true, requests: false };
if (path.endsWith("/runs")) return [];
return { data: [] };
});
vi.mocked(apiClient.patch).mockReset();
@ -710,10 +842,76 @@ it("keeps a finding open to retry when its update fails", async () => {
if (String(path).startsWith("/lens/twin/")) throw new Error("Twin reviews could not be updated");
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
renderWithProviders(<InvestigationsView />);
await user.click(await screen.findByRole("button", { name: `Hide findings for ${lens.settings.name}` }));
await user.click(screen.getByRole("row", { name: issue.title }));
await user.click(await screen.findByRole("button", { name: "Mark resolved" }));
expect(await screen.findByText("Twin reviews could not be updated")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Mark resolved" })).toBeVisible();
});
it("pauses monitoring from the detail menu by saving the investigation with monitoring off", async () => {
testQueryClient.clear();
const watching: Lens = { ...lens, settings: { ...lens.settings, enabled: true } };
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [watching], workers: [], tracing_enabled: true };
if (path === "/lens/lens/runs") return watching.jobs;
return { data: [] };
});
vi.mocked(apiClient.request).mockReset();
vi.mocked(apiClient.request).mockResolvedValue({ ...watching, settings: lens.settings });
const user = userEvent.setup();
renderWithProviders(<InvestigationsView />);
await user.click(await screen.findByRole("button", { name: "Investigation actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Pause monitoring" }));
await waitFor(() => expect(apiClient.request).toHaveBeenCalledTimes(1));
expect(apiClient.request).toHaveBeenCalledWith("PUT", "/lens/lens", {
accessToken: "test",
body: { ...watching.settings, enabled: false },
});
});
it("cancels the running job from the progress banner", async () => {
testQueryClient.clear();
const running = { ...lens.jobs[0], id: "live", status: "running" as const, stage: "Reading executions" };
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens")
return { lenses: [{ ...lens, jobs: [running, lens.jobs[0]] }], workers: [], tracing_enabled: true };
if (path === "/lens/lens/runs") return [running, lens.jobs[0]];
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView />);
await user.click(await screen.findByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(apiClient.post).toHaveBeenCalledWith("/lens/lens/cancel", { accessToken: "test", body: {} }),
);
});
it("refreshes run history as soon as the list reports a job the scheduler started", async () => {
testQueryClient.clear();
const runs = vi.fn().mockResolvedValue(lens.jobs);
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens], workers: [], tracing_enabled: true };
if (path === "/lens/lens/runs") return runs();
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView readOnly />);
await user.click(await screen.findByRole("tab", { name: "History" }));
const history = within(await screen.findByRole("tabpanel", { name: "History" }));
expect(await history.findByText("completed")).toBeVisible();
const fetched = runs.mock.calls.length;
const queued = { ...lens.jobs[0], id: "scheduled", status: "queued" as const, stage: "Queued", findings: null };
runs.mockResolvedValue([queued, lens.jobs[0]]);
await act(async () => {
testQueryClient.setQueryData(lensKeys.list("test"), {
lenses: [{ ...lens, jobs: [queued, lens.jobs[0]] }],
workers: [],
tracing_enabled: true,
});
});
expect(await history.findByText("queued")).toBeVisible();
expect(runs.mock.calls.length).toBeGreaterThan(fetched);
});

View file

@ -1,344 +1,225 @@
"use client";
import type { ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { LensPreviewButton } from "@/components/view_logs/TraceView/LensPreviewButton";
import { useInvalidateLenses } from "../data/mutations";
import { lensQueries } from "../data/queries";
import { useLensApi } from "../data/LensServices";
import { findFinding, findingKey, type OwnedFinding } from "../model/inbox";
import type { Finding, Lens, Settings } from "../model/types";
import { useDialogRoute, useIssueRoute, useLensRoute } from "../route";
import { InvestigationSetup } from "../setup/InvestigationSetup";
import { MonitoringDialog } from "../setup/MonitoringDialog";
import { useLensReadiness } from "../hooks/useLensReadiness";
import { OnboardingSetup } from "../onboarding/OnboardingSetup";
import { InvestigationDetail } from "./detail/InvestigationDetail";
import { RunNowDialog } from "./detail/RunNowDialog";
import { FindingPanelBody } from "./FindingDetails";
import { InvestigationList, type InvestigationRow } from "./InvestigationList";
import { investigationScreen, type Screen, type SetupScreen } from "./investigationScreen";
import {
InvestigationError,
InvestigationMissing,
InvestigationsLoadFailed,
InvestigationsLoading,
InvestigationError,
} from "./InvestigationStatus";
import { InvestigationNavigation } from "./InvestigationNavigation";
import { useInvestigationResults } from "./useInvestigationResults";
import { useLensUpdate, useSaveLens, type LensWrite } from "../api/mutations";
import { lensKeys, lensQueries } from "../api/queries";
import { useLensApi } from "../services";
import { InvestigationDetail } from "./detail/InvestigationDetail";
} from "./InvestigationStates";
import { ReadinessBanner } from "./ReadinessBanner";
import { RequestEvidenceSheet } from "./RequestEvidenceSheet";
import { useNow } from "@/hooks/useNow";
import { useDialogRoute, useIssueRoute, useLensRoute } from "../route";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FindingSheet } from "./FindingSheet";
import { TraceSheet } from "./TraceSheet";
import { InvestigationSetupDialog } from "../setup/InvestigationSetupDialog";
import { WorkerDialog } from "../setup/worker/WorkerDialog";
import { useAnalysisKeyInfo } from "../setup/worker/AnalysisKeyDetails";
import { InvestigationList } from "./InvestigationList";
import { HeaderActions } from "../HeaderActions";
import { RunNowDialog } from "./detail/RunNowDialog";
import { findFinding, findingAgents, findingKey, sampledExecutions } from "../model/inbox";
import { useInvestigationActions } from "./useInvestigationActions";
import { WatchAllBanner } from "./WatchAllBanner";
import { MonitoringDialog } from "../setup/MonitoringDialog";
import { InvestigationsWelcome } from "./InvestigationsWelcome";
import { workerConnected, readiness } from "../model/status";
import { type Finding, type Lens, type Settings } from "../model/types";
export function InvestigationsView({
active = true,
accessToken,
readOnly = false,
}: {
active?: boolean;
accessToken: string;
readOnly?: boolean;
}) {
const api = useLensApi(accessToken);
const client = useQueryClient();
const updateLens = useLensUpdate(accessToken);
const saveLens = useSaveLens(accessToken);
const { dialog, target: dialogTarget, openDialog, closeDialog } = useDialogRoute();
const { issueKey, setIssueKey } = useIssueRoute();
const workerSetup = dialog === "workers";
const now = useNow(2000);
const query = useQuery(lensQueries.list(api, workerSetup));
const models = useQuery(lensQueries.models(api));
const modelDetails = useQuery(lensQueries.modelDetails(api));
const [agentsAsOf] = useState(() => new Date().toISOString());
const agents = useQuery(lensQueries.agents(api, agentsAsOf, "traces"));
const { lensId: selected, setLensId: setSelected } = useLensRoute();
const [skipped, setSkipped] = useState<readonly { id: string; name: string; reason: string }[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const lenses = [...(query.data?.lenses ?? [])].sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
const showEmpty = !query.isLoading && !query.error && lenses.length === 0;
const loaded = !query.isLoading && !query.error;
const showActions = !readOnly && !!query.data && !showEmpty;
const showReadiness = loaded && !showEmpty && !readOnly;
const missingSelection = !!selected && loaded;
const lens = lenses.find((e) => e.id === selected);
const targetLens = dialogTarget ? lenses.find((e) => e.id === dialogTarget) : lens;
const editing = dialog === "new" || dialog === "edit" || dialog === "duplicate" ? dialog : null;
const setupMode = editing === "new" || targetLens ? editing : null;
const peeked = issueKey ? findFinding(lenses, issueKey) : undefined;
const resultsLens = peeked?.lens ?? lens;
const results = useInvestigationResults(accessToken, resultsLens);
const {
finding,
sampledRuns,
target,
evidence,
requestEvidenceData,
requestEvidenceError,
requestEvidenceLoading,
requestOffset,
setRequestOffset,
setEvidence,
setFindingId,
reset: resetResults,
} = results;
const selectLens = (id: string | null) => {
setSelected(id);
resetResults();
};
const setEditing = (mode: "new" | "edit" | "duplicate") => openDialog(mode);
const setWorkerSetup = (open: boolean) => (open ? openDialog("workers") : closeDialog());
const setMonitoring = (open: boolean) => (open ? openDialog("monitoring") : closeDialog());
const connected = query.data?.workers?.some((w) => workerConnected(w, now)) ?? false;
const activeWorkers = query.data?.workers.filter((worker) => !worker.revoked) ?? [];
const defaultKeyId = activeWorkers.length === 1 ? activeWorkers[0].analysis_key_id : undefined;
const analysisAccess = useAnalysisKeyInfo(accessToken, defaultKeyId ?? undefined);
const defaultModel = analysisAccess.data?.models.length === 1 ? analysisAccess.data.models[0] : undefined;
const activity = useQuery(lensQueries.activity(api, loaded));
const { tracesReady, requestsReady, activityReady, ready } = readiness(
activity.data,
activity.error,
connected,
query.error,
);
const setupSettings = () => {
if (editing === "new") return undefined;
if (editing === "duplicate" && targetLens)
return { ...targetLens.settings, name: `${targetLens.settings.name} copy`, enabled: false };
return targetLens?.settings;
};
const refresh = () => {
void client.invalidateQueries({ queryKey: lensKeys.list(api.scope) });
void client.invalidateQueries({ queryKey: lensKeys.histories() });
};
const update = async (write: LensWrite): Promise<boolean> => {
setBusy(true);
setError("");
try {
await updateLens.mutateAsync(write);
return true;
} catch (e) {
setError(e instanceof Error ? e.message : "Could not update lens");
return false;
} finally {
await client.invalidateQueries({ queryKey: lensKeys.list(api.scope) });
setBusy(false);
}
};
const save = async (settings: Settings) => {
if (editing === "edit" && !targetLens) throw new Error("Reopen the investigation to edit its settings");
if (editing !== "edit" && !ready)
throw new Error("Wait for recorded activity and a connected worker before starting an investigation");
const saved = await saveLens.mutateAsync({ id: editing === "edit" ? targetLens?.id : undefined, settings });
closeDialog();
if (!dialogTarget) selectLens(saved.id);
refresh();
};
const openFinding = (owner: Lens, picked: Finding) => setIssueKey(findingKey(owner, picked));
const closeFinding = () => {
setFindingId(null);
setIssueKey(null);
};
const changeFinding = async (status: Finding["status"], reason: string) => {
if (peeked) {
const saved = await update((current) => current.reviewFinding(peeked.lens.id, peeked.finding.id, status, reason));
if (saved) closeFinding();
return;
}
if (!lens || !finding) return;
await update((current) => current.reviewFinding(lens.id, finding.id, status, reason));
};
const sheetFinding = peeked ? peeked.finding : finding;
const sheetRuns = peeked ? sampledExecutions(peeked.lens) : sampledRuns;
const detailAgents = lens && finding ? findingAgents(lens, finding) : [];
const sheetAgents = peeked ? findingAgents(peeked.lens, peeked.finding) : detailAgents;
export interface InvestigationsViewProps {
readonly readOnly?: boolean;
}
const onDetail = !!selected;
const showDetailNav = onDetail && !showEmpty;
const showTables = !onDetail && lenses.length > 0;
const showMissing = missingSelection && !lens;
const assertNever = (value: never): never => {
throw new Error(`Unhandled screen ${JSON.stringify(value)}`);
};
interface BannersProps {
readonly error: Error | null | undefined;
readonly activityReady: boolean | null;
readonly flush: boolean;
readonly refresh: () => void;
}
function Banners({ error, activityReady, flush, refresh }: BannersProps) {
if (!error && activityReady === null) return null;
return (
<section aria-label="Investigations" className="flex w-full min-w-0 flex-1 flex-col gap-3">
{showDetailNav && (
<InvestigationNavigation
lens={lens}
showActions={showActions}
activityReady={activityReady}
connected={connected}
ready={ready}
selectLens={selectLens}
setWorkerSetup={setWorkerSetup}
setEditing={setEditing}
/>
)}
{query.error && !query.data ? (
<InvestigationsLoadFailed queryError={query.error} refresh={refresh} />
) : (
(error || query.error) && <InvestigationError message={error || query.error?.message || ""} refresh={refresh} />
)}
{query.isLoading && <InvestigationsLoading />}
{loaded && showEmpty && (
<InvestigationsWelcome
tracesReady={tracesReady}
requestsReady={requestsReady}
checking={activity.isPending}
traceError={activity.error?.message}
connected={connected}
readOnly={!!readOnly}
onRetry={() => {
void activity.refetch();
refresh();
}}
onConnect={() => setWorkerSetup(true)}
onCreate={() => setEditing("new")}
showPreview={activity.isSuccess && !ready}
/>
)}
{showReadiness && !ready && <ReadinessBanner activityReady={activityReady} className="py-2 text-xs" />}
{showTables && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
{active && (
<HeaderActions>
{!readOnly && (
<WatchAllBanner
lenses={lenses}
busy={busy}
skipped={skipped}
onWatchAll={() =>
update(async (api) => {
const result = await api.watchAll();
setSkipped(result.skipped);
})
}
/>
)}
<InvestigationNavigation
lens={undefined}
showActions={showActions}
activityReady={activityReady}
connected={connected}
ready={ready}
selectLens={selectLens}
setWorkerSetup={setWorkerSetup}
setEditing={setEditing}
/>
</HeaderActions>
)}
<div className={cn("flex flex-col gap-3", flush && "p-2 pb-0")}>
{error && <InvestigationError message={error.message} refresh={refresh} />}
{activityReady !== null && <ReadinessBanner activityReady={activityReady} className="py-2 text-xs" />}
</div>
);
}
export function InvestigationsView({ readOnly = false }: InvestigationsViewProps) {
const api = useLensApi();
const invalidateLenses = useInvalidateLenses();
const actions = useInvestigationActions();
const { dialog, target, openDialog, closeDialog } = useDialogRoute();
const { issueKey, setIssueKey } = useIssueRoute();
const { lensId, setLensId } = useLensRoute();
const list = useQuery(lensQueries.list(api));
const status = useLensReadiness(true);
const { connected } = status;
const screenInput = { list, lensId, dialog, target };
const screen = investigationScreen(screenInput);
const lenses = list.data?.lenses ?? [];
const lens = lenses.find((candidate) => candidate.id === lensId);
const peeked = issueKey ? findFinding(lenses, issueKey) ?? null : null;
const selectedRow = (open: Lens | undefined): InvestigationRow | null => {
if (peeked) return { kind: "finding", ...peeked };
return open ? { kind: "investigation", lens: open } : null;
};
const selectRow = (row: InvestigationRow | null) => {
if (row?.kind === "finding") setIssueKey(findingKey(row.lens, row.finding));
else if (row) setLensId(row.lens.id);
else if (peeked) setIssueKey(null);
else setLensId(null);
};
const reviewPeeked = async (owned: OwnedFinding, status: Finding["status"], reason: string) => {
const saved = await actions.review(owned.lens, owned.finding, status, reason);
if (saved) setIssueKey(null);
};
const dialogLens = target ? lenses.find((candidate) => candidate.id === target) : lens;
const refresh = () => {
actions.reset();
void invalidateLenses();
};
const saveSetup = async (setup: SetupScreen, settings: Settings) => {
if (setup.mode !== "edit" && !status.ready)
throw new Error("Wait for recorded activity and a connected worker before starting an investigation");
const saved = await actions.save({ id: setup.mode === "edit" ? setup.lens.id : undefined, settings });
closeDialog();
if (!target) setLensId(saved.id);
};
const bannerError = screen.kind === "failed" ? undefined : actions.error ?? list.error;
const browsing = screen.kind === "list";
const showReadiness = browsing && !readOnly && !status.ready;
const content = (current: Screen): ReactNode => {
switch (current.kind) {
case "loading":
return <InvestigationsLoading />;
case "failed":
return <InvestigationsLoadFailed queryError={current.error} refresh={refresh} />;
case "missing":
return <InvestigationMissing selectLens={setLensId} />;
case "welcome":
if (status.loading) return <InvestigationsLoading />;
return (
<>
{!status.ready && <LensPreviewButton />}
<OnboardingSetup state={status} className="mx-auto w-full max-w-3xl py-6" />
</>
);
case "list":
return (
<InvestigationList
lenses={lenses}
lenses={current.lenses}
connected={connected}
readOnly={readOnly}
onOpen={selectLens}
selected={selectedRow(current.lens)}
onSelect={selectRow}
onEdit={(id) => openDialog("edit", id)}
onRunNow={(id) => openDialog("run_now", id)}
onOpenFinding={openFinding}
actions={
!readOnly && (
<>
<WatchAllBanner lenses={current.lenses} />
<Button size="sm" className="h-8" disabled={!status.ready} onClick={() => openDialog("new")}>
<Plus className="size-4" /> New investigation
</Button>
</>
)
}
>
{(row) =>
row.kind === "finding" ? (
<FindingPanelBody
owned={row}
readOnly={readOnly}
busy={actions.busy}
onReview={(owned, reviewStatus, reason) => void reviewPeeked(owned, reviewStatus, reason)}
/>
) : (
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<InvestigationDetail
lens={row.lens}
readOnly={readOnly}
ready={status.ready}
busy={actions.busy}
connected={connected}
onEdit={() => openDialog("edit")}
onDuplicate={() => openDialog("duplicate")}
onPause={() => void actions.pause(row.lens)}
onEnableMonitoring={() => openDialog("monitoring")}
onCancelRun={() => void actions.cancelRun(row.lens)}
onRunNow={() => openDialog("run_now")}
onReviewFinding={(owned, reviewStatus, reason) =>
void actions.review(owned.lens, owned.finding, reviewStatus, reason)
}
/>
</div>
)
}
</InvestigationList>
);
case "setup":
return (
<InvestigationSetup
ready={status.ready}
mode={current.mode}
initial={current.mode === "new" ? undefined : current.initial}
defaultSource={!status.tracesReady && status.requestsReady ? "requests" : "traces"}
onClose={closeDialog}
onSave={(settings) => saveSetup(current, settings)}
/>
</div>
)}
{showMissing && <InvestigationMissing selectLens={selectLens} />}
{lens && (
<InvestigationDetail
lens={lens}
readOnly={readOnly}
ready={ready}
busy={busy}
setEditing={setEditing}
setMonitoring={setMonitoring}
onRunNow={() => openDialog("run_now")}
update={update}
connected={connected}
results={results}
/>
)}
{setupMode && (
<InvestigationSetupDialog
ready={ready}
mode={setupMode}
initial={setupSettings()}
defaultModel={defaultModel}
defaultSource={!tracesReady && requestsReady ? "requests" : "traces"}
models={models.data?.data.map((m) => m.id) ?? []}
modelDetails={modelDetails.data?.data ?? []}
modelsLoading={models.isLoading}
modelsError={models.error?.message}
accessToken={accessToken}
onClose={closeDialog}
onSave={save}
/>
)}
{dialog === "run_now" && targetLens && (
);
default:
return assertNever(current);
}
};
return (
<section
aria-label="Investigations"
className={cn("flex w-full min-w-0 flex-1 flex-col", !browsing && "gap-3 p-4")}
>
<Banners
error={bannerError}
activityReady={showReadiness ? status.activityReady : null}
flush={browsing}
refresh={refresh}
/>
{content(screen)}
{dialog === "run_now" && dialogLens && (
<RunNowDialog
lens={targetLens}
agents={Array.isArray(agents.data) ? agents.data : []}
busy={busy}
lens={dialogLens}
busy={actions.busy}
onClose={closeDialog}
onRun={async (request) => {
await update((api) => api.startRun(targetLens.id, request));
await actions.startRun(dialogLens, request);
closeDialog();
}}
/>
)}
{workerSetup && (
<WorkerDialog
accessToken={accessToken}
workers={query.data?.workers ?? []}
onClose={closeDialog}
onChanged={refresh}
onReady={ready && showEmpty ? () => openDialog("new") : undefined}
/>
)}
{dialog === "monitoring" && lens && (
{dialog === "monitoring" && dialogLens && (
<MonitoringDialog
settings={lens.settings}
ready={ready}
settings={dialogLens.settings}
ready={status.ready}
onClose={closeDialog}
onSave={async (settings) => {
await saveLens.mutateAsync({ id: lens.id, settings });
refresh();
await actions.save({ id: dialogLens.id, settings });
}}
/>
)}
<FindingSheet
finding={sheetFinding}
agents={sheetAgents}
sampledRuns={sheetRuns}
readOnly={readOnly}
busy={busy}
onClose={closeFinding}
changeFinding={changeFinding}
onEvidence={(value) => {
setRequestOffset(0);
setEvidence(value);
}}
/>
{resultsLens && target?.source === "traces" && (
<TraceSheet
open={!!evidence}
traceId={target.id}
traceRef={target.traceRef}
initialSpanId={evidence?.span}
accessToken={accessToken}
onClose={() => setEvidence(null)}
/>
)}
<RequestEvidenceSheet
target={target}
setEvidence={setEvidence}
requestEvidenceData={requestEvidenceData}
requestEvidenceError={requestEvidenceError}
requestEvidenceLoading={requestEvidenceLoading}
requestOffset={requestOffset}
setRequestOffset={setRequestOffset}
/>
</section>
);
}

View file

@ -1,154 +0,0 @@
import Link from "next/link";
import { Check, Loader2 } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { LensPreviewButton } from "../LensPreviewButton";
import { uiHref } from "@/utils/uiHref";
import { cn } from "@/lib/cva.config";
export function InvestigationsWelcome({
tracesReady,
requestsReady = false,
checking,
traceError,
connected,
readOnly,
onConnect,
onCreate,
onRetry,
showPreview = false,
}: {
tracesReady: boolean;
requestsReady?: boolean;
checking: boolean;
traceError?: string;
connected: boolean;
readOnly: boolean;
onConnect: () => void;
onCreate: () => void;
onRetry: () => void;
showPreview?: boolean;
}) {
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 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="m-auto w-full max-w-2xl py-10">
{showPreview && <LensPreviewButton />}
<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>
<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
data-state={activityReady ? "active" : "inactive"}
className="grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 text-muted-foreground data-[state=active]:text-foreground sm:grid-cols-[28px_minmax(0,1fr)_auto]"
>
<Step number={2} complete={workerReady} active={waitingForWorker} />
<div>
<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
data-state={workerReady ? "active" : "inactive"}
className="grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 text-muted-foreground data-[state=active]:text-foreground sm:grid-cols-[28px_minmax(0,1fr)_auto]"
>
<Step number={3} complete={false} active={workerReady} />
<div>
<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>
{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 state = stepState(complete, active);
return (
<span
data-state={state}
className={cn(
"flex size-7 items-center justify-center rounded-full text-xs font-medium",
"data-[state=complete]:bg-emerald-50 data-[state=complete]:text-emerald-700 dark:data-[state=complete]:bg-emerald-950 dark:data-[state=complete]:text-emerald-400",
"data-[state=active]:bg-foreground data-[state=active]:text-background",
"data-[state=inactive]:border data-[state=inactive]:text-muted-foreground",
)}
aria-label={complete ? `Step ${number} complete` : `Step ${number}`}
>
{complete ? <Check className="size-4" /> : incomplete}
</span>
);
}
function stepState(complete: boolean, active?: boolean) {
if (complete) return "complete";
if (active) return "active";
return "inactive";
}
function activityStatus(traces: boolean, requests: boolean, checking: boolean) {
if (traces) return "Traces received";
if (requests) return "Request logs received";
return checking ? "Checking for activity…" : "Connect your agent and send a trace.";
}

View file

@ -51,7 +51,7 @@ export function IssueBrief({ title, brief }: { title: string; brief: IssueBrief
className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-background px-2 text-xs font-medium hover:bg-muted"
>
{copied === agent.name ? (
<Check className="size-3.5 text-emerald-600" />
<Check className="size-3.5 text-success" />
) : (
<img src={agent.logo} alt="" aria-hidden className="size-3.5" />
)}

View file

@ -1,69 +0,0 @@
"use client";
import type { components } from "@/lib/http/schema";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
import { evidenceTarget } from "../model/findings";
type RequestEvidence = components["schemas"]["ExecutionContent"];
export function RequestEvidenceSheet({
target,
setEvidence,
requestEvidenceData,
requestEvidenceError,
requestEvidenceLoading,
requestOffset,
setRequestOffset,
}: {
target: ReturnType<typeof evidenceTarget>;
setEvidence: (value: { id: string; span: string } | null) => void;
requestEvidenceData: RequestEvidence | undefined;
requestEvidenceError: Error | null | undefined;
requestEvidenceLoading: boolean;
requestOffset: number;
setRequestOffset: (offset: number) => void;
}) {
return (
<Sheet
open={target?.source === "requests"}
onOpenChange={(open) => {
if (!open) setEvidence(null);
}}
>
<SheetContent className="overflow-y-auto data-[side=right]:sm:max-w-2xl">
<SheetHeader>
<SheetTitle>Request evidence</SheetTitle>
<SheetDescription>Original logged input and output</SheetDescription>
</SheetHeader>
<div className="p-4 space-y-3">
{requestEvidenceLoading && <p role="status">Loading request…</p>}
{requestEvidenceError && <p role="alert">{requestEvidenceError.message}</p>}
{requestEvidenceData?.parts.map((p) => (
<pre className="whitespace-pre-wrap break-words text-xs" key={p.span_id}>
{p.content}
</pre>
))}
{requestEvidenceData?.parts.length === 0 && <p>Request was not found or is past retention</p>}
<div className="flex flex-wrap gap-2">
{requestOffset > 0 && (
<Button variant="outline" onClick={() => setRequestOffset(Math.max(0, requestOffset - 8000))}>
Previous section
</Button>
)}
{requestEvidenceData?.parts.some((p) => p.truncated) && (
<Button
variant="outline"
onClick={() => setRequestOffset(requestOffset === 0 ? 1 : requestOffset + 8000)}
>
Next section
</Button>
)}
</div>
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -1,20 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { RunSelection } from "@/components/view_logs/TraceView/traceRouting";
import { TraceSheet } from "./TraceSheet";
vi.mock("@/components/view_logs/TraceView/TraceDrawer", () => ({
RunView: ({ selection }: { selection: RunSelection }) => <div data-testid="run-view">{selection.spanId}</div>,
}));
describe("TraceSheet", () => {
it("opens another finding's evidence on its own step", () => {
const props = { open: true, traceId: "trace-1", accessToken: "t", onClose: vi.fn() };
const { rerender } = render(<TraceSheet {...props} initialSpanId="step-a" />);
expect(screen.getByTestId("run-view")).toHaveTextContent("step-a");
rerender(<TraceSheet {...props} initialSpanId="step-b" />);
expect(screen.getByTestId("run-view")).toHaveTextContent("step-b");
});
});

View file

@ -1,64 +0,0 @@
import { RunView } from "@/components/view_logs/TraceView/TraceDrawer";
import { useLocalRunSelection } from "@/components/view_logs/TraceView/traceRouting";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
export function TraceSheet({
open,
traceId,
traceRef,
initialSpanId,
accessToken,
onClose,
}: {
open: boolean;
traceId: string;
traceRef?: string;
initialSpanId?: string;
accessToken: string;
onClose: () => void;
}) {
return (
<Sheet
open={open}
onOpenChange={(value) => {
if (!value) onClose();
}}
>
<SheetContent className="w-full overflow-y-auto data-[side=right]:sm:max-w-[90vw]">
<SheetHeader>
<SheetTitle className="text-xl">Original run</SheetTitle>
<SheetDescription>Recorded agent steps and evidence</SheetDescription>
</SheetHeader>
{open && (
<SheetRun
key={`${traceId}:${traceRef}:${initialSpanId}`}
traceId={traceId}
traceRef={traceRef}
initialSpanId={initialSpanId ?? null}
accessToken={accessToken}
onBack={onClose}
/>
)}
</SheetContent>
</Sheet>
);
}
function SheetRun({
traceId,
traceRef,
initialSpanId,
accessToken,
onBack,
}: {
traceId: string;
traceRef?: string;
initialSpanId: string | null;
accessToken: string;
onBack: () => void;
}) {
const selection = useLocalRunSelection(initialSpanId);
return (
<RunView traceId={traceId} traceRef={traceRef} selection={selection} accessToken={accessToken} onBack={onBack} />
);
}

View file

@ -1,20 +1,23 @@
"use client";
import { z } from "zod";
import { useLensUpdate } from "../data/mutations";
import type { Lens } from "../model/types";
export function WatchAllBanner({
lenses,
busy,
onWatchAll,
skipped = [],
}: {
lenses: readonly Lens[];
busy: boolean;
onWatchAll: () => void;
skipped?: readonly { id: string; name: string; reason: string }[];
}) {
const watchAllResult = z.object({
skipped: z.array(z.object({ id: z.string(), name: z.string(), reason: z.string() })),
});
export interface WatchAllBannerProps {
readonly lenses: readonly Lens[];
}
export function WatchAllBanner({ lenses }: WatchAllBannerProps) {
const watchAll = useLensUpdate();
const paused = lenses.filter((lens) => !lens.settings.enabled).length;
if (paused === 0) return null;
const result = watchAllResult.safeParse(watchAll.data);
const skipped = result.success ? result.data.skipped : [];
if (skipped.length >= paused)
return (
<span
@ -27,11 +30,11 @@ export function WatchAllBanner({
);
return (
<span role="status" className="text-xs text-muted-foreground">
{paused} paused ·{" "}
{paused} paused · {watchAll.error && <span className="text-destructive">{watchAll.error.message} · </span>}
<button
type="button"
disabled={busy}
onClick={onWatchAll}
disabled={watchAll.isPending}
onClick={() => watchAll.mutate((api) => api.watchAll())}
className="font-medium text-foreground underline-offset-2 hover:underline disabled:opacity-50"
>
Turn all on

View file

@ -4,39 +4,37 @@ import { Button } from "@/components/ui/button";
import { TabsContent } from "@/components/ui/tabs";
import { type Settings } from "../../model/types";
import { watches } from "../../setup/watches";
import { watches } from "../../model/watches";
export function CriteriaTab({
batchSettings,
readOnly,
setEditing,
}: {
batchSettings: Settings | undefined;
readOnly: boolean;
setEditing: (mode: "edit") => void;
}) {
export interface CriteriaTabProps {
readonly settings: Settings | undefined;
readonly readOnly: boolean;
readonly onEditCriteria: () => void;
}
export function CriteriaTab({ settings, readOnly, onEditCriteria }: CriteriaTabProps) {
return (
<TabsContent value="checks" className="pt-4 space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Used for this run</p>
{!readOnly && (
<Button variant="outline" size="sm" onClick={() => setEditing("edit")}>
<Button variant="outline" size="sm" onClick={onEditCriteria}>
Edit criteria
</Button>
)}
</div>
{batchSettings?.context && (
{settings?.context && (
<div className="space-y-2 border-b pb-5">
<h3 className="text-base font-semibold">What should the agent be doing?</h3>
<p className="text-xs text-muted-foreground">The purpose and expected outcome of the agent.</p>
<p className="mt-2 whitespace-pre-wrap text-sm">{batchSettings.context}</p>
<p className="mt-2 whitespace-pre-wrap text-sm">{settings.context}</p>
</div>
)}
<div className="pt-2">
<h3 className="text-base font-semibold">Watch for</h3>
<p className="mt-1 text-xs text-muted-foreground">Specific problems or patterns to investigate.</p>
</div>
{batchSettings?.checks.map((c, index) => (
{settings?.checks.map((c, index) => (
<div key={c.id} className="flex items-start gap-3 border-b py-4">
<span className="mt-0.5 text-xs tabular-nums text-muted-foreground">{index + 1}.</span>
<CheckSummary check={c} />

View file

@ -1,109 +1,122 @@
"use client";
import { ListRow } from "@/components/shared/ListRow";
import type { ReactNode } from "react";
import { ChevronRight, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Inspector } from "@/components/shared/Inspector";
import { Button } from "@/components/ui/button";
import { TabsContent } from "@/components/ui/tabs";
import { type Lens, type Finding, type Job } from "../../model/types";
import { cn } from "@/lib/cva.config";
const priorityColors = { high: "bg-red-500", medium: "bg-amber-500", low: "bg-slate-400" };
import { sortedFindings } from "../../model/findings";
import type { OwnedFinding } from "../../model/inbox";
import { activeJob } from "../../model/status";
import { type Lens, type Finding, type Job } from "../../model/types";
import { ownedFindingKey } from "../FindingDetails";
import { FINDING_PANEL_WIDTH_KEY } from "../../storage";
import { useFindingFilters, useFindingRoute } from "../../route";
const priorityColors = { high: "bg-destructive", medium: "bg-warning", low: "bg-muted-foreground" };
function emptyFindingTitle(active: boolean, scanned: boolean, status?: string) {
if (status === "failed" || status === "cancelled") return "No findings from this run";
if (active) return "Your findings will appear here";
return scanned ? "No matching findings" : "Ready for the first analysis";
}
export function FindingsTab({
kind,
setKind,
batchFindings,
filter,
setFilter,
visibleFindings,
setFindingId,
active,
lens,
job,
}: {
kind: "issue" | "pattern";
setKind: (kind: "issue" | "pattern") => void;
batchFindings: Finding[];
filter: string;
setFilter: (filter: string) => void;
visibleFindings: Finding[];
setFindingId: (id: string) => void;
export interface FindingsTabProps {
readonly lens: Lens;
readonly job: Job | undefined;
readonly findings: readonly Finding[];
/** The finding panel, rendered inside this tab's Inspector so J/K walk the listed findings. */
readonly children?: ReactNode;
}
active: Job | undefined;
lens: Lens;
job: Job | undefined;
}) {
export function FindingsTab({ lens, job, findings, children }: FindingsTabProps) {
const { kind, setKind, status, setStatus } = useFindingFilters();
const { findingId, setFindingId } = useFindingRoute();
const active = activeJob(lens.jobs) !== undefined;
const openCount = (of: Finding["kind"]) => findings.filter((f) => f.kind === of && f.status === "open").length;
const visible = sortedFindings(findings.filter((f) => (status === "all" || f.status === status) && f.kind === kind));
const picked = findings.find((f) => f.id === findingId);
const selected: OwnedFinding | null = picked ? { lens, finding: picked } : null;
return (
<TabsContent value="findings" className="pt-4 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex gap-1" aria-label="Finding category">
<Button size="sm" variant={kind === "issue" ? "secondary" : "ghost"} onClick={() => setKind("issue")}>
Needs attention ({batchFindings.filter((f) => f.kind === "issue" && f.status === "open").length ?? 0})
</Button>
<Button size="sm" variant={kind === "pattern" ? "secondary" : "ghost"} onClick={() => setKind("pattern")}>
Patterns ({batchFindings.filter((f) => f.kind === "pattern" && f.status === "open").length ?? 0})
</Button>
</div>
<select
aria-label="Finding status"
className="rounded-md border bg-background px-2 py-1 text-xs"
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
<option value="open">Open</option>
<option value="resolved">Resolved</option>
<option value="dismissed">Dismissed</option>
<option value="all">All statuses</option>
</select>
</div>
<p className="text-xs text-muted-foreground">
{kind === "issue"
? "Problems worth investigating, highest priority first."
: "Useful behavior and trends. These do not necessarily need a fix."}
</p>
<div className="divide-y border-y">
{visibleFindings.map((f) => (
<ListRow
key={f.id}
onClick={() => {
setFindingId(f.id);
}}
className="flex w-full gap-3 py-4 text-left hover:bg-muted/30 focus-visible:outline-2 focus-visible:outline-ring"
<Inspector.Root
items={visible.map((finding) => ({ lens, finding }))}
itemKey={ownedFindingKey}
selected={selected}
onSelectedChange={(owned) => setFindingId(owned?.finding.id ?? null)}
noun="finding"
storageKey={FINDING_PANEL_WIDTH_KEY}
>
<TabsContent value="findings" className="pt-4 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex gap-1" aria-label="Finding category">
<Button size="sm" variant={kind === "issue" ? "secondary" : "ghost"} onClick={() => setKind("issue")}>
Needs attention ({openCount("issue")})
</Button>
<Button size="sm" variant={kind === "pattern" ? "secondary" : "ghost"} onClick={() => setKind("pattern")}>
Patterns ({openCount("pattern")})
</Button>
</div>
<select
aria-label="Finding status"
className="rounded-md border bg-background px-2 py-1 text-xs"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<span
data-state={f.priority ?? "medium"}
className={cn("mt-1 size-2 shrink-0 rounded-full", priorityColors[f.priority ?? "medium"])}
aria-label={`${f.priority} priority`}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{f.title}</p>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{f.description}</p>
<option value="open">Open</option>
<option value="resolved">Resolved</option>
<option value="dismissed">Dismissed</option>
<option value="all">All statuses</option>
</select>
</div>
<p className="text-xs text-muted-foreground">
{kind === "issue"
? "Problems worth investigating, highest priority first."
: "Useful behavior and trends. These do not necessarily need a fix."}
</p>
<div className="divide-y border-y">
{visible.map((f) => (
<Inspector.Row
key={f.id}
item={{ lens, finding: f }}
render={
<button
type="button"
className="flex w-full gap-3 py-4 text-left hover:bg-muted/30 focus-visible:outline-2 focus-visible:outline-ring data-[state=selected]:bg-trace-row-selected data-[state=selected]:shadow-[inset_2px_0_0_var(--trace-brand)]"
/>
}
>
<span
data-state={f.priority ?? "medium"}
className={cn("mt-1 size-2 shrink-0 rounded-full", priorityColors[f.priority ?? "medium"])}
aria-label={`${f.priority} priority`}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{f.title}</p>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{f.description}</p>
<p className="mt-2 text-xs text-muted-foreground">
{f.occurrences?.length ?? 0} linked {f.occurrences?.length === 1 ? "run" : "runs"} ·{" "}
{f.kind === "issue" ? `${f.priority} priority` : "Pattern"}
</p>
</div>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Inspector.Row>
))}
{visible.length === 0 && (
<div className="px-6 py-14 text-center">
<CheckCircle2 className="mx-auto mb-3 size-5 text-muted-foreground" />
<p className="text-sm font-medium">{emptyFindingTitle(active, !!lens.last_scan_at, job?.status)}</p>
<p className="mt-2 text-xs text-muted-foreground">
{f.occurrences?.length ?? 0} linked {f.occurrences?.length === 1 ? "run" : "runs"} ·{" "}
{f.kind === "issue" ? `${f.priority} priority` : "Pattern"}
{active
? "Lens is reviewing the selected activity."
: "Findings reflect the runs analyzed, not a guarantee about all activity."}
</p>
</div>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</ListRow>
))}
{visibleFindings.length === 0 && (
<div className="px-6 py-14 text-center">
<CheckCircle2 className="mx-auto mb-3 size-5 text-muted-foreground" />
<p className="text-sm font-medium">{emptyFindingTitle(!!active, !!lens.last_scan_at, job?.status)}</p>
<p className="mt-2 text-xs text-muted-foreground">
{active
? "Lens is reviewing the selected activity."
: "Findings reflect the runs analyzed, not a guarantee about all activity."}
</p>
</div>
)}
</div>
</TabsContent>
)}
</div>
</TabsContent>
{children}
</Inspector.Root>
);
}

View file

@ -1,47 +1,46 @@
"use client";
import { ListRow } from "@/components/shared/ListRow";
import { useState } from "react";
import { ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TabsContent } from "@/components/ui/tabs";
import { ScanDuration } from "../InvestigationProgress";
import type { Lens, Job } from "../../model/types";
import { HistoryTimeline } from "./HistoryTimeline";
import { ScanDuration } from "./JobMeta";
import { useRunHistory } from "./useRunHistory";
import type { Lens } from "../../model/types";
import { useRunRoute } from "../../route";
import { money, when } from "../../model/format";
export function HistoryTab({
history,
historyError,
refetchHistory,
lens,
openBatch,
historyOffset,
setHistoryOffset,
}: {
history: Job[] | undefined;
historyError: Error | null | undefined;
refetchHistory: () => void;
lens: Lens;
openBatch: (id: string) => void;
historyOffset: number;
setHistoryOffset: (offset: number) => void;
}) {
const PAGE = 50;
export interface HistoryTabProps {
readonly lens: Lens;
}
export function HistoryTab({ lens }: HistoryTabProps) {
const [offset, setOffset] = useState(0);
const history = useRunHistory(lens, offset);
const { openRun } = useRunRoute();
const rows = history.data ?? lens.jobs;
return (
<TabsContent value="activity" className="pt-4 space-y-4">
{historyError && (
{history.error && (
<p role="alert" className="text-sm text-destructive">
Could not load run history.{" "}
<Button variant="link" size="sm" onClick={() => void refetchHistory()}>
<Button variant="link" size="sm" onClick={() => void history.refetch()}>
Retry
</Button>
</p>
)}
{rows.length > 0 && <HistoryTimeline jobs={rows} slots={PAGE} onOpen={openRun} />}
<div className="divide-y border-y">
{(history ?? lens.jobs)?.map((j) => (
{rows.map((j) => (
<ListRow
key={j.id}
onClick={() => openBatch(j.id)}
onClick={() => openRun(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">
@ -66,21 +65,16 @@ export function HistoryTab({
</ListRow>
))}
</div>
{(historyOffset > 0 || (history?.length ?? 0) >= 50) && (
{(offset > 0 || (history.data?.length ?? 0) >= PAGE) && (
<div className="flex justify-between">
<Button
variant="ghost"
size="sm"
disabled={!historyOffset}
onClick={() => setHistoryOffset(Math.max(0, historyOffset - 50))}
>
<Button variant="ghost" size="sm" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
Newer runs
</Button>
<Button
variant="ghost"
size="sm"
disabled={(history?.length ?? 0) < 50}
onClick={() => setHistoryOffset(historyOffset + 50)}
disabled={(history.data?.length ?? 0) < PAGE}
onClick={() => setOffset(offset + PAGE)}
>
Older runs
</Button>

View file

@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import type { Job } from "../../model/types";
import { checkColumns } from "./HistoryTimeline";
const coverage: Job["coverage"] = {
eligible: 0,
selected: 0,
screened: 0,
investigated: 0,
inconclusive: 0,
grouping_batches: 0,
grouped_batches: 0,
candidates: 0,
partial: 0,
unassessable: 0,
};
const job: Job = {
assessments: [],
steps: [],
trigger: "schedule",
coverage,
attempts: 0,
error: "",
cost: 0,
id: "scan",
status: "running",
stage: "Reading executions",
created_at: "2026-09-30T12:00:00Z",
start: "2026-09-29T12:00:00Z",
end: "2026-09-30T12:00:00Z",
revision: 1,
settings: {
context: "",
source: "traces",
lookback_hours: 24,
service: "",
agent_name: "",
filters: [],
enabled: false,
interval_minutes: 15,
sample_size: 100,
sample_percent: 100,
concurrency: 8,
team_id: "",
execution_ids: [],
monthly_budget: 20,
name: "Release reviews",
model: "analysis",
checks: [{ enabled: true, id: "failures", instruction: "Find failed outcomes" }],
},
};
const assessment = (id: string, issues: string[]): Job["assessments"][number] => ({
execution_id: id,
cannot_assess: false,
issue_checks: issues,
pattern_checks: [],
});
const check = (id: string, status: Job["status"], screened: number, assessments: Job["assessments"]): Job => ({
...job,
id,
status,
coverage: { ...coverage, screened },
assessments,
});
describe("checkColumns", () => {
const newest = check("newest", "completed", 3, [
assessment("a", ["failures"]),
assessment("b", []),
assessment("c", ["failures", "other"]),
]);
const oldest = check("oldest", "failed", 7, []);
it("pads the left and puts the newest check on the right", () => {
const columns = checkColumns([newest, oldest], 4);
expect(columns.map((c) => c.job?.id ?? null)).toEqual([null, null, "oldest", "newest"]);
expect(columns[0]).toMatchObject({ total: 0, failed: 0 });
});
it("counts reviewed traces and the traces with at least one issue", () => {
expect(checkColumns([newest], 1)[0]).toMatchObject({ total: 3, failed: 2, opacity: 1 });
});
it("fades checks that did not complete", () => {
const [failed] = checkColumns([oldest], 1);
const [cancelled] = checkColumns([{ ...oldest, status: "cancelled" }], 1);
expect(failed.opacity).toBeLessThan(1);
expect(cancelled.opacity).toBe(failed.opacity);
});
it("keeps only the most recent checks that fit", () => {
expect(checkColumns([newest, oldest], 1).map((c) => c.job?.id)).toEqual(["newest"]);
});
});

View file

@ -0,0 +1,92 @@
"use client";
import { useState } from "react";
import { DotFieldCanvas, DotFieldRoot } from "@/components/shared/dotField/DotField";
import type { DotColumn } from "@/components/shared/dotField/dots";
import { when } from "../../model/format";
import type { Job } from "../../model/types";
const UNSUCCESSFUL_OPACITY = 0.35;
export interface CheckColumn extends DotColumn {
job: Job | null;
}
const affectedRuns = (job: Job): number => job.assessments.filter((a) => a.issue_checks.length > 0).length;
/** One column per check, oldest on the left, padded with empty slots so spacing stays stable as checks accrue. */
export function checkColumns(jobs: readonly Job[], slots: number): readonly CheckColumn[] {
const recent = jobs.slice(0, slots).reverse();
const padding = Array.from(
{ length: slots - recent.length },
(): CheckColumn => ({
job: null,
total: 0,
failed: 0,
series: [],
}),
);
const checks = recent.map(
(job): CheckColumn => ({
job,
total: job.coverage.screened,
failed: affectedRuns(job),
series: [],
opacity: job.status === "failed" || job.status === "cancelled" ? UNSUCCESSFUL_OPACITY : 1,
}),
);
return [...padding, ...checks];
}
const pct = (value: number): string => `${value * 100}%`;
export interface HistoryTimelineProps {
readonly jobs: readonly Job[];
readonly slots: number;
readonly onOpen: (jobId: string) => void;
}
/** Every check as a dot column: reviewed traces in grey, affected traces in red, failed checks faded. */
export function HistoryTimeline({ jobs, slots, onOpen }: HistoryTimelineProps) {
const columns = checkColumns(jobs, slots);
const [hover, setHover] = useState<number | null>(null);
const hovered = hover === null ? null : columns[hover];
return (
<div className="relative select-none" data-testid="history-timeline">
<DotFieldRoot columns={columns} hover={hover} className="flex" onPointerLeave={() => setHover(null)}>
<DotFieldCanvas />
{columns.map(({ job }, i) =>
job ? (
<button
key={job.id}
type="button"
aria-label={`Check at ${when(job.created_at)}`}
className="relative h-full flex-1 focus-visible:outline-2 focus-visible:outline-ring"
onPointerEnter={() => setHover(i)}
onFocus={() => setHover(i)}
onBlur={() => setHover(null)}
onClick={() => onOpen(job.id)}
/>
) : (
<span key={`empty-${i}`} className="h-full flex-1" />
),
)}
</DotFieldRoot>
{hovered?.job && hover !== null && (
<div
role="tooltip"
className="pointer-events-none absolute top-full z-floating mt-1 rounded-md border border-border bg-popover px-2.5 py-1.5 font-mono text-xs text-popover-foreground shadow-md"
style={{ left: pct(Math.min(0.75, hover / slots)) }}
>
<div>{when(hovered.job.created_at)}</div>
<div>
{hovered.total} reviewed, {hovered.failed} affected
</div>
{hovered.job.status !== "completed" && <div className="capitalize">{hovered.job.status}</div>}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,101 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import type { Lens } from "../../model/types";
import { InvestigationActions, type InvestigationActionsProps } from "./InvestigationActions";
const lens: Lens = {
version: 0,
spent: 0,
id: "lens",
scope: { all_teams: true, api_key_hash: "", team_id: "" },
settings: {
context: "",
source: "traces",
lookback_hours: 24,
service: "",
agent_name: "",
filters: [],
interval_minutes: 15,
sample_size: 100,
sample_percent: 100,
concurrency: 8,
team_id: "",
execution_ids: [],
monthly_budget: 20,
name: "Release reviews",
model: "analysis",
enabled: true,
checks: [],
},
revision: 1,
created_at: "2026-09-30T10:00:00Z",
next_run_at: "2026-09-30T10:00:00Z",
budget_month: "2026-09",
findings: [],
jobs: [],
};
function renderActions(overrides: Partial<InvestigationActionsProps> = {}) {
const intents = {
onEdit: vi.fn(),
onDuplicate: vi.fn(),
onPause: vi.fn(),
onEnableMonitoring: vi.fn(),
onRunNow: vi.fn(),
};
renderWithProviders(<InvestigationActions lens={lens} ready busy={false} {...intents} {...overrides} />);
return intents;
}
describe("InvestigationActions", () => {
it("reports each menu choice as an intent without touching the API", async () => {
const user = userEvent.setup();
const intents = renderActions();
await user.click(screen.getByRole("button", { name: "Run now" }));
expect(intents.onRunNow).toHaveBeenCalledOnce();
await user.click(screen.getByRole("button", { name: "Investigation actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Pause monitoring" }));
expect(intents.onPause).toHaveBeenCalledOnce();
expect(intents.onEnableMonitoring).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Investigation actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Edit investigation" }));
expect(intents.onEdit).toHaveBeenCalledOnce();
await user.click(screen.getByRole("button", { name: "Investigation actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Duplicate" }));
expect(intents.onDuplicate).toHaveBeenCalledOnce();
});
it("offers to enable monitoring on a paused investigation", async () => {
const user = userEvent.setup();
const paused = { ...lens, settings: { ...lens.settings, enabled: false } };
const intents = renderActions({ lens: paused });
await user.click(screen.getByRole("button", { name: "Investigation actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Enable monitoring" }));
expect(intents.onEnableMonitoring).toHaveBeenCalledOnce();
expect(intents.onPause).not.toHaveBeenCalled();
});
it("blocks a second run while one is queued or running", () => {
const job = {
id: "job",
status: "queued" as const,
stage: "Queued",
trigger: "manual" as const,
attempts: 0,
error: "",
cost: 0,
created_at: lens.created_at,
start: lens.created_at,
end: lens.created_at,
revision: 1,
settings: lens.settings,
assessments: [],
steps: [],
findings: [],
};
renderActions({ lens: { ...lens, jobs: [job] } });
expect(screen.getByRole("button", { name: "Run now" })).toBeDisabled();
});
});

View file

@ -8,28 +8,34 @@ import {
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { type Lens, type Job } from "../../model/types";
import type { LensWrite } from "../../api/mutations";
import { hasActiveJob } from "../../model/status";
import { type Lens } from "../../model/types";
export interface InvestigationIntents {
readonly onEdit: () => void;
readonly onDuplicate: () => void;
readonly onPause: () => void;
readonly onEnableMonitoring: () => void;
readonly onCancelRun: () => void;
readonly onRunNow: () => void;
}
export type InvestigationActionsProps = Omit<InvestigationIntents, "onCancelRun"> & {
readonly lens: Lens;
readonly ready: boolean;
readonly busy: boolean;
};
export function InvestigationActions({
lens,
ready,
busy,
active,
setEditing,
setMonitoring,
update,
onEdit,
onDuplicate,
onPause,
onEnableMonitoring,
onRunNow,
}: {
lens: Lens;
ready: boolean;
busy: boolean;
active: Job | undefined;
setEditing: (mode: "new" | "edit" | "duplicate") => void;
setMonitoring: (open: boolean) => void;
update: (write: LensWrite) => Promise<unknown>;
onRunNow: () => void;
}) {
}: InvestigationActionsProps) {
return (
<div className="flex flex-wrap gap-2">
<DropdownMenu>
@ -37,31 +43,28 @@ export function InvestigationActions({
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => setEditing("edit")}>
<DropdownMenuItem onClick={onEdit}>
<Settings2 />
Edit investigation
</DropdownMenuItem>
<DropdownMenuItem disabled={!ready} onClick={() => setEditing("duplicate")}>
<DropdownMenuItem disabled={!ready} onClick={onDuplicate}>
<Copy />
Duplicate
</DropdownMenuItem>
{lens.settings.enabled ? (
<DropdownMenuItem
disabled={busy}
onClick={() => update((api) => api.saveLens(lens.id, { ...lens.settings, enabled: false }))}
>
<DropdownMenuItem disabled={busy} onClick={onPause}>
<Pause />
Pause monitoring
</DropdownMenuItem>
) : (
<DropdownMenuItem disabled={!ready || busy} onClick={() => setMonitoring(true)}>
<DropdownMenuItem disabled={!ready || busy} onClick={onEnableMonitoring}>
<Play />
Enable monitoring
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<Button disabled={busy || !!active || !ready} onClick={onRunNow}>
<Button disabled={busy || hasActiveJob(lens.jobs) || !ready} onClick={onRunNow}>
<Play className="size-3" />
Run now
</Button>

View file

@ -1,74 +1,50 @@
"use client";
import type { useInvestigationResults } from "../useInvestigationResults";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { RunsTab } from "./RunsTab";
import { InvestigationProgress } from "../InvestigationProgress";
import { StepFeed } from "../StepFeed";
import { InvestigationSummary } from "./InvestigationSummary";
import { InvestigationFailure } from "./InvestigationFailure";
import { scopeLabel, sourceLabels } from "../../model/format";
import { type Lens } from "../../model/types";
import type { LensWrite } from "../../api/mutations";
import type { OwnedFinding } from "../../model/inbox";
import { activeJob } from "../../model/status";
import { type Finding, type Lens } from "../../model/types";
import { FindingPanel } from "../FindingDetails";
import { useSectionRoute } from "../../route";
import { useRunSnapshot } from "../useRunSnapshot";
import { InvestigationActions } from "./InvestigationActions";
import { InvestigationActions, type InvestigationIntents } from "./InvestigationActions";
import { RunPicker } from "./RunPicker";
import { FindingsTab } from "./FindingsTab";
import { CriteriaTab } from "./CriteriaTab";
import { HistoryTab } from "./HistoryTab";
export type InvestigationDetailProps = InvestigationIntents & {
readonly lens: Lens;
readonly readOnly: boolean;
readonly ready: boolean;
readonly busy: boolean;
readonly connected: boolean;
readonly onReviewFinding: (owned: OwnedFinding, status: Finding["status"], reason: string) => void;
};
export function InvestigationDetail({
lens,
readOnly,
ready,
busy,
setEditing,
setMonitoring,
onRunNow,
update,
connected,
results,
}: {
lens: Lens;
readOnly: boolean;
ready: boolean;
busy: boolean;
setEditing: (mode: "new" | "edit" | "duplicate") => void;
setMonitoring: (open: boolean) => void;
onRunNow: () => void;
update: (write: LensWrite) => Promise<unknown>;
connected: boolean;
results: ReturnType<typeof useInvestigationResults>;
}) {
const {
active,
job,
tab,
setTab,
batchSettings,
batchId,
setBatchId,
setFindingId,
selectedOutsideHistory,
history,
historyError,
refetchHistory,
historicalError,
refetchHistorical,
missingSnapshot,
kind,
setKind,
batchFindings,
filter,
setFilter,
visibleFindings,
setRequestOffset,
setEvidence,
openBatch,
historyOffset,
setHistoryOffset,
} = results;
onCancelRun,
onReviewFinding,
...intents
}: InvestigationDetailProps) {
const { section, setSection } = useSectionRoute();
const snapshot = useRunSnapshot(lens);
const { job, batchId, batchSettings, batchFindings, missingSnapshot } = snapshot;
const active = activeJob(lens.jobs);
return (
<div>
<section className="min-w-0 space-y-5">
@ -79,107 +55,47 @@ export function InvestigationDetail({
{sourceLabels[lens.settings.source ?? "traces"]} · {scopeLabel(lens.settings)}
</p>
</div>
{!readOnly && (
<InvestigationActions
lens={lens}
ready={ready}
busy={busy}
active={active}
setEditing={setEditing}
setMonitoring={setMonitoring}
update={update}
onRunNow={onRunNow}
/>
)}
{!readOnly && <InvestigationActions lens={lens} ready={ready} busy={busy} {...intents} />}
</div>
<InvestigationSummary lens={lens} connected={connected} />
{active && (
<InvestigationProgress
key={active.id}
job={active}
onCancel={
readOnly
? undefined
: () => {
void update((api) => api.cancelRun(lens.id));
}
}
/>
)}
{active && <InvestigationProgress key={active.id} job={active} onCancel={readOnly ? undefined : onCancelRun} />}
{active && <StepFeed job={active} />}
{job?.error && <InvestigationFailure job={job} connected={connected} />}
<Tabs value={tab} onValueChange={setTab} key={lens.id}>
<Tabs value={section} onValueChange={setSection} key={lens.id}>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b">
<TabsList variant="line">
<TabsTrigger value="findings">Findings</TabsTrigger>
<TabsTrigger value="checks">Criteria</TabsTrigger>
<TabsTrigger value="runs">
{
{ requests: "Requests", both: "Traces & requests", traces: "Traces" }[
batchSettings?.source ?? "traces"
]
}
</TabsTrigger>
<TabsTrigger value="runs">{sourceLabels[batchSettings?.source ?? "traces"]}</TabsTrigger>
<TabsTrigger value="activity">History</TabsTrigger>
</TabsList>
{tab !== "activity" && (
<RunPicker
batchId={batchId}
setBatchId={setBatchId}
setFindingId={setFindingId}
job={job}
selectedOutsideHistory={selectedOutsideHistory}
history={history}
lens={lens}
/>
)}
{section !== "activity" && <RunPicker lens={lens} job={job} />}
</div>
{historicalError && (
{snapshot.error && (
<p role="alert" className="text-sm text-destructive">
Could not load this run.{" "}
<Button variant="link" onClick={() => void refetchHistorical()}>
<Button variant="link" onClick={snapshot.refetch}>
Retry
</Button>
</p>
)}
{missingSnapshot && tab !== "activity" && (
{missingSnapshot && section !== "activity" && (
<p className="text-sm text-muted-foreground">
This older batch predates saved result snapshots. Its findings remain available under All accumulated
findings.
</p>
)}
<FindingsTab
kind={kind}
setKind={setKind}
batchFindings={batchFindings}
filter={filter}
setFilter={setFilter}
visibleFindings={visibleFindings}
setFindingId={setFindingId}
active={active}
lens={lens}
job={job}
/>
<CriteriaTab batchSettings={batchSettings} readOnly={readOnly} setEditing={setEditing} />
<TabsContent value="runs" className="pt-4 space-y-4">
<RunsTab
key={job?.id ?? batchId}
job={job}
onOpen={(id) => {
setRequestOffset(0);
setEvidence({ id, span: "" });
}}
<FindingsTab lens={lens} job={job} findings={batchFindings}>
<FindingPanel
readOnly={readOnly}
busy={busy}
sampledRuns={job?.sample?.executions}
onReview={onReviewFinding}
/>
</TabsContent>
<HistoryTab
history={history}
historyError={historyError}
refetchHistory={refetchHistory}
lens={lens}
openBatch={openBatch}
historyOffset={historyOffset}
setHistoryOffset={setHistoryOffset}
/>
</FindingsTab>
<CriteriaTab settings={batchSettings} readOnly={readOnly} onEditCriteria={intents.onEdit} />
<RunsTab key={job?.id ?? batchId} lens={lens} job={job} />
<HistoryTab lens={lens} />
</Tabs>
</section>
</div>

View file

@ -1,5 +1,5 @@
import type { ComponentProps } from "react";
import { NextCheck } from "../InvestigationProgress";
import { NextCheck } from "./JobMeta";
import { lensStatus } from "../../model/status";
import { runTime } from "../../model/format";
import { type Lens } from "../../model/types";

View file

@ -0,0 +1,23 @@
"use client";
import { useNow } from "@/hooks/useNow";
import { analysisElapsed } from "../../model/progress";
import { nextCheckStatus } from "../../model/status";
import type { Job, Lens } from "../../model/types";
export function NextCheck({ lens }: { lens: Lens }) {
const now = useNow(15000);
const label = nextCheckStatus(lens, now);
if (!label) return null;
return <p className="mt-1 text-xs text-muted-foreground">{label}</p>;
}
export function ScanDuration({ job }: { job: Job }) {
if (!job.finished_at) return null;
return (
<span title="Total time, including any wait for an analyzer">
{" · Took "}
{analysisElapsed(job.created_at, Date.parse(job.finished_at))}
</span>
);
}

View file

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
@ -13,60 +14,30 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import type { Lens } from "../../model/types";
export interface RunWindow {
agent_name?: string;
start?: string;
end?: string;
lookback_hours?: number;
}
const PRESETS = [
{ label: "Since last run", hours: null },
{ label: "Last hour", hours: 1 },
{ label: "Last 24h", hours: 24 },
{ label: "Last 7d", hours: 168 },
{ label: "Custom", hours: -1 },
] as const;
import { lensQueries } from "../../data/queries";
import { useLensApi } from "../../data/LensServices";
import type { Lens, RunWindow } from "../../model/types";
import { RUN_PRESETS, runRequest, type RunChoice, type RunPreset } from "../../model/runRequest";
const localInput = (date: Date) =>
new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
export interface RunChoice {
preset: (typeof PRESETS)[number]["hours"];
agent: string;
saved: string;
start: string;
end: string;
}
export function runRequest({ preset, agent, saved, start, end }: RunChoice): RunWindow | string {
const agentPart = agent.trim() && agent.trim() !== saved ? { agent_name: agent.trim() } : {};
if (preset === null) return agentPart;
if (preset > 0) return { ...agentPart, lookback_hours: preset };
const startMs = Date.parse(start);
const endMs = Date.parse(end);
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return "Choose a start and end time";
if (startMs >= endMs) return "Start time must be before end time";
return { ...agentPart, start: new Date(startMs).toISOString(), end: new Date(endMs).toISOString() };
}
export function RunNowDialog({
lens,
agents,
busy,
onClose,
onRun,
}: {
lens: Lens;
agents: readonly string[];
busy: boolean;
onClose: () => void;
onRun: (request: RunWindow) => Promise<void>;
}) {
const api = useLensApi();
const agentsQuery = useQuery(lensQueries.agents(api, "traces"));
const agents = Array.isArray(agentsQuery.data) ? agentsQuery.data : [];
const now = new Date();
const [preset, setPreset] = useState<(typeof PRESETS)[number]["hours"]>(null);
const [preset, setPreset] = useState<RunPreset>(null);
const [agent, setAgent] = useState(lens.settings.agent_name ?? "");
const [start, setStart] = useState(localInput(new Date(now.getTime() - 3_600_000)));
const [end, setEnd] = useState(localInput(now));
@ -109,7 +80,7 @@ export function RunNowDialog({
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Traces to review</legend>
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
{RUN_PRESETS.map((p) => (
<button
key={p.label}
type="button"

View file

@ -4,46 +4,39 @@ import { Info } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import { ScanDuration } from "../InvestigationProgress";
import { ScanDuration } from "./JobMeta";
import { useRunHistory } from "./useRunHistory";
import type { Lens, Job } from "../../model/types";
import { useRunRoute } from "../../route";
import { money, when } from "../../model/format";
export function RunPicker({
batchId,
setBatchId,
setFindingId,
job,
selectedOutsideHistory,
history,
lens,
}: {
batchId: string;
setBatchId: (id: string) => void;
setFindingId: (id: string | null) => void;
job: Job | undefined;
selectedOutsideHistory: boolean;
history: Job[] | undefined;
lens: Lens;
}) {
export interface RunPickerProps {
readonly lens: Lens;
readonly job: Job | undefined;
}
export function RunPicker({ lens, job }: RunPickerProps) {
const { batchId, selectRun } = useRunRoute();
const history = useRunHistory(lens, 0);
const options = history.data ?? lens.jobs;
const aggregate = batchId === "latest" || batchId === "all";
const outsideHistory = !aggregate && !options.some((j) => j.id === batchId);
return (
<div className="flex min-w-0 items-center gap-1 pb-1">
<select
aria-label="Investigation run"
className="h-8 w-44 max-w-full truncate rounded-md border-0 bg-transparent px-2 text-xs text-muted-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"
value={batchId}
onChange={(e) => {
setBatchId(e.target.value);
setFindingId(null);
}}
onChange={(e) => selectRun(e.target.value)}
>
<option value="latest">Latest run</option>
{job && selectedOutsideHistory && (
{job && outsideHistory && (
<option value={batchId}>
{when(job.created_at)} · {job.status}
</option>
)}
{(history ?? lens.jobs)?.map((j) => (
{options.map((j) => (
<option key={j.id} value={j.id}>
{when(j.created_at)} · {j.status}
</option>

View file

@ -1,9 +1,25 @@
import { ListRow } from "@/components/shared/ListRow";
"use client";
import { useState } from "react";
import { ChevronRight } from "lucide-react";
import { Inspector } from "@/components/shared/Inspector";
import { Button } from "@/components/ui/button";
import { TabsContent } from "@/components/ui/tabs";
import { runTime } from "../../model/format";
import { type Job } from "../../model/types";
import { type Job, type Lens } from "../../model/types";
import { EvidenceView } from "../Evidence";
import { FINDING_PANEL_WIDTH_KEY } from "../../storage";
import { useRunEvidenceRoute } from "../../route";
const PAGE = 50;
interface RunRef {
readonly id: string;
}
const runKey = (run: RunRef): string => run.id;
function assessmentLabel(assessment: Job["assessments"][number] | undefined): string {
if (!assessment) return "Not reviewed";
@ -11,7 +27,14 @@ function assessmentLabel(assessment: Job["assessments"][number] | undefined): st
return assessment.issue_checks?.length ? "Issue observed" : "No issue observed";
}
export function RunsTab({ job, onOpen }: { job?: Job; onOpen: (id: string) => void }) {
export interface RunsTabProps {
readonly lens: Lens;
readonly job?: Job;
}
/** The runs a batch reviewed; each opens its original trace or request in the side panel. */
export function RunsTab({ lens, job }: RunsTabProps) {
const { runId, selectRun } = useRunEvidenceRoute();
const [runOffset, setRunOffset] = useState(0);
const [runFilter, setRunFilter] = useState("all");
const assessments = new Map(job?.assessments?.map((a) => [a.execution_id, a]));
@ -23,84 +46,103 @@ export function RunsTab({ job, onOpen }: { job?: Job; onOpen: (id: string) => vo
if (runFilter === "clear") return assessment && !assessment.cannot_assess && !assessment.issue_checks?.length;
return assessment?.issue_checks?.includes(runFilter);
});
const page: readonly RunRef[] = visibleRuns.slice(runOffset, runOffset + PAGE);
const selected: RunRef | null = runId === null ? null : runs.find((run) => run.id === runId) ?? { id: runId };
return (
<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="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 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>}
{job?.settings?.checks.map((check) => (
<option key={check.id} value={check.id}>
{check.instruction}
</option>
))}
</select>
</div>
<div className="divide-y border-y">
{visibleRuns.slice(runOffset, runOffset + 50).map((run) => (
<ListRow
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"
/>
</ListRow>
))}
{!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."}
<Inspector.Root
items={page}
itemKey={runKey}
selected={selected}
onSelectedChange={(run) => selectRun(run?.id ?? null)}
noun="run"
storageKey={FINDING_PANEL_WIDTH_KEY}
>
<TabsContent value="runs" className="space-y-4 pt-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>
)}
</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))}
<select
aria-label="Filter reviewed runs"
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);
}}
>
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>
<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>}
{job?.settings?.checks.map((check) => (
<option key={check.id} value={check.id}>
{check.instruction}
</option>
))}
</select>
</div>
)}
</div>
<div className="divide-y border-y">
{visibleRuns.slice(runOffset, runOffset + PAGE).map((run) => (
<Inspector.Row
key={run.id}
item={run}
render={
<button
type="button"
title={run.trace_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 data-[state=selected]:bg-trace-row-selected data-[state=selected]:shadow-[inset_2px_0_0_var(--trace-brand)] 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"
/>
</Inspector.Row>
))}
{!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>
{(runOffset > 0 || visibleRuns.length > PAGE) && (
<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 - PAGE))}
>
Previous runs
</Button>
<span>
{runOffset + 1}–{Math.min(runOffset + PAGE, visibleRuns.length)} of {visibleRuns.length}
</span>
<Button
size="sm"
variant="ghost"
disabled={runOffset + PAGE >= visibleRuns.length}
onClick={() => setRunOffset(runOffset + PAGE)}
>
Next runs
</Button>
</div>
)}
</TabsContent>
<Inspector.Panel label="Run details" testId="run-panel">
{(run: RunRef) => <EvidenceView lensId={lens.id} evidence={{ id: run.id, span: "" }} />}
</Inspector.Panel>
</Inspector.Root>
);
}

View file

@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useLensApi } from "../../data/LensServices";
import { lensKeys, lensQueries } from "../../data/queries";
import { activeJob } from "../../model/status";
import type { Lens } from "../../model/types";
/** Run history polls off its own rows, so a job the list poll discovers has to push it to refetch. */
export function useRunHistory(lens: Lens, historyOffset: number) {
const api = useLensApi();
const client = useQueryClient();
const activeId = activeJob(lens.jobs)?.id;
useEffect(() => {
if (!activeId) return;
void client.invalidateQueries({ queryKey: lensKeys.histories() }, { cancelRefetch: false });
}, [activeId, client]);
return useQuery(lensQueries.history(api, { lensId: lens.id, historyOffset }));
}

View file

@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { fieldValues } from "@/components/shared/search/evaluate";
import type { Job, Lens } from "../model/types";
import { filterInvestigations, INVESTIGATION_INDEX } from "./investigationQuery";
function makeLens(id: string, settings: Partial<Lens["settings"]>, jobs: readonly Pick<Job, "status">[] = []): Lens {
return {
version: 0,
spent: 0,
id,
scope: { all_teams: true, api_key_hash: "", team_id: "" },
settings: {
context: "",
source: "traces",
lookback_hours: 24,
service: "",
agent_name: "",
filters: [],
interval_minutes: 15,
sample_size: 100,
sample_percent: 100,
concurrency: 8,
team_id: "",
execution_ids: [],
monthly_budget: 20,
name: id,
model: "analysis",
enabled: true,
checks: [],
...settings,
},
revision: 1,
created_at: "2026-09-30T10:00:00Z",
next_run_at: "2026-09-30T10:00:00Z",
budget_month: "2026-09",
findings: [],
jobs: jobs as Job[],
};
}
const lenses = [
makeLens("Refund audit", { agent_name: "billing-agent" }, [{ status: "failed" }, { status: "completed" }]),
makeLens("Release reviews", { service: "reviewer", enabled: false }, [{ status: "completed" }]),
makeLens("Lead scoring", { filters: [{ key: "team", value: "sales" }] }),
];
const names = (query: string) => filterInvestigations(lenses, query).map((lens) => lens.settings.name);
describe("filterInvestigations", () => {
it("matches free text against the name and the scope", () => {
expect(names("REFUND")).toEqual(["Refund audit"]);
expect(names("sales")).toEqual(["Lead scoring"]);
expect(names("reviewer")).toEqual(["Release reviews"]);
});
it("filters by the latest run's status, treating no runs as never", () => {
expect(names("status:failed")).toEqual(["Refund audit"]);
expect(names("status:completed")).toEqual(["Release reviews"]);
expect(names("status:never")).toEqual(["Lead scoring"]);
});
it("filters by schedule and by agent, falling back to the service", () => {
expect(names("schedule:paused")).toEqual(["Release reviews"]);
expect(names("-schedule:paused")).toEqual(["Refund audit", "Lead scoring"]);
expect(names("agent:billing-agent")).toEqual(["Refund audit"]);
expect(names("agent:reviewer")).toEqual(["Release reviews"]);
});
});
describe("INVESTIGATION_INDEX values", () => {
it("offers only the agents and statuses present", () => {
expect(fieldValues(INVESTIGATION_INDEX, lenses, "agent")).toEqual(["billing-agent", "reviewer"]);
expect(fieldValues(INVESTIGATION_INDEX, lenses, "status")).toEqual(["completed", "failed", "never"]);
});
});

View file

@ -0,0 +1,30 @@
import { Bot, CalendarClock, CircleDashed, SquareChevronRight } from "lucide-react";
import { type ClientIndex, filterItems } from "@/components/shared/search/evaluate";
import type { FieldSpec, QueryLanguage } from "@/components/shared/search/language";
import { scopeLabel } from "../model/format";
import type { Lens } from "../model/types";
const INVESTIGATION_FIELDS = {
name: { group: "Investigation", icon: SquareChevronRight, suggestValues: true },
agent: { group: "Investigation", icon: Bot, suggestValues: true },
status: { group: "Latest run", icon: CircleDashed, suggestValues: true },
schedule: { group: "Investigation", icon: CalendarClock, suggestValues: true },
} as const satisfies Record<string, FieldSpec>;
export type InvestigationField = keyof typeof INVESTIGATION_FIELDS;
export const INVESTIGATION_QUERY: QueryLanguage<InvestigationField> = { fields: INVESTIGATION_FIELDS };
export const INVESTIGATION_INDEX: ClientIndex<Lens, InvestigationField> = {
read: {
name: (lens) => [lens.settings.name],
agent: (lens) => [lens.settings.agent_name, lens.settings.service].filter(Boolean),
status: (lens) => [lens.jobs[0]?.status ?? "never"],
schedule: (lens) => [lens.settings.enabled ? "watching" : "paused"],
},
freeText: (lens) => [lens.settings.name, scopeLabel(lens.settings)],
};
export const filterInvestigations = (lenses: Lens[], query: string): Lens[] =>
filterItems(INVESTIGATION_QUERY, INVESTIGATION_INDEX, lenses, query);

View file

@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { investigationScreen, type ScreenInput } from "./investigationScreen";
import type { Lens, LensList } from "../model/types";
function makeLens(id: string, created_at: string): Lens {
return {
version: 0,
spent: 0,
id,
scope: { all_teams: true, api_key_hash: "", team_id: "" },
settings: {
context: "",
source: "traces",
lookback_hours: 24,
service: "",
agent_name: "",
filters: [],
interval_minutes: 15,
sample_size: 100,
sample_percent: 100,
concurrency: 8,
team_id: "",
execution_ids: [],
monthly_budget: 20,
name: `Lens ${id}`,
model: "analysis",
enabled: true,
checks: [],
},
revision: 1,
created_at,
next_run_at: created_at,
budget_month: "2026-09",
findings: [],
jobs: [],
};
}
const older = makeLens("older", "2026-09-01T10:00:00Z");
const newer = makeLens("newer", "2026-09-30T10:00:00Z");
const list = (lenses: Lens[]): LensList => ({ lenses, workers: [], tracing_enabled: true });
const ready = (lenses: Lens[]): ScreenInput["list"] => ({ data: list(lenses), error: null });
const route = { lensId: null, dialog: null, target: null } as const;
describe("investigationScreen", () => {
it("loads, then fails only while there is nothing cached to show", () => {
expect(investigationScreen({ ...route, list: { data: undefined, error: null } })).toEqual({ kind: "loading" });
const error = new Error("down");
expect(investigationScreen({ ...route, list: { data: undefined, error } })).toEqual({ kind: "failed", error });
expect(investigationScreen({ ...route, list: { data: list([newer]), error } })).toMatchObject({ kind: "list" });
});
it("welcomes an empty account and lists newest investigations first otherwise", () => {
expect(investigationScreen({ ...route, list: ready([]) })).toEqual({ kind: "welcome" });
expect(investigationScreen({ ...route, list: ready([older, newer]) })).toEqual({
kind: "list",
lenses: [newer, older],
});
});
it("keeps the list on screen with the selected investigation, or reports it missing", () => {
expect(investigationScreen({ ...route, list: ready([older, newer]), lensId: "older" })).toEqual({
kind: "list",
lenses: [newer, older],
lens: older,
});
expect(investigationScreen({ ...route, list: ready([older]), lensId: "gone" })).toEqual({ kind: "missing" });
expect(investigationScreen({ ...route, list: { data: undefined, error: null }, lensId: "older" })).toEqual({
kind: "loading",
});
});
it("enters setup for new, edit and duplicate, prefilled from the targeted investigation", () => {
expect(investigationScreen({ ...route, list: ready([older]), dialog: "new" })).toEqual({
kind: "setup",
mode: "new",
});
const edit: ScreenInput = { ...route, list: ready([older]), lensId: "older", dialog: "edit" };
const editing = { kind: "setup", mode: "edit", lens: older, initial: older.settings };
expect(investigationScreen(edit)).toEqual(editing);
const duplicate: ScreenInput = {
...route,
list: ready([older, newer]),
lensId: "older",
dialog: "duplicate",
target: "newer",
};
const duplicating = {
kind: "setup",
mode: "duplicate",
lens: newer,
initial: { ...newer.settings, name: "Lens newer copy", enabled: false },
};
expect(investigationScreen(duplicate)).toEqual(duplicating);
});
it("falls back to the plain screen when an edit names an investigation that is not loaded", () => {
const editGone: ScreenInput = { ...route, list: ready([older]), lensId: "gone", dialog: "edit" };
expect(investigationScreen(editGone)).toEqual({ kind: "missing" });
const runNow: ScreenInput = { ...route, list: ready([older]), dialog: "run_now", target: "older" };
expect(investigationScreen(runNow)).toMatchObject({ kind: "list" });
});
});

View file

@ -0,0 +1,51 @@
import type { Lens, LensList, Settings } from "../model/types";
import type { LensDialog } from "../route";
export type SetupMode = "new" | "edit" | "duplicate";
export interface ScreenInput {
readonly list: { readonly data: LensList | undefined; readonly error: Error | null };
readonly lensId: string | null;
readonly dialog: LensDialog | null;
readonly target: string | null;
}
export type SetupScreen =
| { readonly kind: "setup"; readonly mode: "new" }
| { readonly kind: "setup"; readonly mode: "edit" | "duplicate"; readonly lens: Lens; readonly initial: Settings };
export type Screen =
| { readonly kind: "loading" }
| { readonly kind: "failed"; readonly error: Error }
| { readonly kind: "welcome" }
| { readonly kind: "list"; readonly lenses: readonly Lens[]; readonly lens?: Lens }
| { readonly kind: "missing" }
| SetupScreen;
const isSetupMode = (dialog: LensDialog | null): dialog is SetupMode =>
dialog === "new" || dialog === "edit" || dialog === "duplicate";
function setupScreen(mode: SetupMode, lens: Lens | undefined): SetupScreen | undefined {
if (mode === "new") return { kind: "setup", mode };
if (!lens) return undefined;
const initial =
mode === "duplicate" ? { ...lens.settings, name: `${lens.settings.name} copy`, enabled: false } : lens.settings;
return { kind: "setup", mode, lens, initial };
}
export function investigationScreen({ list, lensId, dialog, target }: ScreenInput): Screen {
const lenses = list.data?.lenses ?? [];
const selected = lenses.find((lens) => lens.id === lensId);
const setup = isSetupMode(dialog)
? setupScreen(dialog, target ? lenses.find((lens) => lens.id === target) : selected)
: undefined;
if (setup) return setup;
if (!list.data) return list.error ? { kind: "failed", error: list.error } : { kind: "loading" };
if (lensId && !selected) return { kind: "missing" };
if (lenses.length === 0) return { kind: "welcome" };
return {
kind: "list",
lenses: [...lenses].sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)),
...(selected ? { lens: selected } : {}),
};
}

View file

@ -0,0 +1,26 @@
"use client";
import { useLensUpdate, useSaveLens, type LensWrite } from "../data/mutations";
import type { Finding, Lens, RunWindow, Settings } from "../model/types";
/** Every write the Investigations screens can ask for, so leaves only ever express intent. */
export function useInvestigationActions() {
const update = useLensUpdate();
const save = useSaveLens();
const attempt = (write: LensWrite): Promise<boolean> =>
update.mutateAsync(write).then(
() => true,
() => false,
);
return {
busy: update.isPending,
error: update.error,
reset: update.reset,
save: (input: { id?: string; settings: Settings }): Promise<Lens> => save.mutateAsync(input),
pause: (lens: Lens) => attempt((api) => api.saveLens(lens.id, { ...lens.settings, enabled: false })),
cancelRun: (lens: Lens) => attempt((api) => api.cancelRun(lens.id)),
startRun: (lens: Lens, request: RunWindow) => attempt((api) => api.startRun(lens.id, request)),
review: (lens: Lens, finding: Finding, status: Finding["status"], reason: string) =>
attempt((api) => api.reviewFinding(lens.id, finding.id, status, reason)),
};
}

View file

@ -1,107 +0,0 @@
"use client";
import { useQueryStates } from "nuqs";
import { useCallback, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useLensApi } from "../services";
import { lensQueries } from "../api/queries";
import { evidenceTarget, mergeFeedback, sortedFindings } from "../model/findings";
import type { Job, Lens } from "../model/types";
import { RESULT_PARSERS, RESULT_TABS, type ResultTab } from "../route";
const isResultTab = (tab: string): tab is ResultTab => (RESULT_TABS as readonly string[]).includes(tab);
export function useInvestigationResults(accessToken: string, lens: Lens | undefined) {
const api = useLensApi(accessToken);
const [params, setParams] = useQueryStates(RESULT_PARSERS);
const { run: batchId, section: tab, finding: findingId, finding_status: filter, kind } = params;
const [historyOffset, setHistoryOffset] = useState(0);
const setBatchId = useCallback((run: string) => void setParams({ run }), [setParams]);
const setTab = useCallback((section: string) => void (isResultTab(section) && setParams({ section })), [setParams]);
const setFindingId = useCallback(
(finding: string | null) => void setParams({ finding }, { history: "push" }),
[setParams],
);
const setFilter = useCallback((finding_status: string) => void setParams({ finding_status }), [setParams]);
const setKind = useCallback((next: typeof kind) => void setParams({ kind: next }), [setParams]);
const evidence = params.evidence === null ? null : { id: params.evidence, span: params.evidence_span ?? "" };
const setEvidence = useCallback(
(next: { id: string; span: string } | null) =>
void setParams({ evidence: next?.id ?? null, evidence_span: next?.span || null }, { history: "push" }),
[setParams],
);
const history = useQuery(lensQueries.history(api, { lensId: lens?.id, historyOffset }));
const historical = useQuery(lensQueries.run(api, lens?.id, batchId));
const { job, missingSnapshot, selectedOutsideHistory, batchSettings, batchFindings } = runSnapshot(
lens,
batchId,
historical.data,
history.data,
);
const finding = batchFindings.find((f) => f.id === findingId);
const openBatch = (id: string) => void setParams({ run: id, section: null, finding: null });
const active = lens?.jobs?.find((j) => j.status === "queued" || j.status === "running");
const visibleFindings = sortedFindings(
batchFindings.filter((f) => (filter === "all" || f.status === filter) && f.kind === kind),
);
const sampledRuns = job?.sample?.executions ?? [];
const target = evidence ? evidenceTarget(evidence.id) : null;
const [requestOffset, setRequestOffset] = useState(0);
const evidenceInput = { lensId: lens?.id, evidenceId: evidence?.id, requestOffset, source: target?.source };
const requestEvidence = useQuery(lensQueries.evidence(api, evidenceInput));
const reset = useCallback(() => {
setHistoryOffset(0);
void setParams({ run: null, finding: null, section: null });
}, [setParams]);
return {
batchId,
setBatchId,
historyOffset,
setHistoryOffset,
tab,
setTab,
findingId,
setFindingId,
filter,
setFilter,
kind,
setKind,
evidence,
setEvidence,
history: history.data,
historyError: history.error,
refetchHistory: history.refetch,
historicalError: historical.error,
refetchHistorical: historical.refetch,
job,
missingSnapshot,
selectedOutsideHistory,
batchSettings,
batchFindings,
finding,
openBatch,
active,
visibleFindings,
sampledRuns,
target,
requestOffset,
setRequestOffset,
requestEvidenceData: requestEvidence.data,
requestEvidenceError: requestEvidence.error,
requestEvidenceLoading: requestEvidence.isLoading,
reset,
};
}
function runSnapshot(lens: Lens | undefined, batchId: string, historical: Job | undefined, history: Job[] | undefined) {
const job = ["latest", "all"].includes(batchId) ? lens?.jobs?.[0] : historical;
const missingSnapshot = job?.status === "completed" && job.findings == null && batchId !== "all";
const selectedOutsideHistory = !["latest", "all"].includes(batchId) && !history?.some((j) => j.id === batchId);
const batchSettings = job?.settings ?? lens?.settings;
const batchFindings = mergeFeedback(
batchId === "all" ? lens?.findings ?? [] : job?.findings ?? [],
lens?.findings ?? [],
);
return { job, missingSnapshot, selectedOutsideHistory, batchSettings, batchFindings };
}

View file

@ -0,0 +1,32 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { analysisFraction, type ProgressSample } from "../model/progress";
const KEEP = 120;
type Progress = Pick<ProgressSample, "step" | "done"> & { readonly total: number };
/** A wall-clock log of progress changes; the clock is the external system, so it lives outside render. */
function createSampleLog() {
const listeners = new Set<() => void>();
const state = { samples: [] as readonly ProgressSample[] };
return {
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => void listeners.delete(listener);
},
snapshot: () => state.samples,
record: (progress: Progress) => {
const sample = { at: Date.now(), ...progress, fraction: analysisFraction(progress) };
state.samples = [...state.samples, sample].slice(-KEEP);
listeners.forEach((listener) => listener());
},
};
}
export function useProgressSamples({ step, done, total }: Progress): readonly ProgressSample[] {
const [log] = useState(createSampleLog);
useEffect(() => log.record({ step, done, total }), [log, step, done, total]);
return useSyncExternalStore(log.subscribe, log.snapshot, log.snapshot);
}

View file

@ -0,0 +1,38 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useLensApi } from "../data/LensServices";
import { lensQueries } from "../data/queries";
import { mergeFeedback } from "../model/findings";
import type { Finding, Job, Lens, Settings } from "../model/types";
import { useRunRoute } from "../route";
export interface RunSnapshot {
readonly batchId: string;
readonly job: Job | undefined;
readonly missingSnapshot: boolean;
readonly batchSettings: Settings | undefined;
readonly batchFindings: readonly Finding[];
readonly error: Error | null;
readonly refetch: () => void;
}
const isAggregate = (batchId: string) => batchId === "latest" || batchId === "all";
/** The run a detail view is looking at: the latest job from the list, or one older job fetched on its own. */
export function useRunSnapshot(lens: Lens | undefined): RunSnapshot {
const api = useLensApi();
const { batchId } = useRunRoute();
const historical = useQuery(lensQueries.run(api, lens?.id, batchId));
const job = isAggregate(batchId) ? lens?.jobs[0] : historical.data;
const current = lens?.findings ?? [];
return {
batchId,
job,
missingSnapshot: job?.status === "completed" && job.findings == null && batchId !== "all",
batchSettings: job?.settings ?? lens?.settings,
batchFindings: mergeFeedback(batchId === "all" ? current : job?.findings ?? [], current),
error: historical.error,
refetch: () => void historical.refetch(),
};
}

View file

@ -8,7 +8,14 @@ export function sortedFindings(findings: Finding[]): Finding[] {
);
}
export function evidenceTarget(id: string): { source: string; team: string; id: string; traceRef?: string } | null {
export interface EvidenceTarget {
readonly source: string;
readonly team: string;
readonly id: string;
readonly traceRef?: string;
}
export function evidenceTarget(id: string): EvidenceTarget | null {
try {
const parsed: unknown = JSON.parse(atob(id.replace(/-/g, "+").replace(/_/g, "/")));
if (!Array.isArray(parsed) || ![3, 4].includes(parsed.length) || !parsed.every((item) => typeof item === "string"))

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { durationLabel, durationText, money, scopeLabel, when } from "./format";
import { agoLabel, durationLabel, durationText, money, scopeLabel, when } from "./format";
describe("Lens labels", () => {
it("formats an empty scope and joins recorded scope constraints in their original order", () => {
@ -30,3 +30,19 @@ describe("Lens labels", () => {
expect(when(null)).toBe("Not yet");
});
});
describe("agoLabel", () => {
const now = Date.UTC(2026, 9, 3, 12, 0, 0);
it("rounds down to the largest whole unit", () => {
expect(agoLabel(now - 2_000, now)).toBe("just now");
expect(agoLabel(now - 45_000, now)).toBe("45s ago");
expect(agoLabel(now - 12 * 60_000 - 59_000, now)).toBe("12m ago");
expect(agoLabel(now - 3 * 3_600_000, now)).toBe("3h ago");
expect(agoLabel(now - 2 * 86_400_000, now)).toBe("2d ago");
});
it("treats a timestamp slightly in the future as just now instead of negative", () => {
expect(agoLabel(now + 10_000, now)).toBe("just now");
});
});

View file

@ -1,6 +1,7 @@
import { formatActivityTimestamp as runTime } from "@/utils/activityTimestamp";
import type { Settings } from "./types";
export { formatActivityTimestamp as runTime } from "@/utils/activityTimestamp";
export { runTime };
export function scopeLabel(settings: Partial<Pick<Settings, "service" | "agent_name" | "filters">>): string {
return (
@ -27,6 +28,15 @@ export function durationLabel(value: number, base: "minutes" | "hours" = "minute
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
}
export function agoLabel(thenMs: number, nowMs: number): string {
const seconds = Math.max(0, Math.floor((nowMs - thenMs) / 1000));
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
export const money = (n: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 3 }).format(n);
export const when = (value?: string | null) => (value ? runTime(value) : "Not yet");

View file

@ -38,7 +38,12 @@ export function openFindings(lens: Lens): Finding[] {
export const findingKey = (lens: Lens, finding: Finding) => `${lens.id}:${finding.id}`;
export function findFinding(lenses: readonly Lens[], key: string) {
export interface OwnedFinding {
readonly lens: Lens;
readonly finding: Finding;
}
export function findFinding(lenses: readonly Lens[], key: string): OwnedFinding | undefined {
return lenses
.flatMap((lens) => lens.findings.map((finding) => ({ lens, finding })))
.find(({ lens, finding }) => findingKey(lens, finding) === key);

View file

@ -82,7 +82,7 @@ export interface ProgressSample {
fraction: number;
}
export const stageWeights = [0.6, 0.2, 0.2];
const stageWeights = [0.6, 0.2, 0.2];
export function analysisFraction({
step,

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { initialSetupStep, readiness, type ReadinessInput } from "./readiness";
const base: ReadinessInput = {
traces: { recorded: undefined, failed: false, disabled: false, checked: false },
tracingConfigured: false,
activity: undefined,
activityError: null,
connected: true,
listError: null,
investigations: 0,
};
describe("readiness", () => {
it("counts traces confirmed from storage as recorded activity even when the activity check fails", () => {
const failing = { ...base, activityError: new Error("Activity unavailable") };
expect(readiness(failing).ready).toBe(false);
const seen = { ...failing, traces: { ...base.traces, recorded: true } };
expect(readiness(seen)).toMatchObject({ tracesReady: true, ready: true });
expect(readiness({ ...seen, traces: { ...seen.traces, failed: true } }).tracesReady).toBe(false);
});
it("needs a connected worker and a healthy list on top of recorded activity", () => {
const active = { ...base, activity: { traces: false, requests: true } };
expect(readiness(active)).toMatchObject({ requestsReady: true, activityReady: true, ready: true });
expect(readiness({ ...active, connected: false }).ready).toBe(false);
expect(readiness({ ...active, listError: new Error("down") }).ready).toBe(false);
});
it("treats tracing as enabled once storage answers or the gateway reports it configured, unless storage says it is off", () => {
expect(readiness(base).tracingEnabled).toBe(false);
expect(readiness({ ...base, tracingConfigured: true }).tracingEnabled).toBe(true);
expect(readiness({ ...base, traces: { ...base.traces, checked: true } }).tracingEnabled).toBe(true);
const disabled = { ...base, tracingConfigured: true, traces: { ...base.traces, checked: true, disabled: true } };
expect(readiness(disabled).tracingEnabled).toBe(false);
});
it("knows whether any investigation exists", () => {
expect(readiness(base).hasInvestigations).toBe(false);
expect(readiness({ ...base, investigations: 2 }).hasInvestigations).toBe(true);
});
});
describe("initialSetupStep", () => {
it("opens the first step that still needs work", () => {
const offline = { ...base, connected: false };
expect(initialSetupStep(readiness(offline))).toBe(0);
expect(initialSetupStep(readiness({ ...offline, tracingConfigured: true }))).toBe(1);
const recorded = { ...offline, activity: { traces: false, requests: true } };
expect(initialSetupStep(readiness(recorded))).toBe(2);
expect(initialSetupStep(readiness({ ...recorded, connected: true }))).toBe(3);
});
});

View file

@ -0,0 +1,47 @@
export interface Readiness {
readonly tracingEnabled: boolean;
readonly tracesReady: boolean;
readonly requestsReady: boolean;
readonly activityReady: boolean;
readonly connected: boolean;
readonly hasInvestigations: boolean;
readonly ready: boolean;
}
export interface ReadinessInput {
readonly traces: {
readonly recorded: boolean | undefined;
readonly failed: boolean;
readonly disabled: boolean;
readonly checked: boolean;
};
readonly tracingConfigured: boolean;
readonly activity: { readonly traces: boolean; readonly requests: boolean } | undefined;
readonly activityError: unknown;
readonly connected: boolean;
readonly listError: unknown;
readonly investigations: number;
}
export function readiness(input: ReadinessInput): Readiness {
const { traces, activity, activityError, connected, listError } = input;
/** Traces confirmed straight from trace storage count as recorded activity even if the activity check fails. */
const tracesSeen = traces.recorded === true && !traces.failed;
const tracesReady = tracesSeen || (activity?.traces === true && !activityError);
const requestsReady = activity?.requests === true && !activityError;
const activityReady = tracesReady || requestsReady;
return {
tracingEnabled: !traces.disabled && (traces.checked || input.tracingConfigured),
tracesReady,
requestsReady,
activityReady,
connected,
hasInvestigations: input.investigations > 0,
ready: activityReady && connected && !listError,
};
}
export function initialSetupStep(state: Readiness): number {
if (state.activityReady) return state.connected ? 3 : 2;
return state.tracingEnabled ? 1 : 0;
}

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { runRequest } from "./RunNowDialog";
import { runRequest } from "./runRequest";
const base = { agent: "support", saved: "support", start: "", end: "" };

View file

@ -0,0 +1,30 @@
import type { RunWindow } from "./types";
export const RUN_PRESETS = [
{ label: "Since last run", hours: null },
{ label: "Last hour", hours: 1 },
{ label: "Last 24h", hours: 24 },
{ label: "Last 7d", hours: 168 },
{ label: "Custom", hours: -1 },
] as const;
export type RunPreset = (typeof RUN_PRESETS)[number]["hours"];
export interface RunChoice {
preset: RunPreset;
agent: string;
saved: string;
start: string;
end: string;
}
export function runRequest({ preset, agent, saved, start, end }: RunChoice): RunWindow | string {
const agentPart = agent.trim() && agent.trim() !== saved ? { agent_name: agent.trim() } : {};
if (preset === null) return agentPart;
if (preset > 0) return { ...agentPart, lookback_hours: preset };
const startMs = Date.parse(start);
const endMs = Date.parse(end);
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return "Choose a start and end time";
if (startMs >= endMs) return "Start time must be before end time";
return { ...agentPart, start: new Date(startMs).toISOString(), end: new Date(endMs).toISOString() };
}

View file

@ -1,4 +1,4 @@
import { investigationActivity, nextCheckStatus, workerConnected } from "./status";
import { activeJob, investigationActivity, listPollInterval, nextCheckStatus, workerConnected } from "./status";
import { describe, expect, it } from "vitest";
import type { Job, Lens, LensList } from "./types";
@ -111,3 +111,49 @@ it("summarizes activity across investigations, preferring a running scan over a
expect(investigationActivity([withStatus("queued"), withStatus("running")])).toBe("running");
expect(investigationActivity([{ ...lens, jobs: [lens.jobs[0], { ...job, status: "running" }] }])).toBe("running");
});
describe("List polling cadence", () => {
const now = Date.parse("2026-10-01T12:00:00Z");
const connectedWorker: 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: "" },
};
const staleWorker = { ...connectedWorker, last_seen: "2026-10-01T11:00:00Z" };
const list = (workers: LensList["workers"], lenses: Lens[] = [lens]): LensList => ({
lenses,
workers,
tracing_enabled: true,
});
it("polls slowly when nothing is running and Settings is closed", () => {
expect(listPollInterval(list([staleWorker]), false, now)).toBe(10000);
expect(listPollInterval(undefined, false, now)).toBe(10000);
});
it("polls fast while Settings waits for a worker and slows down once one connects", () => {
expect(listPollInterval(undefined, true, now)).toBe(2000);
expect(listPollInterval(list([staleWorker]), true, now)).toBe(2000);
expect(listPollInterval(list([connectedWorker]), true, now)).toBe(10000);
});
it("speeds up as soon as any investigation is queued or running, whatever tab is open", () => {
const queued: Lens = { ...lens, jobs: [{ ...job, status: "queued" }] };
const running: Lens = { ...lens, jobs: [lens.jobs[0], { ...job, status: "running" }] };
expect(listPollInterval(list([connectedWorker], [lens, queued]), false, now)).toBe(2000);
expect(listPollInterval(list([connectedWorker], [running]), false, now)).toBe(2000);
expect(listPollInterval(list([connectedWorker], [lens]), false, now)).toBe(10000);
});
});
describe("activeJob", () => {
it("picks the queued or running job and ignores finished ones", () => {
const done = { ...job, id: "done", status: "completed" as const };
expect(activeJob([done, job])).toBe(job);
expect(activeJob([done, { ...job, status: "queued" }])?.status).toBe("queued");
expect(activeJob([done])).toBeUndefined();
});
});

View file

@ -1,10 +1,25 @@
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
import type { Lens, LensList } from "./types";
import type { Job, Lens, LensList } from "./types";
export function workerConnected(worker: LensList["workers"][number], now = Date.now()): boolean {
return !worker.revoked && !!worker.analysis_key_id && now - Date.parse(worker.last_seen) < 120000;
}
export function activeJob(jobs: readonly Job[]): Job | undefined {
return jobs.find((job) => job.status === "queued" || job.status === "running");
}
export function hasActiveJob(jobs: readonly Job[]): boolean {
return activeJob(jobs) !== undefined;
}
/** One observer polls `/lens`: fast while work is in flight or a worker is being connected, slow otherwise. */
export function listPollInterval(list: LensList | undefined, settingsOpen: boolean, now: number): number {
const running = list?.lenses.some((lens) => hasActiveJob(lens.jobs)) ?? false;
const connected = list?.workers.some((worker) => workerConnected(worker, now)) ?? false;
return running || (settingsOpen && !connected) ? 2000 : 10000;
}
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";
@ -31,19 +46,6 @@ export function nextCheckStatus(lens: Lens, now: number): string | null {
return `Next check ${time} · ${relative}`;
}
export function readiness(
activity: { traces: boolean; requests: boolean } | undefined,
activityError: unknown,
connected: boolean,
listError: unknown,
) {
const tracesReady = activity?.traces === true && !activityError;
const requestsReady = activity?.requests === true && !activityError;
const activityReady = tracesReady || requestsReady;
const ready = activityReady && connected && !listError;
return { tracesReady, requestsReady, activityReady, ready };
}
export type InvestigationActivity = "running" | "queued" | "idle";
export function investigationActivity(lenses: readonly Lens[]): InvestigationActivity {

View file

@ -32,3 +32,17 @@ export type ActivitySelection = Pick<Settings, "source"> &
>;
export type Worker = LensList["workers"][number];
export interface RunWindow {
agent_name?: string;
start?: string;
end?: string;
lookback_hours?: number;
}
export interface AnalysisModelInfo {
model_group: string;
providers: string[];
mode?: string | null;
supported_openai_params?: string[] | null;
}

View file

@ -1,4 +1,4 @@
import type { Settings } from "../model/types";
import type { Settings } from "./types";
export interface Watch {
id: string;

View file

@ -0,0 +1,149 @@
import { useId } from "react";
import { cn } from "@/lib/cva.config";
import styles from "./LensIntroduction.module.css";
const dotColors = [
"fill-sky-400 dark:fill-sky-300",
"fill-indigo-400 dark:fill-indigo-300",
"fill-violet-400 dark:fill-violet-300",
];
const swarmRows = Array.from({ length: 11 }, (_, row) => ({
y: 4 + row * 10,
duration: `${[4.8, 5.2, 5.6, 5][row % 4]}s`,
delay: `${-row * 0.17}s`,
dots: Array.from({ length: 80 }, (_, column) => {
const seed = (column % 24) + row * 31;
const variation = (seed * 73 + seed * seed * 19) % 101;
return {
x: column * 10 - 235,
color: dotColors[variation % dotColors.length],
opacity: variation < 15 ? 0 : 0.5 + variation * 0.005,
};
}),
}));
const organizedColumns = Array.from({ length: 58 }, (_, column) => column - 8);
export function GatewayFlow() {
const id = useId();
return (
<div className="mt-5 sm:mt-6">
<div className="grid grid-cols-3 gap-3 text-xs">
<div>
<p className="font-semibold">Agent swarms</p>
<p className="mt-0.5 leading-4 text-muted-foreground">Every run, every recorded step</p>
</div>
<div className="text-center">
<p className="font-semibold">LiteLLM gateway</p>
<p className="mt-0.5 leading-4 text-muted-foreground">One place, your infrastructure</p>
</div>
<div className="text-right">
<p className="font-semibold">Lens</p>
<p className="mt-0.5 leading-4 text-muted-foreground">Findings to improve your agents</p>
</div>
</div>
<svg
aria-hidden="true"
viewBox="0 0 1000 108"
className={cn(styles.flowGraphic, "mt-2 h-auto w-full")}
focusable="false"
>
<defs>
<pattern id={`${id}-grid`} width="40" height="40" patternUnits="userSpaceOnUse">
<circle cx="5" cy="4" r="1" className="fill-muted-foreground/15" />
</pattern>
<filter id={`${id}-soft-edge`} x="-10%" y="-30%" width="120%" height="160%">
<feGaussianBlur stdDeviation="3" />
</filter>
<clipPath id={`${id}-after-gate`}>
<rect x="500" width="500" height="108" />
</clipPath>
<linearGradient id={`${id}-incoming`} x1="480" x2="530" y1="0" y2="0" gradientUnits="userSpaceOnUse">
<stop stopColor="white" />
<stop offset="1" stopColor="black" />
</linearGradient>
<mask id={`${id}-before-gate`}>
<rect width="1000" height="108" fill={`url(#${id}-incoming)`} />
</mask>
<mask id={`${id}-swarm-shape`}>
<g fill="none" stroke="white" strokeWidth="24" strokeLinecap="round" filter={`url(#${id}-soft-edge)`}>
<path d="M -30 20 C 80 0 142 38 244 22 S 402 36 530 54" />
<path d="M -30 54 C 90 76 174 32 278 52 S 420 60 530 54" />
<path d="M -30 90 C 80 112 172 66 276 86 S 420 68 530 54" />
</g>
</mask>
<mask id={`${id}-organized-shape`}>
<path
d="M 490 54 C 620 54 664 66 772 58 S 920 42 1020 52"
fill="none"
stroke="white"
strokeWidth="32"
filter={`url(#${id}-soft-edge)`}
/>
</mask>
<linearGradient id={`${id}-fade`}>
<stop offset="0" stopColor="white" stopOpacity="0" />
<stop offset="0.06" stopColor="white" />
<stop offset="0.94" stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
<mask id={`${id}-edges`}>
<rect width="1000" height="108" fill={`url(#${id}-fade)`} />
</mask>
<radialGradient id={`${id}-glow`} className="text-indigo-500 dark:text-indigo-400">
<stop stopColor="currentColor" stopOpacity="0.14" />
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
</radialGradient>
<linearGradient id={`${id}-gate`} x1="0" y1="0" x2="0" y2="1">
<stop stopColor="currentColor" className="text-sky-400" />
<stop offset="0.5" stopColor="currentColor" className="text-indigo-500 dark:text-indigo-300" />
<stop offset="1" stopColor="currentColor" className="text-violet-400" />
</linearGradient>
</defs>
<g mask={`url(#${id}-edges)`}>
<rect width="1000" height="108" fill={`url(#${id}-grid)`} />
<ellipse cx="500" cy="54" rx="44" ry="40" fill={`url(#${id}-glow)`} className={styles.gatewayGlow} />
<g clipPath={`url(#${id}-after-gate)`}>
<g mask={`url(#${id}-organized-shape)`}>
<g className={cn(styles.organizedDots, "fill-indigo-500 dark:fill-indigo-400")}>
{organizedColumns.map((column) => (
<g key={column} opacity={0.6 + (((column + 8) * 3) % 8) * 0.05}>
{[34, 44, 54, 64, 74].map((y) => (
<circle key={y} cx={505 + column * 10} cy={y} r="2.3" />
))}
</g>
))}
</g>
</g>
</g>
<g mask={`url(#${id}-swarm-shape)`}>
<g mask={`url(#${id}-before-gate)`}>
{swarmRows.map((row, index) => (
<g
key={index}
className={styles.swarmRow}
style={{ animationDuration: row.duration, animationDelay: row.delay }}
>
{row.dots.map((dot, column) => (
<circle key={column} cx={dot.x} cy={row.y} r="2.3" className={dot.color} opacity={dot.opacity} />
))}
</g>
))}
</g>
</g>
</g>
<rect x="497" y="30" width="6" height="48" rx="3" className="fill-background/90" />
<path
d="M 490 30 H 497 V 78 H 490 M 510 30 H 503 V 78 H 510"
fill="none"
stroke={`url(#${id}-gate)`}
strokeWidth="1.4"
opacity="0.75"
/>
<circle cx="500" cy="54" r="4" className="fill-background stroke-indigo-400" strokeWidth="1.4" />
<circle cx="500" cy="54" r="1.6" className="fill-indigo-500 dark:fill-indigo-300" />
</svg>
</div>
);
}

View file

@ -0,0 +1,81 @@
"use client";
import { useRef } from "react";
import { Check, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { LensReadiness } from "../hooks/useLensReadiness";
import { LensIntroduction } from "./LensIntroduction";
import { OnboardingSetup } from "./OnboardingSetup";
const PREREQUISITES = [
{ title: "LiteLLM gateway", detail: "Access to its configuration" },
{ title: "ClickHouse", detail: "Self-hosted or managed trace storage" },
{ title: "A server with Docker", detail: "To run the analysis worker" },
{ title: "An analysis model", detail: "Available through your gateway" },
] as const;
export interface LensGettingStartedProps {
readonly state: LensReadiness;
readonly onStart: () => void;
readonly onExit: (to: "traces" | "investigations") => void;
readonly onDemo?: () => void;
}
export function LensGettingStarted({ state, onStart, onExit, onDemo }: LensGettingStartedProps) {
const setupRef = useRef<HTMLElement>(null);
const exitTo = state.tracesReady ? "traces" : "investigations";
const start = () => {
onStart();
setupRef.current?.scrollIntoView({ block: "start" });
setupRef.current?.focus({ preventScroll: true });
};
return (
<div className="w-full space-y-6">
<LensIntroduction onStart={start} onDemo={onDemo} />
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_280px] xl:gap-8">
<OnboardingSetup
ref={setupRef}
tabIndex={-1}
state={state}
onFocusCapture={onStart}
action={
(state.activityReady || state.hasInvestigations) && (
<Button variant="outline" size="sm" onClick={() => onExit(exitTo)}>
{exitTo === "traces" ? "View traces" : "View investigations"}
</Button>
)
}
/>
<Prerequisites />
</div>
</div>
);
}
function Prerequisites() {
return (
<aside className="rounded-2xl border bg-card p-6" aria-labelledby="lens-prerequisites">
<h2 id="lens-prerequisites" className="text-base font-semibold">
Before you start
</h2>
<ul className="mt-5 space-y-5 text-sm">
{PREREQUISITES.map((item) => (
<li key={item.title} className="flex gap-3">
<Check aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium">{item.title}</p>
<p className="mt-1 leading-5 text-muted-foreground">{item.detail}</p>
</div>
</li>
))}
</ul>
<div className="mt-5 flex gap-2 border-t pt-5 text-xs leading-5 text-muted-foreground">
<ShieldCheck aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<p>
Your infrastructure stores the traces. Investigation content is sent to your selected model provider through
the gateway.
</p>
</div>
</aside>
);
}

View file

@ -0,0 +1,80 @@
"use client";
import { useId, useState } from "react";
import { Loader2, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useStoredValue } from "@/lib/storage";
import { useLensReadiness } from "../hooks/useLensReadiness";
import { LENS_INTRO_DISMISSED, LENS_INTRO_SEEN } from "../storage";
import { LensGettingStarted, type LensGettingStartedProps } from "./LensGettingStarted";
import { useOnboarding } from "./OnboardingContext";
export interface LensIntro {
readonly open: boolean;
close(forever: boolean): void;
}
/** Opens on the first Lens visit of a session, or whenever the URL asks for setup. "Don't show this again" outlives the tab; a plain close only rests for the session. */
export function useLensIntro({ demo, settingUp }: { demo: boolean; settingUp: boolean }): LensIntro {
const [dismissed, setDismissed] = useStoredValue(LENS_INTRO_DISMISSED);
const [seen, setSeen] = useStoredValue(LENS_INTRO_SEEN);
const close = (forever: boolean) => {
setSeen(true);
if (forever) setDismissed(true);
};
const firstVisit = !demo && !dismissed && !seen;
return { open: settingUp || firstVisit, close };
}
export type LensIntroDialogProps = Omit<LensGettingStartedProps, "state"> & {
open: boolean;
onClose: (forever: boolean) => void;
};
export function LensIntroDialog({ open, onClose, ...gettingStarted }: LensIntroDialogProps) {
const [forever, setForever] = useState(false);
const checkboxId = useId();
const close = () => onClose(forever);
return (
<Dialog open={open} onOpenChange={(next) => !next && close()}>
<DialogContent
showCloseButton={false}
className="max-h-[calc(100vh-4rem)] gap-0 overflow-y-auto p-0 sm:max-w-4xl"
>
<div className="sticky top-0 z-raised flex items-center justify-end gap-3 bg-popover/90 px-4 py-2 backdrop-blur-sm">
<Label htmlFor={checkboxId} className="gap-2 text-xs font-normal text-muted-foreground">
<Checkbox id={checkboxId} checked={forever} onCheckedChange={(checked) => setForever(checked === true)} />
Don’t show this again
</Label>
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={close}>
<XIcon />
</Button>
</div>
<DialogTitle className="sr-only">Get started with Lens</DialogTitle>
<DialogDescription className="sr-only">
What Lens does, and the steps to connect tracing, a worker and your first investigation.
</DialogDescription>
<div className="px-5 pb-5">
<IntroContent {...gettingStarted} />
</div>
</DialogContent>
</Dialog>
);
}
function IntroContent(props: Omit<LensGettingStartedProps, "state">) {
const { canViewInvestigations } = useOnboarding();
const state = useLensReadiness(canViewInvestigations);
if (state.loading)
return (
<p role="status" className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
Checking Lens setup…
</p>
);
return <LensGettingStarted state={state} {...props} />;
}

View file

@ -0,0 +1,114 @@
.swarmRow {
animation: swarm-flow 5.2s linear infinite;
}
.organizedDots {
animation: organized-flow 1.6s linear infinite;
}
.gatewayGlow {
opacity: 0.7;
transition: opacity 300ms ease;
}
.flowGraphic:hover .gatewayGlow {
opacity: 1;
}
.sampleCard {
transition:
border-color 200ms ease,
box-shadow 200ms ease;
}
.sampleCard:hover,
.sampleCard:focus-within {
border-color: color-mix(in oklab, var(--color-indigo-500) 22%, var(--border));
box-shadow: 0 4px 20px -16px color-mix(in oklab, var(--color-indigo-500) 40%, transparent);
}
.examples {
--evidence-tint: color-mix(in oklab, var(--color-rose-400) 6%, transparent);
--evidence-outline: color-mix(in oklab, var(--color-rose-400) 32%, transparent);
}
.evidenceLink {
cursor: pointer;
border-radius: 4px;
outline-offset: 4px;
transition: background-color 200ms ease;
}
.evidenceLink:focus-visible {
outline: 2px solid var(--ring);
}
.evidenceLink:hover {
background: var(--evidence-tint);
}
.evidenceOutput,
.evidenceFinding,
.evidenceBar {
transition:
background-color 200ms ease,
box-shadow 200ms ease;
}
.evidenceFinding {
border-radius: 6px;
}
.examples:is(
[data-evidence-active="true"],
:has([data-evidence-link]:hover),
:has([data-evidence-link]:focus-visible)
) {
.evidenceOutput {
box-shadow: inset 0 0 0 1px var(--evidence-outline);
}
.evidenceFinding {
background: var(--evidence-tint);
box-shadow: 0 0 0 7px var(--evidence-tint);
}
.evidenceBar {
box-shadow: 0 0 10px 2px var(--evidence-outline);
}
}
@keyframes swarm-flow {
from {
transform: translateX(0);
}
to {
transform: translateX(240px);
}
}
@keyframes organized-flow {
from {
transform: translateX(0);
}
to {
transform: translateX(80px);
}
}
@media (prefers-reduced-motion: reduce) {
.swarmRow,
.organizedDots {
animation: none;
opacity: 0.75;
}
.gatewayGlow,
.sampleCard,
.evidenceLink,
.evidenceOutput,
.evidenceFinding,
.evidenceBar {
transition: none;
}
}

Some files were not shown because too many files have changed in this diff Show more