Compare commits

...

20 Commits

Author SHA1 Message Date
Yuge Zhang 5051fbc3e4 . 2025-11-11 16:04:33 +08:00
Yuge Zhang cbdf474916 . 2025-11-11 14:45:10 +08:00
Yuge Zhang 0952a35b19 . 2025-11-11 14:32:20 +08:00
Yuge Zhang 95481d886f fix dashboard build 2025-11-11 14:30:47 +08:00
Yuge Zhang 9da9b47be6 fix stories chromatic issues 2025-11-11 14:25:44 +08:00
Yuge Zhang 122181c839 setup vitest storybook 2025-11-11 14:04:40 +08:00
Yuge Zhang 1b2807fd90 fix api camel case 2025-11-11 13:41:47 +08:00
Yuge Zhang 762a17f7f2 . 2025-11-11 13:36:46 +08:00
Yuge Zhang f0dc93d13e UI adjustment 2025-11-11 13:30:26 +08:00
Yuge Zhang af332fde86 . 2025-11-11 13:19:48 +08:00
Yuge Zhang 63ac23d20a . 2025-11-11 12:34:07 +08:00
Yuge Zhang 5b60032157 . 2025-11-11 12:20:39 +08:00
Yuge Zhang d25ec968e7 fix column width 2025-11-11 11:39:06 +08:00
Yuge Zhang 16799a4f98 fix favicon 2025-11-11 10:16:14 +08:00
Yuge Zhang 4dbc413d0a drawer overflow fixes and trace url fixes 2025-11-11 10:04:49 +08:00
Yuge Zhang 884614cafe fix table overflow issue 2025-11-11 09:34:32 +08:00
Yuge Zhang 149110aa55 . 2025-11-11 09:31:50 +08:00
Yuge Zhang fa2730687b update client server 2025-11-11 09:29:23 +08:00
Yuge Zhang 6d64a5e416 Install dashboard 2025-11-11 08:53:33 +08:00
Yuge Zhang 4d382f251b add route 2025-11-11 08:43:16 +08:00
31 changed files with 2046 additions and 224 deletions
+8
View File
@@ -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: |
+8
View File
@@ -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
+6
View File
@@ -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
+102 -36
View File
@@ -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
+1 -1
View File
@@ -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: {},
+8
View File
@@ -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]);
+584
View File
@@ -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",
+7 -2
View File
@@ -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"
}
}
+1 -1
View File
@@ -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(
+13 -7
View File
@@ -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(
+29
View File
@@ -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;
+1
View File
@@ -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);
}
+89
View File
@@ -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);
}
+86 -4
View File
@@ -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: {
+332 -31
View File
@@ -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');
}
});
},
};
+5 -4
View File
@@ -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 = [
+289 -66
View File
@@ -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: {
+231 -37
View File
@@ -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 }}
+8
View File
@@ -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 '—';
+18 -3
View File
@@ -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,
};
});
+1 -1
View File
@@ -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"]
}
+42 -3
View File
@@ -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'],
},
},
],
},
});
+3
View File
@@ -0,0 +1,3 @@
// Copyright (c) Microsoft. All rights reserved.
/// <reference types="@vitest/browser-playwright" />
+12
View File
@@ -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
View File
@@ -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]
+4
View File
@@ -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)