mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
commit
c6c605ee25
224 changed files with 12391 additions and 5919 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
172
litellm/tool_loop.py
Normal 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)
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
|
|
|
|||
558
tests/integration/database/test_v1_migration_error_logging.py
Normal file
558
tests/integration/database/test_v1_migration_error_logging.py
Normal 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)
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
420
tests/unit/test_tool_loop.py
Normal file
420
tests/unit/test_tool_loop.py
Normal 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
|
||||
14
ui/litellm-dashboard/package-lock.json
generated
14
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
101
ui/litellm-dashboard/src/components/lens/LensModeSwitch.tsx
Normal file
101
ui/litellm-dashboard/src/components/lens/LensModeSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
@ -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) };
|
||||
}
|
||||
|
|
@ -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) };
|
||||
}
|
||||
56
ui/litellm-dashboard/src/components/lens/data/mutations.ts
Normal file
56
ui/litellm-dashboard/src/components/lens/data/mutations.ts
Normal 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) }),
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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"];
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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§ion=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}§ion=checks` });
|
||||
renderWithProviders(<InvestigationsView />, { searchParams: `?lens=${lens.id}§ion=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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
}
|
||||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
47
ui/litellm-dashboard/src/components/lens/model/readiness.ts
Normal file
47
ui/litellm-dashboard/src/components/lens/model/readiness.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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: "" };
|
||||
|
||||
30
ui/litellm-dashboard/src/components/lens/model/runRequest.ts
Normal file
30
ui/litellm-dashboard/src/components/lens/model/runRequest.ts
Normal 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() };
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Settings } from "../model/types";
|
||||
import type { Settings } from "./types";
|
||||
|
||||
export interface Watch {
|
||||
id: string;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue