Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5051fbc3e4 | |||
| cbdf474916 | |||
| 0952a35b19 | |||
| 95481d886f | |||
| 9da9b47be6 | |||
| 122181c839 | |||
| 1b2807fd90 | |||
| 762a17f7f2 | |||
| f0dc93d13e | |||
| af332fde86 | |||
| 63ac23d20a | |||
| 5b60032157 | |||
| d25ec968e7 | |||
| 16799a4f98 | |||
| 4dbc413d0a | |||
| 884614cafe | |||
| 149110aa55 | |||
| fa2730687b | |||
| 6d64a5e416 | |||
| 4d382f251b |
@@ -26,6 +26,14 @@ jobs:
|
||||
- name: Sync dependencies
|
||||
run: uv sync --frozen --no-default-groups --group dev
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install JavaScript dependencies
|
||||
run: cd dashboard && npm ci
|
||||
- name: Build dashboard
|
||||
run: cd dashboard && npm run build
|
||||
|
||||
- name: Get current version
|
||||
id: get_version
|
||||
run: |
|
||||
|
||||
@@ -60,6 +60,14 @@ jobs:
|
||||
- name: Sync dependencies
|
||||
run: uv sync --frozen --no-default-groups --group dev
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install JavaScript dependencies
|
||||
run: cd dashboard && npm ci
|
||||
- name: Build dashboard
|
||||
run: cd dashboard && npm run build
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
uv build
|
||||
|
||||
@@ -207,3 +207,9 @@ cython_debug/
|
||||
|
||||
# Claude
|
||||
.claude/*.local.json
|
||||
|
||||
# Dashboard generated files
|
||||
agentlightning/dashboard/**/*.css
|
||||
agentlightning/dashboard/**/*.js
|
||||
agentlightning/dashboard/**/*.html
|
||||
agentlightning/dashboard/**/*.svg
|
||||
|
||||
@@ -9,15 +9,17 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
from contextlib import suppress
|
||||
from typing import Any, Awaitable, Callable, Dict, Generic, List, Literal, Optional, Sequence, TypeVar
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, Generic, List, Literal, Optional, Sequence, TypeVar, Union
|
||||
|
||||
import aiohttp
|
||||
import uvicorn
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException
|
||||
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException
|
||||
from fastapi import Query as FastAPIQuery
|
||||
from fastapi import Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
|
||||
@@ -38,7 +40,9 @@ from .base import UNSET, LightningStore, Unset
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AGL_API_V1_PREFIX = "/v1/agl"
|
||||
API_V1_PREFIX = "/v1"
|
||||
API_AGL_PREFIX = "/agl"
|
||||
API_V1_AGL_PREFIX = API_V1_PREFIX + API_AGL_PREFIX
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -236,6 +240,14 @@ def _apply_filters_sort_paginate(
|
||||
return PaginatedResponse(items=paginated_items, limit=limit, offset=offset, total=total)
|
||||
|
||||
|
||||
class CachedStaticFiles(StaticFiles):
|
||||
def file_response(self, *args: Any, **kwargs: Any) -> Response:
|
||||
resp = super().file_response(*args, **kwargs)
|
||||
# hashed filenames are safe to cache "forever"
|
||||
resp.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
|
||||
return resp
|
||||
|
||||
|
||||
class LightningStoreServer(LightningStore):
|
||||
"""
|
||||
Server wrapper that exposes a LightningStore via HTTP API.
|
||||
@@ -416,7 +428,7 @@ class LightningStoreServer(LightningStore):
|
||||
while time.time() - current_time < 10:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
with suppress(Exception):
|
||||
async with session.get(f"{self.endpoint}{AGL_API_V1_PREFIX}/health") as response:
|
||||
async with session.get(f"{self.endpoint}{API_V1_AGL_PREFIX}/health") as response:
|
||||
if response.status == 200:
|
||||
return True
|
||||
await asyncio.sleep(0.1)
|
||||
@@ -528,7 +540,7 @@ class LightningStoreServer(LightningStore):
|
||||
return await call_next(request)
|
||||
except Exception as exc:
|
||||
# decide whether to convert this into your 400 JSONResponse
|
||||
if request.url.path.startswith(AGL_API_V1_PREFIX):
|
||||
if request.url.path.startswith(API_V1_AGL_PREFIX):
|
||||
logger.exception("Unhandled application error", exc_info=exc)
|
||||
payload = {
|
||||
"detail": "Internal server error",
|
||||
@@ -544,7 +556,8 @@ class LightningStoreServer(LightningStore):
|
||||
async def _log_time( # pyright: ignore[reportUnusedFunction]
|
||||
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
):
|
||||
if not request.url.path.startswith("/v1/agl/"):
|
||||
# If not API request, just pass through
|
||||
if not request.url.path.startswith(API_V1_AGL_PREFIX):
|
||||
return await call_next(request)
|
||||
|
||||
start = time.perf_counter()
|
||||
@@ -562,11 +575,13 @@ class LightningStoreServer(LightningStore):
|
||||
)
|
||||
return response
|
||||
|
||||
@self.app.get(AGL_API_V1_PREFIX + "/health")
|
||||
api = APIRouter(prefix=API_V1_PREFIX)
|
||||
|
||||
@api.get(API_AGL_PREFIX + "/health")
|
||||
async def health(): # pyright: ignore[reportUnusedFunction]
|
||||
return {"status": "ok"}
|
||||
|
||||
@self.app.post(AGL_API_V1_PREFIX + "/queues/rollouts/enqueue", status_code=201, response_model=Rollout)
|
||||
@api.post(API_AGL_PREFIX + "/queues/rollouts/enqueue", status_code=201, response_model=Rollout)
|
||||
async def enqueue_rollout(request: RolloutRequest): # pyright: ignore[reportUnusedFunction]
|
||||
return await self.enqueue_rollout(
|
||||
input=request.input,
|
||||
@@ -576,11 +591,11 @@ class LightningStoreServer(LightningStore):
|
||||
metadata=request.metadata,
|
||||
)
|
||||
|
||||
@self.app.post(AGL_API_V1_PREFIX + "/queues/rollouts/dequeue", response_model=Optional[AttemptedRollout])
|
||||
@api.post(API_AGL_PREFIX + "/queues/rollouts/dequeue", response_model=Optional[AttemptedRollout])
|
||||
async def dequeue_rollout(): # pyright: ignore[reportUnusedFunction]
|
||||
return await self.dequeue_rollout()
|
||||
|
||||
@self.app.post(AGL_API_V1_PREFIX + "/rollouts", status_code=201, response_model=AttemptedRollout)
|
||||
@api.post(API_AGL_PREFIX + "/rollouts", status_code=201, response_model=AttemptedRollout)
|
||||
async def start_rollout(request: RolloutRequest): # pyright: ignore[reportUnusedFunction]
|
||||
return await self.start_rollout(
|
||||
input=request.input,
|
||||
@@ -590,7 +605,7 @@ class LightningStoreServer(LightningStore):
|
||||
metadata=request.metadata,
|
||||
)
|
||||
|
||||
@self.app.get(AGL_API_V1_PREFIX + "/rollouts", response_model=PaginatedResponse[Rollout])
|
||||
@api.get(API_AGL_PREFIX + "/rollouts", response_model=PaginatedResponse[Union[AttemptedRollout, Rollout]])
|
||||
async def query_rollouts(params: QueryRolloutsRequest = Depends()): # pyright: ignore[reportUnusedFunction]
|
||||
# Get all rollouts from the underlying store
|
||||
all_rollouts = await self.query_rollouts()
|
||||
@@ -614,7 +629,7 @@ class LightningStoreServer(LightningStore):
|
||||
params.offset,
|
||||
)
|
||||
|
||||
@self.app.get(AGL_API_V1_PREFIX + "/rollouts/{rollout_id}", response_model=Rollout)
|
||||
@api.get(API_AGL_PREFIX + "/rollouts/{rollout_id}", response_model=Union[AttemptedRollout, Rollout])
|
||||
async def get_rollout_by_id(rollout_id: str): # pyright: ignore[reportUnusedFunction]
|
||||
return await self.get_rollout_by_id(rollout_id)
|
||||
|
||||
@@ -629,7 +644,7 @@ class LightningStoreServer(LightningStore):
|
||||
else:
|
||||
return UNSET
|
||||
|
||||
@self.app.post(AGL_API_V1_PREFIX + "/rollouts/{rollout_id}", response_model=Rollout)
|
||||
@api.post(API_AGL_PREFIX + "/rollouts/{rollout_id}", response_model=Rollout)
|
||||
async def update_rollout( # pyright: ignore[reportUnusedFunction]
|
||||
rollout_id: str, request: UpdateRolloutRequest = Body(...)
|
||||
):
|
||||
@@ -643,13 +658,11 @@ class LightningStoreServer(LightningStore):
|
||||
metadata=request.metadata if "metadata" in request.model_fields_set else UNSET,
|
||||
)
|
||||
|
||||
@self.app.post(
|
||||
AGL_API_V1_PREFIX + "/rollouts/{rollout_id}/attempts", status_code=201, response_model=AttemptedRollout
|
||||
)
|
||||
@api.post(API_AGL_PREFIX + "/rollouts/{rollout_id}/attempts", status_code=201, response_model=AttemptedRollout)
|
||||
async def start_attempt(rollout_id: str): # pyright: ignore[reportUnusedFunction]
|
||||
return await self.start_attempt(rollout_id)
|
||||
|
||||
@self.app.post(AGL_API_V1_PREFIX + "/rollouts/{rollout_id}/attempts/{attempt_id}", response_model=Attempt)
|
||||
@api.post(API_AGL_PREFIX + "/rollouts/{rollout_id}/attempts/{attempt_id}", response_model=Attempt)
|
||||
async def update_attempt( # pyright: ignore[reportUnusedFunction]
|
||||
rollout_id: str, attempt_id: str, request: UpdateAttemptRequest = Body(...)
|
||||
):
|
||||
@@ -662,7 +675,7 @@ class LightningStoreServer(LightningStore):
|
||||
metadata=_get_mandatory_field_or_unset(request, "metadata"),
|
||||
)
|
||||
|
||||
@self.app.get(AGL_API_V1_PREFIX + "/rollouts/{rollout_id}/attempts", response_model=PaginatedResponse[Attempt])
|
||||
@api.get(API_AGL_PREFIX + "/rollouts/{rollout_id}/attempts", response_model=PaginatedResponse[Attempt])
|
||||
async def query_attempts( # pyright: ignore[reportUnusedFunction]
|
||||
rollout_id: str, params: QueryAttemptsRequest = Depends()
|
||||
):
|
||||
@@ -679,11 +692,11 @@ class LightningStoreServer(LightningStore):
|
||||
params.offset,
|
||||
)
|
||||
|
||||
@self.app.get(AGL_API_V1_PREFIX + "/rollouts/{rollout_id}/attempts/latest", response_model=Optional[Attempt])
|
||||
@api.get(API_AGL_PREFIX + "/rollouts/{rollout_id}/attempts/latest", response_model=Optional[Attempt])
|
||||
async def get_latest_attempt(rollout_id: str): # pyright: ignore[reportUnusedFunction]
|
||||
return await self.get_latest_attempt(rollout_id)
|
||||
|
||||
@self.app.get(AGL_API_V1_PREFIX + "/resources", response_model=PaginatedResponse[ResourcesUpdate])
|
||||
@api.get(API_AGL_PREFIX + "/resources", response_model=PaginatedResponse[ResourcesUpdate])
|
||||
async def query_resources(params: QueryResourcesRequest = Depends()): # pyright: ignore[reportUnusedFunction]
|
||||
# Get all resources
|
||||
all_resources = await self.query_resources()
|
||||
@@ -705,29 +718,29 @@ class LightningStoreServer(LightningStore):
|
||||
params.offset,
|
||||
)
|
||||
|
||||
@self.app.post(AGL_API_V1_PREFIX + "/resources", status_code=201, response_model=ResourcesUpdate)
|
||||
@api.post(API_AGL_PREFIX + "/resources", status_code=201, response_model=ResourcesUpdate)
|
||||
async def add_resources(resources: NamedResources): # pyright: ignore[reportUnusedFunction]
|
||||
return await self.add_resources(resources)
|
||||
|
||||
@self.app.get(AGL_API_V1_PREFIX + "/resources/latest", response_model=Optional[ResourcesUpdate])
|
||||
@api.get(API_AGL_PREFIX + "/resources/latest", response_model=Optional[ResourcesUpdate])
|
||||
async def get_latest_resources(): # pyright: ignore[reportUnusedFunction]
|
||||
return await self.get_latest_resources()
|
||||
|
||||
@self.app.post(AGL_API_V1_PREFIX + "/resources/{resources_id}", response_model=ResourcesUpdate)
|
||||
@api.post(API_AGL_PREFIX + "/resources/{resources_id}", response_model=ResourcesUpdate)
|
||||
async def update_resources( # pyright: ignore[reportUnusedFunction]
|
||||
resources_id: str, resources: NamedResources
|
||||
):
|
||||
return await self.update_resources(resources_id, resources)
|
||||
|
||||
@self.app.get(AGL_API_V1_PREFIX + "/resources/{resources_id}", response_model=Optional[ResourcesUpdate])
|
||||
@api.get(API_AGL_PREFIX + "/resources/{resources_id}", response_model=Optional[ResourcesUpdate])
|
||||
async def get_resources_by_id(resources_id: str): # pyright: ignore[reportUnusedFunction]
|
||||
return await self.get_resources_by_id(resources_id)
|
||||
|
||||
@self.app.post(AGL_API_V1_PREFIX + "/spans", status_code=201, response_model=Span)
|
||||
@api.post(API_AGL_PREFIX + "/spans", status_code=201, response_model=Span)
|
||||
async def add_span(span: Span): # pyright: ignore[reportUnusedFunction]
|
||||
return await self.add_span(span)
|
||||
|
||||
@self.app.get(AGL_API_V1_PREFIX + "/spans", response_model=PaginatedResponse[Span])
|
||||
@api.get(API_AGL_PREFIX + "/spans", response_model=PaginatedResponse[Span])
|
||||
async def query_spans(params: QuerySpansRequest = Depends()): # pyright: ignore[reportUnusedFunction]
|
||||
# Get all spans for the rollout/attempt
|
||||
all_spans = await self.query_spans(params.rollout_id, params.attempt_id)
|
||||
@@ -755,33 +768,76 @@ class LightningStoreServer(LightningStore):
|
||||
all_spans, filters, params.filter_logic, params.sort_by, params.sort_order, params.limit, params.offset
|
||||
)
|
||||
|
||||
@self.app.post(AGL_API_V1_PREFIX + "/spans/next", response_model=NextSequenceIdResponse)
|
||||
@api.post(API_AGL_PREFIX + "/spans/next", response_model=NextSequenceIdResponse)
|
||||
async def get_next_span_sequence_id(request: NextSequenceIdRequest): # pyright: ignore[reportUnusedFunction]
|
||||
sequence_id = await self.get_next_span_sequence_id(request.rollout_id, request.attempt_id)
|
||||
return NextSequenceIdResponse(sequence_id=sequence_id)
|
||||
|
||||
@self.app.post(AGL_API_V1_PREFIX + "/waits/rollouts", response_model=List[Rollout])
|
||||
@api.post(API_AGL_PREFIX + "/waits/rollouts", response_model=List[Rollout])
|
||||
async def wait_for_rollouts(request: WaitForRolloutsRequest): # pyright: ignore[reportUnusedFunction]
|
||||
return await self.wait_for_rollouts(rollout_ids=request.rollout_ids, timeout=request.timeout)
|
||||
|
||||
# Reserved methods for OTEL traces
|
||||
# https://opentelemetry.io/docs/specs/otlp/#otlphttp-request
|
||||
@self.app.post("/v1/traces")
|
||||
@api.post("/traces")
|
||||
async def otlp_traces(): # pyright: ignore[reportUnusedFunction]
|
||||
return Response(status_code=501)
|
||||
|
||||
@self.app.post("/v1/metrics")
|
||||
@api.post("/metrics")
|
||||
async def otlp_metrics(): # pyright: ignore[reportUnusedFunction]
|
||||
return Response(status_code=501)
|
||||
|
||||
@self.app.post("/v1/logs")
|
||||
@api.post("/logs")
|
||||
async def otlp_logs(): # pyright: ignore[reportUnusedFunction]
|
||||
return Response(status_code=501)
|
||||
|
||||
@self.app.post("/v1/development/profiles")
|
||||
@api.post("/development/profiles")
|
||||
async def otlp_development_profiles(): # pyright: ignore[reportUnusedFunction]
|
||||
return Response(status_code=501)
|
||||
|
||||
# Mount the API router of /v1/...
|
||||
self.app.include_router(api)
|
||||
|
||||
# Finally, mount the dashboard assets
|
||||
self._setup_dashboard()
|
||||
|
||||
def _setup_dashboard(self):
|
||||
"""Setup the dashboard static files and SPA."""
|
||||
assert self.app is not None
|
||||
|
||||
dashboard_dir = (Path(__file__).parent.parent / "dashboard").resolve()
|
||||
if not dashboard_dir.exists():
|
||||
logger.error("Dashboard directory not found at %s. Please build the dashboard first.", dashboard_dir)
|
||||
return
|
||||
|
||||
dashboard_assets_dir = dashboard_dir / "assets"
|
||||
if not dashboard_assets_dir.exists():
|
||||
logger.error(
|
||||
"Dashboard assets directory not found at %s. Please build the dashboard first.", dashboard_assets_dir
|
||||
)
|
||||
return
|
||||
|
||||
index_file = dashboard_dir / "index.html"
|
||||
if not index_file.exists():
|
||||
logger.error("Dashboard index file not found at %s. Please build the dashboard first.", index_file)
|
||||
return
|
||||
|
||||
# Mount the static files in dashboard/assets
|
||||
self.app.mount("/assets", CachedStaticFiles(directory=dashboard_assets_dir), name="assets")
|
||||
|
||||
# SPA fallback (client-side routing)
|
||||
# Anything that's not /v1/* or a real file in /assets will serve index.html
|
||||
@self.app.get("/", include_in_schema=False)
|
||||
def root(): # pyright: ignore[reportUnusedFunction]
|
||||
return FileResponse(index_file)
|
||||
|
||||
@self.app.get("/{full_path:path}", include_in_schema=False)
|
||||
def spa_fallback(full_path: str): # pyright: ignore[reportUnusedFunction]
|
||||
# Let the frontend router handle it
|
||||
return FileResponse(index_file)
|
||||
|
||||
logger.info("Agent-lightning dashboard will be available at %s", self.endpoint)
|
||||
|
||||
# Delegate methods
|
||||
async def _call_store_method(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
backend = self._backend()
|
||||
@@ -955,7 +1011,7 @@ class LightningStoreClient(LightningStore):
|
||||
retry_delays: Sequence[float] = (1.0, 2.0, 5.0),
|
||||
health_retry_delays: Sequence[float] = (0.1, 0.2, 0.5),
|
||||
):
|
||||
self.server_address = server_address.rstrip("/") + AGL_API_V1_PREFIX
|
||||
self.server_address = server_address.rstrip("/") + API_V1_AGL_PREFIX
|
||||
self._sessions: Dict[int, aiohttp.ClientSession] = {} # id(loop) -> ClientSession
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@@ -1216,7 +1272,14 @@ class LightningStoreClient(LightningStore):
|
||||
|
||||
data = await self._request_json("get", "/rollouts", params=params if params else None)
|
||||
# Extract items from PaginatedResponse
|
||||
return [Rollout.model_validate(item) for item in data["items"]]
|
||||
return [
|
||||
(
|
||||
AttemptedRollout.model_validate(item)
|
||||
if isinstance(item, dict) and "attempt" in item
|
||||
else Rollout.model_validate(item)
|
||||
)
|
||||
for item in data["items"]
|
||||
]
|
||||
|
||||
async def query_attempts(self, rollout_id: str) -> List[Attempt]:
|
||||
data = await self._request_json("get", f"/rollouts/{rollout_id}/attempts")
|
||||
@@ -1260,7 +1323,10 @@ class LightningStoreClient(LightningStore):
|
||||
"""
|
||||
try:
|
||||
data = await self._request_json("get", f"/rollouts/{rollout_id}")
|
||||
return Rollout.model_validate(data) if data else None
|
||||
if isinstance(data, dict) and "attempt" in data:
|
||||
return AttemptedRollout.model_validate(data)
|
||||
else:
|
||||
return Rollout.model_validate(data)
|
||||
except Exception as e:
|
||||
logger.error(f"get_rollout_by_id failed after all retries for rollout_id={rollout_id}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
@@ -10,7 +10,7 @@ const config: StorybookConfig = {
|
||||
},
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.story.@(js|jsx|ts|tsx)'],
|
||||
staticDirs: ['../static'],
|
||||
addons: ['@storybook/addon-themes'],
|
||||
addons: ['@storybook/addon-themes', '@storybook/addon-vitest'],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import { setProjectAnnotations } from '@storybook/react-vite';
|
||||
import * as projectAnnotations from './preview';
|
||||
|
||||
// This is an important step to apply the right configuration when testing your stories.
|
||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||
setProjectAnnotations([projectAnnotations]);
|
||||
Generated
+584
@@ -25,6 +25,7 @@
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
||||
"@storybook/addon-themes": "^9.1.10",
|
||||
"@storybook/addon-vitest": "^9.1.16",
|
||||
"@storybook/react": "^9.1.10",
|
||||
"@storybook/react-vite": "^9.1.10",
|
||||
"@stylistic/eslint-plugin": "^5.5.0",
|
||||
@@ -36,6 +37,8 @@
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@vitest/browser-playwright": "4.0.4",
|
||||
"@vitest/coverage-v8": "4.0.4",
|
||||
"chromatic": "^13.3.3",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-config-mantine": "^4.0.3",
|
||||
@@ -45,6 +48,7 @@
|
||||
"jsdom": "^27.0.0",
|
||||
"msw": "^2.11.6",
|
||||
"msw-storybook-addon": "^2.0.6",
|
||||
"playwright": "^1.56.1",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-mantine": "1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
@@ -433,6 +437,16 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/memoize": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/memoize/-/memoize-2.0.3.tgz",
|
||||
@@ -1802,6 +1816,13 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.2.tgz",
|
||||
@@ -2195,6 +2216,44 @@
|
||||
"storybook": "^9.1.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-vitest": {
|
||||
"version": "9.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-9.1.16.tgz",
|
||||
"integrity": "sha512-X0rOOUMb5UHbfekcjnTeiDTarZdsg5irXXPxxL//8QQCFyCLF6Bdm1YNlCdF560PtwaaQPXzlxByD0FfGbtdWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@storybook/icons": "^1.4.0",
|
||||
"prompts": "^2.4.0",
|
||||
"ts-dedent": "^2.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "^3.0.0 || ^4.0.0",
|
||||
"@vitest/browser-playwright": "^4.0.0",
|
||||
"@vitest/runner": "^3.0.0 || ^4.0.0",
|
||||
"storybook": "^9.1.16",
|
||||
"vitest": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/runner": {
|
||||
"optional": true
|
||||
},
|
||||
"vitest": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/builder-vite": {
|
||||
"version": "9.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-9.1.16.tgz",
|
||||
@@ -2238,6 +2297,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/icons": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.6.0.tgz",
|
||||
"integrity": "sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react": {
|
||||
"version": "9.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-9.1.16.tgz",
|
||||
@@ -2870,6 +2943,263 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.4.tgz",
|
||||
"integrity": "sha512-1ZXztcBtRd3maKliHzWbQohsyRjam0ws6OPRWNWfGxFUOHTlNBtDnJAm8z1x7IzVkZ6JcOAumHJAbxNJh4tkDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/mocker": "4.0.4",
|
||||
"@vitest/utils": "4.0.4",
|
||||
"magic-string": "^0.30.19",
|
||||
"pixelmatch": "7.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"sirv": "^3.0.2",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser-playwright": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.4.tgz",
|
||||
"integrity": "sha512-jGKnGZ5ZKXuwQ1Ldwll/rZxk3webz4gz3kvoTYX2NH2ASPiwFGck8D09Sf2wVjCuDqebPXXd69zUIt1o4yQ5tA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/browser": "4.0.4",
|
||||
"@vitest/mocker": "4.0.4",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright": "*",
|
||||
"vitest": "4.0.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.4.tgz",
|
||||
"integrity": "sha512-UTtKgpjWj+pvn3lUM55nSg34098obGhSHH+KlJcXesky8b5wCUgg7s60epxrS6yAG8slZ9W8T9jGWg4PisMf5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.0.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.19"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser-playwright/node_modules/@vitest/spy": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.4.tgz",
|
||||
"integrity": "sha512-G9L13AFyYECo40QG7E07EdYnZZYCKMTSp83p9W8Vwed0IyCG1GnpDLxObkx8uOGPXfDpdeVf24P1Yka8/q1s9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser-playwright/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser-playwright/node_modules/tinyrainbow": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser/node_modules/@vitest/mocker": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.4.tgz",
|
||||
"integrity": "sha512-UTtKgpjWj+pvn3lUM55nSg34098obGhSHH+KlJcXesky8b5wCUgg7s60epxrS6yAG8slZ9W8T9jGWg4PisMf5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.0.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.19"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser/node_modules/@vitest/pretty-format": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.4.tgz",
|
||||
"integrity": "sha512-lHI2rbyrLVSd1TiHGJYyEtbOBo2SDndIsN3qY4o4xe2pBxoJLD6IICghNCvD7P+BFin6jeyHXiUICXqgl6vEaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser/node_modules/@vitest/spy": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.4.tgz",
|
||||
"integrity": "sha512-G9L13AFyYECo40QG7E07EdYnZZYCKMTSp83p9W8Vwed0IyCG1GnpDLxObkx8uOGPXfDpdeVf24P1Yka8/q1s9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser/node_modules/@vitest/utils": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.4.tgz",
|
||||
"integrity": "sha512-4bJLmSvZLyVbNsYFRpPYdJViG9jZyRvMZ35IF4ymXbRZoS+ycYghmwTGiscTXduUg2lgKK7POWIyXJNute1hjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.4",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser/node_modules/tinyrainbow": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.4.tgz",
|
||||
"integrity": "sha512-YM7gDj2TX2AXyGLz0p/B7hvTsTfaQc+kSV/LU0nEnKlep/ZfbdCDppPND4YQiQC43OXyrhkG3y8ZSTqYb2CKqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.0.4",
|
||||
"ast-v8-to-istanbul": "^0.3.5",
|
||||
"debug": "^4.4.3",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-lib-source-maps": "^5.0.6",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"magicast": "^0.3.5",
|
||||
"std-env": "^3.9.0",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.0.4",
|
||||
"vitest": "4.0.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8/node_modules/@vitest/pretty-format": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.4.tgz",
|
||||
"integrity": "sha512-lHI2rbyrLVSd1TiHGJYyEtbOBo2SDndIsN3qY4o4xe2pBxoJLD6IICghNCvD7P+BFin6jeyHXiUICXqgl6vEaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8/node_modules/@vitest/utils": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.4.tgz",
|
||||
"integrity": "sha512-4bJLmSvZLyVbNsYFRpPYdJViG9jZyRvMZ35IF4ymXbRZoS+ycYghmwTGiscTXduUg2lgKK7POWIyXJNute1hjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.4",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8/node_modules/tinyrainbow": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
@@ -3325,6 +3655,35 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz",
|
||||
"integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/astral-regex": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
||||
@@ -5532,6 +5891,13 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-tags": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz",
|
||||
@@ -6154,6 +6520,60 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-source-maps": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
|
||||
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.23",
|
||||
"debug": "^4.1.1",
|
||||
"istanbul-lib-coverage": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/iterator.prototype": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
|
||||
@@ -6338,6 +6758,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
"integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/known-css-properties": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz",
|
||||
@@ -6466,6 +6896,34 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
|
||||
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.25.4",
|
||||
"@babel/types": "^7.25.4",
|
||||
"source-map-js": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mantine-datatable": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mantine-datatable/-/mantine-datatable-8.2.0.tgz",
|
||||
@@ -6628,6 +7086,16 @@
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -7104,6 +7572,76 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pixelmatch": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz",
|
||||
"integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"pngjs": "^7.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pixelmatch": "bin/pixelmatch"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.56.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -7398,6 +7936,20 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
"integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"kleur": "^3.0.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -8220,6 +8772,28 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/sirv": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@polka/url": "^1.0.0-next.24",
|
||||
"mrmime": "^2.0.0",
|
||||
"totalist": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||
@@ -9133,6 +9707,16 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"eslint": "eslint .",
|
||||
"stylelint": "stylelint '**/*.css'",
|
||||
"prettier": "prettier --check \"**/*.{ts,tsx,mjs,cjs}\"",
|
||||
"vitest": "vitest run",
|
||||
"vitest": "vitest run --project unit",
|
||||
"vitest-storybook": "vitest run --project storybook",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"chromatic": "chromatic"
|
||||
@@ -33,6 +34,7 @@
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
||||
"@storybook/addon-themes": "^9.1.10",
|
||||
"@storybook/addon-vitest": "^9.1.16",
|
||||
"@storybook/react": "^9.1.10",
|
||||
"@storybook/react-vite": "^9.1.10",
|
||||
"@stylistic/eslint-plugin": "^5.5.0",
|
||||
@@ -65,6 +67,9 @@
|
||||
"typescript-eslint": "^8.46.0",
|
||||
"vite": "^7.1.9",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.0.0"
|
||||
"vitest": "^4.0.0",
|
||||
"playwright": "^1.56.1",
|
||||
"@vitest/browser-playwright": "4.0.4",
|
||||
"@vitest/coverage-v8": "4.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="../src/favicon.svg" />
|
||||
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no" />
|
||||
<title>Agent-lightning Dashboard</title>
|
||||
</head>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { Editor } from '@monaco-editor/react';
|
||||
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
||||
import type { DataTableSortStatus } from 'mantine-datatable';
|
||||
import { createSearchParams, Link, useInRouterContext, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
CopyButton,
|
||||
@@ -269,7 +271,7 @@ export function JsonEditor({ value }: JsonEditorProps) {
|
||||
const editorTheme = colorScheme === 'dark' ? 'vs-dark' : 'vs-light';
|
||||
|
||||
return (
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<Box data-testid='json-editor-container' style={{ flex: 1, minHeight: 0 }}>
|
||||
<Editor
|
||||
height='100%'
|
||||
language='json'
|
||||
@@ -322,6 +324,15 @@ function RolloutTracesDrawerBody({ rollout, attempt, onShowRollout, onShowSpanDe
|
||||
const { data, isFetching, isError, error, refetch } = useGetSpansQuery(queryArgs);
|
||||
const spans = data?.items ?? [];
|
||||
const totalRecords = data?.total ?? 0;
|
||||
const tracesLinkSearch = useMemo(() => {
|
||||
const params = createSearchParams({
|
||||
rolloutId: rollout.rolloutId,
|
||||
...(attempt?.attemptId ? { attemptId: attempt.attemptId } : {}),
|
||||
});
|
||||
return params.toString();
|
||||
}, [attempt?.attemptId, rollout.rolloutId]);
|
||||
const tracesLinkHref = tracesLinkSearch ? `/traces?${tracesLinkSearch}` : '/traces';
|
||||
const isWithinRouter = useInRouterContext();
|
||||
|
||||
const handleSortStatusChange = useCallback((status: DataTableSortStatus<TracesTableRecord>) => {
|
||||
setSort({
|
||||
@@ -341,11 +352,38 @@ function RolloutTracesDrawerBody({ rollout, attempt, onShowRollout, onShowSpanDe
|
||||
|
||||
return (
|
||||
<Stack gap='md' style={{ flex: 1, minHeight: 0 }}>
|
||||
<Text size='sm' c='dimmed'>
|
||||
Showing spans for rollout {rollout.rolloutId}
|
||||
{attempt ? ` · Attempt ${attempt.sequenceId} (${attempt.attemptId})` : ' · Latest attempt'}
|
||||
</Text>
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<Group justify='space-between' align='center' gap='sm' wrap='nowrap'>
|
||||
<Text size='sm' style={{ flex: 1, minWidth: 0 }}>
|
||||
Showing spans for{' '}
|
||||
<Text component='span' fw={600}>
|
||||
{rollout.rolloutId}
|
||||
{attempt ? ` · Attempt ${attempt.sequenceId} (${attempt.attemptId})` : ' · Latest attempt'}
|
||||
</Text>
|
||||
</Text>
|
||||
{isWithinRouter ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={tracesLinkHref}
|
||||
size='sm'
|
||||
aria-label={`Open traces page for rollout ${rollout.rolloutId}${
|
||||
attempt ? ` attempt ${attempt.sequenceId}` : ''
|
||||
}`}
|
||||
>
|
||||
View full traces
|
||||
</Anchor>
|
||||
) : (
|
||||
<Anchor
|
||||
href={tracesLinkHref}
|
||||
size='sm'
|
||||
aria-label={`Open traces page for rollout ${rollout.rolloutId}${
|
||||
attempt ? ` attempt ${attempt.sequenceId}` : ''
|
||||
}`}
|
||||
>
|
||||
View full traces
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
<Box data-testid='traces-drawer-table-container' style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
<TracesTable
|
||||
spans={spans}
|
||||
totalRecords={totalRecords}
|
||||
@@ -374,10 +412,16 @@ export function AppDrawerContainer() {
|
||||
const dispatch = useAppDispatch();
|
||||
const isOpen = useAppSelector(selectDrawerIsOpen);
|
||||
const content = useAppSelector(selectDrawerContent);
|
||||
const isRouterAvailable = useInRouterContext();
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dispatch(closeDrawer());
|
||||
}, [dispatch]);
|
||||
const handleNavigation = useCallback(() => {
|
||||
if (isOpen) {
|
||||
dispatch(closeDrawer());
|
||||
}
|
||||
}, [dispatch, isOpen]);
|
||||
|
||||
const derivedContent = useMemo(() => {
|
||||
if (!content) {
|
||||
@@ -444,5 +488,29 @@ export function AppDrawerContainer() {
|
||||
|
||||
const { title, body } = derivedContent;
|
||||
|
||||
return <AppDrawer opened={isOpen} onClose={handleClose} title={title} body={body} />;
|
||||
return (
|
||||
<>
|
||||
{isRouterAvailable ? <DrawerLocationWatcher onNavigation={handleNavigation} /> : null}
|
||||
<AppDrawer opened={isOpen} onClose={handleClose} title={title} body={body} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type DrawerLocationWatcherProps = {
|
||||
onNavigation: () => void;
|
||||
};
|
||||
|
||||
function DrawerLocationWatcher({ onNavigation }: DrawerLocationWatcherProps) {
|
||||
const location = useLocation();
|
||||
const lastLocationKeyRef = useRef(location.key);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastLocationKeyRef.current === location.key) {
|
||||
return;
|
||||
}
|
||||
lastLocationKeyRef.current = location.key;
|
||||
onNavigation();
|
||||
}, [location.key, onNavigation]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode, type SetStat
|
||||
import { IconCheck, IconCopy, IconRefresh } from '@tabler/icons-react';
|
||||
import { DataTable, type DataTableColumn, type DataTableSortStatus } from 'mantine-datatable';
|
||||
import { ActionIcon, Box, Button, CopyButton, Group, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { useElementSize, useViewportSize } from '@mantine/hooks';
|
||||
import { getLayoutAwareWidth } from '@/layouts/helper';
|
||||
import type { Resources } from '@/types';
|
||||
import { getErrorDescriptor } from '@/utils/error';
|
||||
import { formatDateTime, safeStringify } from '@/utils/format';
|
||||
@@ -162,6 +163,12 @@ export function ResourcesTable({
|
||||
}: ResourcesTableProps) {
|
||||
const [expandedRecordIds, setExpandedRecordIds] = useState<string[]>([]);
|
||||
const { ref: tableContainerRef, width: containerWidth } = useElementSize();
|
||||
const { width: viewportWidth } = useViewportSize();
|
||||
|
||||
const layoutAwareContainerWidth = useMemo(
|
||||
() => getLayoutAwareWidth(containerWidth, viewportWidth),
|
||||
[containerWidth, viewportWidth],
|
||||
);
|
||||
|
||||
const resourcesRecords = useMemo<ResourcesTableRecord[]>(() => {
|
||||
if (!resourcesList) {
|
||||
@@ -173,8 +180,8 @@ export function ResourcesTable({
|
||||
const columns = useMemo(() => createResourcesColumns({}), []);
|
||||
|
||||
const responsiveColumns = useMemo(
|
||||
() => createResponsiveColumns(columns, containerWidth, COLUMN_VISIBILITY),
|
||||
[columns, containerWidth],
|
||||
() => createResponsiveColumns(columns, layoutAwareContainerWidth, COLUMN_VISIBILITY),
|
||||
[columns, layoutAwareContainerWidth],
|
||||
);
|
||||
|
||||
const totalPages = useMemo(
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { useElementSize, useViewportSize } from '@mantine/hooks';
|
||||
import {
|
||||
type Attempt,
|
||||
type AttemptStatus,
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type RolloutsSortState,
|
||||
type RolloutStatus,
|
||||
} from '@/features/rollouts';
|
||||
import { getLayoutAwareWidth } from '@/layouts/helper';
|
||||
import {
|
||||
clampToNow,
|
||||
formatDateTime,
|
||||
@@ -78,14 +79,14 @@ const ROLLOUT_MODE_OPTIONS: RolloutMode[] = ['train', 'val', 'test'];
|
||||
const DEFAULT_RECORDS_PER_PAGE_OPTIONS = [50, 100, 200, 500];
|
||||
|
||||
const COLUMN_VISIBILITY: Record<string, ColumnVisibilityConfig> = {
|
||||
rolloutId: { fixedWidth: 10, priority: 0 },
|
||||
rolloutId: { fixedWidth: 12.5, priority: 0 },
|
||||
actionsPlaceholder: { fixedWidth: 6.5, priority: 0 },
|
||||
inputText: { minWidth: 14, priority: 1 },
|
||||
statusValue: { fixedWidth: 10, priority: 1 },
|
||||
startTimestamp: { fixedWidth: 12, priority: 2 },
|
||||
durationSeconds: { fixedWidth: 10, priority: 2 },
|
||||
attemptId: { fixedWidth: 12, priority: 3 },
|
||||
resourcesId: { fixedWidth: 8, priority: 3 },
|
||||
resourcesId: { fixedWidth: 10, priority: 3 },
|
||||
mode: { fixedWidth: 8, priority: 3 },
|
||||
lastHeartbeatTimestamp: { fixedWidth: 10, priority: 3 },
|
||||
workerId: { fixedWidth: 10, priority: 3 },
|
||||
@@ -293,7 +294,14 @@ function createRolloutColumns({
|
||||
accessor: 'inputText',
|
||||
title: 'Input',
|
||||
render: ({ inputText }) => (
|
||||
<Text size='sm' ff='monospace' c='dimmed' lineClamp={1} style={{ width: '100%' }}>
|
||||
<Text
|
||||
size='sm'
|
||||
ff='monospace'
|
||||
c='dimmed'
|
||||
lineClamp={1}
|
||||
title={inputText}
|
||||
style={{ width: '100%', wordBreak: 'break-all', overflow: 'hidden' }}
|
||||
>
|
||||
{inputText}
|
||||
</Text>
|
||||
),
|
||||
@@ -533,6 +541,11 @@ export function RolloutTable({
|
||||
}: RolloutTableProps) {
|
||||
const [expandedRecordIds, setExpandedRecordIds] = useState<string[]>([]);
|
||||
const { ref: tableContainerRef, width: containerWidth } = useElementSize();
|
||||
const { width: viewportWidth } = useViewportSize();
|
||||
|
||||
const layoutAwareContainerWidth = useMemo(() => {
|
||||
return getLayoutAwareWidth(containerWidth, viewportWidth);
|
||||
}, [containerWidth, viewportWidth]);
|
||||
|
||||
const rolloutRecords = useMemo<RolloutTableRecord[]>(() => {
|
||||
if (!rollouts) {
|
||||
@@ -566,8 +579,8 @@ export function RolloutTable({
|
||||
);
|
||||
|
||||
const responsiveColumns = useMemo(
|
||||
() => createResponsiveColumns(columns, containerWidth, COLUMN_VISIBILITY),
|
||||
[columns, containerWidth],
|
||||
() => createResponsiveColumns(columns, layoutAwareContainerWidth, COLUMN_VISIBILITY),
|
||||
[columns, layoutAwareContainerWidth],
|
||||
);
|
||||
|
||||
const totalPages = useMemo(
|
||||
@@ -653,7 +666,7 @@ export function RolloutTable({
|
||||
);
|
||||
|
||||
return (
|
||||
<Box ref={tableContainerRef}>
|
||||
<Box ref={tableContainerRef} data-testid='rollouts-table-container'>
|
||||
<DataTable<RolloutTableRecord>
|
||||
classNames={{ root: 'rollouts-table' }}
|
||||
withTableBorder
|
||||
|
||||
@@ -11,23 +11,25 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { DataTable, type DataTableColumn, type DataTableSortStatus } from 'mantine-datatable';
|
||||
import { ActionIcon, Badge, Box, Button, CopyButton, Group, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { useElementSize, useViewportSize } from '@mantine/hooks';
|
||||
import { getLayoutAwareWidth } from '@/layouts/helper';
|
||||
import type { Span } from '@/types';
|
||||
import { getErrorDescriptor } from '@/utils/error';
|
||||
import { formatDateTime, formatDuration, toTimestamp } from '@/utils/format';
|
||||
import { formatDateTimeWithMilliseconds, formatDuration, toTimestamp } from '@/utils/format';
|
||||
import { createResponsiveColumns, type ColumnVisibilityConfig } from '@/utils/table';
|
||||
|
||||
const DEFAULT_RECORDS_PER_PAGE_OPTIONS = [50, 100, 200, 500];
|
||||
|
||||
const COLUMN_VISIBILITY: Record<string, ColumnVisibilityConfig> = {
|
||||
name: { minWidth: 12.5, priority: 0 },
|
||||
spanId: { fixedWidth: 12, priority: 1 },
|
||||
traceId: { fixedWidth: 12, priority: 2 },
|
||||
spanId: { fixedWidth: 14, priority: 1 },
|
||||
traceId: { fixedWidth: 24, priority: 3 },
|
||||
parentId: { fixedWidth: 12, priority: 2 },
|
||||
statusCode: { fixedWidth: 8, priority: 2 },
|
||||
attributeKeys: { minWidth: 12.5, priority: 2 },
|
||||
startTime: { fixedWidth: 12, priority: 1 },
|
||||
duration: { fixedWidth: 10, priority: 2 },
|
||||
startTime: { fixedWidth: 15, priority: 1 },
|
||||
endTime: { fixedWidth: 15, priority: 1 },
|
||||
duration: { fixedWidth: 10, priority: 3 },
|
||||
actionsPlaceholder: { fixedWidth: 6, priority: 0 },
|
||||
};
|
||||
|
||||
@@ -211,7 +213,14 @@ function createTracesColumns({
|
||||
title: 'Start Time',
|
||||
sortable: true,
|
||||
textAlign: 'left',
|
||||
render: ({ startTime }) => <Text size='sm'>{formatDateTime(toTimestamp(startTime))}</Text>,
|
||||
render: ({ startTime }) => <Text size='sm'>{formatDateTimeWithMilliseconds(toTimestamp(startTime))}</Text>,
|
||||
},
|
||||
{
|
||||
accessor: 'endTime',
|
||||
title: 'End Time',
|
||||
sortable: true,
|
||||
textAlign: 'left',
|
||||
render: ({ endTime }) => <Text size='sm'>{formatDateTimeWithMilliseconds(toTimestamp(endTime))}</Text>,
|
||||
},
|
||||
{
|
||||
accessor: 'duration',
|
||||
@@ -301,6 +310,7 @@ export function TracesTable({
|
||||
recordsPerPageOptions = DEFAULT_RECORDS_PER_PAGE_OPTIONS,
|
||||
}: TracesTableProps) {
|
||||
const { ref: tableContainerRef, width: containerWidth } = useElementSize();
|
||||
const { width: viewportWidth } = useViewportSize();
|
||||
|
||||
const traceRecords = useMemo<TracesTableRecord[]>(() => {
|
||||
if (!spans) {
|
||||
@@ -324,9 +334,14 @@ export function TracesTable({
|
||||
[onShowRollout, onShowSpanDetail, onParentIdClick, spanIds],
|
||||
);
|
||||
|
||||
const layoutAwareContainerWidth = useMemo(
|
||||
() => getLayoutAwareWidth(containerWidth, viewportWidth),
|
||||
[containerWidth, viewportWidth],
|
||||
);
|
||||
|
||||
const responsiveColumns = useMemo(
|
||||
() => createResponsiveColumns(columns, containerWidth, COLUMN_VISIBILITY),
|
||||
[columns, containerWidth],
|
||||
() => createResponsiveColumns(columns, layoutAwareContainerWidth, COLUMN_VISIBILITY),
|
||||
[columns, layoutAwareContainerWidth],
|
||||
);
|
||||
|
||||
const totalPages = useMemo(
|
||||
|
||||
@@ -94,15 +94,21 @@ const normalizeSpan = (value: unknown): Span => {
|
||||
};
|
||||
};
|
||||
const rawStatus = camelized.status ?? { status_code: 'UNSET', description: null };
|
||||
return {
|
||||
const result = {
|
||||
...camelized,
|
||||
parentId: camelized.parentId ?? null,
|
||||
attributes: camelized.attributes ?? {},
|
||||
// The following fields does not need to be normalized to camel case
|
||||
// For example, gen_ai.xxx should not become genAi.xxx
|
||||
attributes: (value as any).attributes ?? {},
|
||||
context: (value as any).context ?? {},
|
||||
parent: (value as any).parent ?? null,
|
||||
resource: (value as any).resource ?? {},
|
||||
status: {
|
||||
status_code: rawStatus.status_code ?? rawStatus.statusCode ?? 'UNSET',
|
||||
description: rawStatus.description ?? null,
|
||||
},
|
||||
};
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeResources = (value: unknown): Resources => {
|
||||
@@ -121,20 +127,20 @@ const normalizePaginatedResponse = <T>(value: unknown, normalizer: (item: unknow
|
||||
throw new Error('Expected paginated response payload');
|
||||
}
|
||||
|
||||
const camelized = camelCaseKeys(value) as {
|
||||
const converted = value as {
|
||||
items?: unknown;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
const itemsSource = Array.isArray(camelized.items) ? camelized.items : [];
|
||||
const itemsSource = Array.isArray(converted.items) ? converted.items : [];
|
||||
|
||||
return {
|
||||
items: itemsSource.map((item) => normalizer(item)),
|
||||
limit: typeof camelized.limit === 'number' ? camelized.limit : itemsSource.length,
|
||||
offset: typeof camelized.offset === 'number' ? camelized.offset : 0,
|
||||
total: typeof camelized.total === 'number' ? camelized.total : itemsSource.length,
|
||||
limit: typeof converted.limit === 'number' ? converted.limit : itemsSource.length,
|
||||
offset: typeof converted.offset === 'number' ? converted.offset : 0,
|
||||
total: typeof converted.total === 'number' ? converted.total : itemsSource.length,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -53,6 +53,24 @@ describe('rollouts feature integration', () => {
|
||||
expect(data.items[0].status).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes attempts directly on rollout payloads when they exist', async () => {
|
||||
const store = createServerBackedStore();
|
||||
const queryArgs = selectRolloutsQueryArgs(store.getState());
|
||||
|
||||
const subscription = store.dispatch(rolloutsApi.endpoints.getRollouts.initiate(queryArgs));
|
||||
const data = await subscription.unwrap();
|
||||
subscription.unsubscribe();
|
||||
|
||||
const rolloutWithAttempt = data.items.find((rollout) => rollout.rolloutId === 'ro-story-002');
|
||||
expect(rolloutWithAttempt).toBeDefined();
|
||||
expect(rolloutWithAttempt?.attempt).not.toBeNull();
|
||||
expect(rolloutWithAttempt?.attempt?.attemptId).toBe('at-story-022');
|
||||
|
||||
const rolloutWithoutAttempt = data.items.find((rollout) => rollout.rolloutId === 'ro-story-004');
|
||||
expect(rolloutWithoutAttempt).toBeDefined();
|
||||
expect(rolloutWithoutAttempt?.attempt).toBeNull();
|
||||
});
|
||||
|
||||
it('retrieves attempts for a rollout from the Python server', async () => {
|
||||
const store = createServerBackedStore();
|
||||
const subscription = store.dispatch(
|
||||
|
||||
@@ -69,6 +69,34 @@ const tracesSlice = createSlice({
|
||||
state.page = initialTracesUiState.page;
|
||||
state.sort = initialTracesUiState.sort;
|
||||
},
|
||||
hydrateTracesStateFromQuery(
|
||||
state,
|
||||
action: PayloadAction<{ rolloutId?: string | null; attemptId?: string | null }>,
|
||||
) {
|
||||
const payload = action.payload;
|
||||
if (Object.hasOwn(payload, 'rolloutId')) {
|
||||
const nextRolloutId = payload.rolloutId ?? null;
|
||||
if (state.rolloutId !== nextRolloutId) {
|
||||
state.rolloutId = nextRolloutId;
|
||||
state.page = 1;
|
||||
state.attemptId = null;
|
||||
} else if (nextRolloutId === null) {
|
||||
state.attemptId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.hasOwn(payload, 'attemptId')) {
|
||||
if (state.rolloutId === null) {
|
||||
state.attemptId = null;
|
||||
return;
|
||||
}
|
||||
const nextAttemptId = payload.attemptId ?? null;
|
||||
if (state.attemptId !== nextAttemptId) {
|
||||
state.attemptId = nextAttemptId;
|
||||
state.page = 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,6 +109,7 @@ export const {
|
||||
setTracesSort,
|
||||
setTracesViewMode,
|
||||
resetTracesFilters,
|
||||
hydrateTracesStateFromQuery,
|
||||
} = tracesSlice.actions;
|
||||
|
||||
export const tracesReducer = tracesSlice.reducer;
|
||||
|
||||
@@ -99,6 +99,7 @@ function useServerConnection({ baseUrl, autoRefreshMs }: ConnectionOptions) {
|
||||
|
||||
check();
|
||||
|
||||
// FIXME: autorefresh only refresh server status, not the data
|
||||
if (autoRefreshMs && autoRefreshMs > 0) {
|
||||
intervalId = window.setInterval(check, autoRefreshMs);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
function parseCssNumber(value: string | null | undefined): number {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function getElementWidth(selectors: string | string[]): number {
|
||||
if (typeof window === 'undefined') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const selectorList = Array.isArray(selectors) ? selectors : [selectors];
|
||||
for (const selector of selectorList) {
|
||||
const element = window.document.querySelector<HTMLElement>(selector);
|
||||
if (element) {
|
||||
return element.getBoundingClientRect().width || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getAppShellContentWidth(): number {
|
||||
if (typeof window === 'undefined') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const selectors = ['.mantine-AppShell-main', '[data-mantine-component="AppShellMain"]'];
|
||||
return getElementWidth(selectors);
|
||||
}
|
||||
|
||||
export function getAppShellOffsets(): number {
|
||||
if (typeof window === 'undefined' || !window.document?.documentElement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rootStyles = window.getComputedStyle(window.document.documentElement);
|
||||
|
||||
const navbarOffset =
|
||||
parseCssNumber(rootStyles.getPropertyValue('--app-shell-navbar-offset')) ||
|
||||
getElementWidth(['.mantine-AppShell-navbar', '[data-mantine-component="AppShellNavbar"]']);
|
||||
|
||||
const asideOffset =
|
||||
parseCssNumber(rootStyles.getPropertyValue('--app-shell-aside-offset')) ||
|
||||
getElementWidth(['.mantine-AppShell-aside', '[data-mantine-component="AppShellAside"]']);
|
||||
|
||||
const paddingVar = parseCssNumber(rootStyles.getPropertyValue('--app-shell-padding'));
|
||||
let paddingTotal = paddingVar ? paddingVar * 2 : 0;
|
||||
|
||||
if (paddingTotal === 0) {
|
||||
const selectors = ['.mantine-AppShell-main', '[data-mantine-component="AppShellMain"]'];
|
||||
for (const selector of selectors) {
|
||||
const main = window.document.querySelector<HTMLElement>(selector);
|
||||
if (!main) {
|
||||
continue;
|
||||
}
|
||||
const mainStyles = window.getComputedStyle(main);
|
||||
const computedPadding = parseCssNumber(mainStyles.paddingLeft) + parseCssNumber(mainStyles.paddingRight);
|
||||
if (computedPadding > 0) {
|
||||
paddingTotal = computedPadding;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return navbarOffset + asideOffset + paddingTotal;
|
||||
}
|
||||
|
||||
export function getLayoutAwareWidth(containerWidth: number, viewportWidth: number): number {
|
||||
const appShellContentWidth = getAppShellContentWidth();
|
||||
const layoutOffsets = getAppShellOffsets();
|
||||
const viewportAvailable = viewportWidth && viewportWidth > 0 ? Math.max(viewportWidth - layoutOffsets, 0) : undefined;
|
||||
const effectiveAvailable = appShellContentWidth > 0 ? appShellContentWidth : viewportAvailable;
|
||||
|
||||
if (!containerWidth && effectiveAvailable) {
|
||||
return effectiveAvailable;
|
||||
}
|
||||
|
||||
if (!effectiveAvailable) {
|
||||
return containerWidth;
|
||||
}
|
||||
|
||||
return Math.min(containerWidth, effectiveAvailable);
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { waitFor, within } from '@testing-library/dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import { AppAlertBanner } from '@/components/AppAlertBanner';
|
||||
import { AppDrawerContainer } from '@/components/AppDrawer.component';
|
||||
import { initialConfigState } from '@/features/config/slice';
|
||||
import { initialResourcesUiState } from '@/features/resources/slice';
|
||||
import { initialRolloutsUiState } from '@/features/rollouts/slice';
|
||||
import { AppLayout } from '@/layouts/AppLayout';
|
||||
import { createAppStore } from '@/store';
|
||||
import type { Resources } from '@/types';
|
||||
import { createResourcesHandlers } from '@/utils/mock';
|
||||
@@ -148,8 +153,8 @@ const sampleResources: Resources[] = [
|
||||
|
||||
const defaultHandlers = createResourcesHandlers(sampleResources);
|
||||
|
||||
function renderWithStore(configOverrides?: Partial<typeof initialConfigState>) {
|
||||
const store = createAppStore({
|
||||
function createStoryStore(configOverrides?: Partial<typeof initialConfigState>) {
|
||||
return createAppStore({
|
||||
config: {
|
||||
...initialConfigState,
|
||||
baseUrl: STORY_BASE_URL,
|
||||
@@ -159,11 +164,53 @@ function renderWithStore(configOverrides?: Partial<typeof initialConfigState>) {
|
||||
rollouts: initialRolloutsUiState,
|
||||
resources: initialResourcesUiState,
|
||||
});
|
||||
}
|
||||
|
||||
function renderWithStore(configOverrides?: Partial<typeof initialConfigState>) {
|
||||
const store = createStoryStore(configOverrides);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ResourcesPage />
|
||||
<AppAlertBanner />
|
||||
<>
|
||||
<ResourcesPage />
|
||||
<AppAlertBanner />
|
||||
<AppDrawerContainer />
|
||||
</>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithAppLayout(configOverrides?: Partial<typeof initialConfigState>) {
|
||||
const store = createStoryStore(configOverrides);
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<AppLayout
|
||||
config={{
|
||||
baseUrl: store.getState().config.baseUrl,
|
||||
autoRefreshMs: store.getState().config.autoRefreshMs,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: '/resources',
|
||||
element: <ResourcesPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{ initialEntries: ['/resources'] },
|
||||
);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
<AppDrawerContainer />
|
||||
</>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
@@ -177,6 +224,41 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSidebarLayout: Story = {
|
||||
name: 'Within AppLayout',
|
||||
render: () => renderWithAppLayout(),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: defaultHandlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Search: Story = {
|
||||
render: () => renderWithStore(),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: defaultHandlers,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText('rs-a1b2c3d4e5f6');
|
||||
|
||||
const searchInput = canvas.getByPlaceholderText('Search by Resources ID');
|
||||
await userEvent.type(searchInput, 'rs-abcdef123456');
|
||||
|
||||
await waitFor(() => {
|
||||
if (canvas.queryByText('rs-a1b2c3d4e5f6')) {
|
||||
throw new Error('Expected search to filter out non-matching resources');
|
||||
}
|
||||
if (!canvas.queryByText('rs-abcdef123456')) {
|
||||
throw new Error('Expected matching resource to remain visible');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyState: Story = {
|
||||
render: () => renderWithStore(),
|
||||
parameters: {
|
||||
|
||||
@@ -5,8 +5,10 @@ import { waitFor, within } from '@testing-library/dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import { AppAlertBanner } from '@/components/AppAlertBanner';
|
||||
import { AppDrawerContainer } from '@/components/AppDrawer.component';
|
||||
import { AppLayout } from '@/layouts/AppLayout';
|
||||
import { createMockHandlers } from '@/utils/mock';
|
||||
import { STORY_BASE_URL, STORY_DATE_NOW_SECONDS } from '../../.storybook/constants';
|
||||
import { allModes } from '../../.storybook/modes';
|
||||
@@ -295,6 +297,105 @@ const sampleSpansByAttempt: Record<string, Span[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
const overflowDrawerSpans: Span[] = Array.from({ length: 160 }, (_, index) => ({
|
||||
rolloutId: 'ro-7fa3b6e2',
|
||||
attemptId: 'at-9001',
|
||||
sequenceId: index + 1,
|
||||
traceId: `tr-overflow-${Math.floor(index / 5)}`,
|
||||
spanId: `sp-overflow-${index + 1}`,
|
||||
parentId: index === 0 ? null : `sp-overflow-${index}`,
|
||||
name: `Overflow span ${index + 1}`,
|
||||
status: { status_code: 'OK', description: null },
|
||||
attributes: { step: `overflow-${index + 1}`, duration_ms: 20 + (index % 5) },
|
||||
startTime: now - 1_200 - index * 20,
|
||||
endTime: now - 1_180 - index * 20,
|
||||
events: [],
|
||||
links: [],
|
||||
context: {},
|
||||
parent: null,
|
||||
resource: {},
|
||||
}));
|
||||
|
||||
const overflowSpansByAttempt: Record<string, Span[]> = {
|
||||
...sampleSpansByAttempt,
|
||||
'ro-7fa3b6e2:at-9001': overflowDrawerSpans,
|
||||
};
|
||||
|
||||
const longJsonLogs = Array.from({ length: 200 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
detail: `Log entry ${index + 1} ${'x'.repeat(32)}`,
|
||||
timestamp: now - index * 2,
|
||||
}));
|
||||
|
||||
const jsonOverflowAttempt: Attempt = {
|
||||
rolloutId: 'ro-json-overflow',
|
||||
attemptId: 'at-json-overflow',
|
||||
sequenceId: 1,
|
||||
status: 'succeeded',
|
||||
startTime: now - 600,
|
||||
endTime: now - 300,
|
||||
workerId: 'worker-scroll',
|
||||
lastHeartbeatTime: now - 300,
|
||||
metadata: { notes: 'Completed with a very large JSON payload' },
|
||||
};
|
||||
|
||||
const jsonOverflowAttemptEndTime = jsonOverflowAttempt.endTime ?? jsonOverflowAttempt.startTime + 1;
|
||||
|
||||
const jsonOverflowRollout: Rollout = {
|
||||
rolloutId: 'ro-json-overflow',
|
||||
input: {
|
||||
task: 'Render large JSON',
|
||||
payload: longJsonLogs,
|
||||
summary: 'This rollout includes many log lines to test scroll behavior.',
|
||||
},
|
||||
status: 'succeeded',
|
||||
mode: 'train',
|
||||
resourcesId: 'rs-json-overflow',
|
||||
startTime: jsonOverflowAttempt.startTime,
|
||||
endTime: jsonOverflowAttemptEndTime,
|
||||
attempt: jsonOverflowAttempt,
|
||||
config: {
|
||||
retries: 0,
|
||||
parameters: { max_steps: 200, batch: 5 },
|
||||
},
|
||||
metadata: {
|
||||
owner: 'scroll-tester',
|
||||
description: 'Synthetic rollout with oversized JSON payload for storybook validation.',
|
||||
tags: Array.from({ length: 40 }, (_, index) => `tag-${index + 1}`),
|
||||
},
|
||||
};
|
||||
|
||||
const jsonOverflowSpans: Span[] = [
|
||||
{
|
||||
rolloutId: jsonOverflowRollout.rolloutId,
|
||||
attemptId: jsonOverflowAttempt.attemptId,
|
||||
sequenceId: 1,
|
||||
traceId: 'tr-json-overflow',
|
||||
spanId: 'sp-json-root',
|
||||
parentId: null,
|
||||
name: 'json-overflow-root',
|
||||
status: { status_code: 'OK', description: null },
|
||||
attributes: { detail: 'root span' },
|
||||
startTime: jsonOverflowAttempt.startTime,
|
||||
endTime: jsonOverflowAttemptEndTime,
|
||||
events: [],
|
||||
links: [],
|
||||
context: {},
|
||||
parent: null,
|
||||
resource: {},
|
||||
},
|
||||
];
|
||||
|
||||
const jsonOverflowRollouts = [jsonOverflowRollout, ...sampleRollouts];
|
||||
const jsonOverflowAttemptsByRollout: Record<string, Attempt[]> = {
|
||||
...attemptsByRollout,
|
||||
[jsonOverflowRollout.rolloutId]: [jsonOverflowAttempt],
|
||||
};
|
||||
const jsonOverflowSpansByAttempt: Record<string, Span[]> = {
|
||||
...sampleSpansByAttempt,
|
||||
[`${jsonOverflowRollout.rolloutId}:${jsonOverflowAttempt.attemptId}`]: jsonOverflowSpans,
|
||||
};
|
||||
|
||||
const longDurationRollouts: Rollout[] = [
|
||||
{
|
||||
rolloutId: 'ro-long-duration',
|
||||
@@ -555,8 +656,12 @@ const autoExpandAttempts: Record<string, Attempt[]> = {
|
||||
},
|
||||
],
|
||||
};
|
||||
function renderWithStore(uiOverrides?: Partial<RolloutsUiState>, configOverrides?: Partial<typeof initialConfigState>) {
|
||||
const store = createAppStore({
|
||||
|
||||
function createStoryStore(
|
||||
uiOverrides?: Partial<RolloutsUiState>,
|
||||
configOverrides?: Partial<typeof initialConfigState>,
|
||||
) {
|
||||
return createAppStore({
|
||||
config: {
|
||||
...initialConfigState,
|
||||
baseUrl: STORY_BASE_URL,
|
||||
@@ -569,6 +674,10 @@ function renderWithStore(uiOverrides?: Partial<RolloutsUiState>, configOverrides
|
||||
},
|
||||
resources: initialResourcesUiState,
|
||||
});
|
||||
}
|
||||
|
||||
function renderWithStore(uiOverrides?: Partial<RolloutsUiState>, configOverrides?: Partial<typeof initialConfigState>) {
|
||||
const store = createStoryStore(uiOverrides, configOverrides);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
@@ -581,7 +690,51 @@ function renderWithStore(uiOverrides?: Partial<RolloutsUiState>, configOverrides
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithAppLayout(
|
||||
uiOverrides?: Partial<RolloutsUiState>,
|
||||
configOverrides?: Partial<typeof initialConfigState>,
|
||||
) {
|
||||
const store = createStoryStore(uiOverrides, configOverrides);
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<AppLayout
|
||||
config={{
|
||||
baseUrl: store.getState().config.baseUrl,
|
||||
autoRefreshMs: store.getState().config.autoRefreshMs,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: '/rollouts',
|
||||
element: <RolloutsPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{ initialEntries: ['/rollouts'] },
|
||||
);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
<AppDrawerContainer />
|
||||
</>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultHandlers = createMockHandlers(sampleRollouts, attemptsByRollout, sampleSpansByAttempt);
|
||||
const overflowHandlers = createMockHandlers(sampleRollouts, attemptsByRollout, overflowSpansByAttempt);
|
||||
const jsonOverflowHandlers = createMockHandlers(
|
||||
jsonOverflowRollouts,
|
||||
jsonOverflowAttemptsByRollout,
|
||||
jsonOverflowSpansByAttempt,
|
||||
);
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => renderWithStore(),
|
||||
@@ -592,6 +745,40 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSidebarLayout: Story = {
|
||||
name: 'Within AppLayout',
|
||||
render: () => renderWithAppLayout(),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: defaultHandlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSidebarStatusFilter: Story = {
|
||||
name: 'Within AppLayout (Status Filter)',
|
||||
render: () => renderWithAppLayout({ statusFilters: ['running'] }),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: defaultHandlers,
|
||||
},
|
||||
},
|
||||
play: async () => {
|
||||
await waitFor(() => {
|
||||
const container = document.querySelector<HTMLElement>('[data-testid="rollouts-table-container"]');
|
||||
const main = document.querySelector<HTMLElement>('.mantine-AppShell-main');
|
||||
if (!container || !main) {
|
||||
throw new Error('Unable to locate rollout table container or AppShell main region');
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const mainRect = main.getBoundingClientRect();
|
||||
if (containerRect.right > mainRect.right + 1) {
|
||||
throw new Error('Rollouts table extends beyond the AppShell content area');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const DarkTheme: Story = {
|
||||
render: () => renderWithStore(undefined, { theme: 'dark' }),
|
||||
parameters: {
|
||||
@@ -721,7 +908,7 @@ export const AutoExpandedAttempt: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const RawJsonDrawer: Story = {
|
||||
export const Search: Story = {
|
||||
render: () => renderWithStore(),
|
||||
parameters: {
|
||||
msw: {
|
||||
@@ -731,21 +918,99 @@ export const RawJsonDrawer: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText('ro-7fa3b6e2');
|
||||
const rolloutCell = canvas.getByText('ro-7fa3b6e2');
|
||||
const rolloutRow = rolloutCell.closest('tr');
|
||||
|
||||
if (!rolloutRow) {
|
||||
throw new Error('Unable to locate rollout row for raw JSON drawer');
|
||||
const searchInput = canvas.getByPlaceholderText('Search by Rollout ID');
|
||||
await userEvent.type(searchInput, 'ro-116eab45');
|
||||
|
||||
await waitFor(() => {
|
||||
if (canvas.queryByText('ro-7fa3b6e2')) {
|
||||
throw new Error('Expected search to filter out non-matching rollouts');
|
||||
}
|
||||
if (!canvas.queryByText('ro-116eab45')) {
|
||||
throw new Error('Expected search to keep the matching rollout visible');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async function openSampleTracesDrawer(canvasElement: HTMLElement) {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText('ro-7fa3b6e2');
|
||||
const rolloutCell = canvas.getByText('ro-7fa3b6e2');
|
||||
const rolloutRow = rolloutCell.closest('tr');
|
||||
|
||||
if (!rolloutRow) {
|
||||
throw new Error('Unable to locate rollout row for traces drawer');
|
||||
}
|
||||
|
||||
const rowScope = within(rolloutRow);
|
||||
const traceButtons = rowScope.getAllByRole('button', { name: 'View traces' });
|
||||
const tracesButton = traceButtons[0];
|
||||
await userEvent.click(tracesButton);
|
||||
|
||||
return within(document.body).findByRole('dialog');
|
||||
}
|
||||
|
||||
async function openRawJsonDrawer(canvasElement: HTMLElement, rolloutId = 'ro-7fa3b6e2') {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText(rolloutId);
|
||||
const rolloutCell = canvas.getByText(rolloutId);
|
||||
const rolloutRow = rolloutCell.closest('tr');
|
||||
|
||||
if (!rolloutRow) {
|
||||
throw new Error(`Unable to locate rollout row for ${rolloutId}`);
|
||||
}
|
||||
|
||||
const rowScope = within(rolloutRow);
|
||||
const rawButtons = rowScope.getAllByRole('button', { name: 'View raw JSON' });
|
||||
const rawButton = rawButtons[0];
|
||||
await userEvent.click(rawButton);
|
||||
|
||||
return within(document.body).findByRole('dialog');
|
||||
}
|
||||
|
||||
export const RawJsonDrawer: Story = {
|
||||
render: () => renderWithStore(),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: defaultHandlers,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const drawer = await openRawJsonDrawer(canvasElement);
|
||||
await waitFor(
|
||||
async () => {
|
||||
await within(drawer).findByText('Attempt');
|
||||
await within(drawer).findByText(/worker-alpha/);
|
||||
},
|
||||
{ timeout: 3_000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const RawJsonDrawerScrollable: Story = {
|
||||
name: 'Raw JSON Drawer Scrollable',
|
||||
render: () => renderWithStore(),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: jsonOverflowHandlers,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const drawer = await openRawJsonDrawer(canvasElement, 'ro-json-overflow');
|
||||
const editorContainer = drawer.querySelector('[data-testid="json-editor-container"]') as HTMLElement | null;
|
||||
if (!editorContainer) {
|
||||
throw new Error('Unable to locate JSON editor container');
|
||||
}
|
||||
|
||||
const rowScope = within(rolloutRow);
|
||||
const rawButtons = rowScope.getAllByRole('button', { name: 'View raw JSON' });
|
||||
const rawButton = rawButtons[0];
|
||||
await userEvent.click(rawButton);
|
||||
|
||||
const drawer = await within(document.body).findByRole('dialog');
|
||||
await within(drawer).findByText('Attempt');
|
||||
await within(drawer).findByText(/worker-alpha/);
|
||||
await waitFor(() => {
|
||||
const scrollable = editorContainer.querySelector('.monaco-scrollable-element') as HTMLElement | null;
|
||||
if (!scrollable) {
|
||||
throw new Error('Monaco editor not ready yet');
|
||||
}
|
||||
if (scrollable.scrollHeight <= scrollable.clientHeight) {
|
||||
throw new Error('Expected JSON content to overflow and allow scrolling');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -757,20 +1022,56 @@ export const TracesDrawer: Story = {
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText('ro-7fa3b6e2');
|
||||
const rolloutCell = canvas.getByText('ro-7fa3b6e2');
|
||||
const rolloutRow = rolloutCell.closest('tr');
|
||||
|
||||
if (!rolloutRow) {
|
||||
throw new Error('Unable to locate rollout row for traces drawer');
|
||||
}
|
||||
|
||||
const rowScope = within(rolloutRow);
|
||||
const traceButtons = rowScope.getAllByRole('button', { name: 'View traces' });
|
||||
const tracesButton = traceButtons[0];
|
||||
await userEvent.click(tracesButton);
|
||||
|
||||
await within(document.body).findByRole('dialog');
|
||||
await openSampleTracesDrawer(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
export const TracesDrawerLink: Story = {
|
||||
name: 'Traces Drawer Link',
|
||||
render: () => renderWithStore(),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: defaultHandlers,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const drawer = await openSampleTracesDrawer(canvasElement);
|
||||
const link = await within(drawer).findByText('View full traces');
|
||||
const href = link.getAttribute('href');
|
||||
if (!href) {
|
||||
throw new Error('Expected traces drawer to render a link to the traces page');
|
||||
}
|
||||
if (!href.includes('rolloutId=ro-7fa3b6e2')) {
|
||||
throw new Error(`Link href ${href} is missing rolloutId query parameter`);
|
||||
}
|
||||
if (!href.includes('attemptId=at-9001')) {
|
||||
throw new Error(`Link href ${href} is missing attemptId query parameter`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const TracesDrawerScrollableTable: Story = {
|
||||
name: 'Traces Drawer Scrollable Table',
|
||||
render: () => renderWithStore(),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: overflowHandlers,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const drawer = await openSampleTracesDrawer(canvasElement);
|
||||
const container = drawer.querySelector('[data-testid="traces-drawer-table-container"]') as HTMLElement | null;
|
||||
if (!container) {
|
||||
throw new Error('Unable to locate traces table container inside drawer');
|
||||
}
|
||||
const overflowStyle = window.getComputedStyle(container).overflowY;
|
||||
if (overflowStyle !== 'auto' && overflowStyle !== 'scroll') {
|
||||
throw new Error('Expected traces table container to allow vertical scrolling');
|
||||
}
|
||||
await waitFor(() => {
|
||||
if (container.scrollHeight <= container.clientHeight) {
|
||||
throw new Error('Expected traces table content to overflow and enable scrolling');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,10 +8,11 @@ import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
|
||||
const AUTO_REFRESH_OPTIONS = [
|
||||
{ label: 'Off', value: '0' },
|
||||
{ label: 'Every 5 seconds', value: '5000' },
|
||||
{ label: 'Every 15 seconds', value: '15000' },
|
||||
{ label: 'Every 60 seconds', value: '60000' },
|
||||
{ label: 'Every 5 minutes', value: '300000' },
|
||||
// TODO: Support real auto-refresh
|
||||
{ label: 'Every 5 seconds', value: '5000', disabled: true },
|
||||
{ label: 'Every 15 seconds', value: '15000', disabled: true },
|
||||
{ label: 'Every 60 seconds', value: '60000', disabled: true },
|
||||
{ label: 'Every 5 minutes', value: '300000', disabled: true },
|
||||
];
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { waitFor, within } from '@testing-library/dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createMemoryRouter, MemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import { AppAlertBanner } from '@/components/AppAlertBanner';
|
||||
import { AppDrawerContainer } from '@/components/AppDrawer.component';
|
||||
import { AppLayout } from '@/layouts/AppLayout';
|
||||
import { createMockHandlers } from '@/utils/mock';
|
||||
import { STORY_BASE_URL, STORY_DATE_NOW_SECONDS } from '../../.storybook/constants';
|
||||
import { allModes } from '../../.storybook/modes';
|
||||
@@ -231,73 +235,105 @@ const singleSpansByAttempt = Object.fromEntries(
|
||||
Object.entries(spansByAttempt).filter(([key]) => key.startsWith(`${singleRollout.rolloutId}:`)),
|
||||
) as Record<string, Span[]>;
|
||||
|
||||
const rolloutWithoutAttempt: Rollout = {
|
||||
rolloutId: 'ro-traces-no-attempt',
|
||||
input: { task: 'Legacy rollout without attempts' },
|
||||
status: 'failed',
|
||||
mode: 'train',
|
||||
resourcesId: 'rs-traces-no-attempt',
|
||||
startTime: now - 7200,
|
||||
endTime: now - 7000,
|
||||
attempt: null,
|
||||
config: { retries: 0 },
|
||||
metadata: { owner: 'casey' },
|
||||
};
|
||||
const noAttemptRollouts: Rollout[] = [rolloutWithoutAttempt];
|
||||
const noAttemptAttemptsByRollout: Record<string, Attempt[]> = {
|
||||
[rolloutWithoutAttempt.rolloutId]: [],
|
||||
};
|
||||
const noAttemptSpansByAttempt: Record<string, Span[]> = {};
|
||||
|
||||
const owners = ['ava', 'ben', 'carla', 'diego'] as const;
|
||||
|
||||
const manyAttemptsByRollout: Record<string, Attempt[]> = {};
|
||||
const manySpansByAttempt: Record<string, Span[]> = {};
|
||||
|
||||
const manyRollouts: Rollout[] = Array.from({ length: 24 }, (_, index) => {
|
||||
const rolloutId = `ro-many-${String(index + 1).padStart(3, '0')}`;
|
||||
const statusOptions = ['running', 'succeeded', 'failed'] as const;
|
||||
const modeOptions = ['train', 'val', 'test'] as const;
|
||||
const status = statusOptions[index % statusOptions.length];
|
||||
const mode = modeOptions[index % modeOptions.length];
|
||||
const startTime = now - (index + 1) * 420;
|
||||
const endTime = status === 'running' ? null : startTime + 240;
|
||||
const attemptId = `${rolloutId}-attempt`;
|
||||
const attemptStatus: Attempt['status'] =
|
||||
status === 'failed' ? 'failed' : status === 'succeeded' ? 'succeeded' : 'running';
|
||||
const attempt: Attempt = {
|
||||
rolloutId,
|
||||
attemptId,
|
||||
sequenceId: 1,
|
||||
status: attemptStatus,
|
||||
startTime,
|
||||
endTime,
|
||||
workerId: `worker-${String.fromCharCode(97 + (index % 26))}`,
|
||||
lastHeartbeatTime: endTime ?? startTime + 180,
|
||||
metadata: { region: index % 2 === 0 ? 'us-east-1' : 'eu-west-1' },
|
||||
};
|
||||
manyAttemptsByRollout[rolloutId] = [attempt];
|
||||
manySpansByAttempt[`${rolloutId}:${attemptId}`] = [
|
||||
{
|
||||
function createSyntheticRollouts(prefix: string, count: number) {
|
||||
const attemptsByRollout: Record<string, Attempt[]> = {};
|
||||
const spansByAttemptByRollout: Record<string, Span[]> = {};
|
||||
const rollouts: Rollout[] = Array.from({ length: count }, (_, index) => {
|
||||
const rolloutId = `ro-${prefix}-${String(index + 1).padStart(3, '0')}`;
|
||||
const statusOptions = ['running', 'succeeded', 'failed'] as const;
|
||||
const modeOptions = ['train', 'val', 'test'] as const;
|
||||
const status = statusOptions[index % statusOptions.length];
|
||||
const mode = modeOptions[index % modeOptions.length];
|
||||
const startTime = now - (index + 1) * 420;
|
||||
const endTime = status === 'running' ? null : startTime + 240;
|
||||
const attemptId = `${rolloutId}-attempt`;
|
||||
const attemptStatus: Attempt['status'] =
|
||||
status === 'failed' ? 'failed' : status === 'succeeded' ? 'succeeded' : 'running';
|
||||
const attempt: Attempt = {
|
||||
rolloutId,
|
||||
attemptId,
|
||||
sequenceId: 1,
|
||||
traceId: `tr-many-${index + 1}`,
|
||||
spanId: `sp-many-${index + 1}-root`,
|
||||
parentId: null,
|
||||
name: 'Synthetic root span',
|
||||
status: {
|
||||
status_code: status === 'failed' ? 'ERROR' : 'OK',
|
||||
description: status === 'failed' ? 'Synthetic failure' : null,
|
||||
},
|
||||
attributes: {
|
||||
'trace.sample': index + 1,
|
||||
'duration_ms': 240,
|
||||
},
|
||||
status: attemptStatus,
|
||||
startTime,
|
||||
endTime: endTime ?? startTime + 240,
|
||||
events: [],
|
||||
links: [],
|
||||
context: {},
|
||||
parent: null,
|
||||
resource: {},
|
||||
},
|
||||
];
|
||||
return {
|
||||
rolloutId,
|
||||
input: { task: `Synthetic trace ${index + 1}` },
|
||||
status,
|
||||
mode,
|
||||
resourcesId: `rs-many-${(index % 7) + 1}`,
|
||||
startTime,
|
||||
endTime,
|
||||
attempt,
|
||||
config: { retries: index % 3 },
|
||||
metadata: { owner: owners[index % owners.length] },
|
||||
};
|
||||
});
|
||||
endTime,
|
||||
workerId: `worker-${String.fromCharCode(97 + (index % 26))}`,
|
||||
lastHeartbeatTime: endTime ?? startTime + 180,
|
||||
metadata: { region: index % 2 === 0 ? 'us-east-1' : 'eu-west-1' },
|
||||
};
|
||||
attemptsByRollout[rolloutId] = [attempt];
|
||||
spansByAttemptByRollout[`${rolloutId}:${attemptId}`] = [
|
||||
{
|
||||
rolloutId,
|
||||
attemptId,
|
||||
sequenceId: 1,
|
||||
traceId: `tr-${prefix}-${index + 1}`,
|
||||
spanId: `sp-${prefix}-${index + 1}-root`,
|
||||
parentId: null,
|
||||
name: `Synthetic root span ${prefix} ${index + 1}`,
|
||||
status: {
|
||||
status_code: status === 'failed' ? 'ERROR' : 'OK',
|
||||
description: status === 'failed' ? 'Synthetic failure' : null,
|
||||
},
|
||||
attributes: {
|
||||
'trace.sample': index + 1,
|
||||
'duration_ms': 240,
|
||||
},
|
||||
startTime,
|
||||
endTime: endTime ?? startTime + 240,
|
||||
events: [],
|
||||
links: [],
|
||||
context: {},
|
||||
parent: null,
|
||||
resource: {},
|
||||
},
|
||||
];
|
||||
return {
|
||||
rolloutId,
|
||||
input: { task: `Synthetic trace ${index + 1}` },
|
||||
status,
|
||||
mode,
|
||||
resourcesId: `rs-${prefix}-${(index % 7) + 1}`,
|
||||
startTime,
|
||||
endTime,
|
||||
attempt,
|
||||
config: { retries: index % 3 },
|
||||
metadata: { owner: owners[index % owners.length] },
|
||||
};
|
||||
});
|
||||
return { rollouts, attemptsByRollout, spansByAttempt: spansByAttemptByRollout };
|
||||
}
|
||||
|
||||
const {
|
||||
rollouts: manyRollouts,
|
||||
attemptsByRollout: manyAttemptsByRollout,
|
||||
spansByAttempt: manySpansByAttempt,
|
||||
} = createSyntheticRollouts('many', 24);
|
||||
|
||||
const {
|
||||
rollouts: vastRollouts,
|
||||
attemptsByRollout: vastAttemptsByRollout,
|
||||
spansByAttempt: vastSpansByAttempt,
|
||||
} = createSyntheticRollouts('vast', 160);
|
||||
|
||||
function createHandlers(delayMs?: number) {
|
||||
return createMockHandlers(sampleRollouts, attemptsByRollout, spansByAttempt, delayMs);
|
||||
@@ -325,11 +361,11 @@ function createRequestTimeoutHandlers() {
|
||||
|
||||
const rolloutsAndAttemptsHandlers = createMockHandlers(sampleRollouts, attemptsByRollout);
|
||||
|
||||
function renderTracesPage(
|
||||
function createStoryStore(
|
||||
preloadedTracesState?: Partial<TracesUiState>,
|
||||
configOverrides?: Partial<typeof initialConfigState>,
|
||||
) {
|
||||
const store = createAppStore({
|
||||
return createAppStore({
|
||||
config: {
|
||||
...initialConfigState,
|
||||
baseUrl: STORY_BASE_URL,
|
||||
@@ -339,12 +375,60 @@ function renderTracesPage(
|
||||
resources: initialResourcesUiState,
|
||||
traces: { ...initialTracesUiState, ...preloadedTracesState },
|
||||
});
|
||||
}
|
||||
|
||||
function renderTracesPage(
|
||||
preloadedTracesState?: Partial<TracesUiState>,
|
||||
configOverrides?: Partial<typeof initialConfigState>,
|
||||
) {
|
||||
const store = createStoryStore(preloadedTracesState, configOverrides);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<TracesPage />
|
||||
<AppAlertBanner />
|
||||
<AppDrawerContainer />
|
||||
<MemoryRouter initialEntries={['/traces']}>
|
||||
<TracesPage />
|
||||
<AppAlertBanner />
|
||||
<AppDrawerContainer />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTracesPageWithAppLayout(
|
||||
preloadedTracesState?: Partial<TracesUiState>,
|
||||
configOverrides?: Partial<typeof initialConfigState>,
|
||||
initialEntry: string = '/traces',
|
||||
) {
|
||||
const store = createStoryStore(preloadedTracesState, configOverrides);
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<AppLayout
|
||||
config={{
|
||||
baseUrl: store.getState().config.baseUrl,
|
||||
autoRefreshMs: store.getState().config.autoRefreshMs,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: '/traces',
|
||||
element: <TracesPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{ initialEntries: [initialEntry] },
|
||||
);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
<AppDrawerContainer />
|
||||
</>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
@@ -358,6 +442,66 @@ export const DefaultView: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSidebarLayout: Story = {
|
||||
name: 'Within AppLayout',
|
||||
render: () => renderTracesPageWithAppLayout(),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: createHandlers(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const QueryParams: Story = {
|
||||
name: 'Loads From Query Params',
|
||||
render: () =>
|
||||
renderTracesPageWithAppLayout(undefined, undefined, '/traces?rolloutId=ro-traces-002&attemptId=at-traces-004'),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: createHandlers(),
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const rolloutInput = (await canvas.findByLabelText('Select rollout')) as HTMLInputElement;
|
||||
await waitFor(() => {
|
||||
if (rolloutInput.value !== 'ro-traces-002') {
|
||||
throw new Error('Expected rollout select to use value from query string');
|
||||
}
|
||||
});
|
||||
const attemptInput = (await canvas.findByLabelText('Select attempt')) as HTMLInputElement;
|
||||
await waitFor(() => {
|
||||
if (attemptInput.value.indexOf('at-traces-004') === -1) {
|
||||
throw new Error('Expected attempt select to use value from query string');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const MissingRolloutQuery: Story = {
|
||||
name: 'Missing Rollout From Query Params',
|
||||
render: () => renderTracesPageWithAppLayout(undefined, undefined, '/traces?rolloutId=ro-missing-999'),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: createMockHandlers(manyRollouts, manyAttemptsByRollout, manySpansByAttempt),
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const rolloutInput = (await canvas.findByLabelText('Select rollout')) as HTMLInputElement;
|
||||
await waitFor(() => {
|
||||
if (rolloutInput.value !== '') {
|
||||
throw new Error('Expected rollout select to remain empty when the query rollout does not exist');
|
||||
}
|
||||
});
|
||||
await waitFor(() => {
|
||||
if (!canvas.getByText('Select a rollout and attempt to view traces.')) {
|
||||
throw new Error('Expected empty selection message when rollout query param is invalid');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const DarkTheme: Story = {
|
||||
render: () => renderTracesPage(undefined, { theme: 'dark' }),
|
||||
parameters: {
|
||||
@@ -386,6 +530,24 @@ export const SingleResult: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const NoAttemptPlaceholder: Story = {
|
||||
render: () => renderTracesPage(),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: createMockHandlers(noAttemptRollouts, noAttemptAttemptsByRollout, noAttemptSpansByAttempt),
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const attemptInput = (await canvas.findByLabelText('Select attempt')) as HTMLInputElement;
|
||||
await waitFor(() => {
|
||||
if (attemptInput.placeholder !== 'No Attempt') {
|
||||
throw new Error('Expected attempt select placeholder to read "No Attempt" when no attempts are available');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyResults: Story = {
|
||||
render: () => renderTracesPage(),
|
||||
parameters: {
|
||||
@@ -395,6 +557,67 @@ export const ManyResults: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeDatasetSearch: Story = {
|
||||
render: () => renderTracesPage(),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: createMockHandlers(vastRollouts, vastAttemptsByRollout, vastSpansByAttempt),
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const rolloutTrigger = (await canvas.findByLabelText('Select rollout')) as HTMLInputElement;
|
||||
await userEvent.click(rolloutTrigger);
|
||||
for (let i = 0; i < 'ro-vast-001'.length + 1; i++) {
|
||||
await userEvent.type(rolloutTrigger, '{backspace}');
|
||||
}
|
||||
await userEvent.type(rolloutTrigger, 'ro-vast-150');
|
||||
|
||||
await waitFor(() => {
|
||||
const option = within(document.body).queryByText('ro-vast-150');
|
||||
if (!option) {
|
||||
throw new Error('Expected remote rollout search to return IDs beyond the initial list');
|
||||
}
|
||||
});
|
||||
|
||||
const option = within(document.body).getByText('ro-vast-150');
|
||||
await userEvent.click(option);
|
||||
|
||||
await waitFor(() => {
|
||||
if (rolloutTrigger.value !== 'ro-vast-150') {
|
||||
throw new Error('Expected rollout select to use the searched rollout ID');
|
||||
}
|
||||
});
|
||||
|
||||
await canvas.findByText('Synthetic root span vast 150');
|
||||
},
|
||||
};
|
||||
|
||||
export const Search: Story = {
|
||||
render: () => renderTracesPage(),
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: createHandlers(),
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByLabelText('Search spans');
|
||||
|
||||
const searchInput = canvas.getByLabelText('Search spans');
|
||||
await userEvent.type(searchInput, 'Fetch');
|
||||
|
||||
await waitFor(() => {
|
||||
if (!canvas.queryByText('Fetch resources')) {
|
||||
throw new Error('Expected matching span to be displayed after searching');
|
||||
}
|
||||
if (canvas.queryByText('Initialize rollout')) {
|
||||
throw new Error('Expected non-matching spans to be filtered out');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingState: Story = {
|
||||
render: () => renderTracesPage(),
|
||||
parameters: {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { IconCheck, IconChevronDown, IconSearch } from '@tabler/icons-react';
|
||||
import type { DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Button, Group, Menu, Select, Skeleton, Stack, TextInput, Title } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { TracesTable, type TracesTableRecord } from '@/components/TracesTable.component';
|
||||
import { selectAutoRefreshMs } from '@/features/config';
|
||||
import {
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
type GetRolloutsQueryArgs,
|
||||
} from '@/features/rollouts';
|
||||
import {
|
||||
hydrateTracesStateFromQuery,
|
||||
resetTracesFilters,
|
||||
selectTracesAttemptId,
|
||||
selectTracesPage,
|
||||
@@ -53,16 +56,38 @@ function getLatestAttempt(attempts: Attempt[]): Attempt | null {
|
||||
return [...attempts].sort((a, b) => a.sequenceId - b.sequenceId).at(-1) ?? null;
|
||||
}
|
||||
|
||||
function findRollout(rollouts: Rollout[] | undefined, rolloutId: string | null): Rollout | null {
|
||||
if (!rollouts || !rolloutId) {
|
||||
return null;
|
||||
function mergeRolloutCache(cache: Record<string, Rollout>, items: Rollout[]): Record<string, Rollout> {
|
||||
if (!items.length) {
|
||||
return cache;
|
||||
}
|
||||
return rollouts.find((rollout) => rollout.rolloutId === rolloutId) ?? null;
|
||||
let changed = false;
|
||||
const next = { ...cache };
|
||||
for (const item of items) {
|
||||
const existing = next[item.rolloutId];
|
||||
if (!existing || existing !== item) {
|
||||
next[item.rolloutId] = item;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : cache;
|
||||
}
|
||||
|
||||
export function TracesPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const autoRefreshMs = useAppSelector(selectAutoRefreshMs);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const searchParamsKey = searchParams.toString();
|
||||
const [hydratedSearchParamsKey, setHydratedSearchParamsKey] = useState<string | null>(null);
|
||||
const [rolloutSearchValue, setRolloutSearchValue] = useState('');
|
||||
const [debouncedRolloutSearchValue] = useDebouncedValue(rolloutSearchValue, 300);
|
||||
const normalizedRolloutSearchValue = debouncedRolloutSearchValue.trim();
|
||||
const rolloutSearchActive = normalizedRolloutSearchValue.length > 0;
|
||||
const [rolloutLookup, setRolloutLookup] = useState<Record<string, Rollout>>({});
|
||||
const hasRolloutQueryParam = searchParams.has('rolloutId');
|
||||
const hasAttemptQueryParam = searchParams.has('attemptId');
|
||||
const [initialHasRolloutQueryParam] = useState(hasRolloutQueryParam);
|
||||
const rolloutIdFromQuery = hasRolloutQueryParam ? searchParams.get('rolloutId') || null : undefined;
|
||||
const attemptIdFromQuery = hasAttemptQueryParam ? searchParams.get('attemptId') || null : undefined;
|
||||
const rolloutId = useAppSelector(selectTracesRolloutId);
|
||||
const attemptId = useAppSelector(selectTracesAttemptId);
|
||||
const searchTerm = useAppSelector(selectTracesSearchTerm);
|
||||
@@ -72,7 +97,7 @@ export function TracesPage() {
|
||||
const viewMode = useAppSelector(selectTracesViewMode);
|
||||
const spansQueryArgs = useAppSelector(selectTracesQueryArgs);
|
||||
|
||||
const rolloutsQueryArgs = useMemo<GetRolloutsQueryArgs>(
|
||||
const baseRolloutsQueryArgs = useMemo<GetRolloutsQueryArgs>(
|
||||
() => ({
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
@@ -88,13 +113,68 @@ export function TracesPage() {
|
||||
isFetching: rolloutsFetching,
|
||||
isError: rolloutsIsError,
|
||||
error: rolloutsError,
|
||||
} = useGetRolloutsQuery(rolloutsQueryArgs, {
|
||||
} = useGetRolloutsQuery(baseRolloutsQueryArgs, {
|
||||
pollingInterval: autoRefreshMs > 0 ? autoRefreshMs : undefined,
|
||||
});
|
||||
|
||||
const rolloutItems = rolloutsData?.items ?? [];
|
||||
const baseRolloutItems = rolloutsData?.items ?? [];
|
||||
|
||||
const selectedRollout = useMemo(() => findRollout(rolloutItems, rolloutId), [rolloutItems, rolloutId]);
|
||||
const rolloutSearchQueryArgs = useMemo<GetRolloutsQueryArgs | null>(() => {
|
||||
if (!rolloutSearchActive) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
sortBy: 'start_time',
|
||||
sortOrder: 'desc',
|
||||
rolloutIdContains: normalizedRolloutSearchValue,
|
||||
};
|
||||
}, [normalizedRolloutSearchValue, rolloutSearchActive]);
|
||||
|
||||
const { data: rolloutSearchData, isFetching: rolloutSearchFetching } = useGetRolloutsQuery(
|
||||
rolloutSearchQueryArgs ?? skipToken,
|
||||
);
|
||||
|
||||
const rolloutByIdQueryArgs = useMemo<GetRolloutsQueryArgs | null>(() => {
|
||||
if (!rolloutId || rolloutLookup[rolloutId]) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
sortBy: 'start_time',
|
||||
sortOrder: 'desc',
|
||||
rolloutIdContains: rolloutId,
|
||||
};
|
||||
}, [rolloutId, rolloutLookup]);
|
||||
|
||||
const { data: rolloutByIdData, isFetching: rolloutByIdFetching } = useGetRolloutsQuery(
|
||||
rolloutByIdQueryArgs ?? skipToken,
|
||||
);
|
||||
|
||||
const searchRolloutItems = rolloutSearchData?.items ?? [];
|
||||
const rolloutByIdItems = rolloutByIdData?.items ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (baseRolloutItems.length > 0) {
|
||||
setRolloutLookup((prev) => mergeRolloutCache(prev, baseRolloutItems));
|
||||
}
|
||||
}, [baseRolloutItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchRolloutItems.length > 0) {
|
||||
setRolloutLookup((prev) => mergeRolloutCache(prev, searchRolloutItems));
|
||||
}
|
||||
}, [searchRolloutItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rolloutByIdItems.length > 0) {
|
||||
setRolloutLookup((prev) => mergeRolloutCache(prev, rolloutByIdItems));
|
||||
}
|
||||
}, [rolloutByIdItems]);
|
||||
|
||||
const selectedRollout = rolloutId ? (rolloutLookup[rolloutId] ?? null) : null;
|
||||
|
||||
const attemptsQueryArgs =
|
||||
rolloutId !== null
|
||||
@@ -125,21 +205,61 @@ export function TracesPage() {
|
||||
pollingInterval: autoRefreshMs > 0 ? autoRefreshMs : undefined,
|
||||
});
|
||||
|
||||
const shouldResolveRollout = rolloutId !== null && !rolloutLookup[rolloutId];
|
||||
|
||||
useEffect(() => {
|
||||
if (!rolloutsData) {
|
||||
return;
|
||||
}
|
||||
if (rolloutItems.length === 0) {
|
||||
|
||||
if (rolloutsData.total === 0) {
|
||||
if (rolloutId !== null) {
|
||||
dispatch(setTracesRolloutId(null));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const rolloutExists = rolloutId ? rolloutItems.some((rollout) => rollout.rolloutId === rolloutId) : false;
|
||||
if (!rolloutExists) {
|
||||
dispatch(setTracesRolloutId(rolloutItems[0].rolloutId));
|
||||
|
||||
if (rolloutId === null) {
|
||||
if (!initialHasRolloutQueryParam && baseRolloutItems[0]) {
|
||||
dispatch(setTracesRolloutId(baseRolloutItems[0].rolloutId));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}, [dispatch, rolloutsData, rolloutId, rolloutItems]);
|
||||
|
||||
if (!shouldResolveRollout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rolloutSearchActive && normalizedRolloutSearchValue === rolloutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rolloutByIdQueryArgs) {
|
||||
if (rolloutByIdFetching || !rolloutByIdData || rolloutsLoading) {
|
||||
return;
|
||||
}
|
||||
if (rolloutByIdData.items.length === 0) {
|
||||
dispatch(setTracesRolloutId(null));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setTracesRolloutId(null));
|
||||
}, [
|
||||
baseRolloutItems,
|
||||
dispatch,
|
||||
initialHasRolloutQueryParam,
|
||||
normalizedRolloutSearchValue,
|
||||
rolloutByIdData,
|
||||
rolloutByIdFetching,
|
||||
rolloutByIdQueryArgs,
|
||||
rolloutId,
|
||||
rolloutLookup,
|
||||
rolloutSearchActive,
|
||||
rolloutsData,
|
||||
rolloutsLoading,
|
||||
shouldResolveRollout,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rolloutId) {
|
||||
@@ -160,20 +280,29 @@ export function TracesPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackAttemptId = selectedRollout?.attempt?.attemptId ?? null;
|
||||
if (fallbackAttemptId !== attemptId) {
|
||||
dispatch(setTracesAttemptId(fallbackAttemptId));
|
||||
if (attemptId === null) {
|
||||
const fallbackAttemptId = selectedRollout?.attempt?.attemptId ?? null;
|
||||
if (fallbackAttemptId !== attemptId) {
|
||||
dispatch(setTracesAttemptId(fallbackAttemptId));
|
||||
}
|
||||
}
|
||||
}, [attemptsData, attemptId, dispatch, rolloutId, selectedRollout]);
|
||||
|
||||
const rolloutOptions = useMemo(
|
||||
() =>
|
||||
rolloutItems.map((rollout) => ({
|
||||
value: rollout.rolloutId,
|
||||
label: rollout.rolloutId,
|
||||
})),
|
||||
[rolloutItems],
|
||||
);
|
||||
const visibleRolloutItems = rolloutSearchActive ? searchRolloutItems : baseRolloutItems;
|
||||
const rolloutSelectIsFetching =
|
||||
(rolloutSearchActive ? rolloutSearchFetching : rolloutsFetching) ||
|
||||
Boolean(rolloutByIdQueryArgs && rolloutByIdFetching);
|
||||
|
||||
const rolloutOptions = useMemo(() => {
|
||||
const options = visibleRolloutItems.map((rollout) => ({
|
||||
value: rollout.rolloutId,
|
||||
label: rollout.rolloutId,
|
||||
}));
|
||||
if (rolloutId && !visibleRolloutItems.some((rollout) => rollout.rolloutId === rolloutId)) {
|
||||
options.push({ value: rolloutId, label: rolloutId });
|
||||
}
|
||||
return options;
|
||||
}, [rolloutId, visibleRolloutItems]);
|
||||
|
||||
const attemptOptions = useMemo(() => {
|
||||
if (attemptsData && attemptsData.items.length > 0) {
|
||||
@@ -196,11 +325,21 @@ export function TracesPage() {
|
||||
return [];
|
||||
}, [attemptsData, selectedRollout]);
|
||||
|
||||
const attemptPlaceholder = useMemo(() => {
|
||||
if (!rolloutId) {
|
||||
return 'Select Attempt';
|
||||
}
|
||||
if (attemptOptions.length === 0) {
|
||||
return 'No Attempt';
|
||||
}
|
||||
return 'Latest Attempt';
|
||||
}, [attemptOptions.length, rolloutId]);
|
||||
|
||||
const rawSpansData = spansData as any as { items?: Span[]; total?: number } | undefined;
|
||||
const spans = rawSpansData?.items ?? [];
|
||||
const spansTotal = rawSpansData?.total ?? 0;
|
||||
const recordsPerPageOptions = [50, 100, 200, 500];
|
||||
const isInitialLoading = rolloutsLoading && rolloutItems.length === 0;
|
||||
const isInitialLoading = rolloutsLoading && baseRolloutItems.length === 0;
|
||||
const isFetching = spansFetching || rolloutsFetching || attemptsFetching;
|
||||
|
||||
const selectionMessage = useMemo<string | undefined>(() => {
|
||||
@@ -264,6 +403,52 @@ export function TracesPage() {
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const payload: { rolloutId?: string | null; attemptId?: string | null } = {};
|
||||
if (hasRolloutQueryParam) {
|
||||
payload.rolloutId = rolloutIdFromQuery;
|
||||
}
|
||||
if (hasAttemptQueryParam) {
|
||||
payload.attemptId = attemptIdFromQuery;
|
||||
}
|
||||
if (Object.keys(payload).length > 0) {
|
||||
dispatch(hydrateTracesStateFromQuery(payload));
|
||||
}
|
||||
setHydratedSearchParamsKey((prev) => (prev === searchParamsKey ? prev : searchParamsKey));
|
||||
}, [attemptIdFromQuery, dispatch, hasAttemptQueryParam, hasRolloutQueryParam, rolloutIdFromQuery, searchParamsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydratedSearchParamsKey !== searchParamsKey) {
|
||||
return;
|
||||
}
|
||||
const next = new URLSearchParams(searchParams);
|
||||
let changed = false;
|
||||
|
||||
if (rolloutId) {
|
||||
if (next.get('rolloutId') !== rolloutId) {
|
||||
next.set('rolloutId', rolloutId);
|
||||
changed = true;
|
||||
}
|
||||
} else if (next.has('rolloutId')) {
|
||||
next.delete('rolloutId');
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (rolloutId && attemptId) {
|
||||
if (next.get('attemptId') !== attemptId) {
|
||||
next.set('attemptId', attemptId);
|
||||
changed = true;
|
||||
}
|
||||
} else if (next.has('attemptId')) {
|
||||
next.delete('attemptId');
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
}, [attemptId, hydratedSearchParamsKey, rolloutId, searchParams, searchParamsKey, setSearchParams]);
|
||||
|
||||
const handleSearchTermChange = useCallback(
|
||||
(value: string) => {
|
||||
dispatch(setTracesSearchTerm(value));
|
||||
@@ -309,10 +494,7 @@ export function TracesPage() {
|
||||
|
||||
const handleShowRollout = useCallback(
|
||||
(record: TracesTableRecord) => {
|
||||
if (rolloutItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
const rollout = rolloutItems.find((item) => item.rolloutId === record.rolloutId);
|
||||
const rollout = rolloutLookup[record.rolloutId];
|
||||
if (!rollout) {
|
||||
return;
|
||||
}
|
||||
@@ -330,13 +512,12 @@ export function TracesPage() {
|
||||
}),
|
||||
);
|
||||
},
|
||||
[attemptsData, dispatch, rolloutItems],
|
||||
[attemptsData, dispatch, rolloutLookup],
|
||||
);
|
||||
|
||||
const handleShowSpanDetail = useCallback(
|
||||
(record: TracesTableRecord) => {
|
||||
const rolloutForSpan =
|
||||
rolloutItems.length > 0 ? (rolloutItems.find((item) => item.rolloutId === record.rolloutId) ?? null) : null;
|
||||
const rolloutForSpan = rolloutLookup[record.rolloutId] ?? null;
|
||||
const attempts = attemptsData?.items ?? [];
|
||||
const attemptForSpan =
|
||||
attempts.find((attempt) => attempt.attemptId === record.attemptId) ?? rolloutForSpan?.attempt ?? null;
|
||||
@@ -350,7 +531,7 @@ export function TracesPage() {
|
||||
}),
|
||||
);
|
||||
},
|
||||
[attemptsData, dispatch, rolloutItems],
|
||||
[attemptsData, dispatch, rolloutLookup],
|
||||
);
|
||||
|
||||
const handleParentIdClick = useCallback(
|
||||
@@ -382,14 +563,27 @@ export function TracesPage() {
|
||||
if (value !== rolloutId) {
|
||||
dispatch(setTracesRolloutId(value));
|
||||
}
|
||||
setRolloutSearchValue('');
|
||||
}}
|
||||
searchable
|
||||
searchValue={rolloutSearchValue}
|
||||
onSearchChange={(value) => {
|
||||
setRolloutSearchValue(value ?? '');
|
||||
}}
|
||||
placeholder='Select rollout'
|
||||
aria-label='Select rollout'
|
||||
nothingFoundMessage={rolloutsFetching ? 'Loading...' : 'No rollouts'}
|
||||
nothingFoundMessage={
|
||||
rolloutSelectIsFetching ? 'Loading...' : rolloutSearchActive ? 'No matching rollouts' : 'No rollouts'
|
||||
}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
onDropdownOpen={() => {
|
||||
setRolloutSearchValue('');
|
||||
}}
|
||||
onDropdownClose={() => {
|
||||
setRolloutSearchValue('');
|
||||
}}
|
||||
w={260}
|
||||
disabled={rolloutOptions.length === 0}
|
||||
disabled={rolloutOptions.length === 0 && !rolloutSelectIsFetching}
|
||||
/>
|
||||
<Select
|
||||
data={attemptOptions}
|
||||
@@ -400,7 +594,7 @@ export function TracesPage() {
|
||||
}
|
||||
}}
|
||||
searchable
|
||||
placeholder='Latest attempt'
|
||||
placeholder={attemptPlaceholder}
|
||||
aria-label='Select attempt'
|
||||
nothingFoundMessage={attemptsFetching ? 'Loading...' : 'No attempts'}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
|
||||
@@ -27,6 +27,14 @@ export function formatDateTime(timestamp: number | null): string {
|
||||
return dayjs(timestamp * 1000).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
export function formatDateTimeWithMilliseconds(timestamp: number | null): string {
|
||||
if (timestamp == null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return dayjs(timestamp * 1000).format('YYYY-MM-DD HH:mm:ss.SSS');
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number | null): string {
|
||||
if (seconds == null) {
|
||||
return '—';
|
||||
|
||||
@@ -48,7 +48,21 @@ export function compareRecords<T, K extends keyof T>(a: T, b: T, key: K): number
|
||||
* Create responsive columns based on container width
|
||||
* Columns with priority 0 are always shown, others are shown based on available space
|
||||
*/
|
||||
const EM_IN_PIXELS = 16;
|
||||
const FALLBACK_EM_IN_PIXELS = 16;
|
||||
|
||||
function getEmInPixels(): number {
|
||||
if (typeof window === 'undefined' || !window.document?.documentElement) {
|
||||
return FALLBACK_EM_IN_PIXELS;
|
||||
}
|
||||
|
||||
const rootFontSize = window.getComputedStyle(window.document.documentElement).fontSize;
|
||||
const parsed = Number.parseFloat(rootFontSize);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return FALLBACK_EM_IN_PIXELS;
|
||||
}
|
||||
|
||||
function resolveWidth(config: ColumnVisibilityConfig): { widthEm: number; fixed: boolean } {
|
||||
if ('fixedWidth' in config && typeof config.fixedWidth === 'number') {
|
||||
@@ -62,7 +76,8 @@ export function createResponsiveColumns<T>(
|
||||
containerWidth: number,
|
||||
columnVisibilityConfig: Record<string, ColumnVisibilityConfig>,
|
||||
): DataTableColumn<T>[] {
|
||||
const measuredWidth = containerWidth ? Math.max(containerWidth - 48, 0) : Number.POSITIVE_INFINITY;
|
||||
const measuredWidth = containerWidth ? Math.max(containerWidth, 0) : Number.POSITIVE_INFINITY;
|
||||
const emInPixels = getEmInPixels();
|
||||
|
||||
const columnEntries = columns.map((column, index) => {
|
||||
const accessorKey = String(column.accessor);
|
||||
@@ -79,7 +94,7 @@ export function createResponsiveColumns<T>(
|
||||
accessorKey,
|
||||
...config,
|
||||
widthEm,
|
||||
widthPx: widthEm * EM_IN_PIXELS,
|
||||
widthPx: widthEm * emInPixels,
|
||||
fixed,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"@test-utils": ["./test-utils"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "public", "test-utils", ".storybook/main.ts", ".storybook/preview.tsx", ".storybook/modes.ts", ".storybook/constants.ts"]
|
||||
"include": ["src", "public", "test-utils", ".storybook/main.ts", ".storybook/preview.tsx", ".storybook/modes.ts", ".storybook/constants.ts", ".storybook/vitest.setup.ts"]
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/// <reference types="vitest/config" />
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { defineConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = __dirname;
|
||||
const appRoot = path.resolve(projectRoot, 'public');
|
||||
|
||||
export default defineConfig({
|
||||
root: appRoot,
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
@@ -21,14 +27,47 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve(projectRoot, 'dist'),
|
||||
outDir: path.resolve(projectRoot, '../agentlightning/dashboard'),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './vitest.setup.mjs',
|
||||
globalSetup: './vitest.global-setup.mjs',
|
||||
root: projectRoot,
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: 'unit',
|
||||
globalSetup: './vitest.global-setup.mjs',
|
||||
},
|
||||
},
|
||||
{
|
||||
// TODO: vitest for storybook has been setup but it's not working yet.
|
||||
extends: true,
|
||||
plugins: [
|
||||
// The plugin will run tests for the stories defined in your Storybook config
|
||||
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
|
||||
storybookTest({
|
||||
configDir: path.join(dirname, '.storybook'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
name: 'storybook',
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
provider: playwright({}),
|
||||
instances: [
|
||||
{
|
||||
browser: 'chromium',
|
||||
},
|
||||
],
|
||||
},
|
||||
setupFiles: ['.storybook/vitest.setup.ts'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/// <reference types="@vitest/browser-playwright" />
|
||||
@@ -152,6 +152,18 @@ uv sync --frozen \
|
||||
|
||||
Read more about Agent-lightning managed dependency groups [here]({{ src("pyproject.toml") }}).
|
||||
|
||||
### Building the Dashboard
|
||||
|
||||
The Agent-Lightning dashboard is built using [Vite](https://vite.dev/). To build the dashboard, run the following command:
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
Some HTML and JavaScript assets will be generated in the `agentlightning/dashboard` directory.
|
||||
|
||||
### Activating Your Environment
|
||||
|
||||
After syncing dependencies, `uv` automatically creates a virtual environment inside the `.venv/` directory.
|
||||
|
||||
+10
-1
@@ -260,7 +260,15 @@ build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["agentlightning"]
|
||||
include = ["agentlightning/**/*.yaml", "agentlightning/**/*.yml", "agentlightning/**/*.poml"]
|
||||
include = [
|
||||
"agentlightning/**/*.yaml",
|
||||
"agentlightning/**/*.yml",
|
||||
"agentlightning/**/*.poml",
|
||||
"agentlightning/**/*.html",
|
||||
"agentlightning/**/*.js",
|
||||
"agentlightning/**/*.css",
|
||||
"agentlightning/**/*.svg",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
exclude = [
|
||||
@@ -268,6 +276,7 @@ exclude = [
|
||||
"tests/**",
|
||||
"docs/**",
|
||||
"scripts/**",
|
||||
"dashboard/**",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
@@ -292,12 +292,16 @@ async def test_client_server_end_to_end(
|
||||
all_rollouts = await client.query_rollouts()
|
||||
assert any(r.rollout_id == enqueued.rollout_id for r in all_rollouts)
|
||||
assert await client.query_rollouts(rollout_ids=[enqueued.rollout_id])
|
||||
# Test that attempt is present in the rollout
|
||||
assert any(hasattr(r, "attempt") and r.attempt is not None for r in all_rollouts) # type: ignore
|
||||
attempts = await client.query_attempts(dequeued_client.rollout_id)
|
||||
assert attempts
|
||||
assert await client.get_latest_attempt(dequeued_client.rollout_id) is not None
|
||||
stored_client_rollout = await client.get_rollout_by_id(dequeued_client.rollout_id)
|
||||
assert stored_client_rollout is not None
|
||||
assert stored_client_rollout.config.unresponsive_seconds == 6.0
|
||||
# Test that attempt is present in the rollout
|
||||
assert hasattr(stored_client_rollout, "attempt") and stored_client_rollout.attempt is not None # type: ignore
|
||||
|
||||
client_span = _make_span(dequeued_client.rollout_id, dequeued_client.attempt.attempt_id, 101, "client-span")
|
||||
stored_span = await client.add_span(client_span)
|
||||
|
||||
Reference in New Issue
Block a user