Compare commits

...

45 Commits

Author SHA1 Message Date
Yuge Zhang 90d7576187 update dashboard test 2025-11-10 22:24:05 +08:00
Yuge Zhang 92bf1e5f84 . 2025-11-10 22:10:51 +08:00
Yuge Zhang 59aceeb358 . 2025-11-10 22:08:13 +08:00
Yuge Zhang b281281caa Merge branch 'main' of github.com:microsoft/agent-lightning into feature/dashboard 2025-11-10 21:56:52 +08:00
Yuge Zhang 9b15e8f7ce . 2025-11-10 21:54:16 +08:00
Yuge Zhang 70b2d716f8 . 2025-11-10 21:53:36 +08:00
Yuge Zhang 196e70aebd . 2025-11-10 21:50:31 +08:00
Yuge Zhang 4c263dc866 . 2025-11-10 21:43:44 +08:00
Yuge Zhang d3d3b6bbfa . 2025-11-10 21:31:00 +08:00
Yuge Zhang e3eb998bbc fix date now 2025-11-10 21:09:55 +08:00
Yuge Zhang a923506833 . 2025-11-10 20:30:16 +08:00
Yuge Zhang e330f09e63 make constants 2025-11-10 20:02:03 +08:00
Yuge Zhang 4865d15635 add viewport modes 2025-11-10 19:41:15 +08:00
Yuge Zhang c2b06137f4 update chromatic 2025-11-10 18:48:40 +08:00
Yuge Zhang b9ee04eeba Merge branch 'main' of github.com:microsoft/agent-lightning into feature/dashboard 2025-11-10 15:39:10 +08:00
Yuge Zhang fc5b550bdb stablize port picker 2025-11-05 12:06:18 +08:00
Yuge Zhang c152ea4b3a Merge branch 'main' of github.com:microsoft/agent-lightning into feature/dashboard 2025-11-05 11:54:05 +08:00
Yuge Zhang 426547dc7c update cors 2025-11-05 11:53:39 +08:00
Yuge Zhang d4902f0286 fix some styling issues 2025-11-05 11:30:30 +08:00
Yuge Zhang 40fc8dba4d reserved otlp endpoints 2025-11-05 11:02:29 +08:00
Yuge Zhang a02ff4a836 migrate /agl/v1 to /v1/agl 2025-11-05 10:55:03 +08:00
Yuge Zhang e5f89e74fa clickable server status 2025-11-05 10:41:08 +08:00
Yuge Zhang b2b8aa1ae6 . 2025-11-03 18:20:15 +08:00
Yuge Zhang 0bd5e56184 fix app.css 2025-11-03 17:13:30 +08:00
Yuge Zhang 70234eb274 . 2025-11-03 15:08:20 +08:00
Yuge Zhang 327c55eaf7 . 2025-11-03 14:39:28 +08:00
Yuge Zhang 03241f35bd . 2025-11-03 14:24:22 +08:00
Yuge Zhang fc7b1de10d . 2025-11-03 13:50:37 +08:00
Yuge Zhang 014f579a94 update drawer for traces 2025-11-03 13:17:01 +08:00
Yuge Zhang 8c984f45e7 add tests on python side 2025-11-03 12:00:52 +08:00
Yuge Zhang 320a349665 add more tests 2025-11-03 11:47:23 +08:00
Yuge Zhang 028590c2d2 update tests 2025-11-03 11:15:54 +08:00
Yuge Zhang 2ab50672a2 . 2025-11-03 10:31:13 +08:00
Yuge Zhang c98d79d714 update mocked server 2025-11-03 10:24:19 +08:00
Yuge Zhang 8931123cbf creating rollouts 2025-11-03 10:01:48 +08:00
Yuge Zhang 0b42af9925 update mock 2025-11-03 00:47:36 +08:00
Yuge Zhang 0eca48331e . 2025-11-02 23:55:20 +08:00
Yuge Zhang a9a700bd89 update mock 2025-11-02 23:53:54 +08:00
Yuge Zhang 6aaa1ac939 update lint 2025-11-02 23:46:29 +08:00
Yuge Zhang ee0f0408c4 update license headers 2025-11-02 23:46:18 +08:00
Yuge Zhang 449acee461 refactor mock 2025-11-02 23:42:34 +08:00
Yuge Zhang 4af2986b7a commit style changes 2025-11-02 22:59:01 +08:00
Yuge Zhang 826f7f5fa8 fix style 2025-11-02 22:57:17 +08:00
Yuge Zhang d66ebdb69e update styles 2025-11-02 20:55:35 +08:00
Yuge Zhang 086a6b0c0f Check in dashboard code 2025-11-02 18:22:50 +08:00
89 changed files with 24473 additions and 14 deletions
+47
View File
@@ -0,0 +1,47 @@
name: Dashboard
permissions:
contents: read
on:
schedule:
# Every day at 5 AM UTC+8
- cron: '0 21 * * *'
workflow_dispatch:
repository_dispatch:
types: [ci-dashboard, ci-all]
run-name: >-
${{ github.event_name == 'repository_dispatch'
&& format(
'PR #{0} - Label {1} - {2}',
github.event.client_payload.pull_number,
github.event.client_payload.ci_label,
github.event.client_payload.correlation_id
)
|| format('Dashboard - {0}', github.event_name) }}
jobs:
dashboard:
if: >
github.event_name != 'repository_dispatch' ||
github.event.action == 'ci-dashboard' ||
github.event.action == 'ci-all'
name: Chromatic
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install JavaScript dependencies
run: cd dashboard && npm ci
- name: Run Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
workingDir: dashboard
exitZeroOnChanges: false
+46 -1
View File
@@ -45,10 +45,11 @@ jobs:
--group agents \
--no-default-groups
if: matrix.setup == 'slow'
# This pre-commit skips JavaScript on purpose.
- name: Run pre-commit
uses: pre-commit/action@v3.0.1
- name: Check Python headers
run: uv run --locked --no-sync scripts/check_python_headers.py
run: uv run --locked --no-sync scripts/check_headers.py
- name: Run Black
run: uv run --locked --no-sync black --check .
- name: Run isort
@@ -60,6 +61,28 @@ jobs:
run: uv run --locked --no-sync pyright -p pyrightconfig.json
if: matrix.setup == 'slow'
lint-js:
name: Lint - JavaScript
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
run: cd dashboard && npm ci
- name: Run ESLint
run: cd dashboard && npm run eslint
- name: Run Prettier
run: cd dashboard && npm run prettier
- name: Run Stylelint
run: cd dashboard && npm run stylelint
- name: Run Typecheck
run: cd dashboard && npm run typecheck
- name: Verify build
run: cd dashboard && npm run build
docs:
name: Build documentation
runs-on: ubuntu-latest
@@ -137,3 +160,25 @@ jobs:
uv run pytest -v --durations=0 tests
env:
PYTEST_ADDOPTS: "--color=yes"
test-js:
name: Test - JavaScript
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: '22'
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
python-version: '3.12'
- name: Sync Python dependencies
run: uv sync --frozen --no-default-groups --extra apo --group dev --group agents --group core-stable
- name: Install JavaScript dependencies
run: cd dashboard && npm ci
- name: Run vitest
run: cd dashboard && npm run vitest
+50
View File
@@ -24,3 +24,53 @@ repos:
pass_filenames: false
always_run: true
args: ["."]
- repo: local
hooks:
- id: prettier
name: prettier (dashboard)
language: system
pass_filenames: false
always_run: true
entry: >
bash -c '
cd dashboard || exit 1
if [ -d node_modules ]; then
echo "✅ node_modules already exists"
npx prettier --cache --write "**/*.{ts,tsx,mjs,cjs}"
else
echo "⚠️ node_modules not found — npx is not reliable. Skipping."
fi
'
- id: eslint
name: eslint (dashboard)
language: system
pass_filenames: false
always_run: true
entry: >
bash -c '
cd dashboard || exit 1
if [ -d node_modules ]; then
echo "✅ node_modules already exists"
npx eslint --cache --fix .
else
echo "⚠️ node_modules not found — npx is not reliable. Skipping."
fi
'
- id: stylelint
name: stylelint (dashboard)
language: system
pass_filenames: false
always_run: true
entry: >
bash -c '
cd dashboard || exit 1
if [ -d node_modules ]; then
echo "✅ node_modules already exists"
npx stylelint --cache --fix "**/*.css"
else
echo "⚠️ node_modules not found — npx is not reliable. Skipping."
fi
'
+132
View File
@@ -0,0 +1,132 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.DS_Store
+47
View File
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft. All rights reserved.
/** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */
const config = {
printWidth: 120,
singleQuote: true,
tabWidth: 2,
useTabs: false,
semi: true,
quoteProps: 'consistent',
jsxSingleQuote: true,
trailingComma: 'all',
bracketSpacing: true,
objectWrap: 'preserve',
arrowParens: 'always',
proseWrap: 'preserve',
endOfLine: 'lf',
plugins: ['@ianvs/prettier-plugin-sort-imports'],
importOrder: [
'.*styles.css$',
'',
'dayjs',
'^react$',
'^next$',
'^next/.*$',
'<BUILTIN_MODULES>',
'<THIRD_PARTY_MODULES>',
'^@mantine/(.*)$',
'^@mantinex/(.*)$',
'^@mantine-tests/(.*)$',
'^@docs/(.*)$',
'^@/.*$',
'^../(?!.*.css$).*$',
'^./(?!.*.css$).*$',
'\\.css$',
],
overrides: [
{
files: '*.mdx',
options: {
printWidth: 120,
},
},
],
};
export default config;
+12
View File
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
// Centralized constants that keep Storybook fixtures deterministic so Chromatic
// snapshots do not drift when the build environment changes.
export const STORY_DATE_NOW_MS = 1762775145209;
export const STORY_DATE_NOW_SECONDS = Math.floor(STORY_DATE_NOW_MS / 1000);
// Use a fixed origin so any code that would normally read window.location.*
// in the app can rely on the same value from Storybook fixtures. Prefer HTTPS
// so Chromatic (which is served over HTTPS) avoids mixed-content fetch errors.
export const STORY_BASE_URL = 'https://storybook.agentlightning.invalid';
export const STORY_LOCATION_HREF = `${STORY_BASE_URL}/storybook`;
+20
View File
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
core: {
disableWhatsNewNotifications: true,
disableTelemetry: true,
enableCrashReports: false,
},
stories: ['../src/**/*.mdx', '../src/**/*.story.@(js|jsx|ts|tsx)'],
staticDirs: ['../static'],
addons: ['@storybook/addon-themes'],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;
+13
View File
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
export const allModes = {
MD: {
viewport: 'md',
},
LG: {
viewport: 'lg',
},
XL: {
viewport: 'xl',
},
} as const;
+79
View File
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.
import '@mantine/core/styles.css';
import 'mantine-datatable/styles.css';
import '../src/styles/theme.css';
import '../src/styles/app.css';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { ColorSchemeScript, MantineProvider } from '@mantine/core';
import { shadcnCssVariableResolver } from '../src/cssVariableResolver';
import { theme as mantineTheme } from '../src/theme';
import { STORY_DATE_NOW_MS } from './constants';
type ColorSchemeValue = 'light' | 'dark';
initialize({
onUnhandledRequest: 'bypass',
serviceWorker: {
url: '/mockServiceWorker.js',
},
});
const fixedDateNow = (() => {
const patched = Date.now as typeof Date.now & { __storybookPatched?: boolean };
if (patched.__storybookPatched) {
return patched;
}
const replacement = (() => STORY_DATE_NOW_MS) as typeof Date.now & { __storybookPatched?: boolean };
replacement.__storybookPatched = true;
return replacement;
})();
Date.now = fixedDateNow;
export const parameters = {
layout: 'fullscreen',
options: {
showPanel: false,
// @ts-expect-error storybook throws build error for (a: any, b: any)
storySort: (a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }),
},
backgrounds: { disable: true },
viewport: {
options: {
md: { name: 'md', styles: { width: '1280px', height: '800px' } },
lg: { name: 'lg', styles: { width: '1920px', height: '1080px' } },
xl: { name: 'xl', styles: { width: '2560px', height: '1440px' } },
},
},
};
export const globalTypes = {
theme: {
name: 'Theme',
description: 'Mantine color scheme',
defaultValue: 'light',
toolbar: {
icon: 'mirror',
items: [
{ value: 'light', title: 'Light' },
{ value: 'dark', title: 'Dark' },
],
},
},
};
export const decorators = [
(Story: any, context: any) => {
const scheme = (context.parameters.theme ?? context.globals.theme ?? 'light') as ColorSchemeValue;
return (
<MantineProvider theme={mantineTheme} cssVariablesResolver={shadcnCssVariableResolver} forceColorScheme={scheme}>
<ColorSchemeScript />
<Story />
</MantineProvider>
);
},
];
export const loaders = [mswLoader];
+5
View File
@@ -0,0 +1,5 @@
# Generated files
dist
# Theme files
theme.css
+28
View File
@@ -0,0 +1,28 @@
{
"extends": ["stylelint-config-standard-scss"],
"rules": {
"custom-property-pattern": null,
"selector-class-pattern": null,
"scss/no-duplicate-mixins": null,
"declaration-empty-line-before": null,
"declaration-block-no-redundant-longhand-properties": null,
"alpha-value-notation": null,
"custom-property-empty-line-before": null,
"property-no-vendor-prefix": null,
"color-function-notation": null,
"length-zero-no-unit": null,
"selector-not-notation": null,
"no-descending-specificity": null,
"comment-empty-line-before": null,
"scss/at-mixin-pattern": null,
"scss/at-rule-no-unknown": null,
"value-keyword-case": null,
"media-feature-range-notation": null,
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": ["global"]
}
]
}
}
+27
View File
@@ -0,0 +1,27 @@
# Agent-lightning Dashboard
This is the dashboard for Agent-lightning. It is a web application that allows you to inspect your Agent-lightning store and debug running experiments.
The dashboard is built with React, Mantine UI, and Storybook.
## npm scripts
## Build and dev scripts
- `dev` start development server
- `build` build production version of the app
- `preview` locally preview production build
### Testing scripts
- `eslint` - runs ESLint
- `stylelint` - runs Stylelint
- `prettier` - runs Prettier
- `typecheck` - runs TypeScript typecheck
- `vitest` runs vitest tests
- `chromatic` runs chromatic tests
### Other scripts
- `storybook` starts storybook dev server
- `build-storybook` build production storybook bundle to `storybook-static`
+50
View File
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
// @ts-check
import stylistic from '@stylistic/eslint-plugin';
import mantine from 'eslint-config-mantine';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
export default defineConfig([
// These are arrays → safe to spread
...tseslint.configs.recommended,
stylistic.configs.customize({ semi: true }),
// mantine is often a single object → include as-is (or spread only if it's actually an array)
...(Array.isArray(mantine) ? mantine : [mantine]),
// ignores go as their own entry
{ ignores: ['**/*.{mjs,cjs,js,d.ts,d.mts}'] },
// file-specific rules
{
files: ['**/*.story.tsx'],
rules: { 'no-console': 'off' },
},
// project/TS settings + your custom rules
{
languageOptions: {
parserOptions: {
tsconfigRootDir: process.cwd(),
project: ['./tsconfig.json'],
},
},
rules: {
// Disabling conflict rules with prettier
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }],
'@stylistic/no-trailing-spaces': 'error',
'@stylistic/no-multiple-empty-lines': ['error', { max: 2, maxEOF: 1 }],
'@stylistic/jsx-quotes': ['error', 'prefer-single'],
'@stylistic/multiline-ternary': 'off',
'@stylistic/arrow-parens': ['error', 'always'],
'@stylistic/jsx-closing-bracket-location': 'off',
'@stylistic/operator-linebreak': 'off',
'@stylistic/jsx-newline': 'off',
'@stylistic/jsx-one-expression-per-line': 'off',
'@stylistic/indent': 'off',
'@stylistic/indent-binary-ops': 'off',
},
},
]);
+10306
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
{
"name": "agent-lightning-dashboard",
"type": "module",
"version": "0.2.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"eslint": "eslint .",
"stylelint": "stylelint '**/*.css'",
"prettier": "prettier --check \"**/*.{ts,tsx,mjs,cjs}\"",
"vitest": "vitest run",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"chromatic": "chromatic"
},
"dependencies": {
"@mantine/core": "8.3.5",
"@mantine/hooks": "8.3.5",
"@monaco-editor/react": "^4.7.0",
"@reduxjs/toolkit": "^2.9.2",
"@tabler/icons-react": "^3.35.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"mantine-datatable": "^8.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.4"
},
"devDependencies": {
"@eslint/js": "^9.37.0",
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@storybook/addon-themes": "^9.1.10",
"@storybook/react": "^9.1.10",
"@storybook/react-vite": "^9.1.10",
"@stylistic/eslint-plugin": "^5.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.7.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react": "^5.0.4",
"chromatic": "^13.3.3",
"eslint": "^9.37.0",
"eslint-config-mantine": "^4.0.3",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"identity-obj-proxy": "^3.0.0",
"jsdom": "^27.0.0",
"msw": "^2.11.6",
"msw-storybook-addon": "^2.0.6",
"postcss": "^8.5.6",
"postcss-preset-mantine": "1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.6.2",
"prop-types": "^15.8.1",
"storybook": "^9.1.10",
"stylelint": "^16.25.0",
"stylelint-config-standard-scss": "^16.0.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.0",
"vite": "^7.1.9",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.0"
}
}
+16
View File
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft. All rights reserved.
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<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>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
// Copyright (c) Microsoft. All rights reserved.
import '../src/main.js';
+30
View File
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
import '@mantine/core/styles.css';
import 'mantine-datatable/styles.css';
import './styles/theme.css';
import './styles/app.css';
import { MantineProvider } from '@mantine/core';
import { useColorScheme } from '@mantine/hooks';
import { shadcnCssVariableResolver } from './cssVariableResolver';
import { selectThemePreference } from './features/config/selectors';
import { Router } from './Router';
import { useAppSelector } from './store/hooks';
import { shadcnTheme } from './theme';
export default function App() {
const themePreference = useAppSelector(selectThemePreference);
const systemColorScheme = useColorScheme();
const resolvedColorScheme = themePreference === 'system' ? systemColorScheme : themePreference;
return (
<MantineProvider
theme={shadcnTheme}
cssVariablesResolver={shadcnCssVariableResolver}
forceColorScheme={resolvedColorScheme}
>
<Router />
</MantineProvider>
);
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom';
import { AppLayoutWithState } from './layouts/AppLayout';
import { ResourcesPage } from './pages/Resources.page';
import { RolloutsPage } from './pages/Rollouts.page';
import { SettingsPage } from './pages/Settings.page';
import { TracesPage } from './pages/Traces.page';
const router = createBrowserRouter([
{
path: '/',
element: <AppLayoutWithState />,
children: [
{
index: true,
element: <Navigate to='/rollouts' replace />,
},
{
path: 'rollouts',
element: <RolloutsPage />,
},
{
path: 'resources',
element: <ResourcesPage />,
},
{
path: 'traces',
element: <TracesPage />,
},
{
path: 'settings',
element: <SettingsPage />,
},
],
},
]);
export function Router() {
return <RouterProvider router={router} />;
}
@@ -0,0 +1,91 @@
// Copyright (c) Microsoft. All rights reserved.
import type { Meta, StoryObj } from '@storybook/react';
import { Provider } from 'react-redux';
import { initialConfigState } from '@/features/config/slice';
import { initialRolloutsUiState } from '@/features/rollouts/slice';
import type { AlertsState, AlertTone } from '@/features/ui/alert';
import { initialDrawerState } from '@/features/ui/drawer/slice';
import { createAppStore } from '@/store';
import { STORY_BASE_URL, STORY_DATE_NOW_MS } from '../../.storybook/constants';
import { AppAlertBanner } from './AppAlertBanner';
const meta: Meta<typeof AppAlertBanner> = {
title: 'Components/AppAlertBanner',
component: AppAlertBanner,
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof AppAlertBanner>;
function renderWithAlert(message: string, tone: AlertTone) {
const alertState: AlertsState = {
alerts: [
{
id: 'storybook-alert',
message,
tone,
isVisible: true,
createdAt: STORY_DATE_NOW_MS,
},
],
};
const store = createAppStore({
config: {
...initialConfigState,
baseUrl: STORY_BASE_URL,
},
drawer: initialDrawerState,
rollouts: initialRolloutsUiState,
alert: alertState,
});
return (
<Provider store={store}>
<div style={{ padding: 24 }}>
<AppAlertBanner />
</div>
</Provider>
);
}
export const InfoAlert: Story = {
render: () => renderWithAlert('Background synchronization completed successfully.', 'info'),
};
export const WarningAlert: Story = {
render: () =>
renderWithAlert('Rollout data may be stale. Check your network connection before continuing.', 'warning'),
};
export const ErrorAlert: Story = {
render: () =>
renderWithAlert('Unable to reach the Agent-lightning API. Retry or adjust the backend settings.', 'error'),
};
export const NoAlert: Story = {
render: () => {
const store = createAppStore({
config: {
...initialConfigState,
baseUrl: STORY_BASE_URL,
},
drawer: initialDrawerState,
rollouts: initialRolloutsUiState,
alert: { alerts: [] },
});
return (
<Provider store={store}>
<div style={{ padding: 24 }}>
<AppAlertBanner />
</div>
</Provider>
);
},
};
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
import { useEffect, useState } from 'react';
import { IconAlertCircle, IconAlertTriangle, IconInfoCircle } from '@tabler/icons-react';
import { Notification, Portal, Transition } from '@mantine/core';
import { hideAlert, selectHighestPriorityAlert, type AppAlert } from '@/features/ui/alert';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
const ALERT_META = {
info: {
color: 'blue',
icon: IconInfoCircle,
},
warning: {
color: 'yellow',
icon: IconAlertTriangle,
},
error: {
color: 'red',
icon: IconAlertCircle,
},
} as const;
export function AppAlertBanner() {
const dispatch = useAppDispatch();
const alert = useAppSelector(selectHighestPriorityAlert);
const [transitionAlert, setTransitionAlert] = useState<AppAlert | null>(alert);
useEffect(() => {
if (alert) {
setTransitionAlert(alert);
}
}, [alert]);
const handleClose = (id?: string) => {
if (id) {
dispatch(hideAlert({ id }));
}
};
const currentAlert = alert ?? transitionAlert;
const mounted = Boolean(alert);
if (!currentAlert) {
return null;
}
const meta = ALERT_META[currentAlert.tone];
const IconComponent = meta.icon;
return (
<Portal>
<Transition
mounted={mounted}
transition='slide-down'
duration={200}
timingFunction='ease'
onExited={() => setTransitionAlert(null)}
>
{(styles) => (
<Notification
icon={<IconComponent size={18} />}
color={meta.color}
variant='light'
withCloseButton
onClose={() => handleClose(currentAlert.id)}
style={{
position: 'fixed',
top: 16,
right: 16,
maxWidth: 450,
width: 'calc(100% - 32px)',
zIndex: 2000,
boxShadow: 'var(--mantine-shadow-md)',
...styles,
}}
>
{currentAlert.message}
</Notification>
)}
</Transition>
</Portal>
);
}
@@ -0,0 +1,448 @@
// Copyright (c) Microsoft. All rights reserved.
import { useCallback, useEffect, useMemo, 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 {
ActionIcon,
Badge,
Box,
CopyButton,
Drawer,
Group,
Stack,
Text,
Tooltip,
useMantineColorScheme,
} from '@mantine/core';
import { useGetSpansQuery } from '@/features/rollouts';
import { closeDrawer, openDrawer, selectDrawerContent, selectDrawerIsOpen } from '@/features/ui/drawer';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import type { Attempt, AttemptStatus, Rollout, RolloutStatus, Span } from '@/types';
import { formatStatusLabel } from '@/utils/format';
import { TracesTable, type TracesTableRecord } from './TracesTable.component';
const ATTEMPT_STATUS_COLORS: Record<AttemptStatus, string> = {
failed: 'red',
preparing: 'violet',
running: 'blue',
succeeded: 'teal',
timeout: 'orange',
unresponsive: 'orange',
};
const ROLLOUT_STATUS_COLORS: Record<RolloutStatus, string> = {
cancelled: 'gray',
failed: 'red',
preparing: 'violet',
queuing: 'blue',
requeuing: 'cyan',
running: 'blue',
succeeded: 'teal',
};
const SPAN_STATUS_COLORS: Record<Span['status']['status_code'], string> = {
UNSET: 'gray',
OK: 'teal',
ERROR: 'red',
};
const TRACES_SORT_FIELD_MAP: Record<string, string> = {
name: 'name',
traceId: 'trace_id',
spanId: 'span_id',
parentId: 'parent_id',
statusCode: 'status_code',
startTime: 'start_time',
duration: 'duration',
};
type SortDirection = 'asc' | 'desc';
type LocalSortState = {
column: string;
direction: SortDirection;
};
function resolveTracesSortField(column: string): string {
return TRACES_SORT_FIELD_MAP[column] ?? 'start_time';
}
function getStatusBadgeColor(status: RolloutStatus | AttemptStatus, isAttempt: boolean) {
if (isAttempt) {
return ATTEMPT_STATUS_COLORS[status as AttemptStatus] ?? 'gray';
}
return ROLLOUT_STATUS_COLORS[status as RolloutStatus] ?? 'gray';
}
function formatJson(value: unknown) {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
export type AppDrawerProps = {
opened: boolean;
onClose: () => void;
title?: ReactNode;
body?: ReactNode;
};
export function AppDrawer({ opened, onClose, title, body }: AppDrawerProps) {
return (
<Drawer
position='right'
size='lg'
opened={opened}
onClose={onClose}
overlayProps={{ opacity: 0.5 }}
withinPortal
styles={{
content: {
display: 'flex',
flexDirection: 'column',
maxHeight: '100vh',
},
body: {
flex: 1,
display: 'flex',
flexDirection: 'column',
padding: 'var(--mantine-spacing-md)',
minHeight: 0,
overflow: 'hidden',
},
}}
title={title}
>
<Stack gap='md' h='100%' style={{ flex: 1, minHeight: 0 }}>
{body}
</Stack>
</Drawer>
);
}
type TraceDrawerTitleProps = {
span: Span;
};
export function TraceDrawerTitle({ span }: TraceDrawerTitleProps) {
const spanStatusCode = span.status?.status_code ?? null;
const spanBadgeColor = spanStatusCode ? (SPAN_STATUS_COLORS[spanStatusCode] ?? 'gray') : undefined;
return (
<Stack gap={3}>
<Group gap={6}>
<Text fw={600}>{span.name ?? span.spanId}</Text>
{spanStatusCode ? (
<Badge size='sm' variant='light' color={spanBadgeColor}>
{spanStatusCode}
</Badge>
) : null}
</Group>
<Group gap={6}>
<Text size='sm' c='dimmed'>
{span.spanId}
</Text>
<CopyButton value={span.spanId}>
{({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy'} withArrow>
<ActionIcon
aria-label={`Copy span ID ${span.spanId}`}
variant='subtle'
color={copied ? 'teal' : 'gray'}
size='sm'
onClick={(event) => {
event.stopPropagation();
copy();
}}
>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
<Group gap='xs'>
<Group gap={3}>
<Text size='sm' c='dimmed' fw={500}>
Rollout
</Text>
<Text size='sm' c='dimmed'>
{span.rolloutId}
</Text>
</Group>
<Group gap={3}>
<Text size='sm' c='dimmed' fw={500}>
Attempt
</Text>
<Text size='sm' c='dimmed'>
{span.attemptId ?? '—'}
</Text>
</Group>
</Group>
</Stack>
);
}
type RolloutAttemptDrawerTitleProps = {
rollout: Rollout;
attempt: Attempt | null;
};
export function RolloutAttemptDrawerTitle({ rollout, attempt }: RolloutAttemptDrawerTitleProps) {
const rolloutId = rollout.rolloutId;
const attemptId = attempt?.attemptId ?? null;
const rolloutStatus = rollout.status ?? null;
const attemptStatus = attempt?.status ?? null;
const rolloutStatusLabel = rolloutStatus ? formatStatusLabel(rolloutStatus) : null;
const attemptStatusLabel = attemptStatus ? formatStatusLabel(attemptStatus) : null;
const hasStatusMismatch = rolloutStatus !== null && attemptStatus !== null && rolloutStatus !== attemptStatus;
const rolloutBadgeColor = rolloutStatus ? getStatusBadgeColor(rolloutStatus, false) : undefined;
const attemptBadgeColor = attemptStatus ? getStatusBadgeColor(attemptStatus, true) : undefined;
const showRolloutBadgeInHeading = Boolean(rolloutStatusLabel && (!attemptStatus || hasStatusMismatch));
const showAttemptBadge = Boolean(attemptStatusLabel && attemptStatus);
return (
<Stack gap={3}>
<Group gap={6}>
<Text fw={600}>{rolloutId}</Text>
<CopyButton value={rolloutId}>
{({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy'} withArrow>
<ActionIcon
aria-label={`Copy rollout ID ${rolloutId}`}
variant='subtle'
color={copied ? 'teal' : 'gray'}
size='sm'
onClick={(event) => {
event.stopPropagation();
copy();
}}
>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
{showRolloutBadgeInHeading && rolloutStatusLabel ? (
<Badge size='sm' variant='light' color={rolloutBadgeColor}>
{rolloutStatusLabel}
</Badge>
) : null}
</Group>
<Group gap='xs'>
{attemptId ? (
<Group gap={3}>
<Text size='sm' c='dimmed' fw={500}>
Attempt
</Text>
<Text size='sm' c='dimmed'>
{attemptId}
</Text>
</Group>
) : null}
{showAttemptBadge && attemptStatusLabel ? (
<Badge size='sm' variant='light' color={attemptBadgeColor}>
{attemptStatusLabel}
</Badge>
) : null}
{!showRolloutBadgeInHeading && !attemptStatus && rolloutStatusLabel ? (
<Badge size='sm' variant='light' color={rolloutBadgeColor}>
{rolloutStatusLabel}
</Badge>
) : null}
</Group>
</Stack>
);
}
type JsonEditorProps = {
value: unknown;
};
export function JsonEditor({ value }: JsonEditorProps) {
const { colorScheme } = useMantineColorScheme();
const editorTheme = colorScheme === 'dark' ? 'vs-dark' : 'vs-light';
return (
<Box style={{ flex: 1, minHeight: 0 }}>
<Editor
height='100%'
language='json'
value={formatJson(value)}
theme={editorTheme}
options={{
readOnly: true,
domReadOnly: true,
minimap: { enabled: false },
automaticLayout: true,
scrollBeyondLastLine: false,
fontSize: 13,
}}
/>
</Box>
);
}
type RolloutTracesDrawerBodyProps = {
rollout: Rollout;
attempt: Attempt | null;
onShowRollout: (record: TracesTableRecord) => void;
onShowSpanDetail: (record: TracesTableRecord) => void;
};
function RolloutTracesDrawerBody({ rollout, attempt, onShowRollout, onShowSpanDetail }: RolloutTracesDrawerBodyProps) {
const [page, setPage] = useState(1);
const [recordsPerPage, setRecordsPerPage] = useState(100);
const [sort, setSort] = useState<LocalSortState>({
column: 'startTime',
direction: 'desc',
});
useEffect(() => {
setPage(1);
}, [rollout.rolloutId, attempt?.attemptId]);
const queryArgs = useMemo(
() => ({
rolloutId: rollout.rolloutId,
attemptId: attempt?.attemptId ?? undefined,
limit: recordsPerPage,
offset: Math.max(0, (page - 1) * recordsPerPage),
sortBy: resolveTracesSortField(sort.column),
sortOrder: sort.direction,
}),
[rollout.rolloutId, attempt?.attemptId, recordsPerPage, page, sort],
);
const { data, isFetching, isError, error, refetch } = useGetSpansQuery(queryArgs);
const spans = data?.items ?? [];
const totalRecords = data?.total ?? 0;
const handleSortStatusChange = useCallback((status: DataTableSortStatus<TracesTableRecord>) => {
setSort({
column: status.columnAccessor as string,
direction: status.direction,
});
}, []);
const handlePageChange = useCallback((nextPage: number) => {
setPage(nextPage);
}, []);
const handleRecordsPerPageChange = useCallback((value: number) => {
setRecordsPerPage(value);
setPage(1);
}, []);
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 }}>
<TracesTable
spans={spans}
totalRecords={totalRecords}
isFetching={isFetching}
isError={isError}
error={error}
searchTerm=''
sort={sort}
page={page}
recordsPerPage={recordsPerPage}
onSortStatusChange={handleSortStatusChange}
onPageChange={handlePageChange}
onRecordsPerPageChange={handleRecordsPerPageChange}
onResetFilters={() => {}}
onRefetch={refetch}
onShowRollout={onShowRollout}
onShowSpanDetail={onShowSpanDetail}
recordsPerPageOptions={[50, 100, 200, 500]}
/>
</Box>
</Stack>
);
}
export function AppDrawerContainer() {
const dispatch = useAppDispatch();
const isOpen = useAppSelector(selectDrawerIsOpen);
const content = useAppSelector(selectDrawerContent);
const handleClose = useCallback(() => {
dispatch(closeDrawer());
}, [dispatch]);
const derivedContent = useMemo(() => {
if (!content) {
return null;
}
if (content.type === 'trace-detail') {
const { span } = content;
const title = <TraceDrawerTitle span={span} />;
const body = <JsonEditor value={span} />;
return { title, body };
}
const rollout = content.rollout;
const attempt = content.attempt;
const title = <RolloutAttemptDrawerTitle rollout={rollout} attempt={attempt} />;
if (content.type === 'rollout-json') {
const jsonValue = content.isNested && content.attempt ? content.attempt : rollout;
const body = jsonValue ? <JsonEditor value={jsonValue} /> : null;
return { title, body };
}
if (content.type === 'rollout-traces') {
const body = (
<RolloutTracesDrawerBody
rollout={rollout}
attempt={attempt}
onShowRollout={() => {
const attemptForRecord = attempt ?? rollout.attempt ?? null;
dispatch(
openDrawer({
type: 'rollout-json',
rollout,
attempt: attemptForRecord,
isNested: content.isNested,
}),
);
}}
onShowSpanDetail={(record) => {
const attemptForRecord = attempt ?? rollout.attempt ?? null;
dispatch(
openDrawer({
type: 'trace-detail',
span: record,
rollout,
attempt: attemptForRecord,
}),
);
}}
/>
);
return { title, body };
}
return null;
}, [content, dispatch]);
if (!content || !derivedContent) {
return null;
}
const { title, body } = derivedContent;
return <AppDrawer opened={isOpen} onClose={handleClose} title={title} body={body} />;
}
@@ -0,0 +1,258 @@
// Copyright (c) Microsoft. All rights reserved.
import type { Meta, StoryObj } from '@storybook/react';
import { Provider } from 'react-redux';
import { initialConfigState } from '@/features/config/slice';
import { initialResourcesUiState } from '@/features/resources/slice';
import { rolloutsApi } from '@/features/rollouts';
import { initialRolloutsUiState } from '@/features/rollouts/slice';
import { initialTracesUiState } from '@/features/traces/slice';
import type { DrawerContent } from '@/features/ui/drawer';
import { createAppStore } from '@/store';
import type { Attempt, Rollout, Span } from '@/types';
import { STORY_BASE_URL, STORY_DATE_NOW_SECONDS } from '../../.storybook/constants';
import { AppDrawerContainer } from './AppDrawer.component';
const meta = {
title: 'Components/AppDrawer',
component: AppDrawerContainer,
parameters: {
layout: 'fullscreen',
},
} satisfies Meta<typeof AppDrawerContainer>;
export default meta;
type Story = StoryObj<typeof AppDrawerContainer>;
const now = STORY_DATE_NOW_SECONDS;
const baseAttempt: Attempt = {
rolloutId: 'ro-story-001',
attemptId: 'at-story-001',
sequenceId: 1,
startTime: now - 3600,
endTime: null,
status: 'running',
workerId: 'worker-story',
lastHeartbeatTime: now - 42,
metadata: { info: 'Sample metadata', runId: 'run-123' },
};
const baseRollout: Rollout = {
rolloutId: 'ro-story-001',
input: {
task: 'Generate daily summary',
payload: { account: 'enterprise', date: '2024-02-19' },
},
startTime: now - 4000,
endTime: null,
mode: 'train',
resourcesId: 'rs-story-001',
status: 'running',
config: { retries: 1, priority: 'high' },
metadata: { owner: 'storybook' },
attempt: baseAttempt,
};
const noAttemptRollout: Rollout = {
...baseRollout,
status: 'queuing',
attempt: null,
};
const mismatchRollout: Rollout = {
...baseRollout,
status: 'running',
attempt: {
...baseAttempt,
status: 'failed',
endTime: now - 1200,
metadata: { info: 'Latest attempt failed', reason: 'Timeout' },
},
};
const sampleSpan: Span = {
rolloutId: 'ro-story-001',
attemptId: 'at-story-001',
sequenceId: 2,
traceId: 'tr-story-001',
spanId: 'sp-story-001',
parentId: null,
name: 'Fetch Resources',
status: { status_code: 'OK', description: 'Completed successfully' },
attributes: {
'http.method': 'GET',
'http.url': 'https://api.example.com/resources',
'duration_ms': 120,
},
startTime: now - 240,
endTime: now - 120,
events: [],
links: [],
context: {},
parent: null,
resource: {},
};
const sampleTraces: Span[] = [
sampleSpan,
{
...sampleSpan,
spanId: 'sp-story-002',
name: 'Process Response',
parentId: 'sp-story-001',
sequenceId: 3,
status: { status_code: 'ERROR', description: 'Unexpected response code' },
attributes: {
...sampleSpan.attributes,
duration_ms: 240,
},
startTime: now - 120,
endTime: now - 30,
},
];
function renderWithDrawer(content: DrawerContent, options?: { spans?: Span[] }) {
const store = createAppStore({
config: {
...initialConfigState,
baseUrl: STORY_BASE_URL,
},
rollouts: initialRolloutsUiState,
resources: initialResourcesUiState,
traces: initialTracesUiState,
drawer: {
isOpen: true,
content,
},
});
if (content.type === 'rollout-traces' && options?.spans) {
const defaultLimit = 100;
const queryArgs = {
rolloutId: content.rollout.rolloutId,
attemptId: content.attempt?.attemptId ?? undefined,
limit: defaultLimit,
offset: 0,
sortBy: 'start_time',
sortOrder: 'desc' as const,
};
store.dispatch(
rolloutsApi.util.upsertQueryData('getSpans', queryArgs, {
items: options.spans,
total: options.spans.length,
limit: defaultLimit,
offset: 0,
}),
);
}
return (
<Provider store={store}>
<AppDrawerContainer />
</Provider>
);
}
export const RolloutJson: Story = {
render: () =>
renderWithDrawer({
type: 'rollout-json',
rollout: baseRollout,
attempt: baseRollout.attempt,
isNested: false,
}),
};
export const NestedAttemptJson: Story = {
render: () =>
renderWithDrawer({
type: 'rollout-json',
rollout: baseRollout,
attempt: {
...baseAttempt,
attemptId: 'at-story-002',
sequenceId: 2,
status: 'failed',
endTime: now - 1200,
metadata: { info: 'Secondary attempt', reason: 'Timeout' },
},
isNested: true,
}),
};
export const RolloutTraces: Story = {
render: () =>
renderWithDrawer(
{
type: 'rollout-traces',
rollout: baseRollout,
attempt: baseRollout.attempt,
isNested: false,
},
{ spans: sampleTraces },
),
};
export const NoAttempt: Story = {
render: () =>
renderWithDrawer({
type: 'rollout-json',
rollout: noAttemptRollout,
attempt: null,
isNested: false,
}),
};
export const StatusMismatch: Story = {
render: () =>
renderWithDrawer({
type: 'rollout-json',
rollout: mismatchRollout,
attempt: mismatchRollout.attempt,
isNested: false,
}),
};
export const SpanDetail: Story = {
render: () =>
renderWithDrawer({
type: 'trace-detail',
span: sampleSpan,
rollout: mismatchRollout,
attempt: mismatchRollout.attempt,
}),
};
export const LightTheme: Story = {
render: () =>
renderWithDrawer({
type: 'rollout-json',
rollout: baseRollout,
attempt: baseRollout.attempt,
isNested: false,
}),
parameters: {
theme: 'light',
},
};
export const DarkTheme: Story = {
render: () =>
renderWithDrawer({
type: 'trace-detail',
span: {
...sampleSpan,
spanId: 'sp-story-002',
name: 'Process Response',
status: { status_code: 'ERROR', description: 'Unexpected response code' },
},
rollout: mismatchRollout,
attempt: mismatchRollout.attempt,
}),
parameters: {
theme: 'dark',
},
};
@@ -0,0 +1,315 @@
// Copyright (c) Microsoft. All rights reserved.
import { useCallback, useEffect, useMemo, useState, type ReactNode, type SetStateAction } from 'react';
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 type { Resources } from '@/types';
import { getErrorDescriptor } from '@/utils/error';
import { formatDateTime, safeStringify } 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> = {
resourcesId: { fixedWidth: 12, priority: 0 },
version: { fixedWidth: 8, priority: 1 },
createTime: { fixedWidth: 14, priority: 2 },
updateTime: { fixedWidth: 14, priority: 2 },
resourceCount: { fixedWidth: 8, priority: 3 },
resourcesPreview: { minWidth: 16, priority: 4 },
};
export type ResourcesTableRecord = Resources & {
resourceCount: number;
canExpand: boolean;
resourcesPreview: string;
};
function buildResourcesRecord(resources: Resources): ResourcesTableRecord {
const resourceCount = Object.keys(resources.resources ?? {}).length;
const resourcesValue =
resources.resources === null || typeof resources.resources === 'undefined'
? '—'
: typeof resources.resources === 'string'
? resources.resources
: safeStringify(resources.resources);
return {
...resources,
resourceCount,
canExpand: resourceCount > 0,
resourcesPreview: resourcesValue,
};
}
type ResourcesColumnsOptions = Record<string, never>;
function createResourcesColumns(_options: ResourcesColumnsOptions): DataTableColumn<ResourcesTableRecord>[] {
return [
{
accessor: 'resourcesId',
title: 'Resources ID',
sortable: true,
render: ({ resourcesId }) => (
<Group gap={2}>
<Text fw={500} size='sm'>
{resourcesId}
</Text>
<CopyButton value={resourcesId}>
{({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy'} withArrow>
<ActionIcon
aria-label={`Copy resources ID ${resourcesId}`}
variant='subtle'
color={copied ? 'teal' : 'gray'}
size='sm'
onClick={(event) => {
event.stopPropagation();
copy();
}}
>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
),
},
{
accessor: 'version',
title: 'Version',
sortable: true,
textAlign: 'left',
render: ({ version }) => <Text size='sm'>{version}</Text>,
},
{
accessor: 'createTime',
title: 'Created',
sortable: true,
textAlign: 'left',
render: ({ createTime }) => <Text size='sm'>{formatDateTime(createTime)}</Text>,
},
{
accessor: 'updateTime',
title: 'Updated',
sortable: true,
textAlign: 'left',
render: ({ updateTime }) => <Text size='sm'>{formatDateTime(updateTime)}</Text>,
},
{
accessor: 'resourceCount',
title: 'Count',
sortable: true,
textAlign: 'left',
render: ({ resourceCount }) => <Text size='sm'>{resourceCount}</Text>,
},
{
accessor: 'resourcesPreview',
title: 'Preview',
render: ({ resourcesPreview }) => (
<Text size='sm' ff='monospace' c='dimmed' lineClamp={1} style={{ width: '100%' }}>
{resourcesPreview}
</Text>
),
},
];
}
type RowExpansionRenderer = (context: {
resources: Resources;
columns: DataTableColumn<ResourcesTableRecord>[];
}) => ReactNode;
export type ResourcesTableProps = {
resourcesList: Resources[] | undefined;
totalRecords: number;
isFetching: boolean;
isError: boolean;
error: unknown;
searchTerm: string;
sort: { column: string; direction: 'asc' | 'desc' };
page: number;
recordsPerPage: number;
onSortStatusChange: (status: DataTableSortStatus<ResourcesTableRecord>) => void;
onPageChange: (page: number) => void;
onRecordsPerPageChange: (value: number) => void;
onResetFilters: () => void;
onRefetch: () => void;
recordsPerPageOptions?: number[];
renderRowExpansion?: RowExpansionRenderer;
};
export function ResourcesTable({
resourcesList,
totalRecords,
isFetching,
isError,
error,
searchTerm,
sort,
page,
recordsPerPage,
onSortStatusChange,
onPageChange,
onRecordsPerPageChange,
onResetFilters,
onRefetch,
recordsPerPageOptions = DEFAULT_RECORDS_PER_PAGE_OPTIONS,
renderRowExpansion,
}: ResourcesTableProps) {
const [expandedRecordIds, setExpandedRecordIds] = useState<string[]>([]);
const { ref: tableContainerRef, width: containerWidth } = useElementSize();
const resourcesRecords = useMemo<ResourcesTableRecord[]>(() => {
if (!resourcesList) {
return [];
}
return resourcesList.map((resourcesItem) => buildResourcesRecord(resourcesItem));
}, [resourcesList]);
const columns = useMemo(() => createResourcesColumns({}), []);
const responsiveColumns = useMemo(
() => createResponsiveColumns(columns, containerWidth, COLUMN_VISIBILITY),
[columns, containerWidth],
);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(Math.max(0, totalRecords) / Math.max(1, recordsPerPage))),
[recordsPerPage, totalRecords],
);
useEffect(() => {
if (page > totalPages) {
onPageChange(totalPages);
}
}, [onPageChange, page, totalPages]);
useEffect(() => {
setExpandedRecordIds((current) =>
current.filter((id) => resourcesRecords.some((record) => record.resourcesId === id && record.canExpand)),
);
}, [resourcesRecords]);
const hasActiveFilters = searchTerm.trim().length > 0;
const sortStatus: DataTableSortStatus<ResourcesTableRecord> = {
columnAccessor: sort.column,
direction: sort.direction,
};
const handleSortStatusChange = useCallback(
(status: DataTableSortStatus<ResourcesTableRecord>) => {
onSortStatusChange(status);
},
[onSortStatusChange],
);
const errorDescriptor = isError ? getErrorDescriptor(error) : null;
const errorMessage = isError
? `Resources are temporarily unavailable${errorDescriptor ? ` (${errorDescriptor})` : ''}.`
: 'Resources are temporarily unavailable.';
const emptyState = (
<Stack gap='sm' align='center' py='lg'>
{isError ? (
<>
<Text fw={600} size='sm'>
{errorMessage}
</Text>
<Text size='sm' c='dimmed' ta='center'>
Use the retry button to try again, or adjust the filters to broaden the results.
</Text>
<Group gap='xs'>
<Button size='xs' variant='light' color='gray' leftSection={<IconRefresh size={14} />} onClick={onRefetch}>
Retry
</Button>
{hasActiveFilters ? (
<Button size='xs' variant='subtle' onClick={onResetFilters}>
Clear filters
</Button>
) : null}
</Group>
</>
) : (
<>
<Text fw={600} size='sm'>
No resources found
</Text>
<Text size='sm' c='dimmed' ta='center'>
{hasActiveFilters
? 'Try adjusting the search to see more results.'
: 'Try refreshing to fetch the latest resources.'}
</Text>
<Group gap='xs'>
<Button size='xs' variant='light' leftSection={<IconRefresh size={14} />} onClick={onRefetch}>
Refresh
</Button>
{hasActiveFilters ? (
<Button size='xs' variant='subtle' onClick={onResetFilters}>
Clear filters
</Button>
) : null}
</Group>
</>
)}
</Stack>
);
return (
<Box ref={tableContainerRef}>
<DataTable<ResourcesTableRecord>
classNames={{ root: 'resources-table' }}
withTableBorder
withColumnBorders
highlightOnHover
verticalAlign='center'
minHeight={resourcesRecords.length === 0 ? 500 : undefined}
idAccessor='resourcesId'
records={resourcesRecords}
columns={responsiveColumns}
totalRecords={totalRecords}
recordsPerPage={recordsPerPage}
page={page}
onPageChange={onPageChange}
onRecordsPerPageChange={onRecordsPerPageChange}
recordsPerPageOptions={recordsPerPageOptions}
sortStatus={sortStatus}
onSortStatusChange={handleSortStatusChange}
fetching={isFetching}
loaderSize='sm'
emptyState={resourcesRecords.length === 0 ? emptyState : undefined}
rowExpansion={
renderRowExpansion
? {
allowMultiple: true,
expandable: ({ record }) => record.canExpand,
expanded: {
recordIds: expandedRecordIds,
onRecordIdsChange: (nextRecordIds: SetStateAction<string[]>) => {
setExpandedRecordIds((previous) => {
const resolved =
typeof nextRecordIds === 'function'
? nextRecordIds(previous)
: ((nextRecordIds ?? []) as (string | number)[]);
return resolved
.map(String)
.filter((id) =>
resourcesRecords.some(
(tableRecord) => tableRecord.resourcesId === id && tableRecord.canExpand,
),
);
});
},
},
content: ({ record }) => renderRowExpansion({ resources: record, columns: responsiveColumns }),
}
: undefined
}
/>
</Box>
);
}
@@ -0,0 +1,312 @@
// Copyright (c) Microsoft. All rights reserved.
import { useMemo, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@tabler/icons-react';
import { Box, Stack, TextInput, Title } from '@mantine/core';
import type { Resources } from '@/types';
import { ResourcesTable } from './ResourcesTable.component';
import { ResourcesTree } from './ResourcesTree.component';
const meta: Meta<typeof ResourcesTable> = {
title: 'Components/ResourcesTable',
component: ResourcesTable,
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof ResourcesTable>;
const sampleResources: Resources[] = [
{
resourcesId: 'rs-story-001',
version: 1,
createTime: 1710806400,
updateTime: 1713412800,
resources: {
model: {
name: 'gpt-4',
version: '2024-01-01',
temperature: 0.7,
maxTokens: 2048,
topP: 0.9,
},
database: {
host: 'db.example.com',
port: 5432,
name: 'production',
pool: {
min: 2,
max: 10,
idle: 30000,
},
},
cache: {
type: 'redis',
host: 'cache.example.com',
port: 6379,
ttl: 3600,
},
},
},
{
resourcesId: 'rs-story-002',
version: 2,
createTime: 1712217600,
updateTime: 1714823200,
resources: {
model: {
name: 'claude-3-opus',
version: '2024-02-01',
temperature: 0.5,
maxTokens: 4096,
},
storage: {
type: 's3',
bucket: 'training-data',
region: 'us-east-1',
credentials: {
accessKeyId: 'AKIA***',
encrypted: true,
},
},
compute: {
instances: [
{ id: 'i-001', type: 't3.large', zone: 'us-east-1a' },
{ id: 'i-002', type: 't3.large', zone: 'us-east-1b' },
],
autoScaling: {
min: 2,
max: 10,
targetCpu: 70,
},
},
},
},
{
resourcesId: 'rs-story-003',
version: 3,
createTime: 1709251200,
updateTime: 1711856800,
resources: {
model: {
name: 'gpt-3.5-turbo',
version: '2023-12-01',
temperature: 0.8,
maxTokens: 1024,
},
monitoring: {
enabled: true,
interval: 60,
metrics: ['cpu', 'memory', 'disk', 'network'],
alerts: {
email: 'ops@example.com',
slack: '#alerts',
pagerduty: true,
},
},
},
},
{
resourcesId: 'rs-story-004',
version: 1,
createTime: 1706745600,
updateTime: 1709347200,
resources: {
apiKeys: {
openai: 'sk-***',
anthropic: 'sk-ant-***',
replicate: 'r8-***',
},
rateLimits: {
requestsPerMinute: 100,
tokensPerDay: 1000000,
concurrent: 5,
},
},
},
{
resourcesId: 'rs-story-005',
version: 1,
createTime: 1704067200,
updateTime: 1706668800,
resources: {},
},
];
type WrapperProps = {
maxWidth: number;
resourcesList?: Resources[] | undefined;
isFetching?: boolean;
isError?: boolean;
error?: unknown;
};
function ResourcesTableStoryWrapper({
maxWidth,
resourcesList = sampleResources,
isFetching = false,
isError = false,
error = null,
}: WrapperProps) {
const [searchTerm, setSearchTerm] = useState('');
const [page, setPage] = useState(1);
const [recordsPerPage, setRecordsPerPage] = useState(5);
const [sort, setSort] = useState<{ column: string; direction: 'asc' | 'desc' }>({
column: 'resourcesId',
direction: 'asc',
});
const baseResources = resourcesList ?? [];
const filteredResources = useMemo(() => {
const normalized = searchTerm.trim().toLowerCase();
if (normalized.length === 0) {
return baseResources;
}
return baseResources.filter((resource) => resource.resourcesId.toLowerCase().includes(normalized));
}, [baseResources, searchTerm]);
const sortedResources = useMemo(() => {
const items = filteredResources.slice();
const resolveSortValue = (resource: Resources, column: string) => {
switch (column) {
case 'version':
return resource.version;
case 'createTime':
return resource.createTime;
case 'updateTime':
return resource.updateTime;
case 'resourceCount':
return Object.keys(resource.resources ?? {}).length;
case 'resourcesId':
default:
return resource.resourcesId;
}
};
items.sort((a, b) => {
const aValue = resolveSortValue(a, sort.column);
const bValue = resolveSortValue(b, sort.column);
if (aValue === bValue) {
return 0;
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return aValue - bValue;
}
return String(aValue).localeCompare(String(bValue));
});
if (sort.direction === 'desc') {
items.reverse();
}
return items;
}, [filteredResources, sort]);
const totalRecordsValue = sortedResources.length;
const pagedResources = useMemo(() => {
const startIndex = (page - 1) * recordsPerPage;
return sortedResources.slice(startIndex, startIndex + recordsPerPage);
}, [page, recordsPerPage, sortedResources]);
return (
<Box mx='auto' style={{ maxWidth, width: '100%', padding: 16 }}>
<Stack gap='md'>
<Title order={2}>Resources</Title>
<TextInput
placeholder='Search by Resources ID'
value={searchTerm}
onChange={(event) => {
setSearchTerm(event.currentTarget.value);
setPage(1);
}}
leftSection={<IconSearch size={16} />}
data-testid='resources-search-input'
w='100%'
style={{ maxWidth: 360 }}
/>
<ResourcesTable
resourcesList={pagedResources}
totalRecords={totalRecordsValue}
isFetching={isFetching}
isError={isError}
error={error}
searchTerm={searchTerm}
sort={sort}
page={page}
recordsPerPage={recordsPerPage}
onSortStatusChange={(status) => {
setSort({
column: status.columnAccessor as string,
direction: status.direction,
});
}}
onPageChange={setPage}
onRecordsPerPageChange={(value) => {
setRecordsPerPage(value);
setPage(1);
}}
onResetFilters={() => {
setSearchTerm('');
setSort({ column: 'resourcesId', direction: 'asc' });
setPage(1);
}}
onRefetch={() => undefined}
recordsPerPageOptions={[5, 10, 20]}
renderRowExpansion={({ resources }) => <ResourcesTree resources={resources} />}
/>
</Stack>
</Box>
);
}
export const WideContainer: Story = {
render: () => <ResourcesTableStoryWrapper maxWidth={1280} />,
};
export const MediumContainer: Story = {
render: () => <ResourcesTableStoryWrapper maxWidth={960} />,
};
export const NarrowContainer: Story = {
render: () => <ResourcesTableStoryWrapper maxWidth={720} />,
};
export const DrawerWidth: Story = {
render: () => <ResourcesTableStoryWrapper maxWidth={520} />,
};
export const ErrorState: Story = {
render: () => (
<ResourcesTableStoryWrapper maxWidth={600} resourcesList={[]} isError error={new Error('Network unreachable')} />
),
};
export const EmptyResources: Story = {
render: () => (
<ResourcesTableStoryWrapper
maxWidth={960}
resourcesList={[
{
resourcesId: 'rs-empty-001',
version: 1,
createTime: 1702000000,
updateTime: 1704600000,
resources: {},
},
]}
/>
),
};
export const LoadingState: Story = {
render: () => <ResourcesTableStoryWrapper maxWidth={960} resourcesList={[]} isFetching />,
};
@@ -0,0 +1,129 @@
// Copyright (c) Microsoft. All rights reserved.
import { useMemo } from 'react';
import { IconAlertCircle, IconChevronRight } from '@tabler/icons-react';
import { Box, Group, Stack, Text, Tree, type TreeNodeData } from '@mantine/core';
import type { Resources } from '@/types';
import { safeStringify } from '@/utils/format';
function convertToTreeData(obj: any, key: string = 'root', parentPath = ''): TreeNodeData {
const isObject = obj !== null && typeof obj === 'object' && !Array.isArray(obj);
const isArray = Array.isArray(obj);
const currentPath = parentPath ? `${parentPath}.${key}` : key;
if (isObject) {
const children = Object.entries(obj).map(([childKey, childValue]) =>
convertToTreeData(childValue, childKey, currentPath),
);
return {
value: currentPath,
label: (
<Group gap={6}>
<Text size='sm' fw={500}>
{key}
</Text>
<Text size='xs' c='dimmed'>
(Object)
</Text>
</Group>
),
children: children.length > 0 ? children : undefined,
};
}
if (isArray) {
const children = obj.map((item: any, index: number) => convertToTreeData(item, `[${index}]`, currentPath));
return {
value: currentPath,
label: (
<Group gap={6}>
<Text size='sm' fw={500}>
{key}
</Text>
<Text size='xs' c='dimmed'>
(Array[
{obj.length}
])
</Text>
</Group>
),
children: children.length > 0 ? children : undefined,
};
}
// Primitive value
return {
value: currentPath,
label: (
<Group gap={6}>
<Text size='sm' fw={500}>
{key}:
</Text>
<Text size='sm' ff='monospace' c='dimmed'>
{safeStringify(obj)}
</Text>
</Group>
),
};
}
export type ResourcesTreeProps = {
resources: Resources;
};
export function ResourcesTree({ resources }: ResourcesTreeProps) {
const resourcesDict = resources.resources ?? {};
const treeData = useMemo<TreeNodeData[]>(() => {
const entries = Object.entries(resourcesDict);
if (entries.length === 0) {
return [];
}
return entries.map(([key, value]) => convertToTreeData(value, key));
}, [resourcesDict]);
if (treeData.length === 0) {
return (
<Stack gap='xs' align='center' py='md'>
<IconAlertCircle size={24} color='gray' />
<Text size='sm' c='dimmed'>
No resources found
</Text>
</Stack>
);
}
return (
<Box p='md' style={{ backgroundColor: 'var(--mantine-color-default-hover)' }}>
<Tree
data={treeData}
levelOffset={20}
expandOnClick
selectOnClick
renderNode={({ node, expanded, hasChildren, elementProps }) => (
<Group gap={4} {...elementProps}>
{hasChildren && (
<Box
style={{
minWidth: 14,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 150ms ease',
}}
>
<IconChevronRight size={14} />
</Box>
)}
{node.label}
</Group>
)}
/>
</Box>
);
}
@@ -0,0 +1,200 @@
// Copyright (c) Microsoft. All rights reserved.
import type { Meta, StoryObj } from '@storybook/react';
import { Box, Stack, Title } from '@mantine/core';
import type { Resources } from '@/types';
import { ResourcesTree } from './ResourcesTree.component';
const meta: Meta<typeof ResourcesTree> = {
title: 'Components/ResourcesTree',
component: ResourcesTree,
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof ResourcesTree>;
const simpleResources: Resources = {
resourcesId: 'rs-simple-001',
version: 1,
createTime: 1704067200,
updateTime: 1706668800,
resources: {
apiKey: { value: 'sk-test-key-123', type: 'secret' },
maxRetries: { value: 3, description: 'Maximum retry attempts' },
timeout: { value: 30000, unit: 'ms' },
enabled: { value: true },
},
};
const nestedResources: Resources = {
resourcesId: 'rs-nested-001',
version: 2,
createTime: 1709251200,
updateTime: 1711856800,
resources: {
model: {
name: 'gpt-4',
version: '2024-01-01',
temperature: 0.7,
maxTokens: 2048,
topP: 0.9,
},
database: {
host: 'db.example.com',
port: 5432,
name: 'production',
pool: {
min: 2,
max: 10,
idle: 30000,
},
},
cache: {
type: 'redis',
host: 'cache.example.com',
port: 6379,
ttl: 3600,
},
},
};
const arrayResources: Resources = {
resourcesId: 'rs-array-001',
version: 3,
createTime: 1712217600,
updateTime: 1714823200,
resources: {
compute: {
instances: [
{ id: 'i-001', type: 't3.large', zone: 'us-east-1a', status: 'running' },
{ id: 'i-002', type: 't3.large', zone: 'us-east-1b', status: 'running' },
{ id: 'i-003', type: 't3.xlarge', zone: 'us-east-1c', status: 'stopped' },
],
autoScaling: {
min: 2,
max: 10,
targetCpu: 70,
},
},
tags: ['production', 'ml-training', 'auto-scale'],
ports: [80, 443, 8080],
},
};
const complexResources: Resources = {
resourcesId: 'rs-complex-001',
version: 4,
createTime: 1706745600,
updateTime: 1710000000,
resources: {
model: {
name: 'claude-3-opus',
version: '2024-02-01',
temperature: 0.5,
maxTokens: 4096,
providers: [
{ name: 'anthropic', priority: 1, enabled: true },
{ name: 'aws-bedrock', priority: 2, enabled: false },
],
},
storage: {
type: 's3',
bucket: 'training-data',
region: 'us-east-1',
credentials: {
accessKeyId: 'AKIA***',
encrypted: true,
},
lifecycle: {
transitionToIA: 30,
transitionToGlacier: 90,
expiration: 365,
},
},
monitoring: {
enabled: true,
interval: 60,
metrics: ['cpu', 'memory', 'disk', 'network'],
alerts: {
email: 'ops@example.com',
slack: '#alerts',
pagerduty: true,
thresholds: {
cpu: { warning: 70, critical: 90 },
memory: { warning: 80, critical: 95 },
disk: { warning: 75, critical: 90 },
},
},
},
apiKeys: {
openai: 'sk-***',
anthropic: 'sk-ant-***',
replicate: 'r8-***',
},
rateLimits: {
requestsPerMinute: 100,
tokensPerDay: 1000000,
concurrent: 5,
burstMultiplier: 1.5,
},
},
};
const emptyResources: Resources = {
resourcesId: 'rs-empty-001',
version: 1,
createTime: 1702000000,
updateTime: 1704600000,
resources: {},
};
type WrapperProps = {
resources: Resources;
maxWidth?: number;
};
function ResourcesTreeStoryWrapper({ resources, maxWidth = 800 }: WrapperProps) {
return (
<Box mx='auto' style={{ maxWidth, width: '100%', padding: 16 }}>
<Stack gap='md'>
<Title order={2}>
Resources:
{resources.resourcesId}
</Title>
<ResourcesTree resources={resources} />
</Stack>
</Box>
);
}
export const SimpleValues: Story = {
render: () => <ResourcesTreeStoryWrapper resources={simpleResources} />,
};
export const NestedObjects: Story = {
render: () => <ResourcesTreeStoryWrapper resources={nestedResources} />,
};
export const WithArrays: Story = {
render: () => <ResourcesTreeStoryWrapper resources={arrayResources} />,
};
export const ComplexStructure: Story = {
render: () => <ResourcesTreeStoryWrapper resources={complexResources} maxWidth={1000} />,
};
export const EmptyResources: Story = {
render: () => <ResourcesTreeStoryWrapper resources={emptyResources} />,
};
export const NarrowContainer: Story = {
render: () => <ResourcesTreeStoryWrapper resources={complexResources} maxWidth={500} />,
};
export const WideContainer: Story = {
render: () => <ResourcesTreeStoryWrapper resources={complexResources} maxWidth={1400} />,
};
@@ -0,0 +1,774 @@
// Copyright (c) Microsoft. All rights reserved.
import { useCallback, useEffect, useMemo, useState, type ReactNode, type SetStateAction } from 'react';
import {
IconAlertCircle,
IconCheck,
IconCopy,
IconFileDescription,
IconRefresh,
IconReload,
IconTimeline,
} from '@tabler/icons-react';
import { DataTable, type DataTableColumn, type DataTableSortStatus } from 'mantine-datatable';
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
CopyButton,
Group,
MultiSelect,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import {
type Attempt,
type AttemptStatus,
type Rollout,
type RolloutMode,
type RolloutsSortState,
type RolloutStatus,
} from '@/features/rollouts';
import {
clampToNow,
formatDateTime,
formatDuration,
formatRelativeTime,
formatStatusLabel,
safeStringify,
toTimestamp,
} from '@/utils/format';
import { createResponsiveColumns, type ColumnVisibilityConfig } from '@/utils/table';
const ROLLOUT_STATUS_OPTIONS: RolloutStatus[] = [
'queuing',
'preparing',
'running',
'failed',
'succeeded',
'cancelled',
'requeuing',
];
const ATTEMPT_STATUS_COLORS: Record<AttemptStatus, string> = {
failed: 'red',
preparing: 'violet',
running: 'blue',
succeeded: 'teal',
timeout: 'orange',
unresponsive: 'orange',
};
const ROLLOUT_STATUS_COLORS: Record<RolloutStatus, string> = {
cancelled: 'gray',
failed: 'red',
preparing: 'violet',
queuing: 'gray',
requeuing: 'gray',
running: 'blue',
succeeded: 'teal',
};
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 },
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 },
mode: { fixedWidth: 8, priority: 3 },
lastHeartbeatTimestamp: { fixedWidth: 10, priority: 3 },
workerId: { fixedWidth: 10, priority: 3 },
};
export type RolloutTableRecord = Rollout & {
attemptId: string | null;
attemptSequence: number | null;
isNested: boolean;
canExpand: boolean;
inputText: string;
attemptStatus?: AttemptStatus;
statusValue: string;
startTimestamp: number | null;
durationSeconds: number | null;
lastHeartbeatTimestamp: number | null;
workerId: string | null;
actionsPlaceholder?: null;
};
function selectHeartbeatTimestamp(attempt?: Attempt | null): number | null {
if (!attempt || attempt.lastHeartbeatTime == null || Number.isNaN(attempt.lastHeartbeatTime)) {
return null;
}
return attempt.lastHeartbeatTime;
}
export function buildRolloutRecord(rollout: Rollout): RolloutTableRecord {
const latestAttempt = rollout.attempt;
const inputValue =
rollout.input === null || typeof rollout.input === 'undefined'
? '—'
: typeof rollout.input === 'string'
? rollout.input
: safeStringify(rollout.input);
const startTimestamp = toTimestamp(latestAttempt?.startTime ?? rollout.startTime);
const endTimestamp = toTimestamp(latestAttempt?.endTime ?? rollout.endTime);
const durationSeconds = clampToNow(startTimestamp, endTimestamp);
const attemptStatus = latestAttempt?.status;
const sequenceId = latestAttempt?.sequenceId;
const statusValue =
attemptStatus && attemptStatus !== rollout.status ? `${rollout.status}-${attemptStatus}` : rollout.status;
return {
...rollout,
attempt: latestAttempt ?? null,
attemptId: latestAttempt?.attemptId ?? null,
attemptSequence: latestAttempt?.sequenceId ?? null,
isNested: false,
canExpand: Boolean(sequenceId && sequenceId > 1),
inputText: inputValue,
attemptStatus,
statusValue,
startTimestamp,
durationSeconds,
lastHeartbeatTimestamp: rollout.attempt?.lastHeartbeatTime ?? null,
workerId: latestAttempt?.workerId ?? null,
actionsPlaceholder: null,
};
}
function buildAttemptRecord(rollout: Rollout, attempt: Attempt): RolloutTableRecord {
const inputValue =
rollout.input === null || typeof rollout.input === 'undefined'
? '—'
: typeof rollout.input === 'string'
? rollout.input
: safeStringify(rollout.input);
const startTimestamp = toTimestamp(attempt.startTime ?? rollout.startTime);
const endTimestamp = toTimestamp(attempt.endTime);
const durationSeconds = clampToNow(startTimestamp, endTimestamp);
const lastHeartbeatTimestamp = selectHeartbeatTimestamp(attempt);
return {
...rollout,
attempt,
attemptId: attempt.attemptId,
attemptSequence: attempt.sequenceId,
isNested: true,
canExpand: false,
inputText: inputValue,
attemptStatus: attempt.status,
statusValue: attempt.status,
startTimestamp,
durationSeconds,
lastHeartbeatTimestamp,
workerId: attempt.workerId ?? null,
actionsPlaceholder: null,
};
}
function getStatusBadge(status: string, kind: 'rollout' | 'attempt') {
const color =
kind === 'rollout'
? (ROLLOUT_STATUS_COLORS[status as RolloutStatus] ?? 'gray')
: (ATTEMPT_STATUS_COLORS[status as AttemptStatus] ?? 'gray');
return (
<Badge size='sm' variant='light' color={color}>
{formatStatusLabel(status)}
</Badge>
);
}
type RolloutColumnsOptions = {
statusFilters: RolloutStatus[];
onStatusFilterChange: (values: RolloutStatus[]) => void;
onStatusFilterReset: () => void;
modeFilters: RolloutMode[];
onModeFilterChange: (values: RolloutMode[]) => void;
onModeFilterReset: () => void;
onViewRawJson?: (record: RolloutTableRecord) => void;
onViewTraces?: (record: RolloutTableRecord) => void;
};
function createRolloutColumns({
statusFilters,
onStatusFilterChange,
onStatusFilterReset,
modeFilters,
onModeFilterChange,
onModeFilterReset,
onViewRawJson,
onViewTraces,
}: RolloutColumnsOptions): DataTableColumn<RolloutTableRecord>[] {
const statusOptions = ROLLOUT_STATUS_OPTIONS.map((status) => ({
value: status,
label: formatStatusLabel(status),
}));
const modeOptions = ROLLOUT_MODE_OPTIONS.map((mode) => ({
value: mode,
label: formatStatusLabel(mode),
}));
return [
{
accessor: 'rolloutId',
title: 'Rollout',
sortable: true,
render: ({ rolloutId }) => (
<Group gap={2}>
<Text fw={500} size='sm'>
{rolloutId}
</Text>
<CopyButton value={rolloutId}>
{({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy'} withArrow>
<ActionIcon
aria-label={`Copy rollout ID ${rolloutId}`}
variant='subtle'
color={copied ? 'teal' : 'gray'}
size='sm'
onClick={(event) => {
event.stopPropagation();
copy();
}}
>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
),
},
{
accessor: 'attemptId',
title: 'Attempt',
sortable: true,
render: ({ attemptId, attemptSequence, isNested }) => (
<Group gap={2}>
<Text size='sm' c={attemptId ? undefined : 'dimmed'}>
{attemptId ?? '—'}
</Text>
{attemptId && (
<CopyButton value={attemptId}>
{({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy'} withArrow>
<ActionIcon
aria-label={`Copy attempt ID ${attemptId}`}
variant='subtle'
color={copied ? 'teal' : 'gray'}
size='sm'
onClick={(event) => {
event.stopPropagation();
copy();
}}
>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
)}
{attemptSequence && (isNested || attemptSequence > 1) && (
<Badge leftSection={<IconReload size={12} />} pl={6} pr={6}>
{attemptSequence}
</Badge>
)}
</Group>
),
},
{
accessor: 'inputText',
title: 'Input',
render: ({ inputText }) => (
<Text size='sm' ff='monospace' c='dimmed' lineClamp={1} style={{ width: '100%' }}>
{inputText}
</Text>
),
},
{
accessor: 'statusValue',
title: 'Status',
sortable: true,
filter: ({ close }) => (
<Stack gap='xs'>
<MultiSelect
label='Status'
description='Filter rollouts by status'
data={statusOptions}
value={statusFilters}
placeholder='Select statuses...'
searchable
clearable
comboboxProps={{ withinPortal: false }}
onChange={(values) => onStatusFilterChange(values as RolloutStatus[])}
/>
<Button
variant='light'
size='xs'
onClick={() => {
onStatusFilterReset();
close();
}}
disabled={statusFilters.length === 0}
>
Clear
</Button>
</Stack>
),
filtering: statusFilters.length > 0,
render: ({ status, attemptStatus, isNested }) => {
if (isNested) {
return <Group gap={4}>{getStatusBadge(attemptStatus ?? 'unknown', 'attempt')}</Group>;
}
if (attemptStatus && attemptStatus !== status) {
return (
<Group gap={4}>
{getStatusBadge(status, 'rollout')}
<Text size='sm' c='dimmed'>
</Text>
{getStatusBadge(attemptStatus, 'attempt')}
</Group>
);
}
return getStatusBadge(status, 'rollout');
},
},
{
accessor: 'resourcesId',
title: 'Resources',
sortable: true,
render: ({ resourcesId }) => (
<Text size='sm' c={resourcesId ? undefined : 'dimmed'}>
{resourcesId ?? '—'}
</Text>
),
},
{
accessor: 'mode',
title: 'Mode',
sortable: true,
filter: ({ close }) => (
<Stack gap='xs'>
<MultiSelect
label='Mode'
description='Filter rollouts by mode'
data={modeOptions}
value={modeFilters}
placeholder='Select modes...'
searchable
clearable
comboboxProps={{ withinPortal: false }}
onChange={(values) => onModeFilterChange(values as RolloutMode[])}
/>
<Button
variant='light'
size='xs'
onClick={() => {
onModeFilterReset();
close();
}}
disabled={modeFilters.length === 0}
>
Clear
</Button>
</Stack>
),
filtering: modeFilters.length > 0,
render: ({ mode }) => (
<Text size='sm' c={mode ? undefined : 'dimmed'}>
{mode ?? '—'}
</Text>
),
},
{
accessor: 'startTimestamp',
title: 'Start Time',
sortable: true,
textAlign: 'left',
render: ({ startTimestamp }) => <Text size='sm'>{formatDateTime(startTimestamp)}</Text>,
},
{
accessor: 'durationSeconds',
title: 'Duration',
sortable: true,
textAlign: 'left',
render: ({ durationSeconds }) => <Text size='sm'>{formatDuration(durationSeconds)}</Text>,
},
{
accessor: 'lastHeartbeatTimestamp',
title: 'Last Heartbeat',
sortable: true,
textAlign: 'left',
render: ({ lastHeartbeatTimestamp, attempt, isNested }) => {
if (!attempt && isNested) {
return (
<Text size='sm' c='dimmed'>
</Text>
);
}
return <Text size='sm'>{formatRelativeTime(lastHeartbeatTimestamp)}</Text>;
},
},
{
accessor: 'workerId',
title: 'Worker',
sortable: true,
render: ({ workerId }) => (
<Text size='sm' c={workerId ? undefined : 'dimmed'}>
{workerId ?? '—'}
</Text>
),
},
{
accessor: 'actionsPlaceholder',
title: 'Actions',
render: (record) => (
<Group gap={4}>
<Tooltip label='View raw JSON' withArrow disabled={!onViewRawJson}>
<ActionIcon
aria-label='View raw JSON'
variant='subtle'
color='gray'
onClick={(event) => {
event.stopPropagation();
onViewRawJson?.(record);
}}
>
<IconFileDescription size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label='View traces' withArrow disabled={!onViewTraces}>
<ActionIcon
aria-label='View traces'
variant='subtle'
color='gray'
onClick={(event) => {
event.stopPropagation();
onViewTraces?.(record);
}}
>
<IconTimeline size={16} />
</ActionIcon>
</Tooltip>
</Group>
),
},
];
}
type RowExpansionRenderer = (context: {
rollout: Rollout;
columns: DataTableColumn<RolloutTableRecord>[];
}) => ReactNode;
export type RolloutTableProps = {
rollouts: Rollout[] | undefined;
totalRecords: number;
isFetching: boolean;
isError: boolean;
error: unknown;
searchTerm: string;
statusFilters: RolloutStatus[];
modeFilters: RolloutMode[];
sort: RolloutsSortState;
page: number;
recordsPerPage: number;
onStatusFilterChange: (values: RolloutStatus[]) => void;
onStatusFilterReset: () => void;
onModeFilterChange: (values: RolloutMode[]) => void;
onModeFilterReset: () => void;
onSortStatusChange: (status: DataTableSortStatus<RolloutTableRecord>) => void;
onPageChange: (page: number) => void;
onRecordsPerPageChange: (value: number) => void;
onResetFilters: () => void;
onRefetch: () => void;
onViewRawJson?: (record: RolloutTableRecord) => void;
onViewTraces?: (record: RolloutTableRecord) => void;
recordsPerPageOptions?: number[];
renderRowExpansion?: RowExpansionRenderer;
};
export function RolloutTable({
rollouts,
totalRecords,
isFetching,
isError,
error,
searchTerm,
statusFilters,
modeFilters,
sort,
page,
recordsPerPage,
onStatusFilterChange,
onStatusFilterReset,
onModeFilterChange,
onModeFilterReset,
onSortStatusChange,
onPageChange,
onRecordsPerPageChange,
onResetFilters,
onRefetch,
onViewRawJson,
onViewTraces,
recordsPerPageOptions = DEFAULT_RECORDS_PER_PAGE_OPTIONS,
renderRowExpansion,
}: RolloutTableProps) {
const [expandedRecordIds, setExpandedRecordIds] = useState<string[]>([]);
const { ref: tableContainerRef, width: containerWidth } = useElementSize();
const rolloutRecords = useMemo<RolloutTableRecord[]>(() => {
if (!rollouts) {
return [];
}
return rollouts.map((rolloutItem) => buildRolloutRecord(rolloutItem));
}, [rollouts]);
const columns = useMemo(
() =>
createRolloutColumns({
statusFilters,
onStatusFilterChange,
onStatusFilterReset,
modeFilters,
onModeFilterChange,
onModeFilterReset,
onViewRawJson,
onViewTraces,
}),
[
statusFilters,
onStatusFilterChange,
onStatusFilterReset,
modeFilters,
onModeFilterChange,
onModeFilterReset,
onViewRawJson,
onViewTraces,
],
);
const responsiveColumns = useMemo(
() => createResponsiveColumns(columns, containerWidth, COLUMN_VISIBILITY),
[columns, containerWidth],
);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(Math.max(0, totalRecords) / Math.max(1, recordsPerPage))),
[recordsPerPage, totalRecords],
);
useEffect(() => {
if (page > totalPages) {
onPageChange(totalPages);
}
}, [onPageChange, page, totalPages]);
useEffect(() => {
setExpandedRecordIds((current) =>
current.filter((id) => rolloutRecords.some((record) => record.rolloutId === id && record.canExpand)),
);
}, [rolloutRecords]);
const hasActiveFilters = searchTerm.trim().length > 0 || statusFilters.length > 0 || modeFilters.length > 0;
const sortStatus: DataTableSortStatus<RolloutTableRecord> = {
columnAccessor: sort.column,
direction: sort.direction,
};
const handleSortStatusChange = useCallback(
(status: DataTableSortStatus<RolloutTableRecord>) => {
onSortStatusChange(status);
},
[onSortStatusChange],
);
const errorMessage =
isError && error && typeof error === 'object' && 'status' in (error as Record<string, unknown>)
? `Rollouts are temporarily unavailable (status: ${String((error as Record<string, unknown>).status)}).`
: 'Rollouts are temporarily unavailable.';
const emptyState = (
<Stack gap='sm' align='center' py='lg'>
{isError ? (
<>
<Text fw={600} size='sm'>
{errorMessage}
</Text>
<Text size='sm' c='dimmed' ta='center'>
Use the retry button to try again, or adjust the filters to broaden the results.
</Text>
<Group gap='xs'>
<Button size='xs' variant='light' color='gray' leftSection={<IconRefresh size={14} />} onClick={onRefetch}>
Retry
</Button>
{hasActiveFilters ? (
<Button size='xs' variant='subtle' onClick={onResetFilters}>
Clear filters
</Button>
) : null}
</Group>
</>
) : (
<>
<Text fw={600} size='sm'>
No rollouts found
</Text>
<Text size='sm' c='dimmed' ta='center'>
{hasActiveFilters
? 'Try adjusting the search or filters to see more results.'
: 'Try refreshing to fetch the latest rollouts.'}
</Text>
<Group gap='xs'>
<Button size='xs' variant='light' leftSection={<IconRefresh size={14} />} onClick={onRefetch}>
Refresh
</Button>
{hasActiveFilters ? (
<Button size='xs' variant='subtle' onClick={onResetFilters}>
Clear filters
</Button>
) : null}
</Group>
</>
)}
</Stack>
);
return (
<Box ref={tableContainerRef}>
<DataTable<RolloutTableRecord>
classNames={{ root: 'rollouts-table' }}
withTableBorder
withColumnBorders
highlightOnHover
verticalAlign='center'
minHeight={rolloutRecords.length === 0 ? 500 : undefined}
idAccessor='rolloutId'
records={rolloutRecords}
columns={responsiveColumns}
totalRecords={totalRecords}
recordsPerPage={recordsPerPage}
page={page}
onPageChange={onPageChange}
onRecordsPerPageChange={onRecordsPerPageChange}
recordsPerPageOptions={recordsPerPageOptions}
sortStatus={sortStatus}
onSortStatusChange={handleSortStatusChange}
fetching={isFetching}
loaderSize='sm'
emptyState={rolloutRecords.length === 0 ? emptyState : undefined}
rowExpansion={
renderRowExpansion
? {
allowMultiple: true,
expandable: ({ record }) => record.canExpand,
expanded: {
recordIds: expandedRecordIds,
onRecordIdsChange: (nextRecordIds: SetStateAction<string[]>) => {
setExpandedRecordIds((previous) => {
const resolved =
typeof nextRecordIds === 'function'
? nextRecordIds(previous)
: ((nextRecordIds ?? []) as (string | number)[]);
return resolved
.map(String)
.filter((id) =>
rolloutRecords.some((tableRecord) => tableRecord.rolloutId === id && tableRecord.canExpand),
);
});
},
},
content: ({ record }) => renderRowExpansion({ rollout: record, columns: responsiveColumns }),
}
: undefined
}
/>
</Box>
);
}
export type RolloutAttemptsTableProps = {
rollout: Rollout;
attempts: Attempt[] | undefined;
isFetching: boolean;
isError: boolean;
onRetry: () => void;
columns: DataTableColumn<RolloutTableRecord>[];
};
export function RolloutAttemptsTable({
rollout,
attempts,
isFetching,
isError,
onRetry,
columns,
}: RolloutAttemptsTableProps) {
const attemptRecords = useMemo<RolloutTableRecord[]>(() => {
if (!attempts) {
return [];
}
return attempts
.map((attempt) => buildAttemptRecord(rollout, attempt))
.sort((a, b) => (b.attemptSequence ?? 0) - (a.attemptSequence ?? 0))
.filter((record) => record.attemptSequence !== rollout.attempt?.sequenceId);
}, [attempts, rollout]);
if (isError && !attemptRecords.length) {
return (
<Alert color='red' variant='light' icon={<IconAlertCircle size={16} />}>
<Stack gap='xs'>
<Text size='sm'>Unable to load attempts for this rollout.</Text>
<Button size='xs' variant='light' leftSection={<IconRefresh size={14} />} onClick={onRetry}>
Retry
</Button>
</Stack>
</Alert>
);
}
const emptyState = (
<Stack gap='xs' align='center' py='md'>
<Text size='sm' c='dimmed'>
No attempts found for this rollout.
</Text>
<Button size='xs' variant='light' leftSection={<IconRefresh size={14} />} onClick={onRetry}>
Refresh
</Button>
</Stack>
);
return (
<DataTable<RolloutTableRecord>
classNames={{ root: 'rollouts-table rollouts-table--nested' }}
withColumnBorders
noHeader
minHeight={0}
idAccessor='attemptId'
verticalAlign='center'
fetching={isFetching}
loaderSize='sm'
records={attemptRecords}
columns={columns}
emptyState={attemptRecords.length === 0 ? emptyState : undefined}
/>
);
}
@@ -0,0 +1,307 @@
// Copyright (c) Microsoft. All rights reserved.
import { useMemo, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@tabler/icons-react';
import { Box, Stack, TextInput, Title } from '@mantine/core';
import type { RolloutsSortState } from '@/features/rollouts';
import type { Rollout, RolloutMode, RolloutStatus } from '@/types';
import { compareRecords } from '@/utils/table';
import { STORY_DATE_NOW_SECONDS } from '../../.storybook/constants';
import { buildRolloutRecord, RolloutTable, type RolloutTableRecord } from './RolloutTable.component';
const meta: Meta<typeof RolloutTable> = {
title: 'Components/RolloutTable',
component: RolloutTable,
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof RolloutTable>;
const now = STORY_DATE_NOW_SECONDS;
const sampleRollouts: Rollout[] = [
{
rolloutId: 'ro-story-001',
input: { task: 'Generate onboarding summary' },
startTime: now - 3200,
endTime: null,
mode: 'train',
resourcesId: 'rs-story-001',
status: 'running',
config: { retries: 1 },
metadata: { owner: 'alice' },
attempt: {
rolloutId: 'ro-story-001',
attemptId: 'at-story-010',
sequenceId: 1,
startTime: now - 3200,
endTime: null,
status: 'running',
workerId: 'worker-east',
lastHeartbeatTime: now - 45,
metadata: { info: 'Worker is processing' },
},
},
{
rolloutId: 'ro-story-002',
input: { task: 'Classify feedback tickets' },
startTime: now - 7200,
endTime: now - 5400,
mode: 'val',
resourcesId: 'rs-story-002',
status: 'succeeded',
config: { retries: 2 },
metadata: { owner: 'bob' },
attempt: {
rolloutId: 'ro-story-002',
attemptId: 'at-story-011',
sequenceId: 2,
startTime: now - 6200,
endTime: now - 5400,
status: 'succeeded',
workerId: 'worker-north',
lastHeartbeatTime: now - 5400,
metadata: { previousAttempt: 'at-story-010' },
},
},
{
rolloutId: 'ro-story-003',
input: { task: 'Analyze experiment results' },
startTime: now - 10800,
endTime: now - 9600,
mode: 'test',
resourcesId: 'rs-story-003',
status: 'failed',
config: { retries: 1 },
metadata: { owner: 'carol' },
attempt: {
rolloutId: 'ro-story-003',
attemptId: 'at-story-012',
sequenceId: 3,
startTime: now - 10200,
endTime: now - 9600,
status: 'failed',
workerId: 'worker-west',
lastHeartbeatTime: now - 9600,
metadata: { reason: 'Timeout' },
},
},
{
rolloutId: 'ro-story-004',
input: { task: 'Evaluate prompt variants' },
startTime: now - 3600,
endTime: null,
mode: 'train',
resourcesId: null,
status: 'preparing',
config: { retries: 0 },
metadata: { owner: 'dave' },
attempt: null,
},
{
rolloutId: 'ro-story-005',
input: { task: 'Generate quick answers' },
startTime: now - 1800,
endTime: null,
mode: 'val',
resourcesId: 'rs-story-004',
status: 'running',
config: { retries: 0 },
metadata: { owner: 'eva' },
attempt: {
rolloutId: 'ro-story-005',
attemptId: 'at-story-013',
sequenceId: 1,
startTime: now - 1800,
endTime: null,
status: 'running',
workerId: null,
lastHeartbeatTime: now - 75,
metadata: null,
},
},
{
rolloutId: 'ro-story-006',
input: { task: 'Compile release notes' },
startTime: now - 9600,
endTime: now - 9000,
mode: null,
resourcesId: 'rs-story-005',
status: 'cancelled',
config: { retries: 3 },
metadata: null,
attempt: {
rolloutId: 'ro-story-006',
attemptId: 'at-story-014',
sequenceId: 1,
startTime: now - 9600,
endTime: now - 9000,
status: 'timeout',
workerId: 'worker-south',
lastHeartbeatTime: now - 9000,
metadata: { info: 'Cancelled by operator' },
},
},
];
type WrapperProps = {
maxWidth: number;
rollouts?: Rollout[] | undefined;
isFetching?: boolean;
isError?: boolean;
error?: unknown;
};
function RolloutTableStoryWrapper({
maxWidth,
rollouts = sampleRollouts,
isFetching = false,
isError = false,
error = null,
}: WrapperProps) {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilters, setStatusFilters] = useState<RolloutStatus[]>([]);
const [modeFilters, setModeFilters] = useState<RolloutMode[]>([]);
const [page, setPage] = useState(1);
const [recordsPerPage, setRecordsPerPage] = useState(5);
const [sort, setSort] = useState<RolloutsSortState>({
column: 'startTimestamp',
direction: 'desc',
});
const tableRecords = useMemo<RolloutTableRecord[]>(() => {
if (!rollouts) {
return [];
}
return rollouts.map((rolloutItem) => buildRolloutRecord(rolloutItem));
}, [rollouts]);
const filteredRecords = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase();
return tableRecords.filter((record) => {
const matchesSearch = normalizedSearch.length === 0 || record.rolloutId.toLowerCase().includes(normalizedSearch);
const matchesStatus = statusFilters.length === 0 || statusFilters.includes(record.status);
const matchesMode = modeFilters.length === 0 || (record.mode !== null && modeFilters.includes(record.mode));
return matchesSearch && matchesStatus && matchesMode;
});
}, [modeFilters, searchTerm, statusFilters, tableRecords]);
const sortedRecords = useMemo(() => {
const sorted = filteredRecords.slice();
if (!sorted.length) {
return sorted;
}
const comparatorKey = sort.column as keyof RolloutTableRecord;
if (!(comparatorKey in sorted[0])) {
return sorted;
}
sorted.sort((a, b) => compareRecords(a, b, comparatorKey));
if (sort.direction === 'desc') {
sorted.reverse();
}
return sorted;
}, [filteredRecords, sort]);
const totalRecordsValue = sortedRecords.length;
const pagedRecords = useMemo(() => {
const startIndex = (page - 1) * recordsPerPage;
const endIndex = startIndex + recordsPerPage;
return sortedRecords.slice(startIndex, endIndex);
}, [page, recordsPerPage, sortedRecords]);
const pagedRollouts = useMemo(() => pagedRecords.map((record) => record as Rollout), [pagedRecords]);
return (
<Box mx='auto' style={{ maxWidth, width: '100%', padding: 16 }}>
<Stack gap='md'>
<Title order={2}>Rollouts</Title>
<TextInput
placeholder='Search by Rollout ID'
value={searchTerm}
onChange={(event) => setSearchTerm(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
data-testid='rollouts-search-input'
w='100%'
style={{ maxWidth: 360 }}
/>
<RolloutTable
rollouts={pagedRollouts}
totalRecords={totalRecordsValue}
isFetching={isFetching}
isError={isError}
error={error}
searchTerm={searchTerm}
statusFilters={statusFilters}
modeFilters={modeFilters}
sort={sort}
page={page}
recordsPerPage={recordsPerPage}
onStatusFilterChange={(values) => {
setStatusFilters(values);
setPage(1);
}}
onStatusFilterReset={() => {
setStatusFilters([]);
setPage(1);
}}
onModeFilterChange={(values) => {
setModeFilters(values);
setPage(1);
}}
onModeFilterReset={() => {
setModeFilters([]);
setPage(1);
}}
onSortStatusChange={(status) => {
setSort({
column: status.columnAccessor as string,
direction: status.direction,
});
}}
onPageChange={setPage}
onRecordsPerPageChange={(value) => {
setRecordsPerPage(value);
setPage(1);
}}
onResetFilters={() => {
setSearchTerm('');
setStatusFilters([]);
setModeFilters([]);
setSort({ column: 'startTimestamp', direction: 'desc' });
setPage(1);
}}
onRefetch={() => undefined}
recordsPerPageOptions={[5, 10, 20]}
/>
</Stack>
</Box>
);
}
export const WideContainer: Story = {
render: () => <RolloutTableStoryWrapper maxWidth={1280} />,
};
export const MediumContainer: Story = {
render: () => <RolloutTableStoryWrapper maxWidth={960} />,
};
export const NarrowContainer: Story = {
render: () => <RolloutTableStoryWrapper maxWidth={720} />,
};
export const DrawerWidth: Story = {
render: () => <RolloutTableStoryWrapper maxWidth={520} />,
};
export const ErrorState: Story = {
render: () => (
<RolloutTableStoryWrapper maxWidth={600} rollouts={[]} isError error={new Error('Network unreachable')} />
),
};
@@ -0,0 +1,447 @@
// Copyright (c) Microsoft. All rights reserved.
import { useCallback, useEffect, useMemo } from 'react';
import {
IconAlertCircle,
IconCheck,
IconCopy,
IconFileDescription,
IconRefresh,
IconRouteSquare,
} 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 type { Span } from '@/types';
import { getErrorDescriptor } from '@/utils/error';
import { formatDateTime, 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 },
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 },
actionsPlaceholder: { fixedWidth: 6, priority: 0 },
};
const STATUS_COLORS: Record<string, string> = {
UNSET: 'gray',
OK: 'teal',
ERROR: 'red',
};
export type TracesTableRecord = Span & {
statusCode: string;
attributeKeys: string;
duration: number;
actionsPlaceholder?: null;
};
export function buildTraceRecord(span: Span): TracesTableRecord {
const statusCode = span.status.status_code;
const attributeKeys = Object.keys(span.attributes ?? {}).join(', ') || '';
const startTimestamp = toTimestamp(span.startTime);
const endTimestamp = toTimestamp(span.endTime);
const duration = endTimestamp && startTimestamp ? endTimestamp - startTimestamp : 0;
return {
...span,
statusCode,
attributeKeys,
duration,
actionsPlaceholder: null,
};
}
type TracesColumnsOptions = {
onShowRollout?: (record: TracesTableRecord) => void;
onShowSpanDetail?: (record: TracesTableRecord) => void;
onParentIdClick?: (parentId: string) => void;
spanIds: Set<string>;
};
function createTracesColumns({
onShowRollout,
onShowSpanDetail,
onParentIdClick,
spanIds,
}: TracesColumnsOptions): DataTableColumn<TracesTableRecord>[] {
return [
{
accessor: 'name',
title: 'Name',
sortable: true,
render: ({ name }) => (
<Text size='sm' fw={500}>
{name}
</Text>
),
},
{
accessor: 'traceId',
title: 'Trace ID',
sortable: true,
render: ({ traceId }) => (
<Group gap={2}>
<Text size='sm'>{traceId}</Text>
<CopyButton value={traceId}>
{({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy'} withArrow>
<ActionIcon
aria-label={`Copy trace ID ${traceId}`}
variant='subtle'
color={copied ? 'teal' : 'gray'}
size='sm'
onClick={(event) => {
event.stopPropagation();
copy();
}}
>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
),
},
{
accessor: 'spanId',
title: 'Span ID',
sortable: true,
render: ({ spanId }) => (
<Group gap={2}>
<Text size='sm'>{spanId}</Text>
<CopyButton value={spanId}>
{({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy'} withArrow>
<ActionIcon
aria-label={`Copy span ID ${spanId}`}
variant='subtle'
color={copied ? 'teal' : 'gray'}
size='sm'
onClick={(event) => {
event.stopPropagation();
copy();
}}
>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
),
},
{
accessor: 'parentId',
title: 'Parent ID',
sortable: true,
render: ({ parentId }) => {
if (!parentId) {
return (
<Text size='sm' c='dimmed'>
</Text>
);
}
const parentExists = spanIds.has(parentId);
const isInteractive = parentExists && typeof onParentIdClick === 'function';
return (
<Group gap={2}>
<Text
size='sm'
c={parentExists ? undefined : 'red'}
style={{ cursor: isInteractive ? 'pointer' : undefined }}
onClick={(event) => {
if (isInteractive) {
event.stopPropagation();
onParentIdClick?.(parentId);
}
}}
>
{parentId.slice(0, 8)}
</Text>
{!parentExists && (
<Tooltip label='Parent span not found in table' withArrow>
<IconAlertCircle size={14} color='red' />
</Tooltip>
)}
</Group>
);
},
},
{
accessor: 'statusCode',
title: 'Status',
sortable: true,
render: ({ statusCode }) => (
<Badge size='sm' variant='light' color={STATUS_COLORS[statusCode] ?? 'gray'}>
{statusCode}
</Badge>
),
},
{
accessor: 'attributeKeys',
title: 'Attribute Keys',
render: ({ attributeKeys }) =>
attributeKeys ? (
<Text size='sm' lineClamp={1}>
{/* TODO: dim "." and "," and other characters are just normal text */}
{attributeKeys}
</Text>
) : (
<Text size='sm' c='dimmed'>
</Text>
),
},
{
accessor: 'startTime',
title: 'Start Time',
sortable: true,
textAlign: 'left',
render: ({ startTime }) => <Text size='sm'>{formatDateTime(toTimestamp(startTime))}</Text>,
},
{
accessor: 'duration',
title: 'Duration',
sortable: true,
textAlign: 'left',
render: ({ duration }) => <Text size='sm'>{formatDuration(duration)}</Text>,
},
{
accessor: 'actionsPlaceholder',
title: 'Actions',
render: (record) => (
<Group gap={2}>
<Tooltip label='Show rollout' withArrow disabled={!onShowRollout}>
<ActionIcon
aria-label='Show rollout'
variant='subtle'
color='gray'
onClick={(event) => {
event.stopPropagation();
onShowRollout?.(record);
}}
>
<IconRouteSquare size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label='Show span detail' withArrow disabled={!onShowSpanDetail}>
<ActionIcon
aria-label='Show span detail'
variant='subtle'
color='gray'
onClick={(event) => {
event.stopPropagation();
onShowSpanDetail?.(record);
}}
>
<IconFileDescription size={16} />
</ActionIcon>
</Tooltip>
</Group>
),
},
];
}
export type TracesTableProps = {
spans: Span[] | undefined;
totalRecords: number;
isFetching: boolean;
isError: boolean;
error: unknown;
selectionMessage?: string;
searchTerm: string;
sort: { column: string; direction: 'asc' | 'desc' };
page: number;
recordsPerPage: number;
onSortStatusChange: (status: DataTableSortStatus<TracesTableRecord>) => void;
onPageChange: (page: number) => void;
onRecordsPerPageChange: (value: number) => void;
onResetFilters: () => void;
onRefetch: () => void;
onShowRollout?: (record: TracesTableRecord) => void;
onShowSpanDetail?: (record: TracesTableRecord) => void;
onParentIdClick?: (parentId: string) => void;
recordsPerPageOptions?: number[];
};
export function TracesTable({
spans,
totalRecords,
isFetching,
isError,
error,
selectionMessage,
searchTerm,
sort,
page,
recordsPerPage,
onSortStatusChange,
onPageChange,
onRecordsPerPageChange,
onResetFilters,
onRefetch,
onShowRollout,
onShowSpanDetail,
onParentIdClick,
recordsPerPageOptions = DEFAULT_RECORDS_PER_PAGE_OPTIONS,
}: TracesTableProps) {
const { ref: tableContainerRef, width: containerWidth } = useElementSize();
const traceRecords = useMemo<TracesTableRecord[]>(() => {
if (!spans) {
return [];
}
return spans.map((span) => buildTraceRecord(span));
}, [spans]);
const spanIds = useMemo(() => {
return new Set(traceRecords.map((record) => record.spanId));
}, [traceRecords]);
const columns = useMemo(
() =>
createTracesColumns({
onShowRollout,
onShowSpanDetail,
onParentIdClick,
spanIds,
}),
[onShowRollout, onShowSpanDetail, onParentIdClick, spanIds],
);
const responsiveColumns = useMemo(
() => createResponsiveColumns(columns, containerWidth, COLUMN_VISIBILITY),
[columns, containerWidth],
);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(Math.max(0, totalRecords) / Math.max(1, recordsPerPage))),
[recordsPerPage, totalRecords],
);
useEffect(() => {
if (page > totalPages) {
onPageChange(totalPages);
}
}, [onPageChange, page, totalPages]);
const hasActiveFilters = searchTerm.trim().length > 0;
const sortStatus: DataTableSortStatus<TracesTableRecord> = {
columnAccessor: sort.column,
direction: sort.direction,
};
const handleSortStatusChange = useCallback(
(status: DataTableSortStatus<TracesTableRecord>) => {
onSortStatusChange(status);
},
[onSortStatusChange],
);
const errorDescriptor = isError ? getErrorDescriptor(error) : null;
const errorMessage = isError
? `Traces are temporarily unavailable${errorDescriptor ? ` (${errorDescriptor})` : ''}.`
: 'Traces are temporarily unavailable.';
const selectionEmptyState = selectionMessage ? (
<Stack gap='sm' align='center' py='xl'>
<Text fw={600} size='sm'>
{selectionMessage}
</Text>
<Text size='sm' c='dimmed' ta='center'>
Choose a rollout and attempt from the controls above to load trace results.
</Text>
</Stack>
) : null;
const fallbackEmptyState = (
<Stack gap='sm' align='center' py='lg'>
{isError ? (
<>
<Text fw={600} size='sm'>
{errorMessage}
</Text>
<Text size='sm' c='dimmed' ta='center'>
Use the retry button to try again, or adjust the filters to broaden the results.
</Text>
<Group gap='xs'>
<Button size='xs' variant='light' color='gray' leftSection={<IconRefresh size={14} />} onClick={onRefetch}>
Retry
</Button>
{hasActiveFilters ? (
<Button size='xs' variant='subtle' onClick={onResetFilters}>
Clear filters
</Button>
) : null}
</Group>
</>
) : (
<>
<Text fw={600} size='sm'>
No traces found
</Text>
<Text size='sm' c='dimmed' ta='center'>
{hasActiveFilters
? 'Try adjusting the search to see more results.'
: 'Try refreshing to fetch the latest traces.'}
</Text>
<Group gap='xs'>
<Button size='xs' variant='light' leftSection={<IconRefresh size={14} />} onClick={onRefetch}>
Refresh
</Button>
{hasActiveFilters ? (
<Button size='xs' variant='subtle' onClick={onResetFilters}>
Clear filters
</Button>
) : null}
</Group>
</>
)}
</Stack>
);
const emptyState = selectionEmptyState ?? fallbackEmptyState;
return (
<Box ref={tableContainerRef}>
<DataTable<TracesTableRecord>
classNames={{ root: 'traces-table' }}
withTableBorder
withColumnBorders
highlightOnHover
verticalAlign='center'
minHeight={traceRecords.length === 0 ? 500 : undefined}
idAccessor='spanId'
records={traceRecords}
columns={responsiveColumns}
totalRecords={totalRecords}
recordsPerPage={recordsPerPage}
page={page}
onPageChange={onPageChange}
onRecordsPerPageChange={onRecordsPerPageChange}
recordsPerPageOptions={recordsPerPageOptions}
sortStatus={sortStatus}
onSortStatusChange={handleSortStatusChange}
fetching={isFetching}
loaderSize='sm'
emptyState={traceRecords.length === 0 ? emptyState : undefined}
/>
</Box>
);
}
@@ -0,0 +1,367 @@
// Copyright (c) Microsoft. All rights reserved.
import { useMemo, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@tabler/icons-react';
import { Box, Stack, TextInput, Title } from '@mantine/core';
import type { Span } from '@/types';
import { compareRecords } from '@/utils/table';
import { buildTraceRecord, TracesTable, type TracesTableRecord } from './TracesTable.component';
const meta: Meta<typeof TracesTable> = {
title: 'Components/TracesTable',
component: TracesTable,
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof TracesTable>;
const now = Math.floor(1762775145209 / 1000);
const sampleSpans: Span[] = [
{
rolloutId: 'ro-trace-001',
attemptId: 'at-trace-001',
sequenceId: 1,
traceId: 'trace-abc123def456',
spanId: 'span-root-001',
parentId: null,
name: 'main_task',
status: { status_code: 'OK', description: null },
attributes: {
'task.type': 'generation',
'task.priority': 'high',
'user.id': 'user-123',
},
startTime: now - 100,
endTime: now - 10,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-trace-001',
attemptId: 'at-trace-001',
sequenceId: 1,
traceId: 'trace-abc123def456',
spanId: 'span-child-001',
parentId: 'span-root-001',
name: 'llm_call',
status: { status_code: 'OK', description: null },
attributes: {
'llm.model': 'gpt-4',
'llm.temperature': 0.7,
'llm.max_tokens': 2048,
},
startTime: now - 90,
endTime: now - 50,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-trace-001',
attemptId: 'at-trace-001',
sequenceId: 1,
traceId: 'trace-abc123def456',
spanId: 'span-child-002',
parentId: 'span-root-001',
name: 'database_query',
status: { status_code: 'OK', description: null },
attributes: {
'db.system': 'postgresql',
'db.operation': 'SELECT',
'db.table': 'users',
},
startTime: now - 80,
endTime: now - 70,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-trace-002',
attemptId: 'at-trace-002',
sequenceId: 1,
traceId: 'trace-xyz789ghi012',
spanId: 'span-error-001',
parentId: 'span-missing-parent',
name: 'failed_operation',
status: { status_code: 'ERROR', description: 'Connection timeout' },
attributes: {
'error.type': 'TimeoutError',
'error.message': 'Connection timed out after 30s',
},
startTime: now - 150,
endTime: now - 120,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-trace-003',
attemptId: 'at-trace-003',
sequenceId: 1,
traceId: 'trace-unset123',
spanId: 'span-unset-001',
parentId: null,
name: 'pending_task',
status: { status_code: 'UNSET', description: null },
attributes: {},
startTime: now - 30,
endTime: now - 5,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-trace-004',
attemptId: 'at-trace-004',
sequenceId: 2,
traceId: 'trace-nested456',
spanId: 'span-parent-001',
parentId: null,
name: 'workflow_execution',
status: { status_code: 'OK', description: null },
attributes: {
'workflow.name': 'data_processing',
'workflow.version': '2.1.0',
},
startTime: now - 200,
endTime: now - 50,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-trace-004',
attemptId: 'at-trace-004',
sequenceId: 2,
traceId: 'trace-nested456',
spanId: 'span-child-nested-001',
parentId: 'span-parent-001',
name: 'step_1_validation',
status: { status_code: 'OK', description: null },
attributes: {
'step.name': 'validation',
'step.index': 1,
},
startTime: now - 195,
endTime: now - 180,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-trace-004',
attemptId: 'at-trace-004',
sequenceId: 2,
traceId: 'trace-nested456',
spanId: 'span-child-nested-002',
parentId: 'span-parent-001',
name: 'step_2_processing',
status: { status_code: 'ERROR', description: 'Validation failed' },
attributes: {
'step.name': 'processing',
'step.index': 2,
'error.type': 'ValidationError',
},
startTime: now - 175,
endTime: now - 160,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
];
type WrapperProps = {
maxWidth: number;
spans?: Span[] | undefined;
isFetching?: boolean;
isError?: boolean;
error?: unknown;
};
function TracesTableStoryWrapper({
maxWidth,
spans = sampleSpans,
isFetching = false,
isError = false,
error = null,
}: WrapperProps) {
const [searchTerm, setSearchTerm] = useState('');
const [page, setPage] = useState(1);
const [recordsPerPage, setRecordsPerPage] = useState(10);
const [sort, setSort] = useState<{ column: string; direction: 'asc' | 'desc' }>({
column: 'startTime',
direction: 'desc',
});
const tableRecords = useMemo<TracesTableRecord[]>(() => {
if (!spans) {
return [];
}
return spans.map((span) => buildTraceRecord(span));
}, [spans]);
const filteredRecords = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase();
if (normalizedSearch.length === 0) {
return tableRecords;
}
return tableRecords.filter(
(record) =>
record.traceId.toLowerCase().includes(normalizedSearch) ||
record.spanId.toLowerCase().includes(normalizedSearch) ||
record.name.toLowerCase().includes(normalizedSearch),
);
}, [searchTerm, tableRecords]);
const sortedRecords = useMemo(() => {
const sorted = filteredRecords.slice();
if (!sorted.length) {
return sorted;
}
const comparatorKey = sort.column as keyof TracesTableRecord;
if (!(comparatorKey in sorted[0])) {
return sorted;
}
sorted.sort((a, b) => compareRecords(a, b, comparatorKey));
if (sort.direction === 'desc') {
sorted.reverse();
}
return sorted;
}, [filteredRecords, sort]);
const totalRecordsValue = sortedRecords.length;
const pagedRecords = useMemo(() => {
const startIndex = (page - 1) * recordsPerPage;
const endIndex = startIndex + recordsPerPage;
return sortedRecords.slice(startIndex, endIndex);
}, [page, recordsPerPage, sortedRecords]);
const pagedSpans = useMemo(() => pagedRecords.map((record) => record as Span), [pagedRecords]);
const handleShowRollout = (record: any) => {
console.log('Show rollout for:', record.rolloutId);
};
const handleShowSpanDetail = (record: any) => {
console.log('Show span detail for:', record.spanId, record);
};
const handleParentIdClick = (parentId: string) => {
console.log('Navigate to parent span:', parentId);
setSearchTerm(parentId);
};
return (
<Box mx='auto' style={{ maxWidth, width: '100%', padding: 16 }}>
<Stack gap='md'>
<Title order={2}>Traces</Title>
<TextInput
placeholder='Search by Trace ID, Span ID, or Name'
value={searchTerm}
onChange={(event) => setSearchTerm(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
data-testid='traces-search-input'
w='100%'
style={{ maxWidth: 360 }}
/>
<TracesTable
spans={pagedSpans}
totalRecords={totalRecordsValue}
isFetching={isFetching}
isError={isError}
error={error}
searchTerm={searchTerm}
sort={sort}
page={page}
recordsPerPage={recordsPerPage}
onSortStatusChange={(status) => {
setSort({
column: status.columnAccessor as string,
direction: status.direction,
});
}}
onPageChange={setPage}
onRecordsPerPageChange={(value) => {
setRecordsPerPage(value);
setPage(1);
}}
onResetFilters={() => {
setSearchTerm('');
setSort({ column: 'startTime', direction: 'desc' });
setPage(1);
}}
onRefetch={() => undefined}
onShowRollout={handleShowRollout}
onShowSpanDetail={handleShowSpanDetail}
onParentIdClick={handleParentIdClick}
recordsPerPageOptions={[10, 20, 50]}
/>
</Stack>
</Box>
);
}
export const WideContainer: Story = {
render: () => <TracesTableStoryWrapper maxWidth={1400} />,
};
export const MediumContainer: Story = {
render: () => <TracesTableStoryWrapper maxWidth={960} />,
};
export const NarrowContainer: Story = {
render: () => <TracesTableStoryWrapper maxWidth={720} />,
};
export const DrawerWidth: Story = {
render: () => <TracesTableStoryWrapper maxWidth={520} />,
};
export const ErrorState: Story = {
render: () => <TracesTableStoryWrapper maxWidth={960} spans={[]} isError error={new Error('Network unreachable')} />,
};
export const LoadingState: Story = {
render: () => <TracesTableStoryWrapper maxWidth={960} spans={[]} isFetching />,
};
export const EmptyState: Story = {
render: () => <TracesTableStoryWrapper maxWidth={960} spans={[]} />,
};
export const WithMissingParent: Story = {
render: () => (
<TracesTableStoryWrapper maxWidth={1200} spans={sampleSpans.filter((s) => s.spanId === 'span-error-001')} />
),
};
export const NestedSpans: Story = {
render: () => (
<TracesTableStoryWrapper maxWidth={1200} spans={sampleSpans.filter((s) => s.traceId === 'trace-nested456')} />
),
};
+436
View File
@@ -0,0 +1,436 @@
// Copyright (c) Microsoft. All rights reserved.
import { alpha, CSSVariablesResolver } from '@mantine/core';
export const shadcnCssVariableResolver: CSSVariablesResolver = () => ({
variables: {
// variables that do not depend on color scheme
'--mantine-heading-font-weight': '600',
'--mantine-primary-color-filled-hover': alpha('var(--mantine-primary-color-filled)', 0.9),
'--mantine-primary-color-light': 'var(--mantine-color-zinc-light)',
'--mantine-primary-color-light-hover': 'var(--mantine-color-zinc-light-hover)',
'--mantine-primary-color-light-color': 'var(--mantine-color-zinc-light-color)',
},
light: {
// all variables that depend on light color scheme
'--mantine-primary-color-contrast': 'var(--mantine-color-zinc-0)', // used as primary color contrast
'--mantine-color-text': 'var(--mantine-color-secondary-9)', // used as text color
'--mantine-color-body': 'var(--mantine-color-white)', // used as body color
'--mantine-color-error': 'var(--mantine-color-error-10)', // used as error color
'--mantine-color-placeholder': 'var(--mantine-color-secondary-10)', // used as placeholder color
'--mantine-color-anchor': 'var(--mantine-color-secondary-10)', // used as anchor color
'--mantine-color-default': 'var(--mantine-color-secondary-0)', // used as default surface color
'--mantine-color-default-hover': 'var(--mantine-color-secondary-1)', // used as default hover color
'--mantine-color-default-color': 'var(--mantine-color-secondary-9)', // used as default text color
'--mantine-color-default-border': 'var(--mantine-color-secondary-2)', // used as default border color
'--mantine-color-dimmed': 'var(--mantine-color-secondary-10)', // used as dimmed text color
'--mantine-color-secondary-filled': 'var(--mantine-color-white)', // used as secondary surface color
'--mantine-color-secondary-filled-hover': 'var(--mantine-color-secondary-1)', // used as secondary hover color
'--mantine-color-secondary-light': 'var(--mantine-color-secondary-1)', // used as primary light color
'--mantine-color-secondary-light-hover': alpha('var(--mantine-color-secondary-light)', 0.8), // used as primary light hover color
'--mantine-color-secondary-text': 'var(--mantine-primary-color-contrast)', // can be used as secondary text color
'--mantine-color-secondary-light-color': 'var(--mantine-color-secondary-8)', // used as primary light variant's text color
'--mantine-color-secondary-outline': 'var(--mantine-color-secondary-2)',
'--mantine-color-secondary-outline-hover': 'var(--mantine-color-secondary-1)',
// all filled colors
'--mantine-color-zinc-filled': 'var(--mantine-color-zinc-8)',
'--mantine-color-zinc-filled-hover': alpha('var(--mantine-color-zinc-8)', 0.9),
'--mantine-color-slate-filled': 'var(--mantine-color-slate-8)',
'--mantine-color-slate-filled-hover': alpha('var(--mantine-color-slate-8)', 0.9),
'--mantine-color-gray-filled': 'var(--mantine-color-gray-8)',
'--mantine-color-gray-filled-hover': alpha('var(--mantine-color-gray-8)', 0.9),
'--mantine-color-neutral-filled': 'var(--mantine-color-neutral-8)',
'--mantine-color-neutral-filled-hover': alpha('var(--mantine-color-neutral-8)', 0.9),
'--mantine-color-stone-filled': 'var(--mantine-color-stone-8)',
'--mantine-color-stone-filled-hover': alpha('var(--mantine-color-stone-8)', 0.9),
'--mantine-color-red-filled': 'var(--mantine-color-red-5)',
'--mantine-color-red-filled-hover': alpha('var(--mantine-color-red-5)', 0.9),
'--mantine-color-rose-filled': 'var(--mantine-color-rose-5)',
'--mantine-color-rose-filled-hover': alpha('var(--mantine-color-rose-5)', 0.9),
'--mantine-color-orange-filled': 'var(--mantine-color-orange-5)',
'--mantine-color-orange-filled-hover': alpha('var(--mantine-color-orange-5)', 0.9),
'--mantine-color-amber-filled': 'var(--mantine-color-amber-5)',
'--mantine-color-amber-filled-hover': alpha('var(--mantine-color-amber-5)', 0.9),
'--mantine-color-yellow-filled': 'var(--mantine-color-yellow-4)',
'--mantine-color-yellow-filled-hover': alpha('var(--mantine-color-yellow-4)', 0.9),
'--mantine-color-lime-filled': 'var(--mantine-color-lime-5)',
'--mantine-color-lime-filled-hover': alpha('var(--mantine-color-lime-5)', 0.9),
'--mantine-color-green-filled': 'var(--mantine-color-green-6)',
'--mantine-color-green-filled-hover': alpha('var(--mantine-color-green-6)', 0.9),
'--mantine-color-emerald-filled': 'var(--mantine-color-emerald-5)',
'--mantine-color-emerald-filled-hover': alpha('var(--mantine-color-emerald-5)', 0.9),
'--mantine-color-teal-filled': 'var(--mantine-color-teal-5)',
'--mantine-color-teal-filled-hover': alpha('var(--mantine-color-teal-5)', 0.9),
'--mantine-color-cyan-filled': 'var(--mantine-color-cyan-5)',
'--mantine-color-cyan-filled-hover': alpha('var(--mantine-color-cyan-5)', 0.9),
'--mantine-color-sky-filled': 'var(--mantine-color-sky-5)',
'--mantine-color-sky-filled-hover': alpha('var(--mantine-color-sky-5)', 0.9),
'--mantine-color-blue-filled': 'var(--mantine-color-blue-6)',
'--mantine-color-blue-filled-hover': alpha('var(--mantine-color-blue-6)', 0.9),
'--mantine-color-indigo-filled': 'var(--mantine-color-indigo-5)',
'--mantine-color-indigo-filled-hover': alpha('var(--mantine-color-indigo-5)', 0.9),
'--mantine-color-violet-filled': 'var(--mantine-color-violet-5)',
'--mantine-color-violet-filled-hover': alpha('var(--mantine-color-violet-5)', 0.9),
'--mantine-color-purple-filled': 'var(--mantine-color-purple-5)',
'--mantine-color-purple-filled-hover': alpha('var(--mantine-color-purple-5)', 0.9),
'--mantine-color-fuchsia-filled': 'var(--mantine-color-fuchsia-5)',
'--mantine-color-fuchsia-filled-hover': alpha('var(--mantine-color-fuchsia-5)', 0.9),
'--mantine-color-pink-filled': 'var(--mantine-color-pink-5)',
'--mantine-color-pink-filled-hover': alpha('var(--mantine-color-pink-5)', 0.9),
// all light colors
'--mantine-color-zinc-light': alpha('var(--mantine-color-zinc-4)', 0.1),
'--mantine-color-zinc-light-hover': alpha('var(--mantine-color-zinc-light)', 0.8),
'--mantine-color-zinc-light-color': 'var(--mantine-color-zinc-6)',
'--mantine-color-slate-light': alpha('var(--mantine-color-slate-4)', 0.1),
'--mantine-color-slate-light-hover': alpha('var(--mantine-color-slate-light)', 0.8),
'--mantine-color-slate-light-color': 'var(--mantine-color-slate-6)',
'--mantine-color-gray-light': alpha('var(--mantine-color-gray-4)', 0.1),
'--mantine-color-gray-light-hover': alpha('var(--mantine-color-gray-light)', 0.8),
'--mantine-color-gray-light-color': 'var(--mantine-color-gray-6)',
'--mantine-color-neutral-light': alpha('var(--mantine-color-neutral-4)', 0.1),
'--mantine-color-neutral-light-hover': alpha('var(--mantine-color-neutral-light)', 0.8),
'--mantine-color-neutral-light-color': 'var(--mantine-color-neutral-6)',
'--mantine-color-stone-light': alpha('var(--mantine-color-stone-4)', 0.1),
'--mantine-color-stone-light-hover': alpha('var(--mantine-color-stone-light)', 0.8),
'--mantine-color-stone-light-color': 'var(--mantine-color-stone-6)',
'--mantine-color-red-light': alpha('var(--mantine-color-red-4)', 0.1),
'--mantine-color-red-light-hover': alpha('var(--mantine-color-red-light)', 0.8),
'--mantine-color-red-light-color': 'var(--mantine-color-red-6)',
'--mantine-color-rose-light': alpha('var(--mantine-color-rose-4)', 0.1),
'--mantine-color-rose-light-hover': alpha('var(--mantine-color-rose-light)', 0.8),
'--mantine-color-rose-light-color': 'var(--mantine-color-rose-6)',
'--mantine-color-orange-light': alpha('var(--mantine-color-orange-4)', 0.1),
'--mantine-color-orange-light-hover': alpha('var(--mantine-color-orange-light)', 0.8),
'--mantine-color-orange-light-color': 'var(--mantine-color-orange-6)',
'--mantine-color-amber-light': alpha('var(--mantine-color-amber-4)', 0.1),
'--mantine-color-amber-light-hover': alpha('var(--mantine-color-amber-light)', 0.8),
'--mantine-color-amber-light-color': 'var(--mantine-color-amber-6)',
'--mantine-color-yellow-light': alpha('var(--mantine-color-yellow-4)', 0.1),
'--mantine-color-yellow-light-hover': alpha('var(--mantine-color-yellow-light)', 0.8),
'--mantine-color-yellow-light-color': 'var(--mantine-color-yellow-6)',
'--mantine-color-lime-light': alpha('var(--mantine-color-lime-4)', 0.1),
'--mantine-color-lime-light-hover': alpha('var(--mantine-color-lime-light)', 0.8),
'--mantine-color-lime-light-color': 'var(--mantine-color-lime-6)',
'--mantine-color-green-light': alpha('var(--mantine-color-green-4)', 0.1),
'--mantine-color-green-light-hover': alpha('var(--mantine-color-green-light)', 0.8),
'--mantine-color-green-light-color': 'var(--mantine-color-green-6)',
'--mantine-color-emerald-light': alpha('var(--mantine-color-emerald-4)', 0.1),
'--mantine-color-emerald-light-hover': alpha('var(--mantine-color-emerald-light)', 0.8),
'--mantine-color-emerald-light-color': 'var(--mantine-color-emerald-6)',
'--mantine-color-teal-light': alpha('var(--mantine-color-teal-4)', 0.1),
'--mantine-color-teal-light-hover': alpha('var(--mantine-color-teal-light)', 0.8),
'--mantine-color-teal-light-color': 'var(--mantine-color-teal-6)',
'--mantine-color-cyan-light': alpha('var(--mantine-color-cyan-4)', 0.1),
'--mantine-color-cyan-light-hover': alpha('var(--mantine-color-cyan-light)', 0.8),
'--mantine-color-cyan-light-color': 'var(--mantine-color-cyan-6)',
'--mantine-color-sky-light': alpha('var(--mantine-color-sky-4)', 0.1),
'--mantine-color-sky-light-hover': alpha('var(--mantine-color-sky-light)', 0.8),
'--mantine-color-sky-light-color': 'var(--mantine-color-sky-6)',
'--mantine-color-blue-light': alpha('var(--mantine-color-blue-4)', 0.1),
'--mantine-color-blue-light-hover': alpha('var(--mantine-color-blue-light)', 0.8),
'--mantine-color-blue-light-color': 'var(--mantine-color-blue-6)',
'--mantine-color-indigo-light': alpha('var(--mantine-color-indigo-4)', 0.1),
'--mantine-color-indigo-light-hover': alpha('var(--mantine-color-indigo-light)', 0.8),
'--mantine-color-indigo-light-color': 'var(--mantine-color-indigo-6)',
'--mantine-color-violet-light': alpha('var(--mantine-color-violet-4)', 0.1),
'--mantine-color-violet-light-hover': alpha('var(--mantine-color-violet-light)', 0.8),
'--mantine-color-violet-light-color': 'var(--mantine-color-violet-6)',
'--mantine-color-purple-light': alpha('var(--mantine-color-purple-4)', 0.1),
'--mantine-color-purple-light-hover': alpha('var(--mantine-color-purple-light)', 0.8),
'--mantine-color-purple-light-color': 'var(--mantine-color-purple-6)',
'--mantine-color-fuchsia-light': alpha('var(--mantine-color-fuchsia-4)', 0.1),
'--mantine-color-fuchsia-light-hover': alpha('var(--mantine-color-fuchsia-light)', 0.8),
'--mantine-color-fuchsia-light-color': 'var(--mantine-color-fuchsia-6)',
'--mantine-color-pink-light': alpha('var(--mantine-color-pink-4)', 0.1),
'--mantine-color-pink-light-hover': alpha('var(--mantine-color-pink-light)', 0.8),
'--mantine-color-pink-light-color': 'var(--mantine-color-pink-6)',
// all outline colors
'--mantine-color-zinc-outline': 'var(--mantine-color-zinc-8)',
'--mantine-color-zinc-outline-hover': alpha('var(--mantine-color-zinc-4)', 0.1),
'--mantine-color-slate-outline': 'var(--mantine-color-slate-8)',
'--mantine-color-slate-outline-hover': alpha('var(--mantine-color-slate-4)', 0.1),
'--mantine-color-gray-outline': 'var(--mantine-color-gray-8)',
'--mantine-color-gray-outline-hover': alpha('var(--mantine-color-gray-4)', 0.1),
'--mantine-color-neutral-outline': 'var(--mantine-color-neutral-8)',
'--mantine-color-neutral-outline-hover': alpha('var(--mantine-color-neutral-4)', 0.1),
'--mantine-color-stone-outline': 'var(--mantine-color-stone-8)',
'--mantine-color-stone-outline-hover': alpha('var(--mantine-color-stone-4)', 0.1),
'--mantine-color-red-outline': 'var(--mantine-color-red-5)',
'--mantine-color-red-outline-hover': alpha('var(--mantine-color-red-4)', 0.1),
'--mantine-color-rose-outline': 'var(--mantine-color-rose-5)',
'--mantine-color-rose-outline-hover': alpha('var(--mantine-color-rose-4)', 0.1),
'--mantine-color-orange-outline': 'var(--mantine-color-orange-5)',
'--mantine-color-orange-outline-hover': alpha('var(--mantine-color-orange-4)', 0.1),
'--mantine-color-amber-outline': 'var(--mantine-color-amber-5)',
'--mantine-color-amber-outline-hover': alpha('var(--mantine-color-amber-4)', 0.1),
'--mantine-color-yellow-outline': 'var(--mantine-color-yellow-4)',
'--mantine-color-yellow-outline-hover': alpha('var(--mantine-color-yellow-4)', 0.1),
'--mantine-color-lime-outline': 'var(--mantine-color-lime-5)',
'--mantine-color-lime-outline-hover': alpha('var(--mantine-color-lime-4)', 0.1),
'--mantine-color-green-outline': 'var(--mantine-color-green-6)',
'--mantine-color-green-outline-hover': alpha('var(--mantine-color-green-4)', 0.1),
'--mantine-color-emerald-outline': 'var(--mantine-color-emerald-5)',
'--mantine-color-emerald-outline-hover': alpha('var(--mantine-color-emerald-4)', 0.1),
'--mantine-color-teal-outline': 'var(--mantine-color-teal-5)',
'--mantine-color-teal-outline-hover': alpha('var(--mantine-color-teal-4)', 0.1),
'--mantine-color-cyan-outline': 'var(--mantine-color-cyan-5)',
'--mantine-color-cyan-outline-hover': alpha('var(--mantine-color-cyan-4)', 0.1),
'--mantine-color-sky-outline': 'var(--mantine-color-sky-5)',
'--mantine-color-sky-outline-hover': alpha('var(--mantine-color-sky-4)', 0.1),
'--mantine-color-blue-outline': 'var(--mantine-color-blue-6)',
'--mantine-color-blue-outline-hover': alpha('var(--mantine-color-blue-4)', 0.1),
'--mantine-color-indigo-outline': 'var(--mantine-color-indigo-5)',
'--mantine-color-indigo-outline-hover': alpha('var(--mantine-color-indigo-4)', 0.1),
'--mantine-color-violet-outline': 'var(--mantine-color-violet-5)',
'--mantine-color-violet-outline-hover': alpha('var(--mantine-color-violet-4)', 0.1),
'--mantine-color-purple-outline': 'var(--mantine-color-purple-5)',
'--mantine-color-purple-outline-hover': alpha('var(--mantine-color-purple-4)', 0.1),
'--mantine-color-fuchsia-outline': 'var(--mantine-color-fuchsia-5)',
'--mantine-color-fuchsia-outline-hover': alpha('var(--mantine-color-fuchsia-4)', 0.1),
'--mantine-color-pink-outline': 'var(--mantine-color-pink-5)',
'--mantine-color-pink-outline-hover': alpha('var(--mantine-color-pink-4)', 0.1),
// all contrast colors
'--mantine-color-zinc-contrast': 'var(--mantine-color-zinc-0)',
'--mantine-color-slate-contrast': 'var(--mantine-color-slate-0)',
'--mantine-color-gray-contrast': 'var(--mantine-color-gray-0)',
'--mantine-color-neutral-contrast': 'var(--mantine-color-neutral-0)',
'--mantine-color-stone-contrast': 'var(--mantine-color-stone-0)',
'--mantine-color-red-contrast': 'var(--mantine-color-red-0)',
'--mantine-color-rose-contrast': 'var(--mantine-color-rose-0)',
'--mantine-color-orange-contrast': 'var(--mantine-color-stone-0)',
'--mantine-color-amber-contrast': 'var(--mantine-color-amber-0)',
'--mantine-color-yellow-contrast': '#422006',
'--mantine-color-lime-contrast': 'var(--mantine-color-lime-0)',
'--mantine-color-green-contrast': 'var(--mantine-color-rose-0)',
'--mantine-color-emerald-contrast': 'var(--mantine-color-emerald-0)',
'--mantine-color-teal-contrast': 'var(--mantine-color-teal-0)',
'--mantine-color-cyan-contrast': 'var(--mantine-color-cyan-0)',
'--mantine-color-sky-contrast': 'var(--mantine-color-sky-0)',
'--mantine-color-blue-contrast': 'var(--mantine-color-slate-0)',
'--mantine-color-indigo-contrast': 'var(--mantine-color-indigo-0)',
'--mantine-color-violet-contrast': 'var(--mantine-color-gray-0)',
'--mantine-color-purple-contrast': 'var(--mantine-color-purple-0)',
'--mantine-color-fuchsia-contrast': 'var(--mantine-color-fuchsia-0)',
'--mantine-color-pink-contrast': 'var(--mantine-color-pink-0)',
},
dark: {
// all variables that depend on dark color scheme
'--mantine-primary-color-contrast': 'var(--mantine-color-zinc-8)', // used as primary color contrast
'--mantine-color-text': 'var(--mantine-color-secondary-0)', // used as text color
'--mantine-color-body': 'var(--mantine-color-secondary-9)', // used as body color
'--mantine-color-error': 'var(--mantine-color-error-10)', // used as error color
'--mantine-color-placeholder': 'var(--mantine-color-secondary-4)', // used as placeholder color
'--mantine-color-anchor': 'var(--mantine-color-secondary-4)', // used as anchor color
'--mantine-color-default': 'var(--mantine-color-secondary-9)', // used as default surface color
'--mantine-color-default-hover': 'var(--mantine-color-secondary-7)', // used as default hover color
'--mantine-color-default-color': 'var(--mantine-color-secondary-1)', // used as default text color
'--mantine-color-default-border': 'var(--mantine-color-secondary-7)', // used as default border color
'--mantine-color-dimmed': 'var(--mantine-color-secondary-4)', // used as dimmed text color
'--mantine-color-secondary-filled': 'var(--mantine-color-secondary-8)', // used as secondary surface color
'--mantine-color-secondary-filled-hover': alpha('var(--mantine-color-secondary-filled)', 0.9), // used as secondary hover color
'--mantine-color-secondary-light': 'var(--mantine-color-secondary-7)', // used as primary light color
'--mantine-color-secondary-light-hover': alpha('var(--mantine-color-secondary-light)', 0.8), // used as primary light hover color
'--mantine-color-secondary-text': 'var(--mantine-primary-color-contrast)', // can be used as secondary text color
'--mantine-color-secondary-light-color': 'var(--mantine-color-secondary-0)', // used as primary light text color
'--mantine-color-secondary-outline': 'var(--mantine-color-secondary-7)',
'--mantine-color-secondary-outline-hover': 'var(--mantine-color-secondary-7)',
// all filled colors
'--mantine-color-zinc-filled': 'var(--mantine-color-zinc-0)',
'--mantine-color-zinc-filled-hover': alpha('var(--mantine-color-zinc-0)', 0.9),
'--mantine-color-slate-filled': 'var(--mantine-color-slate-0)',
'--mantine-color-slate-filled-hover': alpha('var(--mantine-color-slate-0)', 0.9),
'--mantine-color-gray-filled': 'var(--mantine-color-gray-0)',
'--mantine-color-gray-filled-hover': alpha('var(--mantine-color-gray-0)', 0.9),
'--mantine-color-neutral-filled': 'var(--mantine-color-neutral-0)',
'--mantine-color-neutral-filled-hover': alpha('var(--mantine-color-neutral-0)', 0.9),
'--mantine-color-stone-filled': 'var(--mantine-color-stone-0)',
'--mantine-color-stone-filled-hover': alpha('var(--mantine-color-stone-0)', 0.9),
'--mantine-color-red-filled': 'var(--mantine-color-red-5)',
'--mantine-color-red-filled-hover': alpha('var(--mantine-color-red-5)', 0.9),
'--mantine-color-rose-filled': 'var(--mantine-color-rose-5)',
'--mantine-color-rose-filled-hover': alpha('var(--mantine-color-rose-5)', 0.9),
'--mantine-color-orange-filled': 'var(--mantine-color-orange-6)',
'--mantine-color-orange-filled-hover': alpha('var(--mantine-color-orange-6)', 0.9),
'--mantine-color-amber-filled': 'var(--mantine-color-amber-5)',
'--mantine-color-amber-filled-hover': alpha('var(--mantine-color-amber-5)', 0.9),
'--mantine-color-yellow-filled': 'var(--mantine-color-yellow-4)',
'--mantine-color-yellow-filled-hover': alpha('var(--mantine-color-yellow-4)', 0.9),
'--mantine-color-lime-filled': 'var(--mantine-color-lime-4)',
'--mantine-color-lime-filled-hover': alpha('var(--mantine-color-lime-4)', 0.9),
'--mantine-color-green-filled': 'var(--mantine-color-green-5)',
'--mantine-color-green-filled-hover': alpha('var(--mantine-color-green-5)', 0.9),
'--mantine-color-emerald-filled': 'var(--mantine-color-emerald-5)',
'--mantine-color-emerald-filled-hover': alpha('var(--mantine-color-emerald-5)', 0.9),
'--mantine-color-teal-filled': 'var(--mantine-color-teal-4)',
'--mantine-color-teal-filled-hover': alpha('var(--mantine-color-teal-4)', 0.9),
'--mantine-color-cyan-filled': 'var(--mantine-color-cyan-4)',
'--mantine-color-cyan-filled-hover': alpha('var(--mantine-color-cyan-4)', 0.9),
'--mantine-color-sky-filled': 'var(--mantine-color-sky-4)',
'--mantine-color-sky-filled-hover': alpha('var(--mantine-color-sky-4)', 0.9),
'--mantine-color-blue-filled': 'var(--mantine-color-blue-5)',
'--mantine-color-blue-filled-hover': alpha('var(--mantine-color-blue-5)', 0.9),
'--mantine-color-indigo-filled': 'var(--mantine-color-indigo-6)',
'--mantine-color-indigo-filled-hover': alpha('var(--mantine-color-indigo-6)', 0.9),
'--mantine-color-violet-filled': 'var(--mantine-color-violet-6)',
'--mantine-color-violet-filled-hover': alpha('var(--mantine-color-violet-6)', 0.9),
'--mantine-color-purple-filled': 'var(--mantine-color-purple-6)',
'--mantine-color-purple-filled-hover': alpha('var(--mantine-color-purple-6)', 0.9),
'--mantine-color-fuchsia-filled': 'var(--mantine-color-fuchsia-7)',
'--mantine-color-fuchsia-filled-hover': alpha('var(--mantine-color-fuchsia-7)', 0.9),
'--mantine-color-pink-filled': 'var(--mantine-color-pink-6)',
'--mantine-color-pink-filled-hover': alpha('var(--mantine-color-pink-6)', 0.9),
// all light colors
'--mantine-color-zinc-light': alpha('var(--mantine-color-zinc-4)', 0.15),
'--mantine-color-zinc-light-hover': alpha('var(--mantine-color-zinc-light)', 0.8),
'--mantine-color-zinc-light-color': 'var(--mantine-color-zinc-3)',
'--mantine-color-slate-light': alpha('var(--mantine-color-slate-4)', 0.15),
'--mantine-color-slate-light-hover': alpha('var(--mantine-color-slate-light)', 0.8),
'--mantine-color-slate-light-color': 'var(--mantine-color-slate-3)',
'--mantine-color-gray-light': alpha('var(--mantine-color-gray-4)', 0.15),
'--mantine-color-gray-light-hover': alpha('var(--mantine-color-gray-light)', 0.8),
'--mantine-color-gray-light-color': 'var(--mantine-color-gray-3)',
'--mantine-color-neutral-light': alpha('var(--mantine-color-neutral-4)', 0.15),
'--mantine-color-neutral-light-hover': alpha('var(--mantine-color-neutral-light)', 0.8),
'--mantine-color-neutral-light-color': 'var(--mantine-color-neutral-3)',
'--mantine-color-stone-light': alpha('var(--mantine-color-stone-4)', 0.15),
'--mantine-color-stone-light-hover': alpha('var(--mantine-color-stone-light)', 0.8),
'--mantine-color-stone-light-color': 'var(--mantine-color-stone-3)',
'--mantine-color-red-light': alpha('var(--mantine-color-red-4)', 0.15),
'--mantine-color-red-light-hover': alpha('var(--mantine-color-red-light)', 0.8),
'--mantine-color-red-light-color': 'var(--mantine-color-red-3)',
'--mantine-color-rose-light': alpha('var(--mantine-color-rose-4)', 0.15),
'--mantine-color-rose-light-hover': alpha('var(--mantine-color-rose-light)', 0.8),
'--mantine-color-rose-light-color': 'var(--mantine-color-rose-3)',
'--mantine-color-orange-light': alpha('var(--mantine-color-orange-4)', 0.15),
'--mantine-color-orange-light-hover': alpha('var(--mantine-color-orange-light)', 0.8),
'--mantine-color-orange-light-color': 'var(--mantine-color-orange-3)',
'--mantine-color-amber-light': alpha('var(--mantine-color-amber-4)', 0.15),
'--mantine-color-amber-light-hover': alpha('var(--mantine-color-amber-light)', 0.8),
'--mantine-color-amber-light-color': 'var(--mantine-color-amber-3)',
'--mantine-color-yellow-light': alpha('var(--mantine-color-yellow-4)', 0.15),
'--mantine-color-yellow-light-hover': alpha('var(--mantine-color-yellow-light)', 0.8),
'--mantine-color-yellow-light-color': 'var(--mantine-color-yellow-3)',
'--mantine-color-lime-light': alpha('var(--mantine-color-lime-4)', 0.15),
'--mantine-color-lime-light-hover': alpha('var(--mantine-color-lime-light)', 0.8),
'--mantine-color-lime-light-color': 'var(--mantine-color-lime-3)',
'--mantine-color-green-light': alpha('var(--mantine-color-green-4)', 0.15),
'--mantine-color-green-light-hover': alpha('var(--mantine-color-green-light)', 0.8),
'--mantine-color-green-light-color': 'var(--mantine-color-green-3)',
'--mantine-color-emerald-light': alpha('var(--mantine-color-emerald-4)', 0.15),
'--mantine-color-emerald-light-hover': alpha('var(--mantine-color-emerald-light)', 0.8),
'--mantine-color-emerald-light-color': 'var(--mantine-color-emerald-3)',
'--mantine-color-teal-light': alpha('var(--mantine-color-teal-4)', 0.15),
'--mantine-color-teal-light-hover': alpha('var(--mantine-color-teal-light)', 0.8),
'--mantine-color-teal-light-color': 'var(--mantine-color-teal-3)',
'--mantine-color-cyan-light': alpha('var(--mantine-color-cyan-4)', 0.15),
'--mantine-color-cyan-light-hover': alpha('var(--mantine-color-cyan-light)', 0.8),
'--mantine-color-cyan-light-color': 'var(--mantine-color-cyan-3)',
'--mantine-color-sky-light': alpha('var(--mantine-color-sky-4)', 0.15),
'--mantine-color-sky-light-hover': alpha('var(--mantine-color-sky-light)', 0.8),
'--mantine-color-sky-light-color': 'var(--mantine-color-sky-3)',
'--mantine-color-blue-light': alpha('var(--mantine-color-blue-4)', 0.15),
'--mantine-color-blue-light-hover': alpha('var(--mantine-color-blue-light)', 0.8),
'--mantine-color-blue-light-color': 'var(--mantine-color-blue-3)',
'--mantine-color-indigo-light': alpha('var(--mantine-color-indigo-4)', 0.15),
'--mantine-color-indigo-light-hover': alpha('var(--mantine-color-indigo-light)', 0.8),
'--mantine-color-indigo-light-color': 'var(--mantine-color-indigo-3)',
'--mantine-color-violet-light': alpha('var(--mantine-color-violet-4)', 0.15),
'--mantine-color-violet-light-hover': alpha('var(--mantine-color-violet-light)', 0.8),
'--mantine-color-violet-light-color': 'var(--mantine-color-violet-3)',
'--mantine-color-purple-light': alpha('var(--mantine-color-purple-4)', 0.15),
'--mantine-color-purple-light-hover': alpha('var(--mantine-color-purple-light)', 0.8),
'--mantine-color-purple-light-color': 'var(--mantine-color-purple-3)',
'--mantine-color-fuchsia-light': alpha('var(--mantine-color-fuchsia-4)', 0.15),
'--mantine-color-fuchsia-light-hover': alpha('var(--mantine-color-fuchsia-light)', 0.8),
'--mantine-color-fuchsia-light-color': 'var(--mantine-color-fuchsia-3)',
'--mantine-color-pink-light': alpha('var(--mantine-color-pink-4)', 0.15),
'--mantine-color-pink-light-hover': alpha('var(--mantine-color-pink-light)', 0.8),
'--mantine-color-pink-light-color': 'var(--mantine-color-pink-3)',
// all outline colors
'--mantine-color-zinc-outline': 'var(--mantine-color-zinc-0)',
'--mantine-color-zinc-outline-hover': alpha('var(--mantine-color-zinc-4)', 0.15),
'--mantine-color-slate-outline': 'var(--mantine-color-slate-0)',
'--mantine-color-slate-outline-hover': alpha('var(--mantine-color-slate-4)', 0.15),
'--mantine-color-gray-outline': 'var(--mantine-color-gray-0)',
'--mantine-color-gray-outline-hover': alpha('var(--mantine-color-gray-4)', 0.15),
'--mantine-color-neutral-outline': 'var(--mantine-color-neutral-0)',
'--mantine-color-neutral-outline-hover': alpha('var(--mantine-color-neutral-4)', 0.15),
'--mantine-color-stone-outline': 'var(--mantine-color-stone-0)',
'--mantine-color-stone-outline-hover': alpha('var(--mantine-color-stone-4)', 0.15),
'--mantine-color-red-outline': 'var(--mantine-color-red-5)',
'--mantine-color-red-outline-hover': alpha('var(--mantine-color-red-4)', 0.15),
'--mantine-color-rose-outline': 'var(--mantine-color-rose-5)',
'--mantine-color-rose-outline-hover': alpha('var(--mantine-color-rose-4)', 0.15),
'--mantine-color-orange-outline': 'var(--mantine-color-orange-6)',
'--mantine-color-orange-outline-hover': alpha('var(--mantine-color-orange-4)', 0.15),
'--mantine-color-amber-outline': 'var(--mantine-color-amber-5)',
'--mantine-color-amber-outline-hover': alpha('var(--mantine-color-amber-4)', 0.15),
'--mantine-color-yellow-outline': 'var(--mantine-color-yellow-4)',
'--mantine-color-yellow-outline-hover': alpha('var(--mantine-color-yellow-4)', 0.15),
'--mantine-color-lime-outline': 'var(--mantine-color-lime-4)',
'--mantine-color-lime-outline-hover': alpha('var(--mantine-color-lime-4)', 0.15),
'--mantine-color-green-outline': 'var(--mantine-color-green-5)',
'--mantine-color-green-outline-hover': alpha('var(--mantine-color-green-4)', 0.15),
'--mantine-color-emerald-outline': 'var(--mantine-color-emerald-5)',
'--mantine-color-emerald-outline-hover': alpha('var(--mantine-color-emerald-4)', 0.15),
'--mantine-color-teal-outline': 'var(--mantine-color-teal-4)',
'--mantine-color-teal-outline-hover': alpha('var(--mantine-color-teal-4)', 0.15),
'--mantine-color-cyan-outline': 'var(--mantine-color-cyan-4)',
'--mantine-color-cyan-outline-hover': alpha('var(--mantine-color-cyan-4)', 0.15),
'--mantine-color-sky-outline': 'var(--mantine-color-sky-4)',
'--mantine-color-sky-outline-hover': alpha('var(--mantine-color-sky-4)', 0.15),
'--mantine-color-blue-outline': 'var(--mantine-color-blue-5)',
'--mantine-color-blue-outline-hover': alpha('var(--mantine-color-blue-4)', 0.15),
'--mantine-color-indigo-outline': 'var(--mantine-color-indigo-6)',
'--mantine-color-indigo-outline-hover': alpha('var(--mantine-color-indigo-4)', 0.15),
'--mantine-color-violet-outline': 'var(--mantine-color-violet-6)',
'--mantine-color-violet-outline-hover': alpha('var(--mantine-color-violet-4)', 0.15),
'--mantine-color-purple-outline': 'var(--mantine-color-purple-6)',
'--mantine-color-purple-outline-hover': alpha('var(--mantine-color-purple-4)', 0.15),
'--mantine-color-fuchsia-outline': 'var(--mantine-color-fuchsia-7)',
'--mantine-color-fuchsia-outline-hover': alpha('var(--mantine-color-fuchsia-4)', 0.15),
'--mantine-color-pink-outline': 'var(--mantine-color-pink-6)',
'--mantine-color-pink-outline-hover': alpha('var(--mantine-color-pink-4)', 0.15),
// all contrast colors
'--mantine-color-zinc-contrast': 'var(--mantine-color-zinc-8)',
'--mantine-color-slate-contrast': 'var(--mantine-color-slate-8)',
'--mantine-color-gray-contrast': 'var(--mantine-color-gray-8)',
'--mantine-color-neutral-contrast': 'var(--mantine-color-neutral-8)',
'--mantine-color-stone-contrast': 'var(--mantine-color-stone-8)',
'--mantine-color-red-contrast': 'var(--mantine-color-red-0)',
'--mantine-color-rose-contrast': 'var(--mantine-color-rose-0)',
'--mantine-color-orange-contrast': 'var(--mantine-color-stone-0)',
'--mantine-color-amber-contrast': 'var(--mantine-color-stone-8)',
'--mantine-color-yellow-contrast': '#422006',
'--mantine-color-lime-contrast': 'var(--mantine-color-stone-8)',
'--mantine-color-green-contrast': 'var(--mantine-color-green-9)',
'--mantine-color-emerald-contrast': 'var(--mantine-color-stone-0)',
'--mantine-color-teal-contrast': 'var(--mantine-color-slate-8)',
'--mantine-color-cyan-contrast': 'var(--mantine-color-slate-8)',
'--mantine-color-sky-contrast': 'var(--mantine-color-slate-8)',
'--mantine-color-blue-contrast': 'var(--mantine-color-slate-0)',
'--mantine-color-indigo-contrast': 'var(--mantine-color-gray-0)',
'--mantine-color-violet-contrast': 'var(--mantine-color-gray-0)',
'--mantine-color-purple-contrast': 'var(--mantine-color-gray-0)',
'--mantine-color-fuchsia-contrast': 'var(--mantine-color-gray-0)',
'--mantine-color-pink-contrast': 'var(--mantine-color-gray-0)',
},
});
+4
View File
@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.06935 0.740967C8.46471 0.740967 8.78513 1.06143 8.78513 1.45675C8.78513 1.73028 8.6317 1.96783 8.40619 2.08833V2.6357C8.60265 2.66378 9.18471 2.67955 9.92197 3.01465C10.8483 3.4357 11.121 3.77803 11.4378 4.2357C11.6095 4.48376 11.7205 4.74914 11.7909 5.0357H11.9009C12.273 5.0357 12.5746 5.33732 12.5746 5.70938V6.38307C12.5746 6.75514 12.273 7.05675 11.9009 7.05675H11.8192C11.7591 7.40026 11.6762 7.69328 11.6062 7.85675C11.413 8.30755 11.129 8.48833 10.9746 8.53044C11.0869 8.57254 11.4395 8.67604 11.6483 8.82517C11.943 9.0357 12.2378 9.39174 12.2378 9.75149C12.2378 10.0462 12.1957 10.4673 11.9851 10.6778C11.7074 10.9556 11.2272 11.4778 10.9746 11.6883L6.34303 15.8568L7.35356 13.4989L10.3851 9.54096H8.02724L8.86934 6.59359L8.19658 7.34393L8.19567 7.35149L5.2483 10.762H7.6904L7.10093 12.7831L5.62724 11.8989L4.91146 11.4357C4.53251 11.1831 4.32198 11.0989 4.06935 10.6778C3.91617 10.4225 3.90093 10.0462 3.90093 9.75149C3.90093 9.39174 4.19567 9.0357 4.4904 8.82517C4.69915 8.67604 4.78514 8.61465 4.99567 8.53044C4.82724 8.44623 4.7257 8.30755 4.53251 7.85675C4.46245 7.69328 4.37956 7.40026 4.31951 7.05675H4.23777C3.8657 7.05675 3.56409 6.75514 3.56409 6.38307V5.70938C3.56409 5.33732 3.8657 5.0357 4.23777 5.0357H4.34788C4.41815 4.74914 4.5292 4.48376 4.70093 4.2357C5.01778 3.77803 5.2904 3.4357 6.21672 3.01465C6.95396 2.67955 7.53602 2.66378 7.73251 2.6357V2.08833C7.50704 1.96783 7.35356 1.73028 7.35356 1.45675C7.35356 1.06143 7.67403 0.740967 8.06935 0.740967ZM6.80619 5.0357C6.50389 5.0357 6.25882 5.28077 6.25882 5.58307C6.25882 5.88538 6.50389 6.13044 6.80619 6.13044C7.1085 6.13044 7.35356 5.88538 7.35356 5.58307C7.35356 5.28077 7.1085 5.0357 6.80619 5.0357ZM9.3325 5.0357C9.03018 5.0357 8.78513 5.28077 8.78513 5.58307C8.78513 5.88538 9.03018 6.13044 9.3325 6.13044C9.63481 6.13044 9.87987 5.88538 9.87987 5.58307C9.87987 5.28077 9.63481 5.0357 9.3325 5.0357Z" fill="#F69047"/>
<path d="M12.2279 9.63738C12.2342 9.67527 12.2378 9.71342 12.2378 9.75165C12.2378 10.0464 12.1957 10.4674 11.9851 10.678C11.7074 10.9558 11.2272 11.478 10.9746 11.6885L6.34305 15.8569L7.35357 13.499L7.9831 12.677C8.20912 12.6273 8.41543 12.5774 8.57462 12.5306C9.29041 12.3201 10.1325 11.6885 10.7641 11.099C11.2418 10.6531 11.9076 9.97136 12.2279 9.63738ZM9.62725 3.77271C10.3248 3.77271 10.8904 4.33825 10.8904 5.03586V6.80428C10.8904 7.50191 10.3248 8.06744 9.62725 8.06744H8.4483L8.86935 6.59376L8.19659 7.34409L8.19568 7.35165L7.57479 8.06744H6.59568C5.89805 8.06744 5.33252 7.50191 5.33252 6.80428V5.03586C5.33252 4.33825 5.89805 3.77271 6.59568 3.77271H9.62725ZM6.8062 5.03586C6.5039 5.03586 6.25884 5.28093 6.25884 5.58323C6.25884 5.88554 6.5039 6.1306 6.8062 6.1306C7.10851 6.1306 7.35357 5.88554 7.35357 5.58323C7.35357 5.28093 7.10851 5.03586 6.8062 5.03586ZM9.33251 5.03586C9.0302 5.03586 8.78514 5.28093 8.78514 5.58323C8.78514 5.88554 9.0302 6.1306 9.33251 6.1306C9.63483 6.1306 9.87988 5.88554 9.87988 5.58323C9.87988 5.28093 9.63483 5.03586 9.33251 5.03586Z" fill="#C45259"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+4
View File
@@ -0,0 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
export * from './slice';
export * from './selectors';
@@ -0,0 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
import type { RootState } from '../../store';
export const selectConfig = (state: RootState) => state.config;
export const selectAutoRefreshMs = (state: RootState) => state.config.autoRefreshMs;
export const selectBaseUrl = (state: RootState) => state.config.baseUrl;
export const selectThemePreference = (state: RootState) => state.config.theme;
+30
View File
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { ConfigState, ThemePreference } from '@/types';
export const initialConfigState: ConfigState = {
baseUrl: typeof window !== 'undefined' ? window.location.origin : '',
autoRefreshMs: 0,
theme: 'system',
};
const configSlice = createSlice({
name: 'config',
initialState: initialConfigState,
reducers: {
setBaseUrl(state, action: PayloadAction<string>) {
state.baseUrl = action.payload;
},
setAutoRefreshMs(state, action: PayloadAction<number>) {
state.autoRefreshMs = action.payload;
},
setTheme(state, action: PayloadAction<ThemePreference>) {
state.theme = action.payload;
},
},
});
export const { setAutoRefreshMs, setBaseUrl, setTheme } = configSlice.actions;
export const configReducer = configSlice.reducer;
@@ -0,0 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
export * from './slice';
export * from './selectors';
export { useGetResourcesQuery } from '../rollouts';
@@ -0,0 +1,94 @@
// Copyright (c) Microsoft. All rights reserved.
import { createServerBackedStore } from '@test-utils';
import { describe, expect, it } from 'vitest';
import { rolloutsApi } from '@/features/rollouts';
import type { Resources } from '@/types';
import { selectResourcesQueryArgs } from './selectors';
import {
resetResourcesFilters,
setResourcesPage,
setResourcesRecordsPerPage,
setResourcesSearchTerm,
setResourcesSort,
} from './slice';
const extractResourceIds = (resources: Resources[]): string[] => resources.map((resource) => resource.resourcesId);
describe('resources feature integration', () => {
it('builds default query arguments from the UI state', () => {
const store = createServerBackedStore();
const queryArgs = selectResourcesQueryArgs(store.getState());
expect(queryArgs).toMatchObject({
limit: 50,
offset: 0,
sortBy: 'update_time',
sortOrder: 'desc',
resourcesIdContains: undefined,
});
});
it('fetches resources from the Python LightningStore server', async () => {
const store = createServerBackedStore();
const queryArgs = selectResourcesQueryArgs(store.getState());
const subscription = store.dispatch(rolloutsApi.endpoints.getResources.initiate(queryArgs));
const data = await subscription.unwrap();
subscription.unsubscribe();
expect(data.total).toBe(5);
expect(data.items).toHaveLength(5);
const resourceIds = extractResourceIds(data.items);
expect(resourceIds).toEqual(expect.arrayContaining(['rs-story-001', 'rs-story-005']));
const updateTimes = data.items.map((resource) => resource.updateTime);
const sortedUpdateTimes = [...updateTimes].sort((a, b) => b - a);
expect(updateTimes).toEqual(sortedUpdateTimes);
expect(data.items[0].resources).toBeDefined();
expect(Object.keys(data.items[0].resources)).not.toHaveLength(0);
});
it('paginates resource results based on UI state', async () => {
const store = createServerBackedStore();
store.dispatch(setResourcesRecordsPerPage(2));
store.dispatch(setResourcesPage(2));
const queryArgs = selectResourcesQueryArgs(store.getState());
expect(queryArgs).toMatchObject({ limit: 2, offset: 2 });
const subscription = store.dispatch(rolloutsApi.endpoints.getResources.initiate(queryArgs));
const data = await subscription.unwrap();
subscription.unsubscribe();
expect(data.items).toHaveLength(2);
expect(data.total).toBe(5);
expect(data.items.map((resource) => resource.resourcesId)).toEqual(['rs-story-005', 'rs-story-002']);
});
it('applies search and sorting preferences', async () => {
const store = createServerBackedStore();
store.dispatch(resetResourcesFilters());
store.dispatch(setResourcesSearchTerm('rs-story-003'));
store.dispatch(setResourcesSort({ column: 'version', direction: 'asc' }));
const queryArgs = selectResourcesQueryArgs(store.getState());
expect(queryArgs).toMatchObject({
limit: 50,
offset: 0,
sortBy: 'version',
sortOrder: 'asc',
resourcesIdContains: 'rs-story-003',
});
const subscription = store.dispatch(rolloutsApi.endpoints.getResources.initiate(queryArgs));
const data = await subscription.unwrap();
subscription.unsubscribe();
expect(data.items).toHaveLength(1);
expect(data.items[0].resourcesId).toBe('rs-story-003');
expect(data.items[0].version).toBeGreaterThan(0);
});
});
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
import { createSelector } from '@reduxjs/toolkit';
import type { GetResourcesQueryArgs } from '@/features/rollouts';
import type { RootState } from '@/store';
import type { ResourcesSortState } from './slice';
const RESOURCES_SORT_FIELD_MAP: Record<string, string> = {
resourcesId: 'resources_id',
version: 'version',
createTime: 'create_time',
updateTime: 'update_time',
};
const resolveResourcesSortField = (sort: ResourcesSortState): string =>
RESOURCES_SORT_FIELD_MAP[sort.column] ?? 'update_time';
export const selectResourcesUiState = (state: RootState) => state.resources;
export const selectResourcesSearchTerm = (state: RootState) => selectResourcesUiState(state).searchTerm;
export const selectResourcesPage = (state: RootState) => selectResourcesUiState(state).page;
export const selectResourcesRecordsPerPage = (state: RootState) => selectResourcesUiState(state).recordsPerPage;
export const selectResourcesSort = (state: RootState) => selectResourcesUiState(state).sort;
export const selectResourcesQueryArgs = createSelector(
[selectResourcesSearchTerm, selectResourcesPage, selectResourcesRecordsPerPage, selectResourcesSort],
(searchTerm, page, recordsPerPage, sort): GetResourcesQueryArgs => {
const normalizedSearch = searchTerm.trim();
const limit = Math.max(1, recordsPerPage);
const offset = Math.max(0, (page - 1) * limit);
const sortBy = resolveResourcesSortField(sort);
return {
limit,
offset,
sortBy,
sortOrder: sort.direction,
resourcesIdContains: normalizedSearch.length > 0 ? normalizedSearch : undefined,
};
},
);
+64
View File
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type SortDirection = 'asc' | 'desc';
export type ResourcesSortState = {
column: string;
direction: SortDirection;
};
export type ResourcesUiState = {
searchTerm: string;
page: number;
recordsPerPage: number;
sort: ResourcesSortState;
};
export const initialResourcesUiState: ResourcesUiState = {
searchTerm: '',
page: 1,
recordsPerPage: 50,
sort: {
column: 'updateTime',
direction: 'desc',
},
};
const resourcesSlice = createSlice({
name: 'resources',
initialState: initialResourcesUiState,
reducers: {
setResourcesSearchTerm(state, action: PayloadAction<string>) {
state.searchTerm = action.payload;
state.page = 1;
},
setResourcesPage(state, action: PayloadAction<number>) {
state.page = action.payload;
},
setResourcesRecordsPerPage(state, action: PayloadAction<number>) {
state.recordsPerPage = action.payload;
state.page = 1;
},
setResourcesSort(state, action: PayloadAction<ResourcesSortState>) {
state.sort = action.payload;
},
resetResourcesFilters(state) {
state.searchTerm = initialResourcesUiState.searchTerm;
state.page = initialResourcesUiState.page;
state.recordsPerPage = initialResourcesUiState.recordsPerPage;
state.sort = initialResourcesUiState.sort;
},
},
});
export const {
setResourcesSearchTerm,
setResourcesPage,
setResourcesRecordsPerPage,
setResourcesSort,
resetResourcesFilters,
} = resourcesSlice.actions;
export const resourcesReducer = resourcesSlice.reducer;
+340
View File
@@ -0,0 +1,340 @@
// Copyright (c) Microsoft. All rights reserved.
import type { BaseQueryFn } from '@reduxjs/toolkit/query';
import { createApi, fetchBaseQuery, type FetchArgs, type FetchBaseQueryError } from '@reduxjs/toolkit/query/react';
import type { RootState } from '@/store';
import { camelCaseKeys } from '@/utils/format';
import type {
Attempt,
PaginatedResponse,
Resources,
Rollout,
RolloutMode,
RolloutStatus,
Span,
Timestamp,
} from '../../types';
const rawBaseQuery = fetchBaseQuery({
baseUrl: '/',
});
const buildAbsoluteUrl = (baseUrl: string, path: string) => {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
const normalizedBase = baseUrl.replace(/\/+$/, '');
const normalizedPath = path.replace(/^\/+/, '');
if (!normalizedBase) {
return `/${normalizedPath}`;
}
return `${normalizedBase}/${normalizedPath}`;
};
const normalizeHeartbeat = (
attempt: Partial<Attempt> & { lastHeartbeatTime?: Timestamp | null; lastHeartBeatTime?: Timestamp | null },
): Timestamp | null => {
if (typeof attempt.lastHeartbeatTime === 'number') {
return attempt.lastHeartbeatTime;
}
if (typeof attempt.lastHeartBeatTime === 'number') {
return attempt.lastHeartBeatTime;
}
if (typeof attempt.startTime === 'number') {
return attempt.startTime;
}
return null;
};
const normalizeAttempt = (value: unknown): Attempt | null => {
if (value === null || typeof value === 'undefined') {
return null;
}
const camelized = camelCaseKeys(value) as Attempt & {
lastHeartbeatTime?: Timestamp | null;
lastHeartBeatTime?: Timestamp | null;
};
const { lastHeartbeatTime, lastHeartBeatTime, ...rest } = camelized;
return {
...rest,
lastHeartbeatTime: normalizeHeartbeat({ ...rest, lastHeartbeatTime, lastHeartBeatTime }),
};
};
const normalizeAttemptStrict = (value: unknown): Attempt => {
const normalized = normalizeAttempt(value);
if (!normalized) {
throw new Error('Expected attempt payload');
}
return normalized;
};
const normalizeRollout = (value: unknown): Rollout => {
const camelized = camelCaseKeys(value) as Rollout & { attempt?: unknown };
const { attempt, ...rest } = camelized;
return {
...rest,
attempt: normalizeAttempt(attempt),
};
};
const normalizeSpan = (value: unknown): Span => {
const camelized = camelCaseKeys(value) as Span & {
status?: {
status_code?: Span['status']['status_code'];
statusCode?: Span['status']['status_code'];
description?: string | null;
};
};
const rawStatus = camelized.status ?? { status_code: 'UNSET', description: null };
return {
...camelized,
parentId: camelized.parentId ?? null,
attributes: camelized.attributes ?? {},
status: {
status_code: rawStatus.status_code ?? rawStatus.statusCode ?? 'UNSET',
description: rawStatus.description ?? null,
},
};
};
const normalizeResources = (value: unknown): Resources => {
const camelized = camelCaseKeys(value) as Resources;
return {
resourcesId: camelized.resourcesId,
version: camelized.version,
createTime: camelized.createTime,
updateTime: camelized.updateTime,
resources: camelized.resources ?? {},
};
};
const normalizePaginatedResponse = <T>(value: unknown, normalizer: (item: unknown) => T): PaginatedResponse<T> => {
if (!value || typeof value !== 'object') {
throw new Error('Expected paginated response payload');
}
const camelized = camelCaseKeys(value) as {
items?: unknown;
limit?: number;
offset?: number;
total?: number;
};
const itemsSource = Array.isArray(camelized.items) ? camelized.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,
};
};
const dynamicBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> = async (
args,
api,
extraOptions,
) => {
const state = api.getState() as RootState;
const stateBaseUrl = state.config?.baseUrl;
const fallbackBaseUrl = typeof window !== 'undefined' ? window.location.origin : '';
const baseUrl = stateBaseUrl && stateBaseUrl.trim().length > 0 ? stateBaseUrl : fallbackBaseUrl;
const preparedArgs: FetchArgs =
typeof args === 'string'
? { url: args }
: {
...args,
url: args.url ?? '',
};
const absoluteUrl = buildAbsoluteUrl(baseUrl, preparedArgs.url ?? '');
return rawBaseQuery({ ...preparedArgs, url: absoluteUrl }, api, extraOptions);
};
export type GetRolloutsQueryArgs = {
limit: number;
offset: number;
sortBy?: string | null;
sortOrder?: 'asc' | 'desc';
statusIn?: RolloutStatus[];
rolloutIdContains?: string | null;
modeIn?: RolloutMode[];
};
export type GetResourcesQueryArgs = {
limit: number;
offset: number;
sortBy?: string | null;
sortOrder?: 'asc' | 'desc';
resourcesIdContains?: string | null;
};
export type GetRolloutAttemptsQueryArgs = {
rolloutId: string;
limit?: number;
offset?: number;
sortBy?: string | null;
sortOrder?: 'asc' | 'desc';
};
export type GetSpansQueryArgs = {
rolloutId: string;
attemptId?: string | null;
limit?: number;
offset?: number;
sortBy?: string | null;
sortOrder?: 'asc' | 'desc';
traceIdContains?: string | null;
spanIdContains?: string | null;
parentIdContains?: string | null;
nameContains?: string | null;
filterLogic?: 'and' | 'or' | null;
};
export const rolloutsApi = createApi({
reducerPath: 'rolloutsApi',
baseQuery: dynamicBaseQuery,
tagTypes: ['Rollout', 'Span', 'Resources'],
endpoints: (builder) => ({
getResources: builder.query<PaginatedResponse<Resources>, GetResourcesQueryArgs>({
query: ({ limit, offset, sortBy, sortOrder, resourcesIdContains }) => {
const searchParams = new URLSearchParams();
searchParams.set('limit', String(typeof limit === 'number' ? limit : -1));
searchParams.set('offset', String(typeof offset === 'number' ? offset : 0));
if (sortBy) {
searchParams.set('sort_by', sortBy);
}
if (sortOrder) {
searchParams.set('sort_order', sortOrder);
}
if (resourcesIdContains && resourcesIdContains.trim().length > 0) {
searchParams.set('resources_id_contains', resourcesIdContains.trim());
}
const queryString = searchParams.toString();
const url = queryString.length > 0 ? `v1/agl/resources?${queryString}` : 'v1/agl/resources';
return { url, method: 'GET' };
},
transformResponse: (response: unknown) => normalizePaginatedResponse(response, normalizeResources),
providesTags: (result) =>
result
? [
{ type: 'Resources' as const, id: 'LIST' },
...result.items.map((item) => ({ type: 'Resources' as const, id: item.resourcesId })),
]
: [{ type: 'Resources' as const, id: 'LIST' }],
}),
getRollouts: builder.query<PaginatedResponse<Rollout>, GetRolloutsQueryArgs>({
query: ({ limit, offset, sortBy, sortOrder, statusIn, rolloutIdContains, modeIn }) => {
const searchParams = new URLSearchParams();
searchParams.set('limit', String(typeof limit === 'number' ? limit : -1));
searchParams.set('offset', String(typeof offset === 'number' ? offset : 0));
if (sortBy) {
searchParams.set('sort_by', sortBy);
}
if (sortOrder) {
searchParams.set('sort_order', sortOrder);
}
if (statusIn && statusIn.length > 0) {
statusIn.forEach((status) => searchParams.append('status_in', status));
}
if (modeIn && modeIn.length > 0) {
modeIn.forEach((mode) => searchParams.append('mode_in', mode));
}
if (rolloutIdContains && rolloutIdContains.trim().length > 0) {
searchParams.set('rollout_id_contains', rolloutIdContains.trim());
}
const queryString = searchParams.toString();
const url = queryString.length > 0 ? `v1/agl/rollouts?${queryString}` : 'v1/agl/rollouts';
return { url, method: 'GET' };
},
transformResponse: (response: unknown) => normalizePaginatedResponse(response, normalizeRollout),
providesTags: (result) =>
result
? [
{ type: 'Rollout' as const, id: 'LIST' },
...result.items.map((rollout) => ({ type: 'Rollout' as const, id: rollout.rolloutId })),
]
: [{ type: 'Rollout' as const, id: 'LIST' }],
}),
getRolloutAttempts: builder.query<PaginatedResponse<Attempt>, GetRolloutAttemptsQueryArgs>({
query: ({ rolloutId, limit = -1, offset = 0, sortBy, sortOrder }) => {
const searchParams = new URLSearchParams();
searchParams.set('limit', String(typeof limit === 'number' ? limit : -1));
searchParams.set('offset', String(typeof offset === 'number' ? offset : 0));
if (sortBy) {
searchParams.set('sort_by', sortBy);
}
if (sortOrder) {
searchParams.set('sort_order', sortOrder);
}
const queryString = searchParams.toString();
const url =
queryString.length > 0
? `v1/agl/rollouts/${rolloutId}/attempts?${queryString}`
: `v1/agl/rollouts/${rolloutId}/attempts`;
return { url, method: 'GET' };
},
transformResponse: (response: unknown) => normalizePaginatedResponse(response, normalizeAttemptStrict),
providesTags: (_result, _error, queryArgs) => [{ type: 'Rollout', id: queryArgs.rolloutId }],
}),
getSpans: builder.query<PaginatedResponse<Span>, GetSpansQueryArgs>({
query: (args) => {
if (!args.rolloutId) {
throw new Error('rolloutId is required to fetch spans');
}
const searchParams = new URLSearchParams({ rollout_id: args.rolloutId });
if (args.attemptId) {
searchParams.set('attempt_id', args.attemptId);
}
if (typeof args.limit === 'number') {
searchParams.set('limit', String(args.limit));
}
if (typeof args.offset === 'number') {
searchParams.set('offset', String(args.offset));
}
if (args.sortBy) {
searchParams.set('sort_by', args.sortBy);
}
if (args.sortOrder) {
searchParams.set('sort_order', args.sortOrder);
}
if (args.traceIdContains) {
searchParams.set('trace_id_contains', args.traceIdContains);
}
if (args.spanIdContains) {
searchParams.set('span_id_contains', args.spanIdContains);
}
if (args.parentIdContains) {
searchParams.set('parent_id_contains', args.parentIdContains);
}
if (args.nameContains) {
searchParams.set('name_contains', args.nameContains);
}
if (args.filterLogic) {
searchParams.set('filter_logic', args.filterLogic);
}
return { url: `v1/agl/spans?${searchParams.toString()}`, method: 'GET' };
},
transformResponse: (response: unknown) => normalizePaginatedResponse(response, normalizeSpan),
providesTags: (_result, _error, args) =>
args
? [
{ type: 'Span' as const, id: `${args.rolloutId}:${args.attemptId ?? 'latest'}` },
{ type: 'Span' as const, id: 'LIST' },
]
: [{ type: 'Span' as const, id: 'LIST' }],
}),
}),
});
export const { useGetResourcesQuery, useGetRolloutsQuery, useGetRolloutAttemptsQuery, useGetSpansQuery } = rolloutsApi;
+6
View File
@@ -0,0 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
export * from './api';
export * from './slice';
export * from './selectors';
export * from '../../types';
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft. All rights reserved.
import { createServerBackedStore } from '@test-utils';
import { describe, expect, it } from 'vitest';
import { rolloutsApi } from './api';
import { selectRolloutsQueryArgs } from './selectors';
import {
resetRolloutsFilters,
setRolloutsModeFilters,
setRolloutsPage,
setRolloutsRecordsPerPage,
setRolloutsSearchTerm,
setRolloutsSort,
setRolloutsStatusFilters,
} from './slice';
describe('rollouts feature integration', () => {
it('builds default query arguments from the UI state', () => {
const store = createServerBackedStore();
const queryArgs = selectRolloutsQueryArgs(store.getState());
expect(queryArgs).toMatchObject({
limit: 100,
offset: 0,
sortBy: 'start_time',
sortOrder: 'desc',
rolloutIdContains: undefined,
statusIn: undefined,
modeIn: undefined,
});
});
it('retrieves rollouts from the Python LightningStore server', 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();
expect(data.total).toBe(6);
expect(data.items).toHaveLength(6);
const rolloutIds = data.items.map((rollout) => rollout.rolloutId);
expect(rolloutIds).toEqual(
expect.arrayContaining(['ro-story-001', 'ro-story-002', 'ro-story-003', 'ro-story-004', 'ro-story-005']),
);
const startTimes = data.items.map((rollout) => rollout.startTime);
const sortedStartTimes = [...startTimes].sort((a, b) => b - a);
expect(startTimes).toEqual(sortedStartTimes);
expect(data.items[0].rolloutId).toBe('ro-story-005');
expect(data.items[0].status).toBeDefined();
});
it('retrieves attempts for a rollout from the Python server', async () => {
const store = createServerBackedStore();
const subscription = store.dispatch(
rolloutsApi.endpoints.getRolloutAttempts.initiate({ rolloutId: 'ro-story-002' }),
);
const data = await subscription.unwrap();
subscription.unsubscribe();
expect(data.total).toBe(2);
expect(data.items.map((attempt) => attempt.attemptId)).toEqual(['at-story-021', 'at-story-022']);
});
it('paginates rollouts with custom UI state', async () => {
const store = createServerBackedStore();
store.dispatch(setRolloutsRecordsPerPage(2));
store.dispatch(setRolloutsPage(2));
const queryArgs = selectRolloutsQueryArgs(store.getState());
expect(queryArgs).toMatchObject({ limit: 2, offset: 2 });
const subscription = store.dispatch(rolloutsApi.endpoints.getRollouts.initiate(queryArgs));
const data = await subscription.unwrap();
subscription.unsubscribe();
expect(data.items).toHaveLength(2);
expect(data.items.map((rollout) => rollout.rolloutId)).toEqual(['ro-story-004', 'ro-story-002']);
});
it('filters and sorts rollouts based on UI selections', async () => {
const store = createServerBackedStore();
store.dispatch(resetRolloutsFilters());
store.dispatch(setRolloutsStatusFilters(['succeeded']));
store.dispatch(setRolloutsModeFilters(['val']));
store.dispatch(setRolloutsSearchTerm('ro-story-002'));
store.dispatch(setRolloutsSort({ column: 'rolloutId', direction: 'asc' }));
const queryArgs = selectRolloutsQueryArgs(store.getState());
expect(queryArgs).toMatchObject({
statusIn: ['succeeded'],
modeIn: ['val'],
rolloutIdContains: 'ro-story-002',
sortBy: 'rollout_id',
sortOrder: 'asc',
});
const subscription = store.dispatch(rolloutsApi.endpoints.getRollouts.initiate(queryArgs));
const data = await subscription.unwrap();
subscription.unsubscribe();
expect(data.items).toHaveLength(1);
expect(data.items[0].rolloutId).toBe('ro-story-002');
expect(data.items[0].status).toBe('succeeded');
});
});
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft. All rights reserved.
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '@/store';
import type { RolloutMode, RolloutStatus } from '@/types';
import type { RolloutsSortState } from './slice';
const ROLLOUTS_SORT_FIELD_MAP: Record<string, string> = {
rolloutId: 'rollout_id',
attemptId: 'attempt_id',
statusValue: 'status',
resourcesId: 'resources_id',
mode: 'mode',
startTimestamp: 'start_time',
durationSeconds: 'duration',
lastHeartbeatTimestamp: 'last_heartbeat_time',
workerId: 'worker_id',
};
const resolveRolloutsSortField = (sort: RolloutsSortState): string =>
ROLLOUTS_SORT_FIELD_MAP[sort.column] ?? 'start_time';
export const selectRolloutsUiState = (state: RootState) => state.rollouts;
export const selectRolloutsSearchTerm = (state: RootState) => state.rollouts.searchTerm;
export const selectRolloutsStatusFilters = (state: RootState) => state.rollouts.statusFilters;
export const selectRolloutsModeFilters = (state: RootState) => state.rollouts.modeFilters;
export const selectRolloutsPage = (state: RootState) => state.rollouts.page;
export const selectRolloutsRecordsPerPage = (state: RootState) => state.rollouts.recordsPerPage;
export const selectRolloutsSort = (state: RootState) => state.rollouts.sort;
export const selectRolloutsQueryArgs = createSelector(
[
selectRolloutsSearchTerm,
selectRolloutsStatusFilters,
selectRolloutsModeFilters,
selectRolloutsPage,
selectRolloutsRecordsPerPage,
selectRolloutsSort,
],
(
searchTerm: string,
statusFilters: RolloutStatus[],
modeFilters: RolloutMode[],
page: number,
recordsPerPage: number,
sort: RolloutsSortState,
) => {
const normalizedSearch = searchTerm.trim();
const limit = Math.max(1, recordsPerPage);
const offset = Math.max(0, (page - 1) * limit);
const sortBy = resolveRolloutsSortField(sort);
return {
limit,
offset,
sortBy,
sortOrder: sort.direction,
statusIn: statusFilters.length > 0 ? statusFilters : undefined,
rolloutIdContains: normalizedSearch.length > 0 ? normalizedSearch : undefined,
modeIn: modeFilters.length > 0 ? modeFilters : undefined,
};
},
);
+80
View File
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { RolloutMode, RolloutStatus } from '../../types';
export type SortDirection = 'asc' | 'desc';
export type RolloutsSortState = {
column: string;
direction: SortDirection;
};
export type RolloutsUiState = {
searchTerm: string;
statusFilters: RolloutStatus[];
modeFilters: RolloutMode[];
page: number;
recordsPerPage: number;
sort: RolloutsSortState;
};
export const initialRolloutsUiState: RolloutsUiState = {
searchTerm: '',
statusFilters: [],
modeFilters: [],
page: 1,
recordsPerPage: 100,
sort: {
column: 'startTimestamp',
direction: 'desc',
},
};
const rolloutsSlice = createSlice({
name: 'rollouts',
initialState: initialRolloutsUiState,
reducers: {
setRolloutsSearchTerm(state, action: PayloadAction<string>) {
state.searchTerm = action.payload;
state.page = 1;
},
setRolloutsStatusFilters(state, action: PayloadAction<RolloutStatus[]>) {
state.statusFilters = action.payload;
state.page = 1;
},
setRolloutsModeFilters(state, action: PayloadAction<RolloutMode[]>) {
state.modeFilters = action.payload;
state.page = 1;
},
setRolloutsPage(state, action: PayloadAction<number>) {
state.page = action.payload;
},
setRolloutsRecordsPerPage(state, action: PayloadAction<number>) {
state.recordsPerPage = action.payload;
state.page = 1;
},
setRolloutsSort(state, action: PayloadAction<RolloutsSortState>) {
state.sort = action.payload;
},
resetRolloutsFilters(state) {
state.statusFilters = initialRolloutsUiState.statusFilters;
state.modeFilters = initialRolloutsUiState.modeFilters;
state.searchTerm = initialRolloutsUiState.searchTerm;
state.page = initialRolloutsUiState.page;
state.sort = initialRolloutsUiState.sort;
},
},
});
export const {
setRolloutsSearchTerm,
setRolloutsStatusFilters,
setRolloutsModeFilters,
setRolloutsPage,
setRolloutsRecordsPerPage,
setRolloutsSort,
resetRolloutsFilters,
} = rolloutsSlice.actions;
export const rolloutsReducer = rolloutsSlice.reducer;
+4
View File
@@ -0,0 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
export * from './slice';
export * from './selectors';
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
import { createSelector } from '@reduxjs/toolkit';
import type { GetSpansQueryArgs } from '@/features/rollouts';
import type { RootState } from '@/store';
import type { TracesSortState } from './slice';
export const selectTracesState = (state: RootState) => state.traces;
export const selectTracesRolloutId = (state: RootState) => selectTracesState(state).rolloutId;
export const selectTracesAttemptId = (state: RootState) => selectTracesState(state).attemptId;
export const selectTracesSearchTerm = (state: RootState) => selectTracesState(state).searchTerm;
export const selectTracesPage = (state: RootState) => selectTracesState(state).page;
export const selectTracesRecordsPerPage = (state: RootState) => selectTracesState(state).recordsPerPage;
export const selectTracesSort = (state: RootState) => selectTracesState(state).sort;
export const selectTracesViewMode = (state: RootState) => selectTracesState(state).viewMode;
const TRACES_SORT_FIELD_MAP: Record<string, string> = {
name: 'name',
traceId: 'trace_id',
spanId: 'span_id',
parentId: 'parent_id',
statusCode: 'status_code',
startTime: 'start_time',
duration: 'duration',
};
const resolveTracesSortField = (sort: TracesSortState): string => TRACES_SORT_FIELD_MAP[sort.column] ?? 'start_time';
export const selectTracesQueryArgs = createSelector(
[
selectTracesRolloutId,
selectTracesAttemptId,
selectTracesSearchTerm,
selectTracesPage,
selectTracesRecordsPerPage,
selectTracesSort,
],
(rolloutId, attemptId, searchTerm, page, recordsPerPage, sort): GetSpansQueryArgs | undefined => {
if (!rolloutId) {
return undefined;
}
const limit = Math.max(1, recordsPerPage);
const offset = Math.max(0, (page - 1) * limit);
const normalizedSearch = searchTerm.trim();
const containsValue = normalizedSearch.length > 0 ? normalizedSearch : undefined;
const sortBy = resolveTracesSortField(sort);
return {
rolloutId,
attemptId: attemptId ?? undefined,
limit,
offset,
sortBy,
sortOrder: sort.direction,
traceIdContains: containsValue,
spanIdContains: containsValue,
nameContains: containsValue,
filterLogic: containsValue ? 'or' : undefined,
};
},
);
+86
View File
@@ -0,0 +1,86 @@
// Copyright (c) Microsoft. All rights reserved.
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type SortDirection = 'asc' | 'desc';
export type TracesSortState = {
column: string;
direction: SortDirection;
};
export type TracesViewMode = 'table' | 'waterfall' | 'tree';
export type TracesUiState = {
rolloutId: string | null;
attemptId: string | null;
searchTerm: string;
page: number;
recordsPerPage: number;
sort: TracesSortState;
viewMode: TracesViewMode;
};
export const initialTracesUiState: TracesUiState = {
rolloutId: null,
attemptId: null,
searchTerm: '',
page: 1,
recordsPerPage: 100,
sort: {
column: 'startTime',
direction: 'desc',
},
viewMode: 'table',
};
const tracesSlice = createSlice({
name: 'traces',
initialState: initialTracesUiState,
reducers: {
setTracesRolloutId(state, action: PayloadAction<string | null>) {
state.rolloutId = action.payload;
state.page = 1;
state.attemptId = null;
},
setTracesAttemptId(state, action: PayloadAction<string | null>) {
state.attemptId = action.payload;
state.page = 1;
},
setTracesSearchTerm(state, action: PayloadAction<string>) {
state.searchTerm = action.payload;
state.page = 1;
},
setTracesPage(state, action: PayloadAction<number>) {
state.page = action.payload;
},
setTracesRecordsPerPage(state, action: PayloadAction<number>) {
state.recordsPerPage = action.payload;
state.page = 1;
},
setTracesSort(state, action: PayloadAction<TracesSortState>) {
state.sort = action.payload;
},
setTracesViewMode(state, action: PayloadAction<TracesViewMode>) {
state.viewMode = action.payload;
},
resetTracesFilters(state) {
state.searchTerm = initialTracesUiState.searchTerm;
state.page = initialTracesUiState.page;
state.sort = initialTracesUiState.sort;
},
},
});
export const {
setTracesRolloutId,
setTracesAttemptId,
setTracesSearchTerm,
setTracesPage,
setTracesRecordsPerPage,
setTracesSort,
setTracesViewMode,
resetTracesFilters,
} = tracesSlice.actions;
export const tracesReducer = tracesSlice.reducer;
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
import { createServerBackedStore } from '@test-utils';
import { describe, expect, it } from 'vitest';
import { rolloutsApi } from '../rollouts';
import { selectTracesQueryArgs } from './selectors';
import { setTracesPage, setTracesRecordsPerPage, setTracesRolloutId, setTracesSearchTerm } from './slice';
describe('traces feature integration', () => {
it('requires a rollout id before building query arguments', () => {
const store = createServerBackedStore();
expect(selectTracesQueryArgs(store.getState())).toBeUndefined();
});
it('builds query arguments when targeting a rollout', () => {
const store = createServerBackedStore();
store.dispatch(setTracesRolloutId('ro-story-001'));
const queryArgs = selectTracesQueryArgs(store.getState());
expect(queryArgs).toBeDefined();
expect(queryArgs).toMatchObject({
rolloutId: 'ro-story-001',
limit: 100,
offset: 0,
sortBy: 'start_time',
sortOrder: 'desc',
filterLogic: undefined,
});
store.dispatch(setTracesSearchTerm('span-00'));
const filteredArgs = selectTracesQueryArgs(store.getState());
expect(filteredArgs?.filterLogic).toBe('or');
});
it('fetches spans from the Python LightningStore server', async () => {
const store = createServerBackedStore();
store.dispatch(setTracesRolloutId('ro-story-001'));
const queryArgs = selectTracesQueryArgs(store.getState());
expect(queryArgs).toBeDefined();
const subscription = store.dispatch(rolloutsApi.endpoints.getSpans.initiate(queryArgs!));
const data = await subscription.unwrap();
subscription.unsubscribe();
expect(data.total).toBe(3);
const spanIds = data.items.map((span) => span.spanId).sort();
expect(spanIds).toEqual(['span-001-root', 'span-002-llm', 'span-003-tool']);
expect(new Set(data.items.map((span) => span.status.status_code))).toEqual(new Set(['OK']));
});
it('paginates spans based on UI state', async () => {
const store = createServerBackedStore();
store.dispatch(setTracesRolloutId('ro-story-001'));
store.dispatch(setTracesRecordsPerPage(2));
const firstPageArgs = selectTracesQueryArgs(store.getState());
expect(firstPageArgs).toMatchObject({ limit: 2, offset: 0 });
const firstPageSub = store.dispatch(rolloutsApi.endpoints.getSpans.initiate(firstPageArgs!));
const firstPage = await firstPageSub.unwrap();
firstPageSub.unsubscribe();
expect(firstPage.items.map((span) => span.spanId)).toEqual(['span-003-tool', 'span-002-llm']);
store.dispatch(setTracesPage(2));
const secondPageArgs = selectTracesQueryArgs(store.getState());
expect(secondPageArgs).toMatchObject({ limit: 2, offset: 2 });
const secondPageSub = store.dispatch(rolloutsApi.endpoints.getSpans.initiate(secondPageArgs!));
const secondPage = await secondPageSub.unwrap();
secondPageSub.unsubscribe();
expect(secondPage.items.map((span) => span.spanId)).toEqual(['span-001-root']);
expect(secondPage.total).toBe(3);
});
it('filters spans using the search term', async () => {
const store = createServerBackedStore();
store.dispatch(setTracesRolloutId('ro-story-001'));
store.dispatch(setTracesSearchTerm('span-003'));
const queryArgs = selectTracesQueryArgs(store.getState());
expect(queryArgs).toMatchObject({
traceIdContains: 'span-003',
spanIdContains: 'span-003',
nameContains: 'span-003',
filterLogic: 'or',
});
const subscription = store.dispatch(rolloutsApi.endpoints.getSpans.initiate(queryArgs!));
const data = await subscription.unwrap();
subscription.unsubscribe();
expect(data.items).toHaveLength(1);
expect(data.items[0].spanId).toBe('span-003-tool');
});
});
+4
View File
@@ -0,0 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
export * from './slice';
export * from './selectors';
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '@/store';
import type { AlertsState, AlertTone } from './slice';
const ALERT_PRIORITY: Record<AlertTone, number> = {
error: 3,
warning: 2,
info: 1,
};
const selectAlertState = (state: RootState): AlertsState => state.alert;
export const selectVisibleAlerts = createSelector(selectAlertState, (state) =>
state.alerts.filter((alert) => alert.isVisible),
);
export const selectHighestPriorityAlert = createSelector(selectVisibleAlerts, (alerts) => {
if (alerts.length === 0) {
return null;
}
return alerts
.slice()
.sort((a, b) => {
const priorityDiff = ALERT_PRIORITY[a.tone] - ALERT_PRIORITY[b.tone];
if (priorityDiff !== 0) {
return priorityDiff;
}
return a.createdAt - b.createdAt;
})
.at(-1)!;
});
+72
View File
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type AlertTone = 'info' | 'warning' | 'error';
export type AppAlert = {
id: string;
message: string;
tone: AlertTone;
isVisible: boolean;
createdAt: number;
};
export type AlertsState = {
alerts: AppAlert[];
};
const initialState: AlertsState = {
alerts: [],
};
type ShowAlertPayload = {
id: string;
message: string;
tone?: AlertTone;
};
type HideAlertPayload = {
id: string;
};
const alertsSlice = createSlice({
name: 'alert',
initialState,
reducers: {
showAlert(state, action: PayloadAction<ShowAlertPayload>) {
const { id, message, tone = 'info' } = action.payload;
const existing = state.alerts.find((alert) => alert.id === id);
if (existing) {
existing.message = message;
existing.tone = tone;
existing.isVisible = true;
existing.createdAt = Date.now();
} else {
state.alerts.push({
id,
message,
tone,
isVisible: true,
createdAt: Date.now(),
});
}
},
hideAlert(state, action: PayloadAction<HideAlertPayload>) {
state.alerts = state.alerts.filter((alert) => alert.id !== action.payload.id);
},
dismissAlert(state, action: PayloadAction<HideAlertPayload>) {
const entry = state.alerts.find((alert) => alert.id === action.payload.id);
if (entry) {
entry.isVisible = false;
}
},
clearAlerts(state) {
state.alerts = [];
},
},
});
export const { showAlert, hideAlert, dismissAlert, clearAlerts } = alertsSlice.actions;
export const alertReducer = alertsSlice.reducer;
@@ -0,0 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
export * from './slice';
export * from './selectors';
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
import type { RootState } from '@/store';
export const selectDrawerState = (state: RootState) => state.drawer;
export const selectDrawerIsOpen = (state: RootState) => selectDrawerState(state).isOpen;
export const selectDrawerContent = (state: RootState) => selectDrawerState(state).content;
+49
View File
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { Attempt, Rollout, Span } from '@/types';
export type DrawerType = 'rollout-json' | 'rollout-traces' | 'trace-detail';
export type DrawerContent =
| {
type: 'rollout-json' | 'rollout-traces';
rollout: Rollout;
attempt: Attempt | null;
isNested: boolean;
}
| {
type: 'trace-detail';
span: Span;
rollout: Rollout | null;
attempt: Attempt | null;
};
export type DrawerState = {
isOpen: boolean;
content: DrawerContent | null;
};
export const initialDrawerState: DrawerState = {
isOpen: false,
content: null,
};
const drawerSlice = createSlice({
name: 'drawer',
initialState: initialDrawerState,
reducers: {
openDrawer(state, action: PayloadAction<DrawerContent>) {
state.isOpen = true;
state.content = action.payload;
},
closeDrawer(state) {
state.isOpen = false;
state.content = null;
},
},
});
export const { openDrawer, closeDrawer } = drawerSlice.actions;
export const drawerReducer = drawerSlice.reducer;
+188
View File
@@ -0,0 +1,188 @@
// Copyright (c) Microsoft. All rights reserved.
import type { Meta, StoryObj } from '@storybook/react';
import { http, HttpResponse } from 'msw';
import { Provider } from 'react-redux';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { Stack, Text } from '@mantine/core';
import { initialConfigState } from '@/features/config/slice';
import type { AlertsState, AlertTone } from '@/features/ui/alert';
import { createAppStore } from '@/store';
import { STORY_BASE_URL, STORY_DATE_NOW_MS } from '../../.storybook/constants';
import { AppLayout, AppLayoutProps } from './AppLayout';
const Placeholder = ({ title, description }: { title: string; description: string }) => (
<Stack gap='sm' p='lg'>
<Text size='lg' fw={600}>
{title}
</Text>
<Text size='sm' c='dimmed'>
{description}
</Text>
</Stack>
);
const ROUTES = [
{
path: '/',
element: <AppLayout />,
children: [
{
path: 'rollouts',
element: <Placeholder title='Rollouts' description='Track the rollout queue and status.' />,
},
{
path: 'resources',
element: <Placeholder title='Resources' description='Inspect resource snapshots and metadata.' />,
},
{
path: 'traces',
element: <Placeholder title='Traces' description='Browse telemetry spans across attempts.' />,
},
{
path: 'settings',
element: (
<Placeholder title='Settings' description='Configure server connection, refresh cadence, and appearance.' />
),
},
],
},
];
function createAlertState(message: string, tone: AlertTone): AlertsState {
return {
alerts: [
{
id: 'storybook-alert',
message,
tone,
isVisible: true,
createdAt: STORY_DATE_NOW_MS,
},
],
};
}
function renderAppLayout(args: AppLayoutProps, initialEntry = '/rollouts', alertState?: AlertsState) {
const store = createAppStore({
config: {
...initialConfigState,
baseUrl: STORY_BASE_URL,
},
alert: alertState ?? { alerts: [] },
});
const router = createMemoryRouter(
ROUTES.map((route) => ({
...route,
element: <AppLayout {...args} />,
})),
{ initialEntries: [initialEntry] },
);
return (
<Provider store={store}>
<RouterProvider router={router} />
</Provider>
);
}
const meta: Meta<AppLayoutProps> = {
title: 'Layouts/AppLayout',
component: AppLayout,
parameters: {
layout: 'fullscreen',
},
render: (args) => renderAppLayout(args, '/rollouts'),
args: {
config: {
baseUrl: STORY_BASE_URL,
autoRefreshMs: 0,
},
},
};
export default meta;
type Story = StoryObj<AppLayoutProps>;
export const NoServerConfigured: Story = {
args: {
config: {
baseUrl: '',
autoRefreshMs: 0,
},
},
};
export const ServerOnline: Story = {
parameters: {
msw: {
handlers: [
http.get(`${STORY_BASE_URL}/v1/agl/health`, () => HttpResponse.json({ status: 'ok' }, { status: 200 })),
],
},
},
};
export const ServerOffline: Story = {
parameters: {
msw: {
handlers: [
http.get(`${STORY_BASE_URL}/v1/agl/health`, () =>
HttpResponse.json({ message: 'unavailable' }, { status: 503 }),
),
],
},
},
};
export const ResourcesNavActive: Story = {
render: (args) => renderAppLayout(args, '/resources'),
};
export const TracesNavActive: Story = {
render: (args) => renderAppLayout(args, '/traces'),
};
export const SettingsNavActive: Story = {
render: (args) => renderAppLayout(args, '/settings'),
};
export const PollingEveryFiveSeconds: Story = {
args: {
config: {
baseUrl: STORY_BASE_URL,
autoRefreshMs: 5000,
},
},
parameters: {
msw: {
handlers: [
http.get(`${STORY_BASE_URL}/v1/agl/health`, () => HttpResponse.json({ status: 'ok' }, { status: 200 })),
],
},
},
};
export const InfoAlertActive: Story = {
render: (args) =>
renderAppLayout(args, '/rollouts', createAlertState('Background synchronization completed successfully.', 'info')),
};
export const WarningAlertActive: Story = {
render: (args) =>
renderAppLayout(
args,
'/rollouts',
createAlertState('Rollout data may be stale. Check connectivity before proceeding.', 'warning'),
),
};
export const ErrorAlertActive: Story = {
render: (args) =>
renderAppLayout(
args,
'/rollouts',
createAlertState('Unable to reach Agent-lightning API. Retry or adjust server settings.', 'error'),
),
};
+248
View File
@@ -0,0 +1,248 @@
// Copyright (c) Microsoft. All rights reserved.
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { IconCpu, IconRouteSquare, IconSettings, IconTimeline } from '@tabler/icons-react';
import { Outlet, NavLink as RouterNavLink, useLocation, useNavigate } from 'react-router-dom';
import { AppShell, Badge, Group, Image, NavLink as MantineNavLink, Stack, Text, UnstyledButton } from '@mantine/core';
import { AppAlertBanner } from '@/components/AppAlertBanner';
import { AppDrawerContainer } from '@/components/AppDrawer.component';
import faviconUrl from '../favicon.svg';
import { selectConfig } from '../features/config';
import { useAppSelector } from '../store/hooks';
type ConnectionStatus = 'online' | 'offline' | 'unknown';
type NavItem = {
label: string;
to: string;
icon: ReactNode;
};
const NAV_ITEMS: NavItem[] = [
{ label: 'Rollouts', to: '/rollouts', icon: <IconRouteSquare size={16} /> },
{ label: 'Resources', to: '/resources', icon: <IconCpu size={16} /> },
{ label: 'Traces', to: '/traces', icon: <IconTimeline size={16} /> },
{ label: 'Settings', to: '/settings', icon: <IconSettings size={16} /> },
];
const CONNECTION_STATUS_META: Record<ConnectionStatus, { color: string; label: string }> = {
offline: { color: 'red', label: 'Offline' },
online: { color: 'teal', label: 'Online' },
unknown: { color: 'gray', label: 'Unknown' },
};
const DEFAULT_AUTO_REFRESH_MS = 30_000;
type ConnectionOptions = {
baseUrl?: string;
autoRefreshMs?: number;
};
function getSameOriginUrl() {
return window.location.origin;
}
function buildHealthUrl(baseUrl: string) {
if (baseUrl.startsWith('http://') || baseUrl.startsWith('https://')) {
const normalized = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
return new URL('v1/agl/health', normalized).toString();
}
const normalizedBase = baseUrl.startsWith('/') ? baseUrl : `/${baseUrl}`;
const trimmed = normalizedBase.replace(/\/+$/, '');
return `${trimmed}/v1/agl/health`;
}
function useServerConnection({ baseUrl, autoRefreshMs }: ConnectionOptions) {
const [status, setStatus] = useState<ConnectionStatus>('unknown');
const [isRefreshing, setIsRefreshing] = useState(false);
useEffect(() => {
if (!baseUrl) {
setStatus('unknown');
setIsRefreshing(false);
return;
}
let disposed = false;
let intervalId: number | undefined;
let activeController: AbortController | undefined;
const healthUrl = buildHealthUrl(baseUrl);
const check = async () => {
activeController?.abort();
const controller = new AbortController();
activeController = controller;
if (!disposed) {
setIsRefreshing(true);
}
try {
const response = await fetch(healthUrl, { signal: controller.signal });
if (!disposed && activeController === controller) {
setStatus(response.ok ? 'online' : 'offline');
}
} catch (error) {
if (disposed || (error instanceof DOMException && error.name === 'AbortError')) {
return;
}
if (!disposed && activeController === controller) {
setStatus('offline');
}
} finally {
if (!disposed && activeController === controller) {
setIsRefreshing(false);
}
}
};
check();
if (autoRefreshMs && autoRefreshMs > 0) {
intervalId = window.setInterval(check, autoRefreshMs);
}
return () => {
disposed = true;
activeController?.abort();
if (intervalId) {
window.clearInterval(intervalId);
}
};
}, [autoRefreshMs, baseUrl]);
return { status, isRefreshing };
}
function ConnectionIndicator({
baseUrl,
status = 'unknown',
isRefreshing = false,
onClick,
}: {
baseUrl?: string;
status?: ConnectionStatus;
isRefreshing?: boolean;
onClick?: () => void;
}) {
const { color, label } = CONNECTION_STATUS_META[status ?? 'unknown'];
const connectionTarget = baseUrl && baseUrl.length > 0 ? baseUrl : 'No server configured';
const content = (
<Stack gap={4} data-testid='connection-indicator'>
<Text size='xs' fw={600} c='dimmed'>
Server connection
</Text>
<Group gap='xs'>
{isRefreshing ? (
<Badge color='blue' variant='light' radius='xl' className='connection-refreshing'>
Refreshing
</Badge>
) : (
<Badge color={color} variant='light' radius='xl'>
{label}
</Badge>
)}
<Text size='xs' c='dimmed'>
{connectionTarget}
</Text>
</Group>
</Stack>
);
if (!onClick) {
return content;
}
return (
<UnstyledButton
onClick={onClick}
aria-label='Open settings to edit server connection'
className='connection-indicator-button'
>
{content}
</UnstyledButton>
);
}
export type AppLayoutProps = {
config?: ConnectionOptions;
};
export function AppLayout({ config }: AppLayoutProps = {}) {
const location = useLocation();
const navigate = useNavigate();
const resolvedBaseUrl = config?.baseUrl ?? getSameOriginUrl() ?? '';
const autoRefreshMs = config?.autoRefreshMs !== undefined ? config.autoRefreshMs : DEFAULT_AUTO_REFRESH_MS;
const connectionState = useServerConnection({
baseUrl: resolvedBaseUrl || undefined,
autoRefreshMs,
});
const navItems = useMemo(
() =>
NAV_ITEMS.map((item) => ({
...item,
active: location.pathname === item.to || location.pathname.startsWith(`${item.to}/`),
})),
[location.pathname],
);
return (
<AppShell padding='md' navbar={{ width: 280, breakpoint: 'sm' }}>
<AppShell.Navbar>
<AppShell.Section p='md' mb='md'>
<Group gap='sm'>
<Image src={faviconUrl} alt='Agent-lightning logo' w={32} h={32} />
<Text fw={600} size='sm'>
Agent-lightning Dashboard
</Text>
</Group>
</AppShell.Section>
<AppShell.Section grow p='md' pt={0}>
<Stack gap='xs'>
{navItems.map((item) => (
<MantineNavLink
key={item.to}
component={RouterNavLink}
to={item.to}
label={item.label}
active={item.active}
variant='light'
leftSection={item.icon}
/>
))}
</Stack>
</AppShell.Section>
<AppShell.Section p='md'>
<ConnectionIndicator
baseUrl={resolvedBaseUrl || undefined}
status={connectionState.status}
isRefreshing={connectionState.isRefreshing}
onClick={() => navigate('/settings')}
/>
</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main>
<AppAlertBanner />
<Outlet />
</AppShell.Main>
</AppShell>
);
}
export function AppLayoutWithState() {
const config = useAppSelector(selectConfig);
return (
<>
<AppLayout
config={{
baseUrl: config.baseUrl,
autoRefreshMs: config.autoRefreshMs,
}}
/>
<AppDrawerContainer />
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import { store } from './store';
ReactDOM.createRoot(document.getElementById('root')!).render(
<Provider store={store}>
<App />
</Provider>,
);
@@ -0,0 +1,341 @@
// Copyright (c) Microsoft. All rights reserved.
import type { Meta, StoryObj } from '@storybook/react';
import { delay, http, HttpResponse } from 'msw';
import { Provider } from 'react-redux';
import { AppAlertBanner } from '@/components/AppAlertBanner';
import { initialConfigState } from '@/features/config/slice';
import { initialResourcesUiState } from '@/features/resources/slice';
import { initialRolloutsUiState } from '@/features/rollouts/slice';
import { createAppStore } from '@/store';
import type { Resources } from '@/types';
import { createResourcesHandlers } from '@/utils/mock';
import { STORY_BASE_URL, STORY_DATE_NOW_SECONDS } from '../../.storybook/constants';
import { allModes } from '../../.storybook/modes';
import { ResourcesPage } from './Resources.page';
const meta: Meta<typeof ResourcesPage> = {
title: 'Pages/ResourcesPage',
component: ResourcesPage,
parameters: {
layout: 'fullscreen',
chromatic: {
modes: allModes,
},
},
};
export default meta;
type Story = StoryObj<typeof ResourcesPage>;
const now = STORY_DATE_NOW_SECONDS;
const sampleResources: Resources[] = [
{
resourcesId: 'rs-a1b2c3d4e5f6',
version: 3,
createTime: now - 7 * 24 * 3600,
updateTime: now - 3600,
resources: {
main_llm: {
resource_type: 'llm',
endpoint: 'https://api.openai.com/v1',
model: 'gpt-4',
sampling_parameters: {
temperature: 0.7,
max_tokens: 2048,
},
},
backup_llm: {
resource_type: 'llm',
endpoint: 'https://api.anthropic.com/v1',
model: 'claude-3-opus-20240229',
sampling_parameters: {
temperature: 0.5,
max_tokens: 4096,
},
},
greeting_template: {
resource_type: 'prompt_template',
template: 'Hello {name}! Welcome to {service}.',
engine: 'f-string',
},
},
},
{
resourcesId: 'rs-f6e5d4c3b2a1',
version: 2,
createTime: now - 14 * 24 * 3600,
updateTime: now - 2 * 24 * 3600,
resources: {
production_llm: {
resource_type: 'llm',
endpoint: 'https://api.openai.com/v1',
model: 'gpt-3.5-turbo',
sampling_parameters: {
temperature: 0.8,
max_tokens: 1024,
},
},
system_prompt: {
resource_type: 'prompt_template',
template: 'You are a helpful assistant. {context}',
engine: 'f-string',
},
},
},
{
resourcesId: 'rs-1234567890ab',
version: 1,
createTime: now - 30 * 24 * 3600,
updateTime: now - 15 * 24 * 3600,
resources: {
legacy_llm: {
resource_type: 'llm',
endpoint: 'https://api.openai.com/v1',
model: 'gpt-3.5-turbo-0301',
sampling_parameters: {
temperature: 0.9,
},
},
},
},
{
resourcesId: 'rs-abcdef123456',
version: 5,
createTime: now - 60 * 24 * 3600,
updateTime: now - 12 * 3600,
resources: {
eval_llm: {
resource_type: 'llm',
endpoint: 'https://api.anthropic.com/v1',
model: 'claude-3-sonnet-20240229',
sampling_parameters: {
temperature: 0.3,
max_tokens: 2048,
},
},
judge_template: {
resource_type: 'prompt_template',
template: 'Evaluate the following response: {response}\n\nCriteria: {criteria}',
engine: 'jinja2',
},
dataset: {
resource_type: 'dataset',
path: 's3://my-bucket/eval-data/v1/',
format: 'jsonl',
},
},
},
{
resourcesId: 'rs-999888777666',
version: 1,
createTime: now - 2 * 24 * 3600,
updateTime: now - 2 * 24 * 3600,
resources: {
test_llm: {
resource_type: 'llm',
endpoint: 'http://localhost:8080/v1',
model: 'local-model',
sampling_parameters: {
temperature: 1.0,
},
},
},
},
];
const defaultHandlers = createResourcesHandlers(sampleResources);
function renderWithStore(configOverrides?: Partial<typeof initialConfigState>) {
const store = createAppStore({
config: {
...initialConfigState,
baseUrl: STORY_BASE_URL,
autoRefreshMs: 0,
...configOverrides,
},
rollouts: initialRolloutsUiState,
resources: initialResourcesUiState,
});
return (
<Provider store={store}>
<ResourcesPage />
<AppAlertBanner />
</Provider>
);
}
export const Default: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: defaultHandlers,
},
},
};
export const EmptyState: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: [http.get('*/v1/agl/resources', () => HttpResponse.json({ items: [], limit: 0, offset: 0, total: 0 }))],
},
},
};
export const ServerError: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: [
http.get('*/v1/agl/resources', () => HttpResponse.json({ detail: 'Internal server error' }, { status: 500 })),
],
},
},
};
export const RequestTimeout: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: [
http.get('*/v1/agl/resources', async () => {
await delay(1200);
return HttpResponse.json({ detail: 'Request timed out' }, { status: 504, statusText: 'Timeout' });
}),
],
},
},
};
export const SingleResource: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: createResourcesHandlers([sampleResources[0]]),
},
},
};
export const ParseFailure: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: [
http.get('*/v1/agl/resources', () =>
HttpResponse.text('{ malformed json', {
status: 200,
headers: {
'Content-Type': 'application/json',
},
}),
),
],
},
},
};
export const ManyResources: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: createResourcesHandlers(
Array.from({ length: 50 }, (_, index) => ({
resourcesId: `rs-generated-${index + 1}`.padEnd(17, '0'),
version: (index % 5) + 1,
createTime: now - (50 - index) * 24 * 3600,
updateTime: now - (50 - index) * 3600,
resources: {
llm: {
resource_type: 'llm',
endpoint: `https://api.example.com/v${(index % 3) + 1}`,
model: index % 2 === 0 ? 'gpt-4' : 'claude-3-opus',
sampling_parameters: {
temperature: 0.5 + (index % 5) * 0.1,
},
},
},
})),
),
},
},
};
export const ComplexResources: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: createResourcesHandlers([
{
resourcesId: 'rs-complex-123456',
version: 10,
createTime: now - 90 * 24 * 3600,
updateTime: now - 600,
resources: {
primary_llm: {
resource_type: 'llm',
endpoint: 'https://api.openai.com/v1',
model: 'gpt-4-turbo-preview',
sampling_parameters: {
temperature: 0.7,
top_p: 0.95,
frequency_penalty: 0.1,
presence_penalty: 0.1,
max_tokens: 4096,
},
},
fallback_llm: {
resource_type: 'llm',
endpoint: 'https://api.anthropic.com/v1',
model: 'claude-3-opus-20240229',
sampling_parameters: {
temperature: 0.7,
max_tokens: 4096,
top_k: 40,
},
},
embedding_model: {
resource_type: 'embedding',
endpoint: 'https://api.openai.com/v1',
model: 'text-embedding-3-large',
dimensions: 1536,
},
system_prompt: {
resource_type: 'prompt_template',
template: 'You are an AI assistant. Context: {context}\nUser: {user_input}\nAssistant:',
engine: 'jinja2',
variables: ['context', 'user_input'],
},
training_dataset: {
resource_type: 'dataset',
path: 's3://ml-datasets/training/v2/data.jsonl',
format: 'jsonl',
size_bytes: 1024000000,
num_examples: 50000,
},
validation_dataset: {
resource_type: 'dataset',
path: 's3://ml-datasets/validation/v2/data.jsonl',
format: 'jsonl',
size_bytes: 102400000,
num_examples: 5000,
},
},
},
]),
},
},
};
export const DarkTheme: Story = {
render: () => renderWithStore({ theme: 'dark' }),
parameters: {
theme: 'dark',
msw: {
handlers: defaultHandlers,
},
},
};
+146
View File
@@ -0,0 +1,146 @@
// Copyright (c) Microsoft. All rights reserved.
import { useCallback, useEffect } from 'react';
import { IconSearch } from '@tabler/icons-react';
import type { DataTableSortStatus } from 'mantine-datatable';
import { Skeleton, Stack, TextInput, Title } from '@mantine/core';
import { ResourcesTable, type ResourcesTableRecord } from '@/components/ResourcesTable.component';
import { ResourcesTree } from '@/components/ResourcesTree.component';
import { selectAutoRefreshMs } from '@/features/config';
import {
resetResourcesFilters,
selectResourcesPage,
selectResourcesQueryArgs,
selectResourcesRecordsPerPage,
selectResourcesSearchTerm,
selectResourcesSort,
setResourcesPage,
setResourcesRecordsPerPage,
setResourcesSearchTerm,
setResourcesSort,
} from '@/features/resources';
import { useGetResourcesQuery } from '@/features/rollouts';
import { hideAlert, showAlert } from '@/features/ui/alert';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import type { PaginatedResponse, Resources } from '@/types';
import { getErrorDescriptor } from '@/utils/error';
export function ResourcesPage() {
const dispatch = useAppDispatch();
const autoRefreshMs = useAppSelector(selectAutoRefreshMs);
const searchTerm = useAppSelector(selectResourcesSearchTerm);
const page = useAppSelector(selectResourcesPage);
const recordsPerPage = useAppSelector(selectResourcesRecordsPerPage);
const sort = useAppSelector(selectResourcesSort);
const queryArgs = useAppSelector(selectResourcesQueryArgs);
const resourcesQueryResult = useGetResourcesQuery(queryArgs, {
pollingInterval: autoRefreshMs > 0 ? autoRefreshMs : undefined,
});
const resourcesData = resourcesQueryResult.data as PaginatedResponse<Resources> | undefined;
const { isLoading, isFetching, isError, error, refetch } = resourcesQueryResult;
const handleSearchTermChange = useCallback(
(value: string) => {
dispatch(setResourcesSearchTerm(value));
},
[dispatch],
);
const handleSortStatusChange = useCallback(
(status: DataTableSortStatus<ResourcesTableRecord>) => {
dispatch(
setResourcesSort({
column: status.columnAccessor,
direction: status.direction,
}),
);
},
[dispatch],
);
const handlePageChange = useCallback(
(nextPage: number) => {
dispatch(setResourcesPage(nextPage));
},
[dispatch],
);
const handleRecordsPerPageChange = useCallback(
(value: number) => {
dispatch(setResourcesRecordsPerPage(value));
},
[dispatch],
);
const handleResetFilters = useCallback(() => {
dispatch(resetResourcesFilters());
}, [dispatch]);
const showSkeleton = isLoading && !((resourcesData?.items?.length ?? 0) > 0);
useEffect(() => {
const descriptor = isError ? getErrorDescriptor(error) : null;
if (isError) {
const detailSuffix = descriptor ? ` (${descriptor})` : '';
dispatch(
showAlert({
id: 'resources-fetch',
message: `Unable to refresh resources${detailSuffix}. The table may be out of date until the connection recovers.`,
tone: 'error',
}),
);
return;
}
if (!isLoading && !isFetching) {
dispatch(hideAlert({ id: 'resources-fetch' }));
}
}, [dispatch, error, isError, isFetching, isLoading]);
useEffect(
() => () => {
dispatch(hideAlert({ id: 'resources-fetch' }));
},
[dispatch],
);
return (
<Stack gap='md'>
<Title order={1}>Resources</Title>
<TextInput
placeholder='Search by Resources ID'
value={searchTerm}
onChange={(event) => handleSearchTermChange(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
data-testid='resources-search-input'
w='100%'
style={{ maxWidth: 360 }}
/>
{showSkeleton ? (
<Skeleton height={360} radius='md' />
) : (
<ResourcesTable
resourcesList={resourcesData?.items}
totalRecords={resourcesData?.total ?? 0}
isFetching={isFetching}
isError={isError}
error={error}
searchTerm={searchTerm}
sort={sort}
page={page}
recordsPerPage={recordsPerPage}
onSortStatusChange={handleSortStatusChange}
onPageChange={handlePageChange}
onRecordsPerPageChange={handleRecordsPerPageChange}
onResetFilters={handleResetFilters}
onRefetch={refetch}
renderRowExpansion={({ resources }) => <ResourcesTree resources={resources} />}
/>
)}
</Stack>
);
}
+776
View File
@@ -0,0 +1,776 @@
// 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 { AppAlertBanner } from '@/components/AppAlertBanner';
import { AppDrawerContainer } from '@/components/AppDrawer.component';
import { createMockHandlers } from '@/utils/mock';
import { STORY_BASE_URL, STORY_DATE_NOW_SECONDS } from '../../.storybook/constants';
import { allModes } from '../../.storybook/modes';
import { initialConfigState } from '../features/config/slice';
import { initialResourcesUiState } from '../features/resources/slice';
import type { Attempt, Rollout, Span } from '../features/rollouts';
import { initialRolloutsUiState, type RolloutsUiState } from '../features/rollouts/slice';
import { createAppStore } from '../store';
import { RolloutsPage } from './Rollouts.page';
const meta: Meta<typeof RolloutsPage> = {
title: 'Pages/RolloutsPage',
component: RolloutsPage,
parameters: {
layout: 'fullscreen',
chromatic: {
modes: allModes,
},
},
};
export default meta;
type Story = StoryObj<typeof RolloutsPage>;
const now = STORY_DATE_NOW_SECONDS;
const sampleRollouts: Rollout[] = [
{
rolloutId: 'ro-7fa3b6e2',
input: { task: 'Summarize report' },
status: 'running',
mode: 'train',
resourcesId: 'rs-100',
startTime: now - 1200,
endTime: null,
attempt: {
rolloutId: 'ro-7fa3b6e2',
attemptId: 'at-9001',
sequenceId: 1,
status: 'running',
startTime: now - 1200,
endTime: null,
workerId: 'worker-alpha',
lastHeartbeatTime: now - 30,
metadata: { lastHeartbeatAt: now - 30 },
},
config: { retries: 0 },
metadata: { owner: 'alice' },
},
{
rolloutId: 'ro-116eab45',
input: { task: 'Classify dataset' },
status: 'succeeded',
mode: 'val',
resourcesId: 'rs-101',
startTime: now - 5400,
endTime: now - 3600,
attempt: {
rolloutId: 'ro-116eab45',
attemptId: 'at-9002',
sequenceId: 2,
status: 'succeeded',
startTime: now - 4000,
endTime: now - 3600,
workerId: 'worker-beta',
lastHeartbeatTime: now - 3600,
metadata: { lastHeartbeatAt: now - 3600 },
},
config: { retries: 1 },
metadata: { owner: 'bob' },
},
{
rolloutId: 'ro-9ae77c11',
input: { task: 'Evaluate prompt variations' },
status: 'failed',
mode: 'test',
resourcesId: 'rs-102',
startTime: now - 9600,
endTime: now - 8400,
attempt: {
rolloutId: 'ro-9ae77c11',
attemptId: 'at-9005',
sequenceId: 3,
status: 'failed',
startTime: now - 8800,
endTime: now - 8400,
workerId: 'worker-gamma',
lastHeartbeatTime: now - 8400,
metadata: { lastHeartbeatAt: now - 8400 },
},
config: { retries: 2 },
metadata: { owner: 'carol' },
},
];
const attemptsByRollout: Record<string, Attempt[]> = {
'ro-7fa3b6e2': [
{
rolloutId: 'ro-7fa3b6e2',
attemptId: 'at-9001',
sequenceId: 1,
status: 'running',
startTime: now - 1200,
endTime: null,
workerId: 'worker-alpha',
lastHeartbeatTime: now - 30,
metadata: { lastHeartbeatAt: now - 30 },
},
],
'ro-116eab45': [
{
rolloutId: 'ro-116eab45',
attemptId: 'at-9000',
sequenceId: 1,
status: 'failed',
startTime: now - 5400,
endTime: now - 5000,
workerId: 'worker-beta',
lastHeartbeatTime: now - 5000,
metadata: { lastHeartbeatAt: now - 5000 },
},
{
rolloutId: 'ro-116eab45',
attemptId: 'at-9002',
sequenceId: 2,
status: 'succeeded',
startTime: now - 4000,
endTime: now - 3600,
workerId: 'worker-beta',
lastHeartbeatTime: now - 3600,
metadata: { lastHeartbeatAt: now - 3600 },
},
],
'ro-9ae77c11': [
{
rolloutId: 'ro-9ae77c11',
attemptId: 'at-9003',
sequenceId: 1,
status: 'preparing',
startTime: now - 9600,
endTime: now - 9300,
workerId: 'worker-gamma',
lastHeartbeatTime: now - 9300,
metadata: { lastHeartbeatAt: now - 9300 },
},
{
rolloutId: 'ro-9ae77c11',
attemptId: 'at-9004',
sequenceId: 2,
status: 'running',
startTime: now - 9200,
endTime: now - 8800,
workerId: 'worker-delta',
lastHeartbeatTime: now - 8800,
metadata: { lastHeartbeatAt: now - 8800 },
},
{
rolloutId: 'ro-9ae77c11',
attemptId: 'at-9005',
sequenceId: 3,
status: 'failed',
startTime: now - 8800,
endTime: now - 8400,
workerId: 'worker-gamma',
lastHeartbeatTime: now - 8400,
metadata: { lastHeartbeatAt: now - 8400 },
},
],
};
const sampleSpansByAttempt: Record<string, Span[]> = {
'ro-7fa3b6e2:at-9001': [
{
rolloutId: 'ro-7fa3b6e2',
attemptId: 'at-9001',
sequenceId: 1,
traceId: 'tr-7fa3b6e2-1',
spanId: 'sp-7fa3b6e2-setup',
parentId: null,
name: 'Initialize rollout',
status: { status_code: 'OK', description: null },
attributes: { step: 'init', duration_ms: 120 },
startTime: now - 1100,
endTime: now - 1000,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-7fa3b6e2',
attemptId: 'at-9001',
sequenceId: 2,
traceId: 'tr-7fa3b6e2-1',
spanId: 'sp-7fa3b6e2-run',
parentId: 'sp-7fa3b6e2-setup',
name: 'Execute task',
status: { status_code: 'OK', description: null },
attributes: { step: 'run', duration_ms: 450 },
startTime: now - 950,
endTime: now - 500,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
],
'ro-116eab45:at-9002': [
{
rolloutId: 'ro-116eab45',
attemptId: 'at-9002',
sequenceId: 1,
traceId: 'tr-116eab45-1',
spanId: 'sp-116eab45-validate',
parentId: null,
name: 'Validate input',
status: { status_code: 'OK', description: null },
attributes: { step: 'validate', duration_ms: 80 },
startTime: now - 3800,
endTime: now - 3720,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-116eab45',
attemptId: 'at-9002',
sequenceId: 2,
traceId: 'tr-116eab45-1',
spanId: 'sp-116eab45-execute',
parentId: 'sp-116eab45-validate',
name: 'Execute workflow',
status: { status_code: 'OK', description: null },
attributes: { step: 'execute', duration_ms: 260 },
startTime: now - 3700,
endTime: now - 3440,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
],
'ro-9ae77c11:at-9005': [
{
rolloutId: 'ro-9ae77c11',
attemptId: 'at-9005',
sequenceId: 1,
traceId: 'tr-9ae77c11-1',
spanId: 'sp-9ae77c11-fetch',
parentId: null,
name: 'Fetch resources',
status: { status_code: 'OK', description: null },
attributes: { step: 'fetch', duration_ms: 200 },
startTime: now - 8700,
endTime: now - 8500,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-9ae77c11',
attemptId: 'at-9005',
sequenceId: 2,
traceId: 'tr-9ae77c11-1',
spanId: 'sp-9ae77c11-run',
parentId: 'sp-9ae77c11-fetch',
name: 'Run evaluation',
status: { status_code: 'ERROR', description: 'Worker timeout' },
attributes: { step: 'evaluate', duration_ms: 600 },
startTime: now - 8450,
endTime: now - 7850,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
],
};
const longDurationRollouts: Rollout[] = [
{
rolloutId: 'ro-long-duration',
input: { task: 'Long running training' },
status: 'running',
mode: 'train',
resourcesId: 'rs-200',
startTime: now - 7 * 24 * 3600,
endTime: null,
attempt: {
rolloutId: 'ro-long-duration',
attemptId: 'at-long-001',
sequenceId: 1,
status: 'running',
startTime: now - 7 * 24 * 3600,
endTime: null,
workerId: 'worker-long',
lastHeartbeatTime: now - 45,
metadata: null,
},
config: { retries: 0 },
metadata: { owner: 'delta' },
},
];
const longDurationAttempts: Record<string, Attempt[]> = {
'ro-long-duration': [
{
rolloutId: 'ro-long-duration',
attemptId: 'at-long-001',
sequenceId: 1,
status: 'running',
startTime: now - 7 * 24 * 3600,
endTime: null,
workerId: 'worker-long',
lastHeartbeatTime: now - 45,
metadata: null,
},
],
};
const staleHeartbeatRollouts: Rollout[] = [
{
rolloutId: 'ro-stale-heartbeat',
input: { task: 'Investigate stale worker' },
status: 'running',
mode: 'test',
resourcesId: 'rs-201',
startTime: now - 6 * 3600,
endTime: null,
attempt: {
rolloutId: 'ro-stale-heartbeat',
attemptId: 'at-stale-001',
sequenceId: 1,
status: 'running',
startTime: now - 6 * 3600,
endTime: null,
workerId: 'worker-stale',
lastHeartbeatTime: now - 3 * 24 * 3600,
metadata: null,
},
config: { retries: 0 },
metadata: { owner: 'echo' },
},
];
const staleHeartbeatAttempts: Record<string, Attempt[]> = {
'ro-stale-heartbeat': [
{
rolloutId: 'ro-stale-heartbeat',
attemptId: 'at-stale-001',
sequenceId: 1,
status: 'running',
startTime: now - 6 * 3600,
endTime: null,
workerId: 'worker-stale',
lastHeartbeatTime: now - 3 * 24 * 3600,
metadata: null,
},
],
};
const statusMismatchRollouts: Rollout[] = [
{
rolloutId: 'ro-status-mismatch',
input: { task: 'Edge case validation' },
status: 'running',
mode: 'val',
resourcesId: null,
startTime: now - 3600,
endTime: now - 1800,
attempt: {
rolloutId: 'ro-status-mismatch',
attemptId: 'at-mismatch-003',
sequenceId: 3,
status: 'failed',
startTime: now - 4200,
endTime: now - 1800,
workerId: 'worker-mismatch',
lastHeartbeatTime: now - 1700,
metadata: null,
},
config: { retries: 3 },
metadata: { owner: 'foxtrot' },
},
];
const statusMismatchAttempts: Record<string, Attempt[]> = {
'ro-status-mismatch': [
{
rolloutId: 'ro-status-mismatch',
attemptId: 'at-mismatch-001',
sequenceId: 1,
status: 'preparing',
startTime: now - 5400,
endTime: now - 5000,
workerId: 'worker-mismatch',
lastHeartbeatTime: now - 5000,
metadata: null,
},
{
rolloutId: 'ro-status-mismatch',
attemptId: 'at-mismatch-002',
sequenceId: 2,
status: 'running',
startTime: now - 5000,
endTime: now - 4200,
workerId: 'worker-mismatch',
lastHeartbeatTime: now - 4000,
metadata: null,
},
{
rolloutId: 'ro-status-mismatch',
attemptId: 'at-mismatch-003',
sequenceId: 3,
status: 'failed',
startTime: now - 4200,
endTime: now - 1800,
workerId: 'worker-mismatch',
lastHeartbeatTime: now - 1700,
metadata: null,
},
],
};
const veryLongInput = `{"prompt":"${'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(12)}"}`;
const longInputRollouts: Rollout[] = [
{
rolloutId: 'ro-long-input',
input: veryLongInput,
status: 'queuing',
mode: 'test',
resourcesId: 'rs-300',
startTime: now - 120,
endTime: null,
attempt: null,
config: { retries: 0 },
metadata: { owner: 'golf' },
},
];
const longInputAttempts: Record<string, Attempt[]> = {
'ro-long-input': [],
};
const paginationRollouts: Rollout[] = Array.from({ length: 120 }, (_item, index) => {
const startOffset = index * 90;
const rolloutId = `ro-page-${index + 1}`;
return {
rolloutId,
input: { item: index + 1 },
status: index % 3 === 0 ? 'running' : index % 3 === 1 ? 'failed' : 'succeeded',
mode: index % 2 === 0 ? 'train' : 'test',
resourcesId: index % 5 === 0 ? `rs-${100 + index}` : null,
startTime: now - startOffset - 300,
endTime: index % 3 === 0 ? null : now - startOffset,
attempt: {
rolloutId,
attemptId: `at-page-${index + 1}`,
sequenceId: 1,
status: index % 3 === 0 ? 'running' : index % 3 === 1 ? 'failed' : 'succeeded',
startTime: now - startOffset - 300,
endTime: index % 3 === 0 ? null : now - startOffset,
workerId: `worker-${(index % 7) + 1}`,
lastHeartbeatTime: index % 3 === 0 ? now - startOffset - 60 : now - startOffset,
metadata: null,
},
config: {},
metadata: null,
};
});
const paginationAttempts: Record<string, Attempt[]> = Object.fromEntries(
paginationRollouts.map((rollout) => [rollout.rolloutId, rollout.attempt ? [rollout.attempt] : []]),
);
const autoExpandRollouts: Rollout[] = [
{
rolloutId: 'ro-auto-expand',
input: { task: 'Auto expand test' },
status: 'running',
mode: 'train',
resourcesId: 'rs-400',
startTime: now - 3600,
endTime: null,
attempt: {
rolloutId: 'ro-auto-expand',
attemptId: 'at-expand-003',
sequenceId: 3,
status: 'running',
startTime: now - 3600,
endTime: null,
workerId: 'worker-auto',
lastHeartbeatTime: now - 30,
metadata: null,
},
config: {},
metadata: null,
},
];
const autoExpandAttempts: Record<string, Attempt[]> = {
'ro-auto-expand': [
{
rolloutId: 'ro-auto-expand',
attemptId: 'at-expand-001',
sequenceId: 1,
status: 'preparing',
startTime: now - 5400,
endTime: now - 5000,
workerId: 'worker-auto',
lastHeartbeatTime: now - 5000,
metadata: null,
},
{
rolloutId: 'ro-auto-expand',
attemptId: 'at-expand-002',
sequenceId: 2,
status: 'failed',
startTime: now - 5000,
endTime: now - 4200,
workerId: 'worker-auto',
lastHeartbeatTime: now - 4200,
metadata: null,
},
{
rolloutId: 'ro-auto-expand',
attemptId: 'at-expand-003',
sequenceId: 3,
status: 'running',
startTime: now - 3600,
endTime: null,
workerId: 'worker-auto',
lastHeartbeatTime: now - 30,
metadata: null,
},
],
};
function renderWithStore(uiOverrides?: Partial<RolloutsUiState>, configOverrides?: Partial<typeof initialConfigState>) {
const store = createAppStore({
config: {
...initialConfigState,
baseUrl: STORY_BASE_URL,
autoRefreshMs: 0,
...configOverrides,
},
rollouts: {
...initialRolloutsUiState,
...uiOverrides,
},
resources: initialResourcesUiState,
});
return (
<Provider store={store}>
<>
<RolloutsPage />
<AppAlertBanner />
<AppDrawerContainer />
</>
</Provider>
);
}
const defaultHandlers = createMockHandlers(sampleRollouts, attemptsByRollout, sampleSpansByAttempt);
export const Default: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: defaultHandlers,
},
},
};
export const DarkTheme: Story = {
render: () => renderWithStore(undefined, { theme: 'dark' }),
parameters: {
theme: 'dark',
msw: {
handlers: defaultHandlers,
},
},
};
export const EmptyState: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: [
http.get('*/v1/agl/rollouts', () => HttpResponse.json({ items: [], limit: 0, offset: 0, total: 0 })),
http.get('*/v1/agl/rollouts/:rolloutId/attempts', () =>
HttpResponse.json({ items: [], limit: 0, offset: 0, total: 0 }),
),
],
},
},
};
export const ServerError: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: [
http.get('*/v1/agl/rollouts', () => HttpResponse.json({ detail: 'Internal error' }, { status: 500 })),
http.get('*/v1/agl/rollouts/:rolloutId/attempts', () =>
HttpResponse.json({ items: [], limit: 0, offset: 0, total: 0 }, { status: 200 }),
),
],
},
},
};
export const Loading: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: [
http.get('*/v1/agl/rollouts', async () => {
await delay('infinite');
return HttpResponse.json({ items: [], limit: 0, offset: 0, total: 0 });
}),
http.get('*/v1/agl/rollouts/:rolloutId/attempts', async () => {
await delay('infinite');
return HttpResponse.json({ items: [], limit: 0, offset: 0, total: 0 });
}),
],
},
},
};
export const LongDuration: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: createMockHandlers(longDurationRollouts, longDurationAttempts, {}),
},
},
};
export const StaleHeartbeat: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: createMockHandlers(staleHeartbeatRollouts, staleHeartbeatAttempts, {}),
},
},
};
export const StatusMismatch: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: createMockHandlers(statusMismatchRollouts, statusMismatchAttempts, {}),
},
},
};
export const LongInput: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: createMockHandlers(longInputRollouts, longInputAttempts, {}),
},
},
};
export const Pagination: Story = {
render: () => renderWithStore({ recordsPerPage: 20 }),
parameters: {
msw: {
handlers: createMockHandlers(paginationRollouts, paginationAttempts, {}),
},
},
};
export const AutoExpandedAttempt: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: createMockHandlers(autoExpandRollouts, autoExpandAttempts, {}),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('ro-auto-expand');
const rolloutCell = canvas.getByText('ro-auto-expand');
const rolloutRow = rolloutCell.closest('tr');
if (!rolloutRow) {
throw new Error('Unable to locate the rollout row for expansion');
}
await userEvent.click(rolloutRow);
await waitFor(
async () => {
await canvas.findByText('at-expand-001');
},
{ timeout: 3_000 },
);
},
};
export const RawJsonDrawer: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: defaultHandlers,
},
},
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 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/);
},
};
export const TracesDrawer: Story = {
render: () => renderWithStore(),
parameters: {
msw: {
handlers: defaultHandlers,
},
},
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');
},
};
+258
View File
@@ -0,0 +1,258 @@
// Copyright (c) Microsoft. All rights reserved.
import { useCallback, useEffect } from 'react';
import { IconSearch } from '@tabler/icons-react';
import type { DataTableColumn, DataTableSortStatus } from 'mantine-datatable';
import { Skeleton, Stack, TextInput, Title } from '@mantine/core';
import { RolloutAttemptsTable, RolloutTable, type RolloutTableRecord } from '@/components/RolloutTable.component';
import { selectAutoRefreshMs } from '@/features/config';
import {
resetRolloutsFilters,
selectRolloutsModeFilters,
selectRolloutsPage,
selectRolloutsQueryArgs,
selectRolloutsRecordsPerPage,
selectRolloutsSearchTerm,
selectRolloutsSort,
selectRolloutsStatusFilters,
setRolloutsModeFilters,
setRolloutsPage,
setRolloutsRecordsPerPage,
setRolloutsSearchTerm,
setRolloutsSort,
setRolloutsStatusFilters,
useGetRolloutAttemptsQuery,
useGetRolloutsQuery,
type Rollout,
type RolloutMode,
type RolloutStatus,
} from '@/features/rollouts';
import { hideAlert, showAlert } from '@/features/ui/alert';
import { openDrawer } from '@/features/ui/drawer';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
function RolloutAttemptsContent({
rollout,
columns,
}: {
rollout: Rollout;
columns: DataTableColumn<RolloutTableRecord>[];
}) {
const { data, isFetching, isError, refetch } = useGetRolloutAttemptsQuery({
rolloutId: rollout.rolloutId,
limit: 100,
sortBy: 'sequence_id',
sortOrder: 'desc',
});
return (
<RolloutAttemptsTable
rollout={rollout}
attempts={data?.items}
isFetching={isFetching}
isError={isError}
onRetry={refetch}
columns={columns}
/>
);
}
function toRolloutFromRecord(record: RolloutTableRecord): Rollout {
const { rolloutId, input, startTime, endTime, mode, resourcesId, status, config, metadata, attempt } = record;
return {
rolloutId,
input,
startTime,
endTime,
mode,
resourcesId,
status,
config,
metadata,
attempt: attempt ?? null,
};
}
export function RolloutsPage() {
const dispatch = useAppDispatch();
const autoRefreshMs = useAppSelector(selectAutoRefreshMs);
const searchTerm = useAppSelector(selectRolloutsSearchTerm);
const statusFilters = useAppSelector(selectRolloutsStatusFilters);
const modeFilters = useAppSelector(selectRolloutsModeFilters);
const page = useAppSelector(selectRolloutsPage);
const recordsPerPage = useAppSelector(selectRolloutsRecordsPerPage);
const sort = useAppSelector(selectRolloutsSort);
const rolloutsQueryArgs = useAppSelector(selectRolloutsQueryArgs);
const {
data: rolloutsData,
isLoading,
isFetching,
isError,
error,
refetch,
} = useGetRolloutsQuery(rolloutsQueryArgs, {
pollingInterval: autoRefreshMs > 0 ? autoRefreshMs : undefined,
});
const handleSearchTermChange = useCallback(
(value: string) => {
dispatch(setRolloutsSearchTerm(value));
},
[dispatch],
);
const handleStatusFilterChange = useCallback(
(values: RolloutStatus[]) => {
dispatch(setRolloutsStatusFilters(values));
},
[dispatch],
);
const handleStatusFilterReset = useCallback(() => {
dispatch(setRolloutsStatusFilters([]));
}, [dispatch]);
const handleModeFilterChange = useCallback(
(values: RolloutMode[]) => {
dispatch(setRolloutsModeFilters(values));
},
[dispatch],
);
const handleModeFilterReset = useCallback(() => {
dispatch(setRolloutsModeFilters([]));
}, [dispatch]);
const handleSortStatusChange = useCallback(
(status: DataTableSortStatus<RolloutTableRecord>) => {
dispatch(
setRolloutsSort({
column: status.columnAccessor as string,
direction: status.direction,
}),
);
},
[dispatch],
);
const handlePageChange = useCallback(
(nextPage: number) => {
dispatch(setRolloutsPage(nextPage));
},
[dispatch],
);
const handleRecordsPerPageChange = useCallback(
(value: number) => {
dispatch(setRolloutsRecordsPerPage(value));
},
[dispatch],
);
const handleResetFilters = useCallback(() => {
dispatch(resetRolloutsFilters());
}, [dispatch]);
const handleViewRawJson = useCallback(
(record: RolloutTableRecord) => {
dispatch(
openDrawer({
type: 'rollout-json',
rollout: toRolloutFromRecord(record),
attempt: record.attempt ?? null,
isNested: record.isNested,
}),
);
},
[dispatch],
);
const handleViewTraces = useCallback(
(record: RolloutTableRecord) => {
dispatch(
openDrawer({
type: 'rollout-traces',
rollout: toRolloutFromRecord(record),
attempt: record.attempt ?? null,
isNested: record.isNested,
}),
);
},
[dispatch],
);
const hasRollouts = Array.isArray(rolloutsData?.items) && rolloutsData.items.length > 0;
const showSkeleton = isLoading && !hasRollouts;
useEffect(() => {
if (isError) {
dispatch(
showAlert({
id: 'rollouts-fetch',
message: 'Unable to refresh rollouts. The list below may be out of date until the connection recovers.',
tone: 'error',
}),
);
return;
}
if (!isLoading && !isFetching) {
dispatch(hideAlert({ id: 'rollouts-fetch' }));
}
}, [dispatch, isError, isFetching, isLoading]);
useEffect(
() => () => {
dispatch(hideAlert({ id: 'rollouts-fetch' }));
},
[dispatch],
);
return (
<Stack gap='md'>
<Title order={1}>Rollouts</Title>
<TextInput
placeholder='Search by Rollout ID'
value={searchTerm}
onChange={(event) => handleSearchTermChange(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
data-testid='rollouts-search-input'
w='100%'
style={{ maxWidth: 360 }}
/>
{showSkeleton ? (
<Skeleton height={360} radius='md' />
) : (
<RolloutTable
rollouts={rolloutsData?.items}
totalRecords={rolloutsData?.total ?? 0}
isFetching={isFetching}
isError={isError}
error={error}
searchTerm={searchTerm}
statusFilters={statusFilters}
modeFilters={modeFilters}
sort={sort}
page={page}
recordsPerPage={recordsPerPage}
onStatusFilterChange={handleStatusFilterChange}
onStatusFilterReset={handleStatusFilterReset}
onModeFilterChange={handleModeFilterChange}
onModeFilterReset={handleModeFilterReset}
onSortStatusChange={handleSortStatusChange}
onPageChange={handlePageChange}
onRecordsPerPageChange={handleRecordsPerPageChange}
onResetFilters={handleResetFilters}
onRefetch={refetch}
onViewRawJson={handleViewRawJson}
onViewTraces={handleViewTraces}
renderRowExpansion={({ rollout, columns }) => <RolloutAttemptsContent rollout={rollout} columns={columns} />}
/>
)}
</Stack>
);
}
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
import type { Meta, StoryObj } from '@storybook/react';
import { Provider } from 'react-redux';
import { initialConfigState } from '@/features/config/slice';
import { createAppStore } from '@/store';
import type { ConfigState } from '@/types';
import { STORY_BASE_URL } from '../../.storybook/constants';
import { SettingsPage } from './Settings.page';
const meta: Meta<typeof SettingsPage> = {
title: 'Pages/SettingsPage',
component: SettingsPage,
parameters: {
layout: 'centered',
},
};
export default meta;
type Story = StoryObj<typeof SettingsPage>;
function renderWithConfig(partial?: Partial<ConfigState>) {
const store = createAppStore({
config: {
...initialConfigState,
baseUrl: STORY_BASE_URL,
...partial,
},
});
return (
<Provider store={store}>
<SettingsPage />
</Provider>
);
}
export const Default: Story = {
render: () => renderWithConfig(),
};
export const AutoRefreshEveryMinute: Story = {
render: () => renderWithConfig({ autoRefreshMs: 60_000 }),
};
export const DarkThemeSelected: Story = {
render: () => renderWithConfig({ theme: 'dark' }),
parameters: {
theme: 'dark',
},
};
export const CustomBackendUrl: Story = {
render: () => renderWithConfig({ baseUrl: 'https://api.agent-lightning.dev' }),
};
+95
View File
@@ -0,0 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
import { useEffect, useState } from 'react';
import { Divider, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { selectConfig } from '../features/config';
import { setAutoRefreshMs, setBaseUrl, setTheme } from '../features/config/slice';
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' },
];
const THEME_OPTIONS = [
{ label: 'System default', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
];
export function SettingsPage() {
const dispatch = useAppDispatch();
const config = useAppSelector(selectConfig);
const [baseUrlInput, setBaseUrlInput] = useState(config.baseUrl);
useEffect(() => {
setBaseUrlInput(config.baseUrl);
}, [config.baseUrl]);
return (
<Stack gap='lg' p='md' data-testid='settings-page'>
<Title order={1}>Settings</Title>
<Stack gap='md'>
<Stack gap={4}>
<Text fw={600} size='sm'>
Backend base URL
</Text>
<Text size='xs' c='dimmed'>
The dashboard uses this address for health checks and API calls.
</Text>
<TextInput
placeholder='http://localhost:8000'
value={baseUrlInput}
onChange={(event) => setBaseUrlInput(event.currentTarget.value)}
onBlur={() => dispatch(setBaseUrl(baseUrlInput.trim()))}
data-testid='settings-base-url'
/>
</Stack>
<Divider />
<Stack gap={4}>
<Text fw={600} size='sm'>
Auto-refresh interval
</Text>
<Text size='xs' c='dimmed'>
Controls how often the dashboard polls the server.
</Text>
<Select
value={String(config.autoRefreshMs)}
data={AUTO_REFRESH_OPTIONS}
onChange={(value) => {
if (!value) {
dispatch(setAutoRefreshMs(0));
return;
}
dispatch(setAutoRefreshMs(Number(value)));
}}
data-testid='settings-auto-refresh'
/>
</Stack>
<Divider />
<Stack gap={4}>
<Text fw={600} size='sm'>
Theme
</Text>
<Text size='xs' c='dimmed'>
Choose the color scheme for the dashboard.
</Text>
<Select
value={config.theme}
data={THEME_OPTIONS}
onChange={(value) => {
if (!value) {
return;
}
dispatch(setTheme(value as typeof config.theme));
}}
data-testid='settings-theme'
/>
</Stack>
</Stack>
</Stack>
);
}
+485
View File
@@ -0,0 +1,485 @@
// Copyright (c) Microsoft. All rights reserved.
import type { Meta, StoryObj } from '@storybook/react';
import { delay, http, HttpResponse } from 'msw';
import { Provider } from 'react-redux';
import { AppAlertBanner } from '@/components/AppAlertBanner';
import { AppDrawerContainer } from '@/components/AppDrawer.component';
import { createMockHandlers } from '@/utils/mock';
import { STORY_BASE_URL, STORY_DATE_NOW_SECONDS } from '../../.storybook/constants';
import { allModes } from '../../.storybook/modes';
import { initialConfigState } from '../features/config/slice';
import { initialResourcesUiState } from '../features/resources/slice';
import { initialRolloutsUiState } from '../features/rollouts/slice';
import { initialTracesUiState, type TracesUiState } from '../features/traces/slice';
import { createAppStore } from '../store';
import type { Attempt, Rollout, Span } from '../types';
import { TracesPage } from './Traces.page';
const meta: Meta<typeof TracesPage> = {
title: 'Pages/TracesPage',
component: TracesPage,
parameters: {
layout: 'fullscreen',
chromatic: {
modes: allModes,
},
},
};
export default meta;
type Story = StoryObj<typeof TracesPage>;
const now = STORY_DATE_NOW_SECONDS;
const sampleRollouts: Rollout[] = [
{
rolloutId: 'ro-traces-001',
input: { task: 'Generate onboarding flow' },
status: 'running',
mode: 'train',
resourcesId: 'rs-traces-001',
startTime: now - 1800,
endTime: null,
attempt: {
rolloutId: 'ro-traces-001',
attemptId: 'at-traces-001',
sequenceId: 1,
status: 'running',
startTime: now - 1800,
endTime: null,
workerId: 'worker-delta',
lastHeartbeatTime: now - 30,
metadata: { region: 'us-east-1' },
},
config: { retries: 0 },
metadata: { owner: 'ava' },
},
{
rolloutId: 'ro-traces-002',
input: { task: 'Classify support emails' },
status: 'succeeded',
mode: 'val',
resourcesId: 'rs-traces-002',
startTime: now - 5400,
endTime: now - 3600,
attempt: {
rolloutId: 'ro-traces-002',
attemptId: 'at-traces-004',
sequenceId: 4,
status: 'succeeded',
startTime: now - 4000,
endTime: now - 3600,
workerId: 'worker-epsilon',
lastHeartbeatTime: now - 3600,
metadata: { region: 'us-west-2' },
},
config: { retries: 2 },
metadata: { owner: 'ben' },
},
];
const attemptsByRollout: Record<string, Attempt[]> = {
'ro-traces-001': [
{
rolloutId: 'ro-traces-001',
attemptId: 'at-traces-001',
sequenceId: 1,
status: 'running',
startTime: now - 1800,
endTime: null,
workerId: 'worker-delta',
lastHeartbeatTime: now - 30,
metadata: { region: 'us-east-1' },
},
{
rolloutId: 'ro-traces-001',
attemptId: 'at-traces-002',
sequenceId: 2,
status: 'failed',
startTime: now - 5400,
endTime: now - 4800,
workerId: 'worker-theta',
lastHeartbeatTime: now - 4800,
metadata: { error: 'Network timeout' },
},
],
'ro-traces-002': [
{
rolloutId: 'ro-traces-002',
attemptId: 'at-traces-004',
sequenceId: 4,
status: 'succeeded',
startTime: now - 4000,
endTime: now - 3600,
workerId: 'worker-epsilon',
lastHeartbeatTime: now - 3600,
metadata: { region: 'us-west-2' },
},
],
};
const spansByAttempt: Record<string, Span[]> = {
'ro-traces-001:at-traces-001': [
{
rolloutId: 'ro-traces-001',
attemptId: 'at-traces-001',
sequenceId: 1,
traceId: 'tr-001',
spanId: 'sp-001',
parentId: null,
name: 'Initialize rollout',
status: { status_code: 'OK', description: null },
attributes: { stage: 'init', duration_ms: 120 },
startTime: now - 1600,
endTime: now - 1580,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-traces-001',
attemptId: 'at-traces-001',
sequenceId: 2,
traceId: 'tr-001',
spanId: 'sp-002',
parentId: 'sp-001',
name: 'Fetch resources',
status: { status_code: 'OK', description: null },
attributes: { endpoint: '/resources/latest', duration_ms: 240 },
startTime: now - 1580,
endTime: now - 1540,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
],
'ro-traces-001:at-traces-002': [
{
rolloutId: 'ro-traces-001',
attemptId: 'at-traces-002',
sequenceId: 1,
traceId: 'tr-002',
spanId: 'sp-101',
parentId: null,
name: 'Initialize rollout',
status: { status_code: 'ERROR', description: 'Timeout' },
attributes: { stage: 'init', duration_ms: 600 },
startTime: now - 5300,
endTime: now - 4700,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
],
'ro-traces-002:at-traces-004': [
{
rolloutId: 'ro-traces-002',
attemptId: 'at-traces-004',
sequenceId: 1,
traceId: 'tr-200',
spanId: 'sp-201',
parentId: null,
name: 'Load dataset',
status: { status_code: 'OK', description: null },
attributes: { records: 1200, duration_ms: 420 },
startTime: now - 3800,
endTime: now - 3720,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-traces-002',
attemptId: 'at-traces-004',
sequenceId: 2,
traceId: 'tr-200',
spanId: 'sp-202',
parentId: 'sp-201',
name: 'Classify batch',
status: { status_code: 'OK', description: null },
attributes: { batch: 1, duration_ms: 320 },
startTime: now - 3720,
endTime: now - 3660,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
],
};
const emptyAttemptsByRollout: Record<string, Attempt[]> = {};
const emptySpansByAttempt: Record<string, Span[]> = {};
const singleRollout = sampleRollouts[1];
const singleRollouts: Rollout[] = [singleRollout];
const singleAttemptsByRollout: Record<string, Attempt[]> = {
[singleRollout.rolloutId]: attemptsByRollout[singleRollout.rolloutId] ?? [],
};
const singleSpansByAttempt = Object.fromEntries(
Object.entries(spansByAttempt).filter(([key]) => key.startsWith(`${singleRollout.rolloutId}:`)),
) as 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}`] = [
{
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,
},
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] },
};
});
function createHandlers(delayMs?: number) {
return createMockHandlers(sampleRollouts, attemptsByRollout, spansByAttempt, delayMs);
}
function createRequestTimeoutHandlers() {
return [
http.get('*/v1/agl/rollouts', async () => {
await delay(1200);
return HttpResponse.json({ detail: 'Request timed out' }, { status: 504, statusText: 'Timeout' });
}),
http.get('*/v1/agl/rollouts/:rolloutId/attempts', async ({ params }) => {
await delay(1200);
return HttpResponse.json(
{ detail: 'Request timed out', rolloutId: params.rolloutId },
{ status: 504, statusText: 'Timeout' },
);
}),
http.get('*/v1/agl/spans', async () => {
await delay(1200);
return HttpResponse.json({ detail: 'Request timed out' }, { status: 504, statusText: 'Timeout' });
}),
];
}
const rolloutsAndAttemptsHandlers = createMockHandlers(sampleRollouts, attemptsByRollout);
function renderTracesPage(
preloadedTracesState?: Partial<TracesUiState>,
configOverrides?: Partial<typeof initialConfigState>,
) {
const store = createAppStore({
config: {
...initialConfigState,
baseUrl: STORY_BASE_URL,
...configOverrides,
},
rollouts: initialRolloutsUiState,
resources: initialResourcesUiState,
traces: { ...initialTracesUiState, ...preloadedTracesState },
});
return (
<Provider store={store}>
<TracesPage />
<AppAlertBanner />
<AppDrawerContainer />
</Provider>
);
}
export const DefaultView: Story = {
render: () => renderTracesPage(),
parameters: {
msw: {
handlers: createHandlers(),
},
},
};
export const DarkTheme: Story = {
render: () => renderTracesPage(undefined, { theme: 'dark' }),
parameters: {
theme: 'dark',
msw: {
handlers: createHandlers(),
},
},
};
export const EmptyState: Story = {
render: () => renderTracesPage(),
parameters: {
msw: {
handlers: createMockHandlers([], emptyAttemptsByRollout, emptySpansByAttempt),
},
},
};
export const SingleResult: Story = {
render: () => renderTracesPage(),
parameters: {
msw: {
handlers: createMockHandlers(singleRollouts, singleAttemptsByRollout, singleSpansByAttempt),
},
},
};
export const ManyResults: Story = {
render: () => renderTracesPage(),
parameters: {
msw: {
handlers: createMockHandlers(manyRollouts, manyAttemptsByRollout, manySpansByAttempt),
},
},
};
export const LoadingState: Story = {
render: () => renderTracesPage(),
parameters: {
msw: {
handlers: createHandlers(800),
},
},
};
export const RequestTimeout: Story = {
render: () => renderTracesPage(),
parameters: {
msw: {
handlers: createRequestTimeoutHandlers(),
},
},
};
export const AttemptScoped: Story = {
render: () =>
renderTracesPage({
attemptId: 'at-traces-002',
rolloutId: 'ro-traces-001',
}),
parameters: {
msw: {
handlers: createHandlers(),
},
},
};
export const ServerError: Story = {
render: () => renderTracesPage(),
parameters: {
msw: {
handlers: [
http.get('*/v1/agl/rollouts', () =>
HttpResponse.json({ items: [], limit: 0, offset: 0, total: 0 }, { status: 200 }),
),
http.get('*/v1/agl/rollouts/:rolloutId/attempts', () =>
HttpResponse.json({ items: [], limit: 0, offset: 0, total: 0 }, { status: 200 }),
),
http.get('*/v1/agl/spans', () => HttpResponse.json({ detail: 'server error' }, { status: 500 })),
],
},
},
};
export const RolloutParseFailure: Story = {
render: () => renderTracesPage(),
parameters: {
msw: {
handlers: [
http.get('*/v1/agl/rollouts', () =>
HttpResponse.text('not valid json', {
status: 200,
headers: {
'Content-Type': 'application/json',
},
}),
),
http.get('*/v1/agl/rollouts/:rolloutId/attempts', () =>
HttpResponse.json({ items: [], limit: 0, offset: 0, total: 0 }),
),
http.get('*/v1/agl/spans', () => HttpResponse.json({ items: [], limit: 0, offset: 0, total: 0 })),
],
},
},
};
export const ParseFailure: Story = {
render: () => renderTracesPage(),
parameters: {
msw: {
handlers: [
...rolloutsAndAttemptsHandlers,
http.get('*/v1/agl/spans', () =>
HttpResponse.text('not valid json', {
status: 200,
headers: {
'Content-Type': 'application/json',
},
}),
),
],
},
},
};
+470
View File
@@ -0,0 +1,470 @@
// Copyright (c) Microsoft. All rights reserved.
import { useCallback, useEffect, useMemo } from 'react';
import { skipToken } from '@reduxjs/toolkit/query';
import { IconCheck, IconChevronDown, IconSearch } from '@tabler/icons-react';
import type { DataTableSortStatus } from 'mantine-datatable';
import { Button, Group, Menu, Select, Skeleton, Stack, TextInput, Title } from '@mantine/core';
import { TracesTable, type TracesTableRecord } from '@/components/TracesTable.component';
import { selectAutoRefreshMs } from '@/features/config';
import {
useGetRolloutAttemptsQuery,
useGetRolloutsQuery,
useGetSpansQuery,
type GetRolloutsQueryArgs,
} from '@/features/rollouts';
import {
resetTracesFilters,
selectTracesAttemptId,
selectTracesPage,
selectTracesQueryArgs,
selectTracesRecordsPerPage,
selectTracesRolloutId,
selectTracesSearchTerm,
selectTracesSort,
selectTracesViewMode,
setTracesAttemptId,
setTracesPage,
setTracesRecordsPerPage,
setTracesRolloutId,
setTracesSearchTerm,
setTracesSort,
setTracesViewMode,
} from '@/features/traces';
import { hideAlert, showAlert } from '@/features/ui/alert';
import { openDrawer } from '@/features/ui/drawer';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import type { Attempt, Rollout, Span } from '@/types';
import { getErrorDescriptor } from '@/utils/error';
import { formatStatusLabel } from '@/utils/format';
const VIEW_OPTIONS = [
{ value: 'table', label: 'Table View', disabled: false },
{ value: 'waterfall', label: 'Waterfall View (Coming Soon)', disabled: true },
{ value: 'tree', label: 'Tree View (Coming Soon)', disabled: true },
] as const;
type ViewOptionValue = (typeof VIEW_OPTIONS)[number]['value'];
function getLatestAttempt(attempts: Attempt[]): Attempt | null {
if (!attempts.length) {
return 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;
}
return rollouts.find((rollout) => rollout.rolloutId === rolloutId) ?? null;
}
export function TracesPage() {
const dispatch = useAppDispatch();
const autoRefreshMs = useAppSelector(selectAutoRefreshMs);
const rolloutId = useAppSelector(selectTracesRolloutId);
const attemptId = useAppSelector(selectTracesAttemptId);
const searchTerm = useAppSelector(selectTracesSearchTerm);
const page = useAppSelector(selectTracesPage);
const recordsPerPage = useAppSelector(selectTracesRecordsPerPage);
const sort = useAppSelector(selectTracesSort);
const viewMode = useAppSelector(selectTracesViewMode);
const spansQueryArgs = useAppSelector(selectTracesQueryArgs);
const rolloutsQueryArgs = useMemo<GetRolloutsQueryArgs>(
() => ({
limit: 100,
offset: 0,
sortBy: 'start_time',
sortOrder: 'desc',
}),
[],
);
const {
data: rolloutsData,
isLoading: rolloutsLoading,
isFetching: rolloutsFetching,
isError: rolloutsIsError,
error: rolloutsError,
} = useGetRolloutsQuery(rolloutsQueryArgs, {
pollingInterval: autoRefreshMs > 0 ? autoRefreshMs : undefined,
});
const rolloutItems = rolloutsData?.items ?? [];
const selectedRollout = useMemo(() => findRollout(rolloutItems, rolloutId), [rolloutItems, rolloutId]);
const attemptsQueryArgs =
rolloutId !== null
? {
rolloutId,
limit: 200,
sortBy: 'sequence_id',
sortOrder: 'desc' as const,
}
: skipToken;
const {
data: attemptsData,
isFetching: attemptsFetching,
isError: attemptsIsError,
error: attemptsError,
} = useGetRolloutAttemptsQuery(attemptsQueryArgs, {
pollingInterval: autoRefreshMs > 0 ? autoRefreshMs : undefined,
});
const {
data: spansData,
isFetching: spansFetching,
isError: spansIsError,
error: spansError,
refetch: refetchSpans,
} = useGetSpansQuery(spansQueryArgs ?? skipToken, {
pollingInterval: autoRefreshMs > 0 ? autoRefreshMs : undefined,
});
useEffect(() => {
if (!rolloutsData) {
return;
}
if (rolloutItems.length === 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));
}
}, [dispatch, rolloutsData, rolloutId, rolloutItems]);
useEffect(() => {
if (!rolloutId) {
if (attemptId !== null) {
dispatch(setTracesAttemptId(null));
}
return;
}
if (attemptsData && attemptsData.items.length > 0) {
const hasSelected = attemptId ? attemptsData.items.some((attempt) => attempt.attemptId === attemptId) : false;
if (!hasSelected) {
const latest = getLatestAttempt(attemptsData.items);
if (latest && latest.attemptId !== attemptId) {
dispatch(setTracesAttemptId(latest.attemptId));
}
}
return;
}
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 attemptOptions = useMemo(() => {
if (attemptsData && attemptsData.items.length > 0) {
return [...attemptsData.items]
.sort((a, b) => b.sequenceId - a.sequenceId)
.map((attempt) => ({
value: attempt.attemptId,
label: `Attempt ${attempt.sequenceId} (${attempt.attemptId}) - ${formatStatusLabel(attempt.status)}`,
}));
}
if (selectedRollout?.attempt) {
const attempt = selectedRollout.attempt;
return [
{
value: attempt.attemptId,
label: `Attempt ${attempt.sequenceId} (${attempt.attemptId}) - ${formatStatusLabel(attempt.status)}`,
},
];
}
return [];
}, [attemptsData, selectedRollout]);
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 isFetching = spansFetching || rolloutsFetching || attemptsFetching;
const selectionMessage = useMemo<string | undefined>(() => {
if (!rolloutId && !attemptId) {
return 'Select a rollout and attempt to view traces.';
}
if (!rolloutId) {
return 'Select a rollout to view traces.';
}
if (!attemptId) {
return 'Select an attempt to view traces.';
}
return undefined;
}, [attemptId, rolloutId]);
const tableSpans = selectionMessage ? [] : spans;
const tableIsError = selectionMessage ? false : spansIsError;
const tableError = selectionMessage ? undefined : spansError;
const tableIsFetching = selectionMessage ? false : isFetching;
useEffect(() => {
const anyError = rolloutsIsError || attemptsIsError || spansIsError;
if (anyError) {
const descriptor =
(rolloutsIsError && getErrorDescriptor(rolloutsError)) ||
(attemptsIsError && getErrorDescriptor(attemptsError)) ||
(spansIsError && getErrorDescriptor(spansError)) ||
null;
const detailSuffix = descriptor ? ` (${descriptor})` : '';
dispatch(
showAlert({
id: 'traces-fetch',
message: `Unable to refresh traces${detailSuffix}. The table may be out of date until the connection recovers.`,
tone: 'error',
}),
);
return;
}
if (!rolloutsLoading && !rolloutsFetching && !spansFetching && !attemptsFetching) {
dispatch(hideAlert({ id: 'traces-fetch' }));
}
}, [
attemptsError,
attemptsFetching,
attemptsIsError,
dispatch,
rolloutsError,
rolloutsFetching,
rolloutsIsError,
rolloutsLoading,
spansError,
spansFetching,
spansIsError,
]);
useEffect(
() => () => {
dispatch(hideAlert({ id: 'traces-fetch' }));
},
[dispatch],
);
const handleSearchTermChange = useCallback(
(value: string) => {
dispatch(setTracesSearchTerm(value));
},
[dispatch],
);
const handleSortStatusChange = useCallback(
(status: DataTableSortStatus<TracesTableRecord>) => {
dispatch(
setTracesSort({
column: status.columnAccessor as string,
direction: status.direction,
}),
);
},
[dispatch],
);
const handlePageChange = useCallback(
(nextPage: number) => {
dispatch(setTracesPage(nextPage));
},
[dispatch],
);
const handleRecordsPerPageChange = useCallback(
(value: number) => {
dispatch(setTracesRecordsPerPage(value));
},
[dispatch],
);
const handleResetFilters = useCallback(() => {
dispatch(resetTracesFilters());
}, [dispatch]);
const handleRefetch = useCallback(() => {
if (rolloutId) {
void refetchSpans();
}
}, [refetchSpans, rolloutId]);
const handleShowRollout = useCallback(
(record: TracesTableRecord) => {
if (rolloutItems.length === 0) {
return;
}
const rollout = rolloutItems.find((item) => item.rolloutId === record.rolloutId);
if (!rollout) {
return;
}
const attempts = attemptsData?.items ?? [];
const attemptForRecord =
attempts.find((attempt) => attempt.attemptId === record.attemptId) ?? rollout.attempt ?? null;
dispatch(
openDrawer({
type: 'rollout-json',
rollout,
attempt: attemptForRecord,
isNested: false,
}),
);
},
[attemptsData, dispatch, rolloutItems],
);
const handleShowSpanDetail = useCallback(
(record: TracesTableRecord) => {
const rolloutForSpan =
rolloutItems.length > 0 ? (rolloutItems.find((item) => item.rolloutId === record.rolloutId) ?? null) : null;
const attempts = attemptsData?.items ?? [];
const attemptForSpan =
attempts.find((attempt) => attempt.attemptId === record.attemptId) ?? rolloutForSpan?.attempt ?? null;
dispatch(
openDrawer({
type: 'trace-detail',
span: record,
rollout: rolloutForSpan,
attempt: attemptForSpan,
}),
);
},
[attemptsData, dispatch, rolloutItems],
);
const handleParentIdClick = useCallback(
(parentId: string) => {
dispatch(setTracesSearchTerm(parentId));
},
[dispatch],
);
const handleViewChange = useCallback(
(value: ViewOptionValue) => {
dispatch(setTracesViewMode(value));
},
[dispatch],
);
const activeViewLabel = VIEW_OPTIONS.find((option) => option.value === viewMode)?.label ?? 'Table View';
return (
<Stack gap='md'>
<Group justify='space-between' align='flex-start'>
<Stack gap='sm' style={{ flex: 1, minWidth: 0 }}>
<Title order={1}>Traces</Title>
<Group gap='md' wrap='wrap'>
<Select
data={rolloutOptions}
value={rolloutId ?? null}
onChange={(value) => {
if (value !== rolloutId) {
dispatch(setTracesRolloutId(value));
}
}}
searchable
placeholder='Select rollout'
aria-label='Select rollout'
nothingFoundMessage={rolloutsFetching ? 'Loading...' : 'No rollouts'}
comboboxProps={{ withinPortal: true }}
w={260}
disabled={rolloutOptions.length === 0}
/>
<Select
data={attemptOptions}
value={attemptId ?? null}
onChange={(value) => {
if (value !== attemptId) {
dispatch(setTracesAttemptId(value));
}
}}
searchable
placeholder='Latest attempt'
aria-label='Select attempt'
nothingFoundMessage={attemptsFetching ? 'Loading...' : 'No attempts'}
comboboxProps={{ withinPortal: true }}
w={280}
disabled={!rolloutId || attemptOptions.length === 0}
/>
<TextInput
value={searchTerm}
onChange={(event) => handleSearchTermChange(event.currentTarget.value)}
placeholder='Search spans'
aria-label='Search spans'
leftSection={<IconSearch size={16} />}
w={280}
/>
</Group>
</Stack>
<Menu shadow='md' position='bottom-end' withinPortal>
<Menu.Target>
<Button variant='light' rightSection={<IconChevronDown size={16} />} aria-label='Change traces view'>
{activeViewLabel}
</Button>
</Menu.Target>
<Menu.Dropdown>
{VIEW_OPTIONS.map((option) => (
<Menu.Item
key={option.value}
disabled={option.disabled}
leftSection={option.value === viewMode && !option.disabled ? <IconCheck size={14} /> : null}
onClick={() => {
if (!option.disabled) {
handleViewChange(option.value);
}
}}
>
{option.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</Group>
<Skeleton visible={isInitialLoading} radius='md'>
<TracesTable
spans={tableSpans}
totalRecords={selectionMessage ? 0 : spansTotal}
isFetching={tableIsFetching}
isError={tableIsError}
error={tableError}
selectionMessage={selectionMessage}
searchTerm={searchTerm}
sort={sort}
page={page}
recordsPerPage={recordsPerPage}
onSortStatusChange={handleSortStatusChange}
onPageChange={handlePageChange}
onRecordsPerPageChange={handleRecordsPerPageChange}
onResetFilters={handleResetFilters}
onRefetch={handleRefetch}
onShowRollout={handleShowRollout}
onShowSpanDetail={handleShowSpanDetail}
onParentIdClick={handleParentIdClick}
recordsPerPageOptions={recordsPerPageOptions}
/>
</Skeleton>
</Stack>
);
}
+7
View File
@@ -0,0 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from './index';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
+33
View File
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import { configReducer } from '../features/config';
import { resourcesReducer } from '../features/resources';
import { rolloutsApi, rolloutsReducer } from '../features/rollouts';
import { tracesReducer } from '../features/traces';
import { alertReducer } from '../features/ui/alert';
import { drawerReducer } from '../features/ui/drawer';
const rootReducer = combineReducers({
config: configReducer,
drawer: drawerReducer,
alert: alertReducer,
rollouts: rolloutsReducer,
resources: resourcesReducer,
traces: tracesReducer,
[rolloutsApi.reducerPath]: rolloutsApi.reducer,
});
export type RootState = ReturnType<typeof rootReducer>;
export const createAppStore = (preloadedState?: Partial<RootState>) =>
configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(rolloutsApi.middleware),
preloadedState,
});
export const store = createAppStore();
export type AppStore = ReturnType<typeof createAppStore>;
export type AppDispatch = AppStore['dispatch'];
+99
View File
@@ -0,0 +1,99 @@
@keyframes connection-refreshing-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.connection-refreshing {
animation: connection-refreshing-blink 1s linear infinite;
}
.connection-indicator-button {
display: block;
width: 100%;
text-align: left;
padding: var(--mantine-spacing-xs);
border-radius: var(--mantine-radius-sm);
transition: background-color 150ms ease;
cursor: pointer;
}
.connection-indicator-button:hover,
.connection-indicator-button:focus-visible {
background-color: var(--mantine-color-gray-0);
}
[data-mantine-color-scheme='dark'] .connection-indicator-button:hover,
[data-mantine-color-scheme='dark'] .connection-indicator-button:focus-visible {
background-color: var(--mantine-color-dark-8);
}
.connection-indicator-button:focus-visible {
outline: 2px solid var(--mantine-color-blue-filled);
outline-offset: 2px;
}
[data-mantine-color-scheme='dark'] .mantine-AppShell-root .mantine-AppShell-navbar {
--app-shell-border-color: var(--mantine-color-dark-6);
}
.mantine-datatable {
--mantine-datatable-background-color: var(--mantine-color-body);
}
.mantine-datatable .mantine-datatable-empty-state {
pointer-events: none;
}
.mantine-datatable .mantine-datatable-empty-state[data-active] {
pointer-events: auto;
}
[data-mantine-color-scheme='dark'] .mantine-datatable {
--mantine-datatable-border-color-dark: var(--mantine-color-dark-6);
--mantine-datatable-highlight-on-hover-color: var(--mantine-color-dark-7);
}
/* Rollout Nested Table */
.rollouts-table .mantine-datatable-row-expansion-cell {
padding: 0;
}
.rollouts-table .mantine-datatable-row-expansion-cell-content {
border: 0;
padding: 0;
background-color: transparent;
}
.rollouts-table--nested {
--mantine-datatable-background-color: var(--mantine-color-gray-1);
}
.rollouts-table--nested .mantine-datatable-table,
.rollouts-table--nested .mantine-datatable-table td,
.rollouts-table--nested .mantine-datatable-table th {
background-color: var(--mantine-datatable-background-color);
}
.rollouts-table--nested .mantine-datatable-table tbody tr:first-of-type td {
border-top: 1px solid var(--mantine-color-gray-3);
}
[data-mantine-color-scheme='dark'] .rollouts-table--nested {
--mantine-datatable-background-color: var(--mantine-color-dark-6);
}
[data-mantine-color-scheme='dark'] .rollouts-table--nested .mantine-datatable-table tbody tr:first-of-type td {
border-top: 1px solid var(--mantine-color-dark-4);
}
/* Fix Mantine Conflicts with Mantine-datatable */
.mantine-datatable-page-size-selector-active {
color: var(--mantine-color-text);
background: transparent;
}
+796
View File
@@ -0,0 +1,796 @@
/* Checkbox */
.mantine-Checkbox-root {
--checkbox-color: var(--mantine-primary-color-filled);
--checkbox-icon-color: var(--mantine-primary-color-contrast);
.mantine-Checkbox-input {
background-color: transparent;
border-color: var(--checkbox-color);
&[data-indeterminate],
&:checked {
background-color: var(--checkbox-color);
}
&:disabled {
opacity: 0.5;
& + .mantine-Checkbox-icon {
opacity: 0.5;
color: var(--checkbox-icon-color);
}
}
}
.mantine-Checkbox-label {
&[data-disabled] {
color: inherit;
opacity: 0.7;
}
}
&[data-variant='outline'] {
.mantine-Checkbox-input {
background-color: transparent;
& + .mantine-Checkbox-icon {
color: var(--checkbox-color);
}
}
}
}
/* Checkbox Group */
.mantine-CheckboxGroup-root {
.mantine-CheckboxGroup-error {
margin-top: 8px;
}
}
/* Chip */
.mantine-Chip-root {
--chip-color: var(--mantine-primary-color-contrast);
&[data-variant='outline']:not([data-disabled]) {
.mantine-Chip-label {
background-color: transparent;
--chip-bd: 1px solid var(--mantine-color-default-border);
&:hover {
background-color: var(--mantine-color-default-hover);
}
&:where([data-disabled]) {
--chip-icon-color: var(--mantine-color-text);
border-color: var(--mantine-color-default-border);
color: var(--mantine-color-text);
opacity: 0.5;
&:hover {
background-color: transparent;
color: var(--mantine-color-text);
--chip-bd: 1px solid var(--mantine-color-default-border);
--chip-icon-color: var(--mantine-color-text);
}
}
}
}
}
/* Input */
[data-mantine-color-scheme='light'],
[data-mantine-color-scheme='dark'] {
.mantine-Input-wrapper {
--input-disabled-bg: alpha(var(--mantine-color-default-hover), 0.5);
--input-disabled-color: alpha(var(--mantine-color-text), 0.5);
&[data-variant='default'] {
--input-bd: var(--mantine-color-default-border);
--input-bg: transparent;
--input-bd-focus: var(--mantine-primary-color-filled);
}
&[data-variant='filled'] {
--input-bd: transparent;
--input-bg: var(--mantine-color-default-hover);
--input-bd-focus: var(--mantine-primary-color-filled);
}
&[data-variant='unstyled'] {
--input-bd: transparent;
--input-bg: transparent;
--input-bd-focus: transparent;
}
}
}
/* Color Input */
.mantine-ColorInput-dropdown {
background-color: var(--mantine-color-default);
border-color: var(--mantine-color-default-border);
}
/* Fieldset */
.mantine-Fieldset-root {
border-color: var(--mantine-color-default-border);
background-color: transparent;
&[data-variant='filled'] {
border-color: var(--mantine-color-default-border);
background-color: alpha(var(--mantine-color-default-hover), 0.4);
}
&[data-variant='unstyled'] {
border-color: transparent;
background-color: transparent;
}
}
/* Radio */
.mantine-Radio-root {
.mantine-Radio-radio {
background-color: transparent;
border-color: var(--radio-color);
&:checked {
background-color: var(--radio-color);
border-color: var(--radio-color);
}
& + .mantine-Radio-icon {
color: var(--radio-icon-color);
}
&:disabled {
opacity: 0.5;
}
}
&[data-variant='outline'] {
.mantine-Radio-radio {
background-color: transparent;
border-color: var(--radio-color);
&:checked:not(:disabled) {
background-color: transparent;
border-color: var(--radio-color);
}
&:disabled {
opacity: 0.5;
& + .mantine-Radio-icon {
color: var(--radio-color);
--radio-icon-opacity: 0.5;
}
}
}
}
}
/* Segmented Control */
.mantine-SegmentedControl-root {
background-color: var(--mantine-color-secondary-outline-hover);
.mantine-SegmentedControl-label {
color: var(--mantine-color-dimmed);
&[data-active] {
color: var(--mantine-color-text);
}
}
}
[data-mantine-color-scheme='light'] {
.mantine-SegmentedControl-root {
.mantine-SegmentedControl-label {
&:where(:not([data-disabled], [data-active], [data-read-only])) {
&:hover {
color: var(--mantine-color-black);
}
}
}
}
}
[data-mantine-color-scheme='dark'] {
.mantine-SegmentedControl-root {
.mantine-SegmentedControl-label {
&:where(:not([data-disabled], [data-active], [data-read-only])) {
&:hover {
color: var(--mantine-color-white);
}
}
}
}
}
[data-mantine-color-scheme='light'] {
.mantine-SegmentedControl-root {
.mantine-SegmentedControl-label {
&:where(:not([data-disabled], [data-active], [data-read-only])) {
&:hover {
color: var(--mantine-color-black);
}
}
}
}
}
[data-mantine-color-scheme='dark'] {
.mantine-SegmentedControl-root {
.mantine-SegmentedControl-label {
&:where(:not([data-disabled], [data-active], [data-read-only])) {
&:hover {
color: var(--mantine-color-white);
}
}
}
}
}
/* Switch */
/* Slider */
[data-mantine-color-scheme='light'],
[data-mantine-color-scheme='dark'] {
.mantine-Slider-root {
--slider-track-bg: var(--mantine-color-secondary-outline-hover);
}
}
.mantine-Slider-root {
.mantine-Slider-trackContainer {
&[data-disabled] {
opacity: 0.5;
}
}
.mantine-Slider-label {
background-color: var(--mantine-color-default) !important;
color: var(--mantine-color-text);
border: 1px solid var(--mantine-color-default-border) !important;
top: -45px;
}
}
/* Pills Input */
.mantine-PillsInput-wrapper {
.mantine-PillsInput-input {
&[data-variant='filled'] {
.mantine-Pill-root {
background-color: var(--mantine-color-body);
}
}
}
}
/* Tags Input */
.mantine-TagsInput-wrapper {
.mantine-TagsInput-input {
&[data-variant='filled'] {
.mantine-Pill-root {
background-color: var(--mantine-color-body);
}
}
}
}
/* ActionIcon */
.mantine-ActionIcon-root {
&:where(:disabled:not([data-loading]), [data-disabled]:not([data-loading])) {
border: var(--ai-bd);
opacity: 0.5;
&:active {
transform: none;
}
}
}
/* Button */
.mantine-Button-root {
&:where(:disabled:not([data-loading]), [data-disabled]:not([data-loading])) {
border: var(--button-bd);
opacity: 0.5;
}
}
/* Close Button */
.mantine-CloseButton-root {
&[data-variant='subtle']:where(:not([data-disabled], :disabled)) {
&:hover {
background-color: var(--mantine-color-default-hover);
}
}
}
/* NavLink */
.mantine-NavLink-root {
&:hover {
background-color: var(--mantine-color-default-hover);
}
&:where([data-active], [aria-current='page']) {
background-color: var(--nl-bg);
color: var(--nl-color);
&:hover {
background-color: var(--nl-hover);
}
.description {
--description-opacity: 0.9;
--description-color: var(--nl-color);
}
}
}
/* Pagination */
.mantine-Pagination-root {
.mantine-Pagination-control {
border-color: var(--mantine-color-default-border);
background-color: var(--mantine-color-body);
&:hover {
&:where(:not(:disabled, [data-disabled])) {
background-color: var(--mantine-color-default-hover);
}
}
&:where([data-active]) {
background-color: var(--pagination-active-bg);
border-color: var(--pagination-active-bg);
color: var(--pagination-active-color, var(--mantine-primary-color-contrast));
&:hover {
background-color: rgb(from var(--pagination-active-bg) r g b / 0.9);
}
}
}
}
/* Stepper */
.mantine-Stepper-root {
.mantine-Stepper-stepIcon {
background-color: var(--mantine-color-body);
border-color: var(--mantine-color-default-border);
color: var(--mantine-color-text);
&:where([data-progress]) {
border-color: var(--step-color);
}
&:where([data-completed]) {
color: var(--stepper-icon-color, var(--mantine-color-white));
background-color: var(--step-color);
border-color: var(--step-color);
}
}
}
/* Tabs */
[data-mantine-color-scheme='light'],
[data-mantine-color-scheme='dark'] {
.mantine-Tabs-root {
--tab-border-color: var(--mantine-color-default-border);
&[data-variant='default'] {
--tabs-list-border-width: 2px;
--tab-hover-color: var(--mantine-color-default-hover);
}
&[data-variant='pills'] {
--tabs-list-gap: calc(var(--mantine-spacing-sm) / 2);
--tabs-text-color: var(--mantine-color-body) !important;
--tab-hover-color: var(--mantine-color-default-hover);
}
.mantine-Tabs-tab {
color: var(--mantine-color-dimmed);
&[data-variant='default'],
&[data-variant='outline'] {
&[data-active='true'] {
color: var(--mantine-color-text);
}
&:hover {
background-color: transparent;
color: var(--mantine-color-text);
}
}
&[data-variant='pills'] {
&[data-active='true'] {
color: var(--mantine-primary-color-contrast);
&:hover {
color: var(--mantine-primary-color-contrast);
}
}
&:hover {
color: var(--mantine-color-text);
}
}
}
}
}
/* Notification */
.mantine-Notification-root {
background-color: var(--mantine-color-secondary-filled);
&:where([data-with-border]) {
border: 1px solid var(--mantine-color-default-border);
}
}
/* Progress */
.mantine-Progress-root {
background-color: var(--mantine-color-default-hover);
}
/* Ring Progress */
.mantine-RingProgress-root {
.mantine-RingProgress-curve {
--rp-curve-root-color: var(--mantine-color-default-hover);
}
}
/* SemiCircle Progress */
.mantine-SemiCircleProgress-root {
--scp-empty-segment-color: var(--mantine-color-default-hover);
}
/* Skeleton */
.mantine-Skeleton-root {
&:where([data-visible]) {
&::after {
background-color: var(--mantine-color-default-hover);
}
}
}
/* Drawer */
.mantine-Drawer-root {
.mantine-Drawer-content {
border-right: 1px solid var(--mantine-color-default-border);
border-left: 1px solid var(--mantine-color-default-border);
}
}
/* Hover Card */
.mantine-HoverCard-dropdown {
background-color: var(--mantine-color-body);
border-color: var(--mantine-color-default-border);
border-radius: var(--mantine-radius-default);
}
/* Menu Dropdown */
.mantine-Menu-dropdown {
background-color: var(--mantine-color-body);
border-color: var(--mantine-color-default-border);
.mantine-Menu-item {
background-color: transparent;
&:where([data-hovered]) {
background-color: var(--menu-item-hover, var(--mantine-color-default-hover));
}
}
.mantine-Menu-divider {
border-color: var(--mantine-color-default-border);
}
}
/* Modal */
.mantine-Modal-root {
.mantine-Modal-content {
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-default);
}
}
/* Popover Start */
.mantine-Popover-dropdown {
--popover-border-color: var(--mantine-color-default-border);
background-color: var(--mantine-color-body);
}
.mantine-Popover-arrow {
--popover-border-color: var(--mantine-color-default-border);
}
/* Popover End */
/* Tooltip */
.mantine-Tooltip-tooltip {
font-size: var(--mantine-font-size-xs);
padding-top: 0;
padding-bottom: 0;
}
/* Accordion */
.mantine-Accordion-root {
.mantine-Accordion-control {
color: var(--mantine-color-text);
}
.mantine-Accordion-item {
--item-border-color: var(--mantine-color-default-border);
--item-filled-color: var(--mantine-color-default-hover);
}
&[data-variant='default'] {
.mantine-Accordion-control {
&:where(:not(:disabled, [data-disabled])) {
&:hover {
background-color: transparent;
}
}
}
.mantine-Accordion-label {
&:where(:not(:disabled, [data-disabled])) {
&:hover {
text-decoration: underline;
}
}
}
}
&[data-variant='contained'],
&[data-variant='filled'] {
.mantine-Accordion-control {
&:where(:not(:disabled, [data-disabled])) {
&:hover {
background-color: var(--mantine-color-default-hover);
}
}
}
}
}
/* Avatar */
.mantine-Avatar-root {
--avatar-bg: var(--mantine-primary-color-light);
--avatar-bd: 1px solid transparent;
--avatar-color: var(--mantine-primary-color-light-color);
}
/* Badge */
.mantine-Badge-root {
--badge-color: var(--mantine-primary-color-contrast);
&[data-variant='dot'] {
background-color: var(--mantine-color-secondary-light);
border-color: var(--mantine-color-secondary-light);
color: var(--mantine-color-text);
}
}
/* Card */
.mantine-Card-root {
background-color: var(--mantine-color-default);
.mantine-Card-section {
border-color: var(--mantine-color-default-border);
}
}
/* Timeline */
.mantine-Timeline-root {
--item-border-color: var(--mantine-color-default-border);
.mantine-Timeline-itemBullet {
border-color: var(--mantine-color-default-border);
&:where([data-with-child]) {
background-color: var(--mantine-color-secondary-light);
}
&:where([data-active]) {
border-color: var(--tli-color, var(--tl-color));
background-color: var(--mantine-color-white);
color: var(--tl-icon-color, var(--mantine-color-white));
&:where([data-with-child]) {
background-color: var(--tli-color, var(--tl-color));
color: var(--tl-icon-color, var(--mantine-color-white));
}
}
}
}
/* Code */
.mantine-Code-root {
background-color: var(--code-bg, var(--mantine-color-secondary-filled));
color: var(--mantine-color-text);
}
/* Table */
.mantine-Table-table {
--table-hover-color: var(--mantine-color-default-hover);
--table-striped-color: var(--mantine-color-default-hover);
--table-border-color: var(--mantine-color-default-border);
&:where([data-with-table-border]) {
border: rem(1px) solid var(--table-border-color);
}
}
/* Title */
.mantine-Title-root,
.mantine-Modal-title,
.mantine-Drawer-title {
letter-spacing: -0.025em;
}
/* Divider */
.mantine-Divider-root {
--divider-color: var(--mantine-color-default-border);
}
/* Paper */
.mantine-Paper-root {
&:where([data-with-border]) {
border: rem(1px) solid var(--mantine-color-default-border);
}
}
/* Date Picker */
.mantine-DatePicker-levelsGroup {
.mantine-DatePicker-calendarHeader {
--dch-control-size-xs: calc(1.25rem * var(--mantine-scale));
--dch-control-size-sm: calc(1.75rem * var(--mantine-scale));
--dch-control-size-md: calc(2.25rem * var(--mantine-scale));
--dch-control-size-lg: calc(2.5rem * var(--mantine-scale));
--dch-control-size-xl: calc(2.75rem * var(--mantine-scale));
max-width: none;
gap: var(--mantine-spacing-xs);
.mantine-DatePicker-calendarHeaderControl {
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-default);
&:hover {
background-color: var(--mantine-color-default-hover) !important;
}
}
.mantine-DatePicker-calendarHeaderLevel {
&:hover {
background-color: var(--mantine-color-default-hover) !important;
}
font-size: var(--mantine-font-size-sm);
}
}
.mantine-DatePicker-month {
border-collapse: separate;
border-spacing: 0 5px;
.mantine-DatePicker-weekdaysRow {
.mantine-DatePicker-weekday {
font-size: var(--mantine-font-size-xs);
}
}
.mantine-DatePicker-monthRow {
margin-top: var(--mantine-spacing-xs);
margin-bottom: var(--mantine-spacing-xs);
.mantine-DatePicker-day {
--day-size-xs: calc(1.5rem * var(--mantine-scale));
--day-size-sm: calc(2rem * var(--mantine-scale));
--day-size-md: calc(2.5rem * var(--mantine-scale));
--day-size-lg: calc(3rem * var(--mantine-scale));
--day-size-xl: calc(3.5rem * var(--mantine-scale));
font-size: calc(var(--day-size) / 2.28);
&:hover:where(:not([data-static], [data-disabled], [data-selected], [data-in-range])) {
background-color: var(--mantine-color-default-hover) !important;
border-radius: var(--mantine-radius-default);
&[data-in-range] {
border-radius: 0;
}
}
&:where([data-selected]):hover:where(:not([data-disabled], [data-static])) {
background-color: var(--mantine-primary-color-filled-hover);
border-radius: var(--mantine-radius-default);
}
&[data-weekend] {
color: var(--mantine-color-text);
}
&[data-last-in-range],
&[data-first-in-range] {
border-radius: var(--mantine-radius-default);
}
}
}
}
.mantine-DatePicker-monthsList {
.mantine-DatePicker-monthsListControl {
--dpc-size-xs: calc(1.5rem * var(--mantine-scale));
--dpc-size-sm: calc(1.96rem * var(--mantine-scale));
--dpc-size-md: calc(2.5rem * var(--mantine-scale));
--dpc-size-lg: calc(3rem * var(--mantine-scale));
--dpc-size-xl: calc(3.5rem * var(--mantine-scale));
&:hover:where(:not([data-disabled], :disabled)) {
background-color: var(--mantine-color-default-hover) !important;
}
}
}
.mantine-DatePicker-yearsList {
.mantine-DatePicker-yearsListControl {
--dpc-size-xs: calc(1.5rem * var(--mantine-scale));
--dpc-size-sm: calc(1.96rem * var(--mantine-scale));
--dpc-size-md: calc(2.5rem * var(--mantine-scale));
--dpc-size-lg: calc(3rem * var(--mantine-scale));
--dpc-size-xl: calc(3.5rem * var(--mantine-scale));
&:hover:where(:not([data-disabled], :disabled)) {
background-color: var(--mantine-color-default-hover) !important;
}
}
}
}
/* Spotlight */
.mantine-Spotlight-root {
.mantine-Spotlight-content {
border: 1px solid var(--mantine-color-default-border);
}
.mantine-Spotlight-actionsList {
border-color: var(--mantine-color-default-border);
}
.mantine-Spotlight-action {
&:where(:not([data-selected])) {
&:hover {
background-color: var(--mantine-color-default-hover);
}
}
}
.mantine-Spotlight-footer {
border-color: var(--mantine-color-default-border);
}
}
/* Code Highlight */
.mantine-CodeHighlightTabs-root {
.mantine-CodeHighlightTabs-file {
color: var(--mantine-color-text);
border-color: var(--mantine-color-default-border);
padding: 7px 12px;
font-size: 12px;
&:where([data-active]) {
background-color: var(--mantine-color-default-hover);
color: var(--mantine-color-text);
}
}
.mantine-CodeHighlightTabs-control {
color: var(--mantine-primary-color-contrast);
}
}
/* Chart Tooltip */
.mantine-ChartTooltip-tooltip {
border: 1px solid var(--mantine-color-default-border);
}
+863
View File
@@ -0,0 +1,863 @@
// Copyright (c) Microsoft. All rights reserved.
import {
ActionIcon,
Alert,
Anchor,
Avatar,
Badge,
Blockquote,
Button,
Card,
Checkbox,
Chip,
Container,
createTheme,
Dialog,
Indicator,
MantineColorsTuple,
MantineThemeOverride,
Mark,
NavLink,
Pagination,
Paper,
Radio,
rem,
SegmentedControl,
Select,
Stepper,
Switch,
ThemeIcon,
Timeline,
Tooltip,
} from '@mantine/core';
const CONTAINER_SIZES: Record<string, string> = {
xxs: rem('200px'),
xs: rem('300px'),
sm: rem('400px'),
md: rem('500px'),
lg: rem('600px'),
xl: rem('1400px'),
xxl: rem('1600px'),
};
export const FONT_FAMILY =
'ui-sans-serif, -apple-system, system-ui, "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"';
const zincColors: MantineColorsTuple = [
'#fafafa',
'#f4f4f5',
'#e4e4e7',
'#d4d4d8',
'#a1a1aa',
'#52525b',
'#3f3f46',
'#27272a',
'#18181b',
'#09090b',
'#71717A',
];
const slateColors: MantineColorsTuple = [
'#f8fafc',
'#f1f5f9',
'#e2e8f0',
'#cbd5e1',
'#94a3b8',
'#475569',
'#334155',
'#1e293b',
'#0f172a',
'#020817',
'#64748B',
];
const grayColors: MantineColorsTuple = [
'#f9fafb',
'#f3f4f6',
'#e5e7eb',
'#d1d5db',
'#9ca3af',
'#4b5563',
'#374151',
'#1f2937',
'#111827',
'#030712',
'#6B7280',
];
const neutralColors: MantineColorsTuple = [
'#fafafa',
'#f5f5f5',
'#e5e5e5',
'#d4d4d4',
'#a3a3a3',
'#525252',
'#404040',
'#262626',
'#171717',
'#0a0a0a',
'#737373',
];
const stoneColors: MantineColorsTuple = [
'#fafaf9',
'#f5f5f4',
'#e7e5e4',
'#d6d3d1',
'#a8a29e',
'#57534e',
'#44403c',
'#292524',
'#1c1917',
'#0c0a09',
'#78716C',
];
const redColors: MantineColorsTuple = [
'#FEF2F2',
'#FEE2E2',
'#FECACA',
'#FCA5A5',
'#F87171',
'#DC2626',
'#B91C1C',
'#991B1B',
'#7F1D1D',
'#450A0A',
'#EF4444',
];
const roseColors: MantineColorsTuple = [
'#fff1f2',
'#ffe4e6',
'#fecdd3',
'#fda4af',
'#fb7185',
'#e11d48',
'#be123c',
'#9f1239',
'#881337',
'#4c0519',
'#F43F5E',
];
const orangeColors: MantineColorsTuple = [
'#fff7ed',
'#ffedd5',
'#fed7aa',
'#fdba74',
'#fb923c',
'#f97316',
'#ea580c',
'#9a3412',
'#7c2d12',
'#431407',
'#F97316',
];
const amberColors: MantineColorsTuple = [
'#FFFBEB',
'#FEF3C7',
'#FDE68A',
'#FCD34D',
'#FBBF24',
'#f59e0b',
'#D97706',
'#92400E',
'#78350F',
'#451A03',
'#F59E0B',
];
const yellowColors: MantineColorsTuple = [
'#fefce8',
'#fef9c3',
'#fef08a',
'#fde047',
'#facc15',
'#ca8a04',
'#a16207',
'#854d0e',
'#713f12',
'#3f2c06',
'#F59E0B',
];
const limeColors: MantineColorsTuple = [
'#f7fee7',
'#ecfccb',
'#d9f99d',
'#bef264',
'#a3e635',
'#4d7c0f',
'#3f6212',
'#365314',
'#1a2e05',
'#0f1903',
'#84CC16',
];
const greenColors: MantineColorsTuple = [
'#F0FDF4',
'#DCFCE7',
'#BBF7D0',
'#86EFAC',
'#4ADE80',
'#22c55e',
'#16A34A',
'#166534',
'#14532D',
'#052E16',
'#10B981',
];
const emeraldColors: MantineColorsTuple = [
'#ecfdf5',
'#d1fae5',
'#a7f3d0',
'#6ee7b7',
'#34d399',
'#059669',
'#047857',
'#065f46',
'#064e3b',
'#022c22',
'#10B981',
];
const tealColors: MantineColorsTuple = [
'#f0fdfa',
'#ccfbf1',
'#99f6e4',
'#5eead4',
'#2dd4bf',
'#0d9488',
'#0f766e',
'#115e59',
'#134e4a',
'#042f2e',
'#14B8A6',
];
const cyanColors: MantineColorsTuple = [
'#ecfeff',
'#cffafe',
'#a5f3fc',
'#67e8f9',
'#22d3ee',
'#0891b2',
'#0e7490',
'#155e75',
'#164e63',
'#083344',
'#06B6D4',
];
const skyColors: MantineColorsTuple = [
'#f0f9ff',
'#e0f2fe',
'#bae6fd',
'#7dd3fc',
'#38bdf8',
'#0284c7',
'#0369a1',
'#075985',
'#0c4a6e',
'#082f49',
'#0EA5E9',
];
const blueColors: MantineColorsTuple = [
'#eff6ff',
'#dbeafe',
'#bfdbfe',
'#93c5fd',
'#60a5fa',
'#3b82f6',
'#2563eb',
'#1e40af',
'#1e3a8a',
'#172554',
'#3B82F6',
];
const indigoColors: MantineColorsTuple = [
'#eef2ff',
'#e0e7ff',
'#c7d2fe',
'#a5b4fc',
'#818cf8',
'#4f46e5',
'#4338ca',
'#3730a3',
'#312e81',
'#1e1b4b',
'#6366F1',
];
const violetColors: MantineColorsTuple = [
'#f5f3ff',
'#ede9fe',
'#ddd6fe',
'#c4b5fd',
'#a78bfa',
'#7c3aed',
'#6d28d9',
'#5b21b6',
'#4c1d95',
'#1e1b4b',
'#8B5CF6',
];
const purpleColors: MantineColorsTuple = [
'#faf5ff',
'#f3e8ff',
'#e9d5ff',
'#d8b4fe',
'#c084fc',
'#9333ea',
'#7e22ce',
'#6b21a8',
'#581c87',
'#2e1065',
'#A855F7',
];
const fuchsiaColors: MantineColorsTuple = [
'#fdf4ff',
'#fae8ff',
'#f5d0fe',
'#f0abfc',
'#e879f9',
'#c026d3',
'#a21caf',
'#86198f',
'#701a75',
'#4a044e',
'#D946EF',
];
const pinkColors: MantineColorsTuple = [
'#fdf2f8',
'#fce7f3',
'#fbcfe8',
'#f9a8d4',
'#f472b6',
'#db2777',
'#be185d',
'#9d174d',
'#831843',
'#500724',
'#EC4899',
];
export const shadcnTheme: MantineThemeOverride = createTheme({
colors: {
slate: slateColors,
gray: grayColors,
zinc: zincColors,
neutral: neutralColors,
stone: stoneColors,
red: redColors,
rose: roseColors,
orange: orangeColors,
amber: amberColors,
yellow: yellowColors,
lime: limeColors,
green: greenColors,
emerald: emeraldColors,
teal: tealColors,
cyan: cyanColors,
sky: skyColors,
blue: blueColors,
indigo: indigoColors,
violet: violetColors,
purple: purpleColors,
fuchsia: fuchsiaColors,
pink: pinkColors,
primary: zincColors,
secondary: zincColors,
dark: zincColors,
error: redColors as MantineColorsTuple,
success: greenColors,
info: blueColors,
warning: amberColors,
},
focusRing: 'never',
scale: 1,
primaryColor: 'primary',
primaryShade: { light: 8, dark: 0 },
autoContrast: true,
luminanceThreshold: 0.3,
fontFamily: FONT_FAMILY,
radius: {
xs: rem('6px'),
sm: rem('8px'),
md: rem('12px'),
lg: rem('16px'),
xl: rem('24px'),
},
defaultRadius: 'sm',
spacing: {
'4xs': rem('2px'),
'3xs': rem('4px'),
'2xs': rem('8px'),
'xs': rem('10px'),
'sm': rem('12px'),
'md': rem('16px'),
'lg': rem('20px'),
'xl': rem('24px'),
'2xl': rem('28px'),
'3xl': rem('32px'),
'4xl': rem('40px'),
},
fontSizes: {
'xs': rem('12px'),
'sm': rem('14px'),
'md': rem('16px'),
'lg': rem('18px'),
'xl': rem('20px'),
'2xl': rem('24px'),
'3xl': rem('30px'),
'4xl': rem('36px'),
'5xl': rem('48px'),
},
lineHeights: {
xs: rem('18px'),
sm: rem('20px'),
md: rem('24px'),
lg: rem('28px'),
},
headings: {
fontFamily: FONT_FAMILY,
sizes: {
h1: {
fontSize: rem('36px'),
lineHeight: rem('44px'),
fontWeight: '600',
},
h2: {
fontSize: rem('30px'),
lineHeight: rem('38px'),
fontWeight: '600',
},
h3: {
fontSize: rem('24px'),
lineHeight: rem('32px'),
fontWeight: '600',
},
h4: {
fontSize: rem('20px'),
lineHeight: rem('30px'),
fontWeight: '600',
},
},
},
shadows: {
xs: '0 1px 2px rgba(0, 0, 0, 0.05)',
sm: '0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06)',
md: '0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06)',
lg: '0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.05)',
xl: '0 20px 25px rgba(0, 0, 0, 0.1), 0 10px 10px rgba(0, 0, 0, 0.04)',
xxl: '0 25px 50px rgba(0, 0, 0, 0.25)',
},
cursorType: 'pointer',
other: {
style: 'shadcn',
},
components: {
Container: Container.extend({
vars: (_, { size, fluid }) => ({
root: {
'--container-size': fluid
? '100%'
: size !== undefined && size in CONTAINER_SIZES
? CONTAINER_SIZES[size]
: rem(size),
},
}),
}),
Checkbox: Checkbox.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
return {
root: {
'--checkbox-color': colorKey
? `var(--mantine-color-${colorKey}-filled)`
: 'var(--mantine-primary-color-filled)',
'--checkbox-icon-color': colorKey
? `var(--mantine-color-${colorKey}-contrast)`
: 'var(--mantine-primary-color-contrast)',
},
};
},
}),
Chip: Chip.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
const variant = props.variant ?? 'filled';
return {
root: {
'--chip-bg':
variant !== 'light'
? colorKey
? `var(--mantine-color-${colorKey}-filled)`
: 'var(--mantine-primary-color-filled)'
: undefined,
'--chip-color':
variant === 'filled'
? colorKey
? `var(--mantine-color-${colorKey}-contrast)`
: 'var(--mantine-primary-color-contrast)'
: undefined,
},
};
},
}),
Radio: Radio.extend({
vars: (theme, props) => ({
root: {
'--radio-color': props.color
? Object.keys(theme.colors).includes(props.color)
? `var(--mantine-color-${props.color}-filled)`
: props.color
: 'var(--mantine-primary-color-filled)',
'--radio-icon-color': props.color
? Object.keys(theme.colors).includes(props.color)
? `var(--mantine-color-${props.color}-contrast)`
: props.color
: 'var(--mantine-primary-color-contrast)',
},
}),
}),
SegmentedControl: SegmentedControl.extend({
vars: (theme, props) => ({
root: {
'--sc-color': props.color
? Object.keys(theme.colors).includes(props.color)
? ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(props.color)
? 'var(--mantine-color-body)'
: `var(--mantine-color-${props.color}-filled)`
: props.color
: 'var(--mantine-color-default)',
},
}),
}),
Switch: Switch.extend({
styles: () => ({
thumb: {
backgroundColor: 'var(--mantine-color-default)',
borderColor: 'var(--mantine-color-default-border)',
},
track: {
borderColor: 'var(--mantine-color-default-border)',
},
}),
}),
Select: Select.extend({
defaultProps: {
checkIconPosition: 'right',
},
}),
ActionIcon: ActionIcon.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
const isNeutralColor = colorKey && ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(colorKey);
const isNeutralPrimaryColor =
!colorKey && ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(theme.primaryColor);
const variant = props.variant ?? 'filled';
return {
root: {
'--ai-color': (() => {
if (variant === 'filled') {
if (colorKey) {
return `var(--mantine-color-${colorKey}-contrast)`;
}
return 'var(--mantine-primary-color-contrast)';
}
if (variant === 'white') {
if (isNeutralColor || isNeutralPrimaryColor) {
return 'var(--mantine-color-black)';
}
return undefined;
}
return undefined;
})(),
},
};
},
}),
Button: Button.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
const isNeutralColor = colorKey && ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(colorKey);
const isNeutralPrimaryColor =
!colorKey && ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(theme.primaryColor);
const variant = props.variant ?? 'filled';
return {
root: {
'--button-color': (() => {
if (variant === 'filled') {
if (colorKey) {
return `var(--mantine-color-${colorKey}-contrast)`;
}
return 'var(--mantine-primary-color-contrast)';
}
if (variant === 'white') {
if (isNeutralColor || isNeutralPrimaryColor) {
return 'var(--mantine-color-black)';
}
return undefined;
}
return undefined;
})(),
},
};
},
}),
Anchor: Anchor.extend({
defaultProps: {
underline: 'always',
},
}),
NavLink: NavLink.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
const variant = props.variant ?? 'light';
return {
root: {
'--nl-color':
variant === 'filled'
? colorKey
? `var(--mantine-color-${colorKey}-contrast)`
: 'var(--mantine-primary-color-contrast)'
: undefined,
},
children: {},
};
},
}),
Pagination: Pagination.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
return {
root: {
'--pagination-active-color': colorKey
? `var(--mantine-color-${colorKey}-contrast)`
: 'var(--mantine-primary-color-contrast)',
},
};
},
}),
Stepper: Stepper.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
return {
root: {
'--stepper-icon-color': colorKey
? `var(--mantine-color-${colorKey}-contrast)`
: 'var(--mantine-primary-color-contrast)',
},
};
},
}),
Alert: Alert.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
const isNeutralColor = colorKey && ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(colorKey);
const isNeutralPrimaryColor =
!colorKey && ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(theme.primaryColor);
const variant = props.variant ?? 'light';
return {
root: {
'--alert-color':
variant === 'filled'
? colorKey
? `var(--mantine-color-${colorKey}-contrast)`
: 'var(--mantine-primary-color-contrast)'
: variant === 'white'
? isNeutralColor || isNeutralPrimaryColor
? 'var(--mantine-color-black)'
: undefined
: undefined,
},
};
},
}),
Dialog: Dialog.extend({
defaultProps: {
withBorder: true,
},
}),
Tooltip: Tooltip.extend({
vars: () => ({
tooltip: {
'--tooltip-bg': 'var(--mantine-color-primary-color-filled)',
'--tooltip-color': 'var(--mantine-color-primary-color-contrast)',
},
}),
}),
Avatar: Avatar.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
const isNeutralColor = colorKey && ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(colorKey);
const isNeutralPrimaryColor =
!colorKey && ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(theme.primaryColor);
const variant = props.variant ?? 'light';
return {
root: {
'--avatar-bg':
variant === 'filled'
? colorKey
? `var(--mantine-color-${colorKey}-filled)`
: 'var(--mantine-primary-color-filled)'
: variant === 'light'
? colorKey
? `var(--mantine-color-${colorKey}-light)`
: 'var(--mantine-primary-color-light)'
: undefined,
'--avatar-color':
variant === 'filled'
? colorKey
? `var(--mantine-color-${colorKey}-contrast)`
: 'var(--mantine-primary-color-contrast)'
: variant === 'light'
? colorKey
? `var(--mantine-color-${colorKey}-light-color)`
: 'var(--mantine-primary-color-light-color)'
: variant === 'white'
? isNeutralColor || isNeutralPrimaryColor
? 'var(--mantine-color-black)'
: colorKey
? `var(--mantine-color-${colorKey}-outline)`
: 'var(--mantine-primary-color-filled)'
: variant === 'outline' || variant === 'transparent'
? colorKey
? `var(--mantine-color-${colorKey}-outline)`
: 'var(--mantine-primary-color-filled)'
: undefined,
'--avatar-bd':
variant === 'outline'
? colorKey
? `1px solid var(--mantine-color-${colorKey}-outline)`
: '1px solid var(--mantine-primary-color-filled)'
: undefined,
},
};
},
}),
Badge: Badge.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
const isNeutralColor = colorKey && ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(colorKey);
const isNeutralPrimaryColor =
!colorKey && ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(theme.primaryColor);
const variant = props.variant ?? 'filled';
return {
root: {
'--badge-bg': variant === 'filled' && colorKey ? `var(--mantine-color-${colorKey}-filled)` : undefined,
'--badge-color':
variant === 'filled'
? colorKey
? `var(--mantine-color-${colorKey}-contrast)`
: 'var(--mantine-primary-color-contrast)'
: variant === 'white'
? isNeutralColor || isNeutralPrimaryColor
? 'var(--mantine-color-black)'
: undefined
: undefined,
},
};
},
}),
Card: Card.extend({
defaultProps: {
p: 'xl',
shadow: 'xl',
withBorder: true,
},
styles: (theme) => {
return {
root: {
backgroundColor:
theme.primaryColor === 'rose' || theme.primaryColor === 'green'
? 'var(--mantine-color-secondary-filled)'
: undefined,
},
};
},
}),
Indicator: Indicator.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
return {
root: {
'--indicator-text-color': colorKey
? `var(--mantine-color-${colorKey}-contrast)`
: 'var(--mantine-primary-color-contrast)',
},
};
},
}),
ThemeIcon: ThemeIcon.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
const isNeutralColor = colorKey && ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(colorKey);
const isNeutralPrimaryColor =
!colorKey && ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(theme.primaryColor);
const variant = props.variant ?? 'filled';
return {
root: {
'--ti-color':
variant === 'filled'
? colorKey
? `var(--mantine-color-${colorKey}-contrast)`
: 'var(--mantine-primary-color-contrast)'
: variant === 'white'
? isNeutralColor || isNeutralPrimaryColor
? 'var(--mantine-color-black)'
: undefined
: undefined,
},
};
},
}),
Timeline: Timeline.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
return {
root: {
'--tl-icon-color': colorKey
? `var(--mantine-color-${colorKey}-contrast)`
: 'var(--mantine-primary-color-contrast)',
},
};
},
}),
Blockquote: Blockquote.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : undefined;
return {
root: {
'--bq-bg-dark': colorKey ? `var(--mantine-color-${colorKey}-light)` : 'var(--mantine-primary-color-light)',
'--bq-bg-light': colorKey ? `var(--mantine-color-${colorKey}-light)` : 'var(--mantine-primary-color-light)',
},
};
},
}),
Mark: Mark.extend({
vars: (theme, props) => {
const colorKey = props.color && Object.keys(theme.colors).includes(props.color) ? props.color : 'yellow';
const isNeutralColor = colorKey && ['zinc', 'slate', 'gray', 'neutral', 'stone'].includes(colorKey);
return {
root: {
'--mark-bg-light': `var(--mantine-color-${colorKey}-${isNeutralColor ? '3' : 'filled-hover'})`,
'--mark-bg-dark': `var(--mantine-color-${colorKey}-filled)`,
},
};
},
}),
Paper: Paper.extend({
defaultProps: {
shadow: 'xl',
},
}),
},
});
export const theme = shadcnTheme;
+114
View File
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft. All rights reserved.
// This file should sync with agentlightning/types/core.py
export type RolloutStatus = 'queuing' | 'preparing' | 'running' | 'failed' | 'succeeded' | 'cancelled' | 'requeuing';
export type AttemptStatus = 'preparing' | 'running' | 'failed' | 'succeeded' | 'unresponsive' | 'timeout';
export type RolloutMode = 'train' | 'val' | 'test';
export type TaskInput = any;
export type Timestamp = number;
/**
* Synced with agentlightning.types.core.Attempt
* with camel case and snake case conversions
*/
export type Attempt = {
rolloutId: string;
attemptId: string;
sequenceId: number;
startTime: Timestamp;
endTime: Timestamp | null;
status: AttemptStatus;
workerId: string | null;
lastHeartbeatTime: Timestamp | null;
metadata: Record<string, any> | null;
};
/**
* Synced with agentlightning.types.core.Rollout
* with camel case and snake case conversions
*
* The `attempt` field is from `AttemptedRollout` class,
* which is the latest attempt of the rollout, if any.
*/
export type Rollout = {
rolloutId: string;
input: TaskInput;
startTime: Timestamp;
endTime: Timestamp | null;
mode: RolloutMode | null;
resourcesId: string | null;
status: RolloutStatus;
config: Record<string, any>;
metadata: Record<string, any> | null;
attempt: Attempt | null;
};
export type Resource = Record<string, any>;
/**
* Synced with agentlightning.types.resources.Resources
* with camel case and snake case conversions
*/
export type Resources = {
resourcesId: string;
version: number;
createTime: Timestamp;
updateTime: Timestamp;
resources: Record<string, Resource>;
};
/**
* Paginated response structure matching the Python server's PaginatedResponse
*/
export type PaginatedResponse<T> = {
items: T[];
limit: number;
offset: number;
total: number;
};
/**
* Synced with agentlightning.types.traces.Span
* with camel case and snake case conversions
*/
export type Span = {
rolloutId: string;
attemptId: string;
sequenceId: number;
traceId: string;
spanId: string;
parentId: string | null;
name: string;
status: { status_code: 'UNSET' | 'OK' | 'ERROR'; description: string | null };
attributes: Record<string, any>;
startTime: Timestamp;
endTime: Timestamp;
// The fields below are less frequently used
events: any;
links: any;
context: any;
parent: any;
resource: any;
};
// Configs like server connection
export type Config = {
baseUrl: string; // e.g. http://localhost:8000
autoRefreshMs: number; // polling/refresh interval
theme: 'light' | 'dark';
};
export type ThemePreference = 'light' | 'dark' | 'system';
export type ConfigState = {
baseUrl: string;
autoRefreshMs: number;
theme: ThemePreference;
};
+64
View File
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Derive a human readable descriptor for an RTK Query-style error object.
* Normalises common timeout representations to "Timeout" so the UI can surface a friendly label.
*/
export function getErrorDescriptor(error: unknown): string | null {
if (!error || typeof error !== 'object') {
return null;
}
const record = error as Record<string, unknown>;
const statusValue = record.status;
const statusString = typeof statusValue === 'number' || typeof statusValue === 'string' ? String(statusValue) : null;
const data = record.data;
let detail: string | null = null;
if (data && typeof data === 'object') {
const dataRecord = data as Record<string, unknown>;
const rawDetail = dataRecord.detail ?? dataRecord.message;
if (typeof rawDetail === 'string') {
detail = rawDetail;
}
}
const messageValue = record.message;
const errorValue = record.error;
const nestedErrorMessage =
errorValue && typeof errorValue === 'object' && 'message' in (errorValue as Record<string, unknown>)
? (errorValue as Record<string, unknown>).message
: null;
const directMessage =
typeof messageValue === 'string'
? messageValue
: typeof nestedErrorMessage === 'string'
? nestedErrorMessage
: typeof errorValue === 'string'
? errorValue
: null;
const looksLikeTimeout = (value: string | null) =>
typeof value === 'string' && value.toLowerCase().includes('timeout');
if (statusString === '408' || statusString === '504') {
return 'Timeout';
}
if (looksLikeTimeout(detail)) {
return 'Timeout';
}
if (looksLikeTimeout(directMessage)) {
return 'Timeout';
}
if (detail && detail.trim().length > 0) {
return detail;
}
if (directMessage && directMessage.trim().length > 0) {
return directMessage;
}
if (statusString) {
return `status: ${statusString}`;
}
return null;
}
+136
View File
@@ -0,0 +1,136 @@
// Copyright (c) Microsoft. All rights reserved.
import dayjs from 'dayjs';
export function toTimestamp(value: unknown): number | null {
if (typeof value !== 'number' || Number.isNaN(value)) {
return null;
}
return value;
}
export function clampToNow(start: number | null, end: number | null): number | null {
if (start == null) {
return null;
}
const effectiveEnd = end ?? Date.now() / 1000;
const diff = effectiveEnd - start;
return diff > 0 ? diff : 0;
}
export function formatDateTime(timestamp: number | null): string {
if (timestamp == null) {
return '—';
}
return dayjs(timestamp * 1000).format('YYYY-MM-DD HH:mm:ss');
}
export function formatDuration(seconds: number | null): string {
if (seconds == null) {
return '—';
}
const total = Math.floor(seconds);
if (total <= 0) {
return '—';
}
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
const parts: string[] = [];
if (hours > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0) {
parts.push(`${minutes}m`);
}
if (secs > 0 || parts.length === 0) {
parts.push(`${secs}s`);
}
return parts.join(' ');
}
export function formatRelativeTime(timestamp: number | null): string {
if (timestamp == null) {
return '—';
}
const now = Date.now() / 1000;
const diff = Math.floor(now - timestamp);
if (diff <= 5) {
return 'Just now';
}
return `${formatDuration(diff)} ago`;
}
export function formatStatusLabel(status: string): string {
return status
.replace(/[_-]/g, ' ')
.toLowerCase()
.replace(/^\w|\s\w/g, (match) => match.toUpperCase());
}
export function formatInputPreview(input: unknown, maxLength = 35): { preview: string; full: string } {
if (input === null || input === undefined) {
return { preview: '—', full: '—' };
}
const serialized = typeof input === 'string' ? input : safeStringify(input);
if (serialized.length <= maxLength) {
return { preview: serialized, full: serialized };
}
return { preview: `${serialized.slice(0, maxLength - 3)}...`, full: serialized };
}
export function safeStringify(value: unknown): string {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const toCamelCaseKey = (key: string) => key.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase());
const toSnakeCaseKey = (key: string) =>
key
.replace(/([A-Z])/g, '_$1')
.replace(/[-\s]+/g, '_')
.toLowerCase();
export const camelCaseKeys = <T>(value: T): T => {
if (Array.isArray(value)) {
return value.map((item) => camelCaseKeys(item)) as unknown as T;
}
if (isPlainObject(value)) {
const entries = Object.entries(value).map(([key, item]) => [toCamelCaseKey(key), camelCaseKeys(item)]);
return Object.fromEntries(entries) as T;
}
return value;
};
export const snakeCaseKeys = <T>(value: T): T => {
if (Array.isArray(value)) {
return value.map((item) => snakeCaseKeys(item)) as unknown as T;
}
if (isPlainObject(value)) {
const entries = Object.entries(value).map(([key, item]) => [toSnakeCaseKey(key), snakeCaseKeys(item)]);
return Object.fromEntries(entries) as T;
}
return value;
};
+771
View File
@@ -0,0 +1,771 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Tests for mock API handlers.
*
* These tests verify that the mock implementation behaves consistently with
* the Python server in agentlightning/store/client_server.py.
*/
import { describe, expect, it } from 'vitest';
import type { Attempt, Resources, Rollout, Span } from '@/types';
import {
buildAttemptsResponse,
buildResourcesResponse,
buildRolloutsResponse,
buildSpansResponse,
createMockHandlers,
createResourcesHandlers,
createRolloutsHandlers,
createSpansHandlers,
filterResourcesForParams,
filterRolloutsForParams,
filterSpansForParams,
getResourcesSortValue,
getRolloutSortValue,
getSpanSortValue,
parseNumberParam,
sortAttemptsForParams,
sortResourcesForParams,
sortRolloutsForParams,
sortSpansForParams,
} from './mock';
const now = Math.floor(Date.now() / 1000);
const sampleRollouts: Rollout[] = [
{
rolloutId: 'ro-001',
input: { task: 'Test 1' },
status: 'running',
mode: 'train',
resourcesId: 'rs-100',
startTime: now - 1000,
endTime: null,
attempt: {
rolloutId: 'ro-001',
attemptId: 'at-001',
sequenceId: 1,
status: 'running',
startTime: now - 1000,
endTime: null,
workerId: 'worker-1',
lastHeartbeatTime: now - 10,
metadata: null,
},
config: {},
metadata: null,
},
{
rolloutId: 'ro-002',
input: { task: 'Test 2' },
status: 'succeeded',
mode: 'val',
resourcesId: 'rs-101',
startTime: now - 2000,
endTime: now - 1500,
attempt: {
rolloutId: 'ro-002',
attemptId: 'at-002',
sequenceId: 1,
status: 'succeeded',
startTime: now - 2000,
endTime: now - 1500,
workerId: 'worker-2',
lastHeartbeatTime: now - 1500,
metadata: null,
},
config: {},
metadata: null,
},
{
rolloutId: 'ro-003',
input: { task: 'Test 3' },
status: 'failed',
mode: 'test',
resourcesId: null,
startTime: now - 3000,
endTime: now - 2500,
attempt: {
rolloutId: 'ro-003',
attemptId: 'at-003',
sequenceId: 1,
status: 'failed',
startTime: now - 3000,
endTime: now - 2500,
workerId: 'worker-3',
lastHeartbeatTime: now - 2500,
metadata: null,
},
config: {},
metadata: null,
},
];
const sampleAttempts: Attempt[] = [
{
rolloutId: 'ro-001',
attemptId: 'at-001',
sequenceId: 1,
status: 'running',
startTime: now - 1000,
endTime: null,
workerId: 'worker-1',
lastHeartbeatTime: now - 10,
metadata: null,
},
{
rolloutId: 'ro-001',
attemptId: 'at-002',
sequenceId: 2,
status: 'failed',
startTime: now - 900,
endTime: now - 800,
workerId: 'worker-1',
lastHeartbeatTime: now - 800,
metadata: null,
},
{
rolloutId: 'ro-001',
attemptId: 'at-003',
sequenceId: 3,
status: 'running',
startTime: now - 700,
endTime: null,
workerId: 'worker-2',
lastHeartbeatTime: now - 5,
metadata: null,
},
];
const sampleSpans: Span[] = [
{
rolloutId: 'ro-001',
attemptId: 'at-001',
sequenceId: 1,
traceId: 'tr-001',
spanId: 'sp-001',
parentId: null,
name: 'Initialize',
status: { status_code: 'OK', description: null },
attributes: {},
startTime: now - 1000,
endTime: now - 950,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-001',
attemptId: 'at-001',
sequenceId: 2,
traceId: 'tr-001',
spanId: 'sp-002',
parentId: 'sp-001',
name: 'Process',
status: { status_code: 'OK', description: null },
attributes: {},
startTime: now - 950,
endTime: now - 900,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-001',
attemptId: 'at-001',
sequenceId: 3,
traceId: 'tr-002',
spanId: 'sp-003',
parentId: null,
name: 'Finalize',
status: { status_code: 'ERROR', description: 'Failed' },
attributes: {},
startTime: now - 850,
endTime: now - 800,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
];
const sampleResources: Resources[] = [
{
resourcesId: 'rs-001',
version: 2,
createTime: now - 400,
updateTime: now - 200,
resources: { config: { learning_rate: 0.01 } },
},
{
resourcesId: 'rs-002',
version: 3,
createTime: now - 300,
updateTime: now - 100,
resources: { config: { learning_rate: 0.001 } },
},
{
resourcesId: 'rs-003',
version: 1,
createTime: now - 200,
updateTime: now - 50,
resources: { config: { learning_rate: 0.1 } },
},
];
describe('parseNumberParam', () => {
it('returns default value when param is missing', () => {
const params = new URLSearchParams();
expect(parseNumberParam(params, 'limit', 10)).toBe(10);
});
it('parses valid number', () => {
const params = new URLSearchParams('limit=25');
expect(parseNumberParam(params, 'limit', 10)).toBe(25);
});
it('returns default for invalid number', () => {
const params = new URLSearchParams('limit=invalid');
expect(parseNumberParam(params, 'limit', 10)).toBe(10);
});
it('returns default for NaN', () => {
const params = new URLSearchParams('limit=NaN');
expect(parseNumberParam(params, 'limit', 10)).toBe(10);
});
it('handles negative numbers', () => {
const params = new URLSearchParams('offset=-1');
expect(parseNumberParam(params, 'offset', 0)).toBe(-1);
});
});
describe('filterRolloutsForParams', () => {
it('returns all rollouts when no filters are provided', () => {
const params = new URLSearchParams();
const result = filterRolloutsForParams(sampleRollouts, params);
expect(result).toHaveLength(3);
});
it('filters by single status', () => {
const params = new URLSearchParams('status_in=running');
const result = filterRolloutsForParams(sampleRollouts, params);
expect(result).toHaveLength(1);
expect(result[0].status).toBe('running');
});
it('filters by multiple statuses', () => {
const params = new URLSearchParams();
params.append('status_in', 'running');
params.append('status_in', 'succeeded');
const result = filterRolloutsForParams(sampleRollouts, params);
expect(result).toHaveLength(2);
});
it('filters by mode', () => {
const params = new URLSearchParams();
params.append('mode_in', 'train');
const result = filterRolloutsForParams(sampleRollouts, params);
expect(result).toHaveLength(1);
expect(result[0].mode).toBe('train');
});
it('filters by rollout_id_contains', () => {
const params = new URLSearchParams('rollout_id_contains=002');
const result = filterRolloutsForParams(sampleRollouts, params);
expect(result).toHaveLength(1);
expect(result[0].rolloutId).toBe('ro-002');
});
it('applies multiple filters together', () => {
const params = new URLSearchParams();
params.append('status_in', 'running');
params.append('mode_in', 'train');
const result = filterRolloutsForParams(sampleRollouts, params);
expect(result).toHaveLength(1);
expect(result[0].rolloutId).toBe('ro-001');
});
it('supports filter_logic=or across filters', () => {
const params = new URLSearchParams();
params.append('status_in', 'failed');
params.append('mode_in', 'train');
params.set('filter_logic', 'or');
const result = filterRolloutsForParams(sampleRollouts, params);
expect(result.map((r) => r.rolloutId).sort()).toEqual(['ro-001', 'ro-003']);
});
});
describe('getRolloutSortValue', () => {
const rollout = sampleRollouts[0];
it('returns rollout_id for rollout_id sort', () => {
expect(getRolloutSortValue(rollout, 'rollout_id')).toBe('ro-001');
});
it('returns status for status sort', () => {
expect(getRolloutSortValue(rollout, 'status')).toBe('running');
});
it('returns mode for mode sort', () => {
expect(getRolloutSortValue(rollout, 'mode')).toBe('train');
});
it('returns empty string for null mode', () => {
const rolloutWithoutMode = { ...rollout, mode: null };
expect(getRolloutSortValue(rolloutWithoutMode, 'mode')).toBe('');
});
it('returns start_time as default', () => {
expect(getRolloutSortValue(rollout, 'unknown_field')).toBe(now - 1000);
});
it('falls back to rollout.startTime when attempt is null', () => {
const rolloutWithoutAttempt = { ...rollout, attempt: null };
expect(getRolloutSortValue(rolloutWithoutAttempt, 'start_time')).toBe(now - 1000);
});
});
describe('sortRolloutsForParams', () => {
it('sorts by start_time ascending by default', () => {
const result = sortRolloutsForParams(sampleRollouts, null, 'asc');
expect(result[0].rolloutId).toBe('ro-003');
expect(result[2].rolloutId).toBe('ro-001');
});
it('sorts by start_time descending', () => {
const result = sortRolloutsForParams(sampleRollouts, 'start_time', 'desc');
expect(result[0].rolloutId).toBe('ro-001');
expect(result[2].rolloutId).toBe('ro-003');
});
it('sorts by rollout_id ascending', () => {
const result = sortRolloutsForParams(sampleRollouts, 'rollout_id', 'asc');
expect(result[0].rolloutId).toBe('ro-001');
expect(result[2].rolloutId).toBe('ro-003');
});
it('sorts by status', () => {
const result = sortRolloutsForParams(sampleRollouts, 'status', 'asc');
expect(result[0].status).toBe('failed');
expect(result[1].status).toBe('running');
expect(result[2].status).toBe('succeeded');
});
it('handles null values in sorting', () => {
const rolloutsWithNull: Rollout[] = [{ ...sampleRollouts[0], attempt: null, startTime: 0 }, sampleRollouts[1]];
const result = sortRolloutsForParams(rolloutsWithNull, 'start_time', 'asc');
expect(result[0].attempt).toBeNull();
});
});
describe('buildRolloutsResponse', () => {
it('returns all rollouts with default pagination', () => {
const request = new Request('http://localhost/v1/agl/rollouts');
const response = buildRolloutsResponse(sampleRollouts, request);
expect(response.items).toHaveLength(3);
expect(response.total).toBe(3);
expect(response.limit).toBe(3);
expect(response.offset).toBe(0);
});
it('applies limit pagination', () => {
const request = new Request('http://localhost/v1/agl/rollouts?limit=2');
const response = buildRolloutsResponse(sampleRollouts, request);
expect(response.items).toHaveLength(2);
expect(response.limit).toBe(2);
expect(response.total).toBe(3);
});
it('applies offset pagination', () => {
const request = new Request('http://localhost/v1/agl/rollouts?offset=1');
const response = buildRolloutsResponse(sampleRollouts, request);
expect(response.items).toHaveLength(2);
expect(response.offset).toBe(1);
});
it('applies limit and offset together', () => {
const request = new Request('http://localhost/v1/agl/rollouts?limit=1&offset=1');
const response = buildRolloutsResponse(sampleRollouts, request);
expect(response.items).toHaveLength(1);
expect(response.limit).toBe(1);
expect(response.offset).toBe(1);
});
it('handles negative limit (returns all)', () => {
const request = new Request('http://localhost/v1/agl/rollouts?limit=-1');
const response = buildRolloutsResponse(sampleRollouts, request);
expect(response.items).toHaveLength(3);
expect(response.limit).toBe(3);
});
it('applies filtering before pagination', () => {
const request = new Request('http://localhost/v1/agl/rollouts?status_in=running&limit=10');
const response = buildRolloutsResponse(sampleRollouts, request);
expect(response.items).toHaveLength(1);
expect(response.total).toBe(1);
});
it('applies sorting before pagination', () => {
const request = new Request('http://localhost/v1/agl/rollouts?sort_by=rollout_id&sort_order=desc&limit=1');
const response = buildRolloutsResponse(sampleRollouts, request);
const items = response.items as Array<Record<string, unknown>>;
expect(items[0].rollout_id).toBe('ro-003');
});
it('uses snake_case keys in response', () => {
const request = new Request('http://localhost/v1/agl/rollouts');
const response = buildRolloutsResponse(sampleRollouts, request);
const items = response.items as Array<Record<string, unknown>>;
expect(items[0]).toHaveProperty('rollout_id');
expect(items[0]).toHaveProperty('start_time');
expect(items[0]).not.toHaveProperty('rolloutId');
});
});
describe('sortAttemptsForParams', () => {
it('sorts by sequence_id ascending by default', () => {
const result = sortAttemptsForParams(sampleAttempts, null, 'asc');
expect(result[0].sequenceId).toBe(1);
expect(result[2].sequenceId).toBe(3);
});
it('sorts by sequence_id descending', () => {
const result = sortAttemptsForParams(sampleAttempts, 'sequence_id', 'desc');
expect(result[0].sequenceId).toBe(3);
expect(result[2].sequenceId).toBe(1);
});
it('sorts by start_time', () => {
const result = sortAttemptsForParams(sampleAttempts, 'start_time', 'asc');
expect(result[0].startTime).toBe(now - 1000);
expect(result[2].startTime).toBe(now - 700);
});
});
describe('buildAttemptsResponse', () => {
it('returns all attempts with default pagination', () => {
const request = new Request('http://localhost/v1/agl/rollouts/ro-001/attempts');
const response = buildAttemptsResponse(sampleAttempts, request);
expect(response.items).toHaveLength(3);
expect(response.total).toBe(3);
});
it('applies pagination correctly', () => {
const request = new Request('http://localhost/v1/agl/rollouts/ro-001/attempts?limit=2&offset=1');
const response = buildAttemptsResponse(sampleAttempts, request);
expect(response.items).toHaveLength(2);
expect(response.offset).toBe(1);
});
it('sorts before paginating', () => {
const request = new Request(
'http://localhost/v1/agl/rollouts/ro-001/attempts?sort_by=sequence_id&sort_order=desc&limit=1',
);
const response = buildAttemptsResponse(sampleAttempts, request);
const items = response.items as Array<Record<string, unknown>>;
expect(items[0].sequence_id).toBe(3);
});
});
describe('filterSpansForParams', () => {
it('returns all spans when no filters are provided', () => {
const params = new URLSearchParams();
const result = filterSpansForParams(sampleSpans, params);
expect(result).toHaveLength(3);
});
it('filters by trace_id_contains', () => {
const params = new URLSearchParams('trace_id_contains=tr-001');
const result = filterSpansForParams(sampleSpans, params);
expect(result).toHaveLength(2);
});
it('filters by span_id_contains', () => {
const params = new URLSearchParams('span_id_contains=sp-003');
const result = filterSpansForParams(sampleSpans, params);
expect(result).toHaveLength(1);
expect(result[0].spanId).toBe('sp-003');
});
it('filters by name_contains (case insensitive)', () => {
const params = new URLSearchParams('name_contains=INIT');
const result = filterSpansForParams(sampleSpans, params);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('Initialize');
});
it('applies multiple filters together', () => {
const params = new URLSearchParams();
params.set('trace_id_contains', 'tr-001');
params.set('name_contains', 'Process');
const result = filterSpansForParams(sampleSpans, params);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('Process');
});
it('supports filter_logic=or for spans', () => {
const params = new URLSearchParams();
params.set('trace_id_contains', 'tr-001');
params.set('name_contains', 'Finalize');
params.set('filter_logic', 'or');
const result = filterSpansForParams(sampleSpans, params);
expect(result).toHaveLength(3);
});
});
describe('getSpanSortValue', () => {
const span = sampleSpans[0];
it('returns trace_id for trace_id sort', () => {
expect(getSpanSortValue(span, 'trace_id')).toBe('tr-001');
});
it('returns span_id for span_id sort', () => {
expect(getSpanSortValue(span, 'span_id')).toBe('sp-001');
});
it('returns parent_id for parent_id sort', () => {
expect(getSpanSortValue(sampleSpans[1], 'parent_id')).toBe('sp-001');
});
it('returns empty string for null parent_id', () => {
expect(getSpanSortValue(span, 'parent_id')).toBe('');
});
it('returns name for name sort', () => {
expect(getSpanSortValue(span, 'name')).toBe('Initialize');
});
it('returns status_code for status_code sort', () => {
expect(getSpanSortValue(span, 'status_code')).toBe('OK');
});
it('calculates duration', () => {
const duration = getSpanSortValue(span, 'duration');
expect(duration).toBe(50);
});
it('returns null duration when times are missing', () => {
const spanWithoutEnd: Span = { ...span, endTime: now - 950 };
expect(getSpanSortValue(spanWithoutEnd, 'duration')).toBe(50);
});
it('returns start_time as default', () => {
expect(getSpanSortValue(span, 'unknown_field')).toBe(now - 1000);
});
});
describe('sortSpansForParams', () => {
it('sorts by start_time ascending by default', () => {
const result = sortSpansForParams(sampleSpans, null, 'asc');
expect(result[0].spanId).toBe('sp-001');
expect(result[2].spanId).toBe('sp-003');
});
it('sorts by name', () => {
const result = sortSpansForParams(sampleSpans, 'name', 'asc');
expect(result[0].name).toBe('Finalize');
expect(result[2].name).toBe('Process');
});
it('sorts by duration', () => {
const result = sortSpansForParams(sampleSpans, 'duration', 'desc');
// All spans have duration of 50, so we just check that sorting works
expect(result[0].endTime! - result[0].startTime!).toBe(50);
});
});
describe('buildSpansResponse', () => {
const spansByAttempt = {
'ro-001:at-001': sampleSpans,
};
it('returns empty when rollout_id is missing', () => {
const request = new Request('http://localhost/v1/agl/spans');
const response = buildSpansResponse(spansByAttempt, request);
expect(response.items).toHaveLength(0);
expect(response.total).toBe(0);
});
it('returns spans for rollout and attempt', () => {
const request = new Request('http://localhost/v1/agl/spans?rollout_id=ro-001&attempt_id=at-001');
const response = buildSpansResponse(spansByAttempt, request);
expect(response.items).toHaveLength(3);
expect(response.total).toBe(3);
});
it('handles missing attempt_id', () => {
const request = new Request('http://localhost/v1/agl/spans?rollout_id=ro-001');
const response = buildSpansResponse(spansByAttempt, request);
expect(response.items).toHaveLength(0);
});
it('applies filtering', () => {
const request = new Request(
'http://localhost/v1/agl/spans?rollout_id=ro-001&attempt_id=at-001&trace_id_contains=tr-001',
);
const response = buildSpansResponse(spansByAttempt, request);
expect(response.items).toHaveLength(2);
});
it('applies sorting and pagination', () => {
const request = new Request(
'http://localhost/v1/agl/spans?rollout_id=ro-001&attempt_id=at-001&sort_by=name&limit=2',
);
const response = buildSpansResponse(spansByAttempt, request);
expect(response.items).toHaveLength(2);
const items = response.items as Span[];
expect(items[0].name).toBe('Finalize');
});
it('supports filter_logic=or in responses', () => {
const request = new Request(
'http://localhost/v1/agl/spans?rollout_id=ro-001&attempt_id=at-001&trace_id_contains=tr-001&name_contains=Finalize&filter_logic=or',
);
const response = buildSpansResponse(spansByAttempt, request);
expect(response.items).toHaveLength(3);
});
});
describe('filterResourcesForParams', () => {
it('returns all resources when no filter is applied', () => {
const params = new URLSearchParams();
const result = filterResourcesForParams(sampleResources, params);
expect(result).toHaveLength(3);
});
it('filters resources by resources_id_contains', () => {
const params = new URLSearchParams('resources_id_contains=002');
const result = filterResourcesForParams(sampleResources, params);
expect(result).toHaveLength(1);
expect(result[0].resourcesId).toBe('rs-002');
});
});
describe('getResourcesSortValue', () => {
const resource = sampleResources[0];
it('returns resources_id value', () => {
expect(getResourcesSortValue(resource, 'resources_id')).toBe('rs-001');
});
it('returns version value', () => {
expect(getResourcesSortValue(resource, 'version')).toBe(2);
});
it('returns create_time value', () => {
expect(getResourcesSortValue(resource, 'create_time')).toBe(now - 400);
});
it('returns update_time for unknown sort keys', () => {
expect(getResourcesSortValue(resource, 'unknown')).toBe(now - 200);
});
});
describe('sortResourcesForParams', () => {
it('sorts by update_time ascending by default', () => {
const result = sortResourcesForParams(sampleResources, null, 'asc');
expect(result[0].resourcesId).toBe('rs-001');
expect(result[2].resourcesId).toBe('rs-003');
});
it('sorts by update_time descending', () => {
const result = sortResourcesForParams(sampleResources, 'update_time', 'desc');
expect(result[0].resourcesId).toBe('rs-003');
expect(result[2].resourcesId).toBe('rs-001');
});
it('sorts by version', () => {
const result = sortResourcesForParams(sampleResources, 'version', 'asc');
expect(result[0].version).toBe(1);
expect(result[2].version).toBe(3);
});
});
describe('buildResourcesResponse', () => {
it('returns paginated resources with defaults', () => {
const request = new Request('http://localhost/v1/agl/resources');
const response = buildResourcesResponse(sampleResources, request);
expect(response.items).toHaveLength(3);
expect(response.offset).toBe(0);
expect(response.limit).toBe(3);
expect(response.total).toBe(3);
});
it('applies filter before pagination', () => {
const request = new Request('http://localhost/v1/agl/resources?resources_id_contains=003');
const response = buildResourcesResponse(sampleResources, request);
expect(response.items).toHaveLength(1);
const items = response.items as Array<Record<string, unknown>>;
expect(items[0].resources_id).toBe('rs-003');
});
it('applies sort order and pagination parameters', () => {
const request = new Request('http://localhost/v1/agl/resources?sort_by=version&sort_order=desc&limit=1&offset=1');
const response = buildResourcesResponse(sampleResources, request);
expect(response.items).toHaveLength(1);
expect(response.offset).toBe(1);
const items = response.items as Array<Record<string, unknown>>;
expect(items[0].version).toBe(2);
});
});
describe('createResourcesHandlers', () => {
it('creates handler for resources endpoint', () => {
const handlers = createResourcesHandlers(sampleResources);
expect(handlers).toHaveLength(1);
expect(handlers[0].info.header).toContain('GET');
});
});
describe('createRolloutsHandlers', () => {
it('creates handlers that return correct rollout data', async () => {
const attemptsByRollout = { 'ro-001': sampleAttempts };
const handlers = createRolloutsHandlers(sampleRollouts, attemptsByRollout);
expect(handlers).toHaveLength(2);
expect(handlers[0].info.header).toContain('GET');
expect(handlers[1].info.header).toContain('GET');
});
});
describe('createSpansHandlers', () => {
it('creates handler for spans endpoint', () => {
const spansByAttempt = { 'ro-001:at-001': sampleSpans };
const handlers = createSpansHandlers(spansByAttempt);
expect(handlers).toHaveLength(1);
expect(handlers[0].info.header).toContain('GET');
});
});
describe('createMockHandlers', () => {
it('creates combined handlers for rollouts and attempts', () => {
const attemptsByRollout = { 'ro-001': sampleAttempts };
const handlers = createMockHandlers(sampleRollouts, attemptsByRollout);
expect(handlers).toHaveLength(2);
});
it('includes spans handlers when provided', () => {
const attemptsByRollout = { 'ro-001': sampleAttempts };
const spansByAttempt = { 'ro-001:at-001': sampleSpans };
const handlers = createMockHandlers(sampleRollouts, attemptsByRollout, spansByAttempt);
expect(handlers).toHaveLength(3);
});
it('works without spans handlers', () => {
const attemptsByRollout = { 'ro-001': sampleAttempts };
const handlers = createMockHandlers(sampleRollouts, attemptsByRollout);
expect(handlers).toHaveLength(2);
});
});
+511
View File
@@ -0,0 +1,511 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Mock API handlers for testing and Storybook.
*
* This module provides utilities for creating MSW (Mock Service Worker) handlers
* that simulate the Agent Lightning Store API. The implementation is based on the
* actual Python server in agentlightning/store/client_server.py.
* The mock covers a useful subset of the server's behavior; when functionality
* is missing compared to the Python implementation it is an intentional tradeoff
* aimed at keeping the mocks light for UI tests.
*
* @module mock
*/
import { delay, http, HttpResponse } from 'msw';
import type { Attempt, Resources, Rollout, Span } from '@/types';
import { snakeCaseKeys } from './format';
/**
* Parse a numeric query parameter with a default value.
* Returns the parsed number or the default if parsing fails.
*/
export function parseNumberParam(params: URLSearchParams, key: string, defaultValue: number): number {
const raw = params.get(key);
if (raw == null) {
return defaultValue;
}
const value = Number(raw);
if (!Number.isFinite(value)) {
return defaultValue;
}
return value;
}
/**
* Filter rollouts based on query parameters.
* Supports: status_in, mode_in, rollout_id_contains
*/
export function filterRolloutsForParams(rollouts: Rollout[], params: URLSearchParams): Rollout[] {
const statusFilters = params.getAll('status_in');
const modeFilters = params.getAll('mode_in');
const rolloutIdContains = params.get('rollout_id_contains');
const filterLogic = params.get('filter_logic') === 'or' ? 'or' : 'and';
return rollouts.filter((rollout) => {
const checks: boolean[] = [];
if (statusFilters.length > 0) {
checks.push(statusFilters.includes(rollout.status));
}
if (modeFilters.length > 0) {
checks.push(rollout.mode != null && modeFilters.includes(rollout.mode));
}
if (rolloutIdContains) {
checks.push(rollout.rolloutId.includes(rolloutIdContains));
}
if (checks.length === 0) {
return true;
}
return filterLogic === 'or' ? checks.some(Boolean) : checks.every(Boolean);
});
}
/**
* Get the sort value for a rollout based on the sort_by field.
*/
export function getRolloutSortValue(rollout: Rollout, sortBy: string): string | number | null {
switch (sortBy) {
case 'rollout_id':
return rollout.rolloutId;
case 'status':
return rollout.status;
case 'mode':
return rollout.mode ?? '';
case 'start_time':
default:
return rollout.attempt?.startTime ?? rollout.startTime ?? null;
}
}
/**
* Sort rollouts based on query parameters.
* Default sort_by is 'start_time', default sort_order is 'asc'.
*/
export function sortRolloutsForParams(
rollouts: Rollout[],
sortBy: string | null,
sortOrder: 'asc' | 'desc',
): Rollout[] {
const resolvedSortBy = sortBy ?? 'start_time';
const sorted = [...rollouts].sort((a, b) => {
const aValue = getRolloutSortValue(a, resolvedSortBy);
const bValue = getRolloutSortValue(b, resolvedSortBy);
if (aValue === bValue) {
return 0;
}
if (aValue == null) {
return -1;
}
if (bValue == null) {
return 1;
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return aValue - bValue;
}
return String(aValue).localeCompare(String(bValue));
});
if (sortOrder === 'desc') {
sorted.reverse();
}
return sorted;
}
/**
* Build a paginated rollouts response matching the Python server's format.
* Applies filtering, sorting, and pagination based on query parameters.
*/
export function buildRolloutsResponse(rollouts: Rollout[], request: Request): Record<string, unknown> {
const url = new URL(request.url);
const params = url.searchParams;
const filtered = filterRolloutsForParams(rollouts, params);
const sortBy = params.get('sort_by');
const sortOrder = params.get('sort_order') === 'desc' ? 'desc' : 'asc';
const sorted = sortRolloutsForParams(filtered, sortBy, sortOrder);
const limitParam = parseNumberParam(params, 'limit', sorted.length);
const offsetParam = parseNumberParam(params, 'offset', 0);
const effectiveLimit = limitParam < 0 ? sorted.length : limitParam;
const offset = offsetParam < 0 ? 0 : offsetParam;
const paginated = effectiveLimit >= 0 ? sorted.slice(offset, offset + effectiveLimit) : [...sorted];
return snakeCaseKeys({
items: paginated,
limit: effectiveLimit,
offset,
total: filtered.length,
});
}
/**
* Sort attempts based on query parameters.
* Default sort_by is 'sequence_id', default sort_order is 'asc'.
*/
export function sortAttemptsForParams(
attempts: Attempt[],
sortBy: string | null,
sortOrder: 'asc' | 'desc',
): Attempt[] {
const sorted = [...attempts];
const resolvedSortBy = sortBy ?? 'sequence_id';
sorted.sort((a, b) => {
if (resolvedSortBy === 'start_time') {
return a.startTime - b.startTime;
}
return a.sequenceId - b.sequenceId;
});
if (sortOrder === 'desc') {
sorted.reverse();
}
return sorted;
}
/**
* Build a paginated attempts response matching the Python server's format.
* Applies sorting and pagination based on query parameters.
*/
export function buildAttemptsResponse(attempts: Attempt[], request: Request): Record<string, unknown> {
const url = new URL(request.url);
const params = url.searchParams;
const sortBy = params.get('sort_by');
const sortOrder = params.get('sort_order') === 'desc' ? 'desc' : 'asc';
const sorted = sortAttemptsForParams(attempts, sortBy, sortOrder);
const limitParam = parseNumberParam(params, 'limit', sorted.length);
const offsetParam = parseNumberParam(params, 'offset', 0);
const effectiveLimit = limitParam < 0 ? sorted.length : limitParam;
const offset = offsetParam < 0 ? 0 : offsetParam;
const paginated = effectiveLimit >= 0 ? sorted.slice(offset, offset + effectiveLimit) : [...sorted];
return snakeCaseKeys({
items: paginated,
limit: effectiveLimit,
offset,
total: attempts.length,
});
}
/**
* Filter spans based on query parameters.
* Supports: trace_id_contains, span_id_contains, name_contains
*/
export function filterSpansForParams(spans: Span[], params: URLSearchParams): Span[] {
const traceContains = params.get('trace_id_contains');
const spanContains = params.get('span_id_contains');
const nameContains = params.get('name_contains');
const filterLogic = params.get('filter_logic') === 'or' ? 'or' : 'and';
return spans.filter((span) => {
const checks: boolean[] = [];
if (traceContains) {
checks.push(span.traceId.includes(traceContains));
}
if (spanContains) {
checks.push(span.spanId.includes(spanContains));
}
if (nameContains) {
checks.push(span.name.toLowerCase().includes(nameContains.toLowerCase()));
}
if (checks.length === 0) {
return true;
}
return filterLogic === 'or' ? checks.some(Boolean) : checks.every(Boolean);
});
}
/**
* Get the sort value for a span based on the sort_by field.
*/
export function getSpanSortValue(span: Span, sortBy: string): string | number | null {
switch (sortBy) {
case 'trace_id':
return span.traceId;
case 'span_id':
return span.spanId;
case 'parent_id':
return span.parentId ?? '';
case 'name':
return span.name;
case 'status_code':
return span.status?.status_code ?? '';
case 'duration': {
if (span.startTime != null && span.endTime != null) {
return span.endTime - span.startTime;
}
return null;
}
case 'start_time':
default:
return span.startTime ?? null;
}
}
/**
* Sort spans based on query parameters.
* Default sort_by is 'start_time', default sort_order is 'asc'.
*/
export function sortSpansForParams(spans: Span[], sortBy: string | null, sortOrder: 'asc' | 'desc'): Span[] {
const resolvedSortBy = sortBy ?? 'start_time';
const sorted = [...spans].sort((a, b) => {
const aValue = getSpanSortValue(a, resolvedSortBy);
const bValue = getSpanSortValue(b, resolvedSortBy);
if (aValue === bValue) {
return 0;
}
if (aValue == null) {
return -1;
}
if (bValue == null) {
return 1;
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return aValue - bValue;
}
return String(aValue).localeCompare(String(bValue));
});
if (sortOrder === 'desc') {
sorted.reverse();
}
return sorted;
}
/**
* Build a paginated spans response matching the Python server's format.
* Applies filtering, sorting, and pagination based on query parameters.
*/
export function buildSpansResponse(spansByAttempt: Record<string, Span[]>, request: Request): Record<string, unknown> {
const url = new URL(request.url);
const params = url.searchParams;
const rolloutId = params.get('rollout_id');
if (!rolloutId) {
return snakeCaseKeys({ items: [], limit: 0, offset: 0, total: 0 });
}
const attemptId = params.get('attempt_id');
const key = attemptId ? `${rolloutId}:${attemptId}` : rolloutId;
const spans = spansByAttempt[key] ?? [];
const filtered = filterSpansForParams(spans, params);
const sortBy = params.get('sort_by');
const sortOrder = params.get('sort_order') === 'desc' ? 'desc' : 'asc';
const sorted = sortSpansForParams(filtered, sortBy, sortOrder);
const limitParam = parseNumberParam(params, 'limit', sorted.length);
const offsetParam = parseNumberParam(params, 'offset', 0);
const effectiveLimit = limitParam < 0 ? sorted.length : limitParam;
const offset = offsetParam < 0 ? 0 : offsetParam;
const paginated = effectiveLimit >= 0 ? sorted.slice(offset, offset + effectiveLimit) : [...sorted];
return snakeCaseKeys({
items: paginated,
limit: effectiveLimit,
offset,
total: filtered.length,
});
}
/**
* Create MSW handlers for rollouts and attempts endpoints.
*
* @param rollouts - Array of rollout objects to serve
* @param attemptsByRollout - Map of rollout IDs to their attempts
* @returns Array of MSW request handlers
*
* @example
* ```ts
* const handlers = createRolloutsHandlers(sampleRollouts, attemptsByRollout);
* ```
*/
export function createRolloutsHandlers(rollouts: Rollout[], attemptsByRollout: Record<string, Attempt[]>) {
return [
http.get('*/v1/agl/rollouts', ({ request }) => HttpResponse.json(buildRolloutsResponse(rollouts, request))),
http.get('*/v1/agl/rollouts/:rolloutId/attempts', ({ params, request }) => {
const rolloutId = params.rolloutId as string;
const attempts = attemptsByRollout[rolloutId] ?? [];
return HttpResponse.json(buildAttemptsResponse(attempts, request));
}),
];
}
/**
* Create MSW handlers for spans endpoints.
*
* @param spansByAttempt - Map of "rolloutId:attemptId" to their spans
* @returns Array of MSW request handlers
*
* @example
* ```ts
* const handlers = createSpansHandlers({ 'ro-1:at-1': [span1, span2] });
* ```
*/
export function createSpansHandlers(spansByAttempt: Record<string, Span[]>) {
return [http.get('*/v1/agl/spans', ({ request }) => HttpResponse.json(buildSpansResponse(spansByAttempt, request)))];
}
/**
* Filter resources based on query parameters.
* Supports: resources_id_contains
*/
export function filterResourcesForParams(resources: Resources[], params: URLSearchParams): Resources[] {
const resourcesIdContains = params.get('resources_id_contains');
return resources.filter((resource) => {
if (resourcesIdContains && !resource.resourcesId.includes(resourcesIdContains)) {
return false;
}
return true;
});
}
/**
* Get the sort value for resources based on the sort_by field.
*/
export function getResourcesSortValue(resource: Resources, sortBy: string): string | number | null {
switch (sortBy) {
case 'resources_id':
return resource.resourcesId;
case 'version':
return resource.version;
case 'create_time':
return resource.createTime;
case 'update_time':
default:
return resource.updateTime;
}
}
/**
* Sort resources based on query parameters.
* Default sort_by is 'update_time', default sort_order is 'desc'.
*/
export function sortResourcesForParams(
resources: Resources[],
sortBy: string | null,
sortOrder: 'asc' | 'desc',
): Resources[] {
const resolvedSortBy = sortBy ?? 'update_time';
const sorted = [...resources].sort((a, b) => {
const aValue = getResourcesSortValue(a, resolvedSortBy);
const bValue = getResourcesSortValue(b, resolvedSortBy);
if (aValue === bValue) {
return 0;
}
if (aValue == null) {
return -1;
}
if (bValue == null) {
return 1;
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return aValue - bValue;
}
return String(aValue).localeCompare(String(bValue));
});
if (sortOrder === 'desc') {
sorted.reverse();
}
return sorted;
}
/**
* Build a paginated resources response matching the Python server's format.
* Applies filtering, sorting, and pagination based on query parameters.
*/
export function buildResourcesResponse(resources: Resources[], request: Request): Record<string, unknown> {
const url = new URL(request.url);
const params = url.searchParams;
const filtered = filterResourcesForParams(resources, params);
const sortBy = params.get('sort_by');
const sortOrder = params.get('sort_order') === 'desc' ? 'desc' : 'asc';
const sorted = sortResourcesForParams(filtered, sortBy, sortOrder);
const limitParam = parseNumberParam(params, 'limit', sorted.length);
const offsetParam = parseNumberParam(params, 'offset', 0);
const effectiveLimit = limitParam < 0 ? sorted.length : limitParam;
const offset = offsetParam < 0 ? 0 : offsetParam;
const paginated = effectiveLimit >= 0 ? sorted.slice(offset, offset + effectiveLimit) : [...sorted];
return snakeCaseKeys({
items: paginated,
limit: effectiveLimit,
offset,
total: filtered.length,
});
}
/**
* Create MSW handlers for resources endpoints.
*
* @param resources - Array of resources objects to serve
* @returns Array of MSW request handlers
*
* @example
* ```ts
* const handlers = createResourcesHandlers(sampleResources);
* ```
*/
export function createResourcesHandlers(resources: Resources[]) {
return [
http.get('*/v1/agl/resources', ({ request }) => HttpResponse.json(buildResourcesResponse(resources, request))),
];
}
/**
* Create complete MSW handlers for rollouts, attempts, and spans.
*
* This is a convenience function that combines createRolloutsHandlers and
* createSpansHandlers for stories that need both.
*
* @param rollouts - Array of rollout objects to serve
* @param attemptsByRollout - Map of rollout IDs to their attempts
* @param spansByAttempt - Map of "rolloutId:attemptId" to their spans
* @param delayMs - Optional delay in milliseconds to add before each response (for testing loading states)
* @returns Array of MSW request handlers
*
* @example
* ```ts
* // Create handlers without delay
* const handlers = createMockHandlers(rollouts, attempts, spans);
*
* // Create handlers with 800ms delay to test loading states
* const loadingHandlers = createMockHandlers(rollouts, attempts, spans, 800);
* ```
*/
export function createMockHandlers(
rollouts: Rollout[],
attemptsByRollout: Record<string, Attempt[]>,
spansByAttempt?: Record<string, Span[]>,
delayMs?: number,
) {
// If no delay, use synchronous handlers
if (!delayMs) {
const handlers = createRolloutsHandlers(rollouts, attemptsByRollout);
if (spansByAttempt) {
handlers.push(...createSpansHandlers(spansByAttempt));
}
return handlers;
}
// Create delayed handlers
return [
http.get('*/v1/agl/rollouts', async ({ request }) => {
await delay(delayMs);
return HttpResponse.json(buildRolloutsResponse(rollouts, request));
}),
http.get('*/v1/agl/rollouts/:rolloutId/attempts', async ({ params, request }) => {
await delay(delayMs);
const rolloutId = params.rolloutId as string;
const attempts = attemptsByRollout[rolloutId] ?? [];
return HttpResponse.json(buildAttemptsResponse(attempts, request));
}),
...(spansByAttempt
? [
http.get('*/v1/agl/spans', async ({ request }) => {
await delay(delayMs);
return HttpResponse.json(buildSpansResponse(spansByAttempt, request));
}),
]
: []),
];
}
+126
View File
@@ -0,0 +1,126 @@
// Copyright (c) Microsoft. All rights reserved.
import type { DataTableColumn } from 'mantine-datatable';
/**
* Configuration for column visibility based on screen width
*/
export type ColumnVisibilityConfig =
| {
minWidth: number;
priority: number;
fixedWidth?: never;
}
| {
fixedWidth: number;
priority: number;
minWidth?: never;
};
/**
* Compare two records for sorting purposes
* Handles null/undefined values and both string and number comparisons
*/
export function compareRecords<T, K extends keyof T>(a: T, b: T, key: K): number {
const valueA = a[key];
const valueB = b[key];
if (valueA === valueB) {
return 0;
}
if (valueA === null || valueA === undefined) {
return 1;
}
if (valueB === null || valueB === undefined) {
return -1;
}
if (typeof valueA === 'number' && typeof valueB === 'number') {
return valueA - valueB;
}
return String(valueA).localeCompare(String(valueB));
}
/**
* 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;
function resolveWidth(config: ColumnVisibilityConfig): { widthEm: number; fixed: boolean } {
if ('fixedWidth' in config && typeof config.fixedWidth === 'number') {
return { widthEm: config.fixedWidth, fixed: true };
}
return { widthEm: config.minWidth, fixed: false };
}
export function createResponsiveColumns<T>(
columns: DataTableColumn<T>[],
containerWidth: number,
columnVisibilityConfig: Record<string, ColumnVisibilityConfig>,
): DataTableColumn<T>[] {
const measuredWidth = containerWidth ? Math.max(containerWidth - 48, 0) : Number.POSITIVE_INFINITY;
const columnEntries = columns.map((column, index) => {
const accessorKey = String(column.accessor);
const config =
columnVisibilityConfig[accessorKey] ??
({
minWidth: 10,
priority: 3,
} satisfies ColumnVisibilityConfig);
const { widthEm, fixed } = resolveWidth(config);
return {
column,
index,
accessorKey,
...config,
widthEm,
widthPx: widthEm * EM_IN_PIXELS,
fixed,
};
});
const sortedByPriority = columnEntries
.slice()
.sort((a, b) => (a.priority === b.priority ? a.index - b.index : a.priority - b.priority));
const visibleColumnIndices = new Set<number>();
let usedWidth = 0;
// First pass: add all priority 0 columns
sortedByPriority.forEach((entry) => {
if (entry.priority === 0) {
visibleColumnIndices.add(entry.index);
usedWidth += entry.widthPx;
}
});
// Second pass: add remaining columns that fit
sortedByPriority.forEach((entry) => {
if (visibleColumnIndices.has(entry.index)) {
return;
}
if (usedWidth + entry.widthPx <= measuredWidth) {
visibleColumnIndices.add(entry.index);
usedWidth += entry.widthPx;
}
});
return columnEntries.map(({ column, index, fixed, widthEm }) => {
const columnWidth = `${widthEm}em`;
const existingStyle = (column as any).style ?? {};
return {
...column,
width: fixed ? columnWidth : column.width,
style: {
...existingStyle,
minWidth: columnWidth,
},
hidden: !visibleColumnIndices.has(index),
};
});
}
+3
View File
@@ -0,0 +1,3 @@
// Copyright (c) Microsoft. All rights reserved.
/// <reference types="vite/client" />
+338
View File
@@ -0,0 +1,338 @@
// Copyright (c) Microsoft. All rights reserved.
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.11.6';
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82';
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
const activeClientIds = new Set();
addEventListener('install', function () {
self.skipWaiting();
});
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim());
});
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id');
if (!clientId || !self.clients) {
return;
}
const client = await self.clients.get(clientId);
if (!client) {
return;
}
const allClients = await self.clients.matchAll({
type: 'window',
});
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
});
break;
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
});
break;
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId);
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
});
break;
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId);
const remainingClients = allClients.filter((client) => {
return client.id !== clientId;
});
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister();
}
break;
}
}
});
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now();
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return;
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
return;
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return;
}
const requestId = crypto.randomUUID();
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
});
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event);
const requestCloneForEvents = event.request.clone();
const response = await getResponse(event, client, requestId, requestInterceptedAt);
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents);
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone();
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
);
}
return response;
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);
if (activeClientIds.has(event.clientId)) {
return client;
}
if (client?.frameType === 'top-level') {
return client;
}
const allClients = await self.clients.matchAll({
type: 'window',
});
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible';
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id);
});
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone();
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers);
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept');
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim());
const filteredValues = values.filter((value) => value !== 'msw/passthrough');
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '));
} else {
headers.delete('accept');
}
}
return fetch(requestClone, { headers });
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough();
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough();
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request);
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
);
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data);
}
case 'PASSTHROUGH': {
return passthrough();
}
}
return passthrough();
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error);
}
resolve(event.data);
};
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
});
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error();
}
const mockedResponse = new Response(response.body, response);
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
});
return mockedResponse;
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
};
}
+8
View File
@@ -0,0 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
import userEvent from '@testing-library/user-event';
export * from '@testing-library/react';
export { render } from './render';
export { userEvent };
export { createServerBackedStore, getPythonServerBaseUrl } from './server';
+662
View File
@@ -0,0 +1,662 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Script to inject mock data directly into an InMemoryLightningStore.
This script creates sample rollouts, attempts, spans, and resources that match
the TypeScript mock data (from dashboard/src/components/RolloutTable.story.tsx)
and injects them directly into the store's private variables for testing.
Usage:
from dashboard.test_utils.inject_mock_data import inject_mock_data
store = InMemoryLightningStore()
inject_mock_data(store)
"""
# pyright: reportPrivateUsage=false
import argparse
import asyncio
import time
from typing import List
from agentlightning.store.client_server import LightningStoreServer
from agentlightning.store.memory import InMemoryLightningStore
from agentlightning.types import (
LLM,
Attempt,
OtelResource,
PromptTemplate,
ResourcesUpdate,
Rollout,
RolloutConfig,
Span,
TraceStatus,
)
def inject_mock_data(store: InMemoryLightningStore, now: float | None = None) -> None:
"""
Inject mock data directly into the InMemoryLightningStore.
Args:
store: The InMemoryLightningStore instance to inject data into
now: The current timestamp (defaults to current time)
"""
if now is None:
now = time.time()
# Create rollouts matching the TypeScript mock data
# Based on sampleRollouts from dashboard/src/components/RolloutTable.story.tsx
# Rollout 1: Running
rollout1 = Rollout(
rollout_id="ro-story-001",
input=dict(task="Generate onboarding summary"),
start_time=now - 3200,
end_time=None,
mode="train",
resources_id="rs-story-001",
status="running",
config=RolloutConfig(max_attempts=1),
metadata={"owner": "alice"},
)
attempt1 = Attempt(
rollout_id="ro-story-001",
attempt_id="at-story-010",
sequence_id=1,
start_time=now - 3200,
end_time=None,
status="running",
worker_id="worker-east",
last_heartbeat_time=now - 45,
metadata={"info": "Worker is processing"},
)
# Rollout 2: Succeeded
rollout2 = Rollout(
rollout_id="ro-story-002",
# NOTE: input is a string here, not a dict
input="Classify feedback tickets",
start_time=now - 7200,
end_time=now - 5400,
mode="val",
resources_id="rs-story-002",
status="succeeded",
config=RolloutConfig(max_attempts=2, timeout_seconds=86400, unresponsive_seconds=86400 * 2),
metadata={"owner": "bob"},
)
attempt2_1 = Attempt(
rollout_id="ro-story-002",
attempt_id="at-story-021",
sequence_id=1,
start_time=now - 7200,
end_time=now - 5400,
status="timeout",
last_heartbeat_time=None,
worker_id=None,
metadata=None,
)
attempt2_2 = Attempt(
rollout_id="ro-story-002",
attempt_id="at-story-022",
sequence_id=2,
start_time=now - 6200,
end_time=now - 5400,
status="succeeded",
worker_id="worker-north",
last_heartbeat_time=now - 5400,
metadata={"previousAttempt": "at-story-010"},
)
# Rollout 3: Failed
rollout3 = Rollout(
rollout_id="ro-story-003",
input=dict(task="Analyze experiment results"),
start_time=now - 10800,
end_time=now - 9600,
mode="test",
resources_id="rs-story-003",
status="failed",
config=RolloutConfig(max_attempts=3),
metadata={"owner": "carol"},
)
attempt3_1 = Attempt(
rollout_id="ro-story-003",
attempt_id="at-story-031",
sequence_id=3,
start_time=now - 10200,
end_time=now - 9600,
status="failed",
worker_id="worker-west",
last_heartbeat_time=now - 9600,
metadata={"reason": "Timeout"},
)
attempt3_2 = Attempt(
rollout_id="ro-story-003",
attempt_id="at-story-032",
sequence_id=2,
start_time=now - 9600,
end_time=None,
status="unresponsive",
worker_id="worker-west",
last_heartbeat_time=now - 3600,
metadata={"reason": "Unresponsive"},
)
attempt3_3 = Attempt(
rollout_id="ro-story-003",
attempt_id="at-story-033",
sequence_id=3,
start_time=now - 9000,
end_time=now - 3600,
status="failed",
worker_id="worker-west",
)
# Rollout 4: Queuing (no attempt yet)
rollout4 = Rollout(
rollout_id="ro-story-004",
input=dict(task="Evaluate prompt variants"),
start_time=now - 3600,
end_time=None,
mode="train",
resources_id=None,
status="requeuing",
config=RolloutConfig(max_attempts=5, retry_condition=["timeout"]),
metadata={"owner": "dave"},
)
# Rollout 5: Running
rollout5 = Rollout(
rollout_id="ro-story-005",
input=dict(task="Generate quick answers"),
start_time=now - 1800,
end_time=None,
mode="val",
resources_id="rs-story-004",
status="running",
config=RolloutConfig(max_attempts=1),
metadata={"owner": "eva"},
)
attempt5 = Attempt(
rollout_id="ro-story-005",
attempt_id="at-story-013",
sequence_id=1,
start_time=now - 1800,
end_time=None,
status="running",
worker_id=None,
last_heartbeat_time=now - 75,
metadata=None,
)
# Rollout 6: Cancelled
rollout6 = Rollout(
rollout_id="ro-story-006",
input=dict(task="Compile release notes"),
start_time=now - 9600,
end_time=now - 9000,
mode=None,
resources_id="rs-story-005",
status="cancelled",
config=RolloutConfig(max_attempts=3),
metadata=None,
)
attempt6 = Attempt(
rollout_id="ro-story-006",
attempt_id="at-story-014",
sequence_id=1,
start_time=now - 9600,
end_time=now - 9000,
status="timeout",
worker_id="worker-south",
last_heartbeat_time=now - 9000,
metadata={"info": "Cancelled by operator"},
)
# Inject rollouts directly into store
store._rollouts["ro-story-001"] = rollout1
store._rollouts["ro-story-002"] = rollout2
store._rollouts["ro-story-003"] = rollout3
store._rollouts["ro-story-004"] = rollout4
store._rollouts["ro-story-005"] = rollout5
store._rollouts["ro-story-006"] = rollout6
# Inject attempts directly into store
store._attempts["ro-story-001"] = [attempt1]
store._attempts["ro-story-002"] = [attempt2_1, attempt2_2]
store._attempts["ro-story-003"] = [attempt3_1, attempt3_2, attempt3_3]
store._attempts["ro-story-004"] = [] # No attempt for preparing rollout
store._attempts["ro-story-005"] = [attempt5]
store._attempts["ro-story-006"] = [attempt6]
# Create and inject spans with diverse data
# Spans for ro-story-001 (Running) - Multiple nested spans with ongoing execution
spans_ro1: List[Span] = [
Span(
rollout_id="ro-story-001",
attempt_id="at-story-010",
sequence_id=1,
trace_id="trace-001-main",
span_id="span-001-root",
parent_id=None,
name="agent_execution",
status=TraceStatus(status_code="OK", description=None),
attributes={"component": "agent", "framework": "autogen", "version": "0.2.0"},
events=[],
links=[],
start_time=now - 3200,
end_time=now - 3100,
context=None,
parent=None,
resource=OtelResource(attributes={"service.name": "agent-service"}, schema_url=""),
),
Span(
rollout_id="ro-story-001",
attempt_id="at-story-010",
sequence_id=2,
trace_id="trace-001-main",
span_id="span-002-llm",
parent_id="span-001-root",
name="llm_call",
status=TraceStatus(status_code="OK", description=None),
attributes={"model": "gpt-4", "temperature": 0.7, "tokens": 150},
events=[],
links=[],
start_time=now - 3180,
end_time=now - 3120,
context=None,
parent=None,
resource=OtelResource(attributes={"service.name": "llm-service"}, schema_url=""),
),
Span(
rollout_id="ro-story-001",
attempt_id="at-story-010",
sequence_id=3,
trace_id="trace-001-main",
span_id="span-003-tool",
parent_id="span-001-root",
name="tool_execution",
status=TraceStatus(status_code="OK", description=None),
attributes={"tool": "web_search", "query": "onboarding summary"},
events=[],
links=[],
start_time=now - 3140,
end_time=now - 3100,
context=None,
parent=None,
resource=OtelResource(attributes={"service.name": "tool-service"}, schema_url=""),
),
]
# Spans for ro-story-002 attempt 1 (timeout) - Failed workflow
spans_ro2_a1: List[Span] = [
Span(
rollout_id="ro-story-002",
attempt_id="at-story-021",
sequence_id=1,
trace_id="trace-002-attempt1",
span_id="span-004-root",
parent_id=None,
name="classification_pipeline",
status=TraceStatus(status_code="ERROR", description="Request timeout"),
attributes={"type": "classification", "model": "bert-classifier", "timeout": True},
events=[],
links=[],
start_time=now - 7200,
end_time=now - 6800,
context=None,
parent=None,
resource=OtelResource(attributes={"service.name": "classifier"}, schema_url=""),
),
]
# Spans for ro-story-002 attempt 2 (succeeded) - Successful workflow
spans_ro2_a2: List[Span] = [
Span(
rollout_id="ro-story-002",
attempt_id="at-story-022",
sequence_id=1,
trace_id="trace-002-attempt2",
span_id="span-005-root",
parent_id=None,
name="classification_pipeline",
status=TraceStatus(status_code="OK", description=None),
attributes={"type": "classification", "model": "bert-classifier", "retry": True},
events=[],
links=[],
start_time=now - 6200,
end_time=now - 5400,
context=None,
parent=None,
resource=OtelResource(attributes={"service.name": "classifier"}, schema_url=""),
),
Span(
rollout_id="ro-story-002",
attempt_id="at-story-022",
sequence_id=2,
trace_id="trace-002-attempt2",
span_id="span-006-preprocess",
parent_id="span-005-root",
name="preprocess_tickets",
status=TraceStatus(status_code="OK", description=None),
attributes={"tickets_count": 50, "duration_ms": 120},
events=[],
links=[],
start_time=now - 6200,
end_time=now - 6100,
context=None,
parent=None,
resource=OtelResource(attributes={}, schema_url=""),
),
Span(
rollout_id="ro-story-002",
attempt_id="at-story-022",
sequence_id=3,
trace_id="trace-002-attempt2",
span_id="span-007-classify",
parent_id="span-005-root",
name="run_classifier",
status=TraceStatus(status_code="OK", description=None),
attributes={"batch_size": 10, "accuracy": 0.95},
events=[],
links=[],
start_time=now - 6000,
end_time=now - 5400,
context=None,
parent=None,
resource=OtelResource(attributes={}, schema_url=""),
),
]
# Spans for ro-story-003 attempt 3 (failed) - Error scenario with nested failures
spans_ro3_a3: List[Span] = [
Span(
rollout_id="ro-story-003",
attempt_id="at-story-033",
sequence_id=1,
trace_id="trace-003-experiment",
span_id="span-008-root",
parent_id=None,
name="experiment_analysis",
status=TraceStatus(status_code="ERROR", description="Analysis failed"),
attributes={"experiment_id": "exp-123", "timeout_seconds": 300},
events=[],
links=[],
start_time=now - 9000,
end_time=now - 3600,
context=None,
parent=None,
resource=OtelResource(attributes={"service.name": "experiment-runner"}, schema_url=""),
),
Span(
rollout_id="ro-story-003",
attempt_id="at-story-033",
sequence_id=2,
trace_id="trace-003-experiment",
span_id="span-009-fetch",
parent_id="span-008-root",
name="fetch_experiment_data",
status=TraceStatus(status_code="ERROR", description="Connection timeout"),
attributes={"data_source": "database", "retry_count": 3},
events=[],
links=[],
start_time=now - 9000,
end_time=now - 7000,
context=None,
parent=None,
resource=OtelResource(attributes={}, schema_url=""),
),
Span(
rollout_id="ro-story-003",
attempt_id="at-story-033",
sequence_id=3,
trace_id="trace-003-experiment",
span_id="span-010-process",
parent_id="span-008-root",
name="process_results",
status=TraceStatus(status_code="UNSET", description=None),
attributes={"stage": "aggregation", "partial_results": True},
events=[],
links=[],
start_time=now - 6000,
end_time=now - 3600,
context=None,
parent=None,
resource=OtelResource(attributes={}, schema_url=""),
),
]
# Spans for ro-story-005 (Running) - In-progress with mixed statuses
spans_ro5: List[Span] = [
Span(
rollout_id="ro-story-005",
attempt_id="at-story-013",
sequence_id=1,
trace_id="trace-005-answers",
span_id="span-011-root",
parent_id=None,
name="quick_answer_generation",
status=TraceStatus(status_code="OK", description=None),
attributes={"batch_size": 10, "model": "llama-3"},
events=[],
links=[],
start_time=now - 1800,
end_time=now - 1500,
context=None,
parent=None,
resource=OtelResource(attributes={"service.name": "qa-service"}, schema_url=""),
),
Span(
rollout_id="ro-story-005",
attempt_id="at-story-013",
sequence_id=2,
trace_id="trace-005-answers",
span_id="span-012-llm",
parent_id="span-011-root",
name="llm_inference",
status=TraceStatus(status_code="UNSET", description=None),
attributes={"model": "llama-3-70b", "batch_id": "batch-001"},
events=[],
links=[],
start_time=now - 1700,
end_time=None,
context=None,
parent=None,
resource=OtelResource(attributes={}, schema_url=""),
),
Span(
rollout_id="ro-story-005",
attempt_id="at-story-013",
sequence_id=3,
trace_id="trace-005-answers",
span_id="span-013-retrieve",
parent_id="span-011-root",
name="retrieve_context",
status=TraceStatus(status_code="OK", description=None),
attributes={"documents_count": 5, "vector_db": "chroma"},
events=[],
links=[],
start_time=now - 1780,
end_time=now - 1700,
context=None,
parent=None,
resource=OtelResource(attributes={}, schema_url=""),
),
]
# Spans for ro-story-006 (Cancelled) - Cancelled workflow
spans_ro6: List[Span] = [
Span(
rollout_id="ro-story-006",
attempt_id="at-story-014",
sequence_id=1,
trace_id="trace-006-release",
span_id="span-014-root",
parent_id=None,
name="compile_release_notes",
status=TraceStatus(status_code="ERROR", description="Operation cancelled by user"),
attributes={"release_version": "v2.0.0", "cancelled": True},
events=[],
links=[],
start_time=now - 9600,
end_time=now - 9000,
context=None,
parent=None,
resource=OtelResource(attributes={"service.name": "release-automation"}, schema_url=""),
),
Span(
rollout_id="ro-story-006",
attempt_id="at-story-014",
sequence_id=2,
trace_id="trace-006-release",
span_id="span-015-git",
parent_id="span-014-root",
name="fetch_git_commits",
status=TraceStatus(status_code="OK", description=None),
attributes={"repo": "main", "commits_count": 45},
events=[],
links=[],
start_time=now - 9600,
end_time=now - 9400,
context=None,
parent=None,
resource=OtelResource(attributes={}, schema_url=""),
),
Span(
rollout_id="ro-story-006",
attempt_id="at-story-014",
sequence_id=3,
trace_id="trace-006-release",
span_id="span-016-format",
parent_id="span-014-root",
name="format_changelog",
status=TraceStatus(status_code="ERROR", description="Cancelled during formatting"),
attributes={"format": "markdown", "cancelled_at": "50%"},
events=[],
links=[],
start_time=now - 9300,
end_time=now - 9000,
context=None,
parent=None,
resource=OtelResource(attributes={}, schema_url=""),
),
]
store._spans["ro-story-001"] = spans_ro1
store._spans["ro-story-002"] = spans_ro2_a1 + spans_ro2_a2
store._spans["ro-story-003"] = spans_ro3_a3
store._spans["ro-story-005"] = spans_ro5
store._spans["ro-story-006"] = spans_ro6
# Create and inject resources with diverse types
resource1 = ResourcesUpdate(
resources_id="rs-story-001",
version=1,
create_time=now - 86400,
update_time=now - 86400,
resources={
"model": LLM(
endpoint="https://api.openai.com/v1",
model="gpt-4",
sampling_parameters={"temperature": 0.7, "max_tokens": 1000},
),
},
)
resource2 = ResourcesUpdate(
resources_id="rs-story-002",
version=1,
create_time=now - 86400,
update_time=now - 43200,
resources={
"prompt_main": PromptTemplate(
template="Classify the following ticket: {ticket}\nCategory:",
engine="f-string",
),
},
)
resource3 = ResourcesUpdate(
resources_id="rs-story-003",
version=2,
create_time=now - 86400,
update_time=now - 3600,
resources={
"model": LLM(
endpoint="https://api.anthropic.com/v1",
model="claude-3-sonnet",
sampling_parameters={"temperature": 0.8, "max_tokens": 2048},
),
"prompt": PromptTemplate(
template="Analyze experiment results:\n{% for result in results %}\n- {{ result }}\n{% endfor %}",
engine="jinja",
),
},
)
resource4 = ResourcesUpdate(
resources_id="rs-story-004",
version=1,
create_time=now - 7200,
update_time=now - 7200,
resources={
"system_prompt": PromptTemplate(
template="You are a helpful assistant that generates quick, concise answers.",
engine="f-string",
),
"model": LLM(
endpoint="http://localhost:8000/v1",
model="llama-3-70b",
sampling_parameters={"temperature": 0.6, "top_p": 0.95},
),
},
)
resource5 = ResourcesUpdate(
resources_id="rs-story-005",
version=1,
create_time=now - 14400,
update_time=now - 14400,
resources={
"prompt": PromptTemplate(
template="## Release Notes v{{ version }}\n\n### Changes\n{{ changes }}",
engine="jinja",
),
},
)
store._resources["rs-story-001"] = resource1
store._resources["rs-story-002"] = resource2
store._resources["rs-story-003"] = resource3
store._resources["rs-story-004"] = resource4
store._resources["rs-story-005"] = resource5
store._latest_resources_id = "rs-story-005"
async def main():
parser = argparse.ArgumentParser(description="Run a Python server for the LightningStore")
parser.add_argument("--now", type=float, default=time.time(), help="The current timestamp")
args = parser.parse_args()
store = InMemoryLightningStore()
inject_mock_data(store, now=args.now)
# Start server
server = LightningStoreServer(store, "127.0.0.1", 8765, "*")
await server.start()
print(f"Server started at {server.endpoint}")
print("Press Ctrl+C to stop...")
try:
# Keep server running
await asyncio.Event().wait()
except KeyboardInterrupt:
print("\nStopping server...")
await server.stop()
if __name__ == "__main__":
asyncio.run(main())
+15
View File
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
import { render as testingLibraryRender } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import { theme } from '../src/theme';
export function render(ui: React.ReactNode) {
return testingLibraryRender(ui, {
wrapper: ({ children }: { children: React.ReactNode }) => (
<MantineProvider theme={theme} env='test'>
{children}
</MantineProvider>
),
});
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
import { setBaseUrl } from '@/features/config';
import { createAppStore, type AppStore } from '@/store';
const resolvePythonServerBaseUrl = (): string => {
const host = process.env.VITEST_PYTHON_HOST ?? '127.0.0.1';
const port = process.env.VITEST_PYTHON_PORT ?? '8765';
const configured = process.env.VITEST_PYTHON_URL;
const base = configured && configured.trim().length > 0 ? configured : `http://${host}:${port}`;
return base.replace(/\/$/, '');
};
export const getPythonServerBaseUrl = (): string => resolvePythonServerBaseUrl();
export const createServerBackedStore = (preloadedState?: Parameters<typeof createAppStore>[0]): AppStore => {
const store = createAppStore(preloadedState);
store.dispatch(setBaseUrl(getPythonServerBaseUrl()));
return store;
};
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"types": ["node", "@testing-library/jest-dom", "vitest/globals"],
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@/*": ["./src/*"],
"@test-utils": ["./test-utils"]
}
},
"include": ["src", "public", "test-utils", ".storybook/main.ts", ".storybook/preview.tsx", ".storybook/modes.ts", ".storybook/constants.ts"]
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
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()],
publicDir: path.resolve(projectRoot, 'static'),
server: {
fs: {
allow: [projectRoot],
},
},
build: {
outDir: path.resolve(projectRoot, 'dist'),
emptyOutDir: true,
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './vitest.setup.mjs',
globalSetup: './vitest.global-setup.mjs',
root: projectRoot,
},
});
+221
View File
@@ -0,0 +1,221 @@
// Copyright (c) Microsoft. All rights reserved.
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
const serverHost = process.env.VITEST_PYTHON_HOST ?? '127.0.0.1';
const serverPort = Number.parseInt(process.env.VITEST_PYTHON_PORT ?? '8765', 10);
const serverUrl =
(process.env.VITEST_PYTHON_URL && process.env.VITEST_PYTHON_URL.trim().replace(/\/$/, '')) ??
`http://${serverHost}:${Number.isFinite(serverPort) ? serverPort : 8765}`;
const serverHealthUrl = `${serverUrl}/v1/agl/health`;
let pythonServerProcess = null;
let startedByGlobalSetup = false;
const locatePythonServerPaths = () => {
const candidates = [];
const cwd = process.cwd();
candidates.push({ workspace: cwd, repo: path.resolve(cwd, '..') });
if (
typeof import.meta !== 'undefined' &&
typeof import.meta.url === 'string' &&
import.meta.url.startsWith('file:')
) {
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
candidates.push({ workspace: moduleDir, repo: path.resolve(moduleDir, '..') });
}
for (const candidate of candidates) {
const serverScript = path.resolve(candidate.workspace, 'test-utils/python-server.py');
if (fs.existsSync(serverScript)) {
return { repoRoot: candidate.repo, serverScript };
}
}
throw new Error('Unable to locate dashboard/test-utils/python-server.py from Vitest global setup.');
};
const waitForHealth = async (timeoutMs) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const controller = new AbortController();
const abortTimer = setTimeout(() => controller.abort(), 1000);
const response = await fetch(serverHealthUrl, { signal: controller.signal });
clearTimeout(abortTimer);
if (response.ok) {
return true;
}
} catch {
// Retry until timeout.
}
await sleep(200);
}
return false;
};
const resolvePythonRunners = (repoRoot) => {
const runners = [];
const seenCommands = new Set();
const pushRunner = (command, args, description, extraEnv = {}) => {
if (seenCommands.has(command)) {
return;
}
seenCommands.add(command);
runners.push({ command, args, description, env: extraEnv });
};
const explicit = process.env.VITEST_PYTHON_BIN?.trim();
if (explicit) {
pushRunner(explicit, [], explicit);
}
const hasUv = (() => {
try {
const result = spawnSync('uv', ['--version'], { stdio: 'ignore' });
return result.status === 0;
} catch {
return false;
}
})();
if (hasUv) {
const uvCacheDir = path.resolve(repoRoot, '.uv-cache');
pushRunner('uv', ['run', '--no-sync'], 'uv run --no-sync', { UV_CACHE_DIR: uvCacheDir });
}
const candidates = [process.env.PYTHON, process.env.PYTHON3, 'python3', 'python'].filter(
(candidate) => typeof candidate === 'string' && candidate.length > 0,
);
for (const candidate of candidates) {
if (seenCommands.has(candidate)) {
continue;
}
try {
const result = spawnSync(candidate, ['--version'], { stdio: 'ignore' });
if (result.status === 0) {
pushRunner(candidate, [], candidate);
}
} catch {
// try next candidate
}
}
if (runners.length === 0) {
throw new Error('Unable to locate a Python interpreter or uv CLI for launching the LightningStore server.');
}
return runners;
};
const ensurePythonServer = async () => {
if (await waitForHealth(1000)) {
startedByGlobalSetup = false;
return;
}
const { repoRoot, serverScript } = locatePythonServerPaths();
const runners = resolvePythonRunners(repoRoot);
const attemptErrors = [];
const collectOutput = (stream, buffer) => {
if (!stream) {
return;
}
stream.on('data', (chunk) => {
buffer.push(chunk.toString());
if (process.env.VITEST_DEBUG_PYTHON_SERVER) {
process.stderr.write(chunk);
}
});
};
for (const runner of runners) {
const child = spawn(runner.command, [...runner.args, serverScript], {
cwd: repoRoot,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
PYTHONUNBUFFERED: '1',
...runner.env,
},
});
pythonServerProcess = child;
const recordedStdout = [];
const recordedStderr = [];
collectOutput(child.stdout, recordedStdout);
collectOutput(child.stderr, recordedStderr);
let exitPayload = null;
const exitPromise = new Promise((resolve) => {
child.on('exit', (code, signal) => {
exitPayload = { code, signal };
resolve();
});
});
const ready = await waitForHealth(10000);
if (ready) {
startedByGlobalSetup = true;
return;
}
const reused = await waitForHealth(3000);
if (reused) {
startedByGlobalSetup = false;
if (child.exitCode == null) {
child.kill('SIGTERM');
await exitPromise;
}
pythonServerProcess = null;
return;
}
if (child.exitCode == null) {
child.kill('SIGTERM');
await exitPromise;
} else {
await exitPromise;
}
const extra = exitPayload ? ` (code: ${exitPayload.code ?? 'null'}, signal: ${exitPayload.signal ?? 'null'})` : '';
const stderrLog = recordedStderr.join('').trim();
const stdoutLog = recordedStdout.join('').trim();
const message = [
`[${runner.description}] Failed to start LightningStore server at ${serverUrl}${extra}.`,
stderrLog.length > 0 ? `stderr:\n${stderrLog}` : '',
stdoutLog.length > 0 ? `stdout:\n${stdoutLog}` : '',
]
.filter(Boolean)
.join('\n\n');
attemptErrors.push(message);
}
throw new Error(attemptErrors.join('\n\n'));
};
export default async function setup() {
await ensurePythonServer();
return async () => {
if (!startedByGlobalSetup || !pythonServerProcess) {
return;
}
if (pythonServerProcess.exitCode == null) {
pythonServerProcess.kill('SIGTERM');
await new Promise((resolve) => {
pythonServerProcess?.once('exit', resolve);
});
}
};
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
const { getComputedStyle } = window;
window.getComputedStyle = (elt) => getComputedStyle(elt);
window.HTMLElement.prototype.scrollIntoView = () => {};
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;
@@ -1,6 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
"""Utility script to ensure Python files include the required copyright header."""
"""Ensure tracked source files include the required copyright header."""
from __future__ import annotations
@@ -8,12 +8,34 @@ import subprocess
import sys
from pathlib import Path
REQUIRED_HEADER = "# Copyright (c) Microsoft. All rights reserved."
HEADER_TEXT = "Copyright (c) Microsoft. All rights reserved."
REPO_ROOT = Path(__file__).resolve().parent.parent
COMMENT_PREFIX_BY_SUFFIX: dict[str, str] = {
".py": "#",
".pyi": "#",
".pyw": "#",
".js": "//",
".jsx": "//",
".ts": "//",
".tsx": "//",
".mjs": "//",
".mts": "//",
".cjs": "//",
".cts": "//",
}
REQUIRED_HEADER_BY_SUFFIX = {
suffix: f"{prefix} {HEADER_TEXT}" if not prefix.endswith(" ") else f"{prefix}{HEADER_TEXT}"
for suffix, prefix in COMMENT_PREFIX_BY_SUFFIX.items()
}
def iter_python_files() -> list[Path]:
"""Return a list of tracked Python files respecting .gitignore rules."""
def iter_source_files() -> list[Path]:
"""Return tracked source files matching supported extensions."""
if not REQUIRED_HEADER_BY_SUFFIX:
return []
pathspecs = [f"*{suffix}" for suffix in sorted(REQUIRED_HEADER_BY_SUFFIX)]
result = subprocess.run(
[
"git",
@@ -21,7 +43,8 @@ def iter_python_files() -> list[Path]:
"--cached",
"--others",
"--exclude-standard",
"*.py",
"--",
*pathspecs,
],
capture_output=True,
text=True,
@@ -35,7 +58,14 @@ def main() -> int:
missing_header: list[str] = []
missing_blank_line: list[str] = []
for file_path in iter_python_files():
for file_path in iter_source_files():
expected_header = REQUIRED_HEADER_BY_SUFFIX.get(file_path.suffix.lower())
if expected_header is None:
continue
if not file_path.exists():
continue
try:
with file_path.open("r", encoding="utf-8") as file:
first_line = file.readline().rstrip("\r\n")
@@ -44,7 +74,7 @@ def main() -> int:
print(f"Failed to read {file_path}: {exc}", file=sys.stderr)
return 1
if first_line != REQUIRED_HEADER:
if first_line != expected_header:
missing_header.append(str(file_path.relative_to(REPO_ROOT)))
continue
@@ -53,16 +83,14 @@ def main() -> int:
missing_blank_line.append(str(file_path.relative_to(REPO_ROOT)))
if missing_header:
print("The following Python files are missing the required copyright header:")
print("The following files are missing the required copyright header:")
for path in missing_header:
print(f" - {path}")
print(
"Run the appropriate script or add the header manually:",
f"\n{REQUIRED_HEADER}",
)
header_examples = "\n".join(sorted(set(REQUIRED_HEADER_BY_SUFFIX.values())))
print(f"Run the appropriate script or add the header manually:\n{header_examples}")
if missing_blank_line:
print("The following Python files are missing a blank line after the copyright header:")
print("The following files are missing a blank line after the copyright header:")
for path in missing_blank_line:
print(f" - {path}")
print("Ensure there is an empty line separating the header from the rest of the file.")