diff --git a/ui/AGENTS.md b/ui/AGENTS.md
index 4170abd9..de3dc038 100644
--- a/ui/AGENTS.md
+++ b/ui/AGENTS.md
@@ -49,11 +49,17 @@ pnpm fetch-api-types # regenerate API types from server (must be running on :80
- `core/page-components/` — actual page UI logic lives here
- `core/layouts/` — app shell, sidebar navigation
-### Evaluator forms (`core/page-components/agent-detail/edit-control/evaluators/`)
+### Evaluator forms (`core/evaluators/`)
- Each evaluator type has its own folder: `json/`, `sql/`, `regex/`, `list/`, `luna2/`
- Each folder exports: `form.tsx` (React component), `types.ts` (form types), `index.ts` (re-exports)
- Registry in `evaluators/index.ts` maps evaluator names to form components
+### Form guidelines (control definition + evaluator forms)
+- **Always use the input's `label` prop** — never render a separate `` above the input as the label. Use Mantine's built-in `label` so required asterisks and layout are consistent.
+- **Label with tooltip**: Use `LabelWithTooltip` from `@/core/components/label-with-tooltip` when a field needs an (i) icon that shows help text on hover. Pass `label={}` and, for inputs that support it, `labelProps={labelPropsInline}` so the label renders inline.
+- **Required fields**: Use the input's `required` prop (e.g. Select, TextInput) so Mantine renders the red asterisk. Use `labelPropsInline` from the same module when you need the label inline.
+- Applies to: control definition form (`edit-control/control-definition-form.tsx`) and all evaluator forms (`core/evaluators/*/form.tsx`).
+
### Reusable components (`core/components/`)
- Create reusable components that encapsulate common patterns and logic
- **Best practice**: When creating wrapper components around Mantine components, extend the underlying component's props using `Omit` to exclude overridden props, then spread `...rest` to forward all other props
@@ -96,9 +102,9 @@ export function SearchInput({
## Common changes
### Add a new evaluator form
-1. Create folder in `core/page-components/agent-detail/edit-control/evaluators//`
+1. Create folder in `core/evaluators//`
2. Add `types.ts` with form field types
-3. Add `form.tsx` with the form component (use Mantine form components)
+3. Add `form.tsx` with the form component — use Mantine form components with `label` prop and `LabelWithTooltip` from `@/core/components/label-with-tooltip` for fields that need a tooltip (see Form guidelines above)
4. Add `index.ts` re-exporting form and types
5. Register in `evaluators/index.ts`
diff --git a/ui/empty-css-module.js b/ui/empty-css-module.js
new file mode 100644
index 00000000..69b4152a
--- /dev/null
+++ b/ui/empty-css-module.js
@@ -0,0 +1,3 @@
+// Empty module to replace CSS imports from Jupiter DS
+// (we import the CSS manually in _app.tsx)
+module.exports = {};
diff --git a/ui/next.config.ts b/ui/next.config.ts
index d2478502..59f1ebe3 100644
--- a/ui/next.config.ts
+++ b/ui/next.config.ts
@@ -4,6 +4,30 @@ const nextConfig: NextConfig = {
pageExtensions: ['tsx', 'ts', 'jsx', 'js'],
reactStrictMode: true,
+ // Transpile Jupiter DS package to handle CSS imports
+ transpilePackages: ['@rungalileo/jupiter-ds'],
+
+ // Configure webpack to ignore CSS imports from Jupiter DS
+ // (we import the CSS manually in _app.tsx)
+ webpack: (config) => {
+ const webpack = require('webpack');
+
+ // Replace CSS imports from Jupiter DS with empty module
+ config.plugins.push(
+ new webpack.NormalModuleReplacementPlugin(
+ /^@mantine\/dates\/styles\.css$/,
+ (resource: any) => {
+ // Only replace if imported from Jupiter DS
+ if (resource.context && resource.context.includes('@rungalileo/jupiter-ds')) {
+ resource.request = require.resolve('./empty-css-module.js');
+ }
+ }
+ )
+ );
+
+ return config;
+ },
+
// Optimize for CI/test builds
...(process.env.CI && {
// Disable source maps in CI (faster builds, not needed for tests)
diff --git a/ui/package.json b/ui/package.json
index 09630ff1..78233193 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -17,6 +17,8 @@
"test:integration:report": "playwright show-report"
},
"dependencies": {
+ "@emotion/is-prop-valid": "^1.4.0",
+ "@mantine/charts": "^7.17.8",
"@mantine/code-highlight": "7.17.5",
"@mantine/core": "7.17.5",
"@mantine/dates": "7.17.5",
@@ -26,7 +28,7 @@
"@mantine/modals": "7.17.7",
"@mantine/notifications": "7.17.7",
"@rungalileo/icons": "^0.0.1",
- "@rungalileo/jupiter-ds": "^0.0.2",
+ "@rungalileo/jupiter-ds": "^0.0.7",
"@tabler/icons-react": "3.31",
"@tanstack/react-query": "5.74.4",
"@tanstack/react-query-devtools": "5.72.2",
@@ -38,7 +40,8 @@
"next": "15.4.10",
"openapi-fetch": "0.14.0",
"react": "^19.1.4",
- "react-dom": "^19.1.4"
+ "react-dom": "^19.1.4",
+ "recharts": "2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 12501b16..ee4a2f6d 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -8,6 +8,12 @@ importers:
.:
dependencies:
+ '@emotion/is-prop-valid':
+ specifier: ^1.4.0
+ version: 1.4.0
+ '@mantine/charts':
+ specifier: ^7.17.8
+ version: 7.17.8(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)(recharts@2.15.4(react-dom@19.1.4(react@19.1.4))(react@19.1.4))
'@mantine/code-highlight':
specifier: 7.17.5
version: 7.17.5(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
@@ -36,8 +42,8 @@ importers:
specifier: ^0.0.1
version: 0.0.1(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
'@rungalileo/jupiter-ds':
- specifier: ^0.0.2
- version: 0.0.2(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
+ specifier: ^0.0.7
+ version: 0.0.7(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/dates@7.17.5(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(dayjs@1.11.19)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(@tabler/icons-react@3.31.0(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
'@tabler/icons-react':
specifier: '3.31'
version: 3.31.0(react@19.1.4)
@@ -74,6 +80,9 @@ importers:
react-dom:
specifier: ^19.1.4
version: 19.1.4(react@19.1.4)
+ recharts:
+ specifier: '2'
+ version: 2.15.4(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
devDependencies:
'@eslint/eslintrc':
specifier: ^3.3.3
@@ -207,6 +216,12 @@ packages:
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
+ '@emotion/is-prop-valid@1.4.0':
+ resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==}
+
+ '@emotion/memoize@0.9.0':
+ resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==}
+
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -435,6 +450,15 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@mantine/charts@7.17.8':
+ resolution: {integrity: sha512-lzDa2JM0uD2X32vnUPtERJc4V5nYkrbpOpnC/G3p0Kkwcxh9v59p5uMDxHXoHcv/OsMPALKYWBkY9aGWvD/E4g==}
+ peerDependencies:
+ '@mantine/core': 7.17.8
+ '@mantine/hooks': 7.17.8
+ react: ^18.x || ^19.x
+ react-dom: ^18.x || ^19.x
+ recharts: ^2.13.3
+
'@mantine/code-highlight@7.17.5':
resolution: {integrity: sha512-EzOLEGSbc3Psp/dfpf9yakiWEhcMPZ8qsCuSWvEVJIC40N4VPQ7Pdz1tyN2NSI9Qa31BGzHcqXZcxVtfZ0yG5A==}
peerDependencies:
@@ -596,11 +620,13 @@ packages:
react: '>=18.0.0'
react-dom: '>=18.0.0'
- '@rungalileo/jupiter-ds@0.0.2':
- resolution: {integrity: sha512-OCzGt4iXMYzsll0ADn/4LoN++rsH8Rd2w2jmznJv+BJ7+CN902LTn42CWxh1NqYA1SWn++a57QaZaSuj8MkOjw==}
+ '@rungalileo/jupiter-ds@0.0.7':
+ resolution: {integrity: sha512-edBB7pw62+dKZKtDd3duk1uZ2NQD0iEfl9mQEVjsC+th5ORUpQDKNrgIccC3311e8VBlWYDwBx68FjfwySkwKg==}
peerDependencies:
'@mantine/core': ^7.17.0
+ '@mantine/dates': ^7.17.0
'@mantine/hooks': ^7.17.0
+ '@tabler/icons-react': ^3.0.0
react: '>=18.0.0'
react-dom: '>=18.0.0'
@@ -734,6 +760,33 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
+ '@types/d3-array@3.2.2':
+ resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
+
+ '@types/d3-color@3.1.3':
+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+ '@types/d3-ease@3.0.2':
+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+ '@types/d3-interpolate@3.0.4':
+ resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+
+ '@types/d3-path@3.1.1':
+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+ '@types/d3-scale@4.0.9':
+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
+ '@types/d3-shape@3.1.8':
+ resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
+
+ '@types/d3-time@3.0.4':
+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+ '@types/d3-timer@3.0.2':
+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1091,6 +1144,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ d3-array@3.2.4:
+ resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+ engines: {node: '>=12'}
+
+ d3-color@3.1.0:
+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+ engines: {node: '>=12'}
+
+ d3-ease@3.0.1:
+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+ engines: {node: '>=12'}
+
+ d3-format@3.1.2:
+ resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
+ engines: {node: '>=12'}
+
+ d3-interpolate@3.0.1:
+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+ engines: {node: '>=12'}
+
+ d3-path@3.1.0:
+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+ engines: {node: '>=12'}
+
+ d3-scale@4.0.2:
+ resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+ engines: {node: '>=12'}
+
+ d3-shape@3.2.0:
+ resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+ engines: {node: '>=12'}
+
+ d3-time-format@4.1.0:
+ resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+ engines: {node: '>=12'}
+
+ d3-time@3.1.0:
+ resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -1129,6 +1226,9 @@ packages:
supports-color:
optional: true
+ decimal.js-light@2.5.1:
+ resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1342,9 +1442,16 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ eventemitter3@4.0.7:
+ resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+ fast-equals@5.4.0:
+ resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
+ engines: {node: '>=6.0.0'}
+
fast-glob@3.3.1:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
@@ -1544,6 +1651,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
+ internmap@2.0.3:
+ resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+ engines: {node: '>=12'}
+
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -1802,6 +1913,9 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+ lodash@4.17.23:
+ resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
+
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -2072,6 +2186,9 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+ react-is@18.3.1:
+ resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
+
react-number-format@5.4.4:
resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==}
peerDependencies:
@@ -2098,6 +2215,12 @@ packages:
'@types/react':
optional: true
+ react-smooth@4.0.4:
+ resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -2124,6 +2247,16 @@ packages:
resolution: {integrity: sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw==}
engines: {node: '>=0.10.0'}
+ recharts-scale@0.4.5:
+ resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
+
+ recharts@2.15.4:
+ resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
+ engines: {node: '>=14'}
+ peerDependencies:
+ react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -2306,6 +2439,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
+ tiny-invariant@1.3.3:
+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -2431,6 +2567,9 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ victory-vendor@36.9.2:
+ resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
+
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -2601,6 +2740,12 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emotion/is-prop-valid@1.4.0':
+ dependencies:
+ '@emotion/memoize': 0.9.0
+
+ '@emotion/memoize@0.9.0': {}
+
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))':
dependencies:
eslint: 9.39.2(jiti@2.6.1)
@@ -2799,6 +2944,14 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@mantine/charts@7.17.8(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)(recharts@2.15.4(react-dom@19.1.4(react@19.1.4))(react@19.1.4))':
+ dependencies:
+ '@mantine/core': 7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
+ '@mantine/hooks': 7.17.5(react@19.1.4)
+ react: 19.1.4
+ react-dom: 19.1.4(react@19.1.4)
+ recharts: 2.15.4(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
+
'@mantine/code-highlight@7.17.5(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)':
dependencies:
'@mantine/core': 7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
@@ -2955,10 +3108,12 @@ snapshots:
react: 19.1.4
react-dom: 19.1.4(react@19.1.4)
- '@rungalileo/jupiter-ds@0.0.2(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)':
+ '@rungalileo/jupiter-ds@0.0.7(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/dates@7.17.5(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(dayjs@1.11.19)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(@tabler/icons-react@3.31.0(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)':
dependencies:
'@mantine/core': 7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
+ '@mantine/dates': 7.17.5(@mantine/core@7.17.5(@mantine/hooks@7.17.5(react@19.1.4))(@types/react@19.2.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(@mantine/hooks@7.17.5(react@19.1.4))(dayjs@1.11.19)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
'@mantine/hooks': 7.17.5(react@19.1.4)
+ '@tabler/icons-react': 3.31.0(react@19.1.4)
clsx: 2.1.1
react: 19.1.4
react-dom: 19.1.4(react@19.1.4)
@@ -3071,6 +3226,30 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@types/d3-array@3.2.2': {}
+
+ '@types/d3-color@3.1.3': {}
+
+ '@types/d3-ease@3.0.2': {}
+
+ '@types/d3-interpolate@3.0.4':
+ dependencies:
+ '@types/d3-color': 3.1.3
+
+ '@types/d3-path@3.1.1': {}
+
+ '@types/d3-scale@4.0.9':
+ dependencies:
+ '@types/d3-time': 3.0.4
+
+ '@types/d3-shape@3.1.8':
+ dependencies:
+ '@types/d3-path': 3.1.1
+
+ '@types/d3-time@3.0.4': {}
+
+ '@types/d3-timer@3.0.2': {}
+
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
@@ -3440,6 +3619,44 @@ snapshots:
csstype@3.2.3: {}
+ d3-array@3.2.4:
+ dependencies:
+ internmap: 2.0.3
+
+ d3-color@3.1.0: {}
+
+ d3-ease@3.0.1: {}
+
+ d3-format@3.1.2: {}
+
+ d3-interpolate@3.0.1:
+ dependencies:
+ d3-color: 3.1.0
+
+ d3-path@3.1.0: {}
+
+ d3-scale@4.0.2:
+ dependencies:
+ d3-array: 3.2.4
+ d3-format: 3.1.2
+ d3-interpolate: 3.0.1
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+
+ d3-shape@3.2.0:
+ dependencies:
+ d3-path: 3.1.0
+
+ d3-time-format@4.1.0:
+ dependencies:
+ d3-time: 3.1.0
+
+ d3-time@3.1.0:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-timer@3.0.1: {}
+
damerau-levenshtein@1.0.8: {}
data-view-buffer@1.0.2:
@@ -3474,6 +3691,8 @@ snapshots:
optionalDependencies:
supports-color: 10.2.2
+ decimal.js-light@2.5.1: {}
+
deep-is@0.1.4: {}
define-data-property@1.1.4:
@@ -3836,8 +4055,12 @@ snapshots:
esutils@2.0.3: {}
+ eventemitter3@4.0.7: {}
+
fast-deep-equal@3.1.3: {}
+ fast-equals@5.4.0: {}
+
fast-glob@3.3.1:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -4029,6 +4252,8 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
+ internmap@2.0.3: {}
+
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@@ -4265,6 +4490,8 @@ snapshots:
lodash.merge@4.6.2: {}
+ lodash@4.17.23: {}
+
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -4530,6 +4757,8 @@ snapshots:
react-is@16.13.1: {}
+ react-is@18.3.1: {}
+
react-number-format@5.4.4(react-dom@19.1.4(react@19.1.4))(react@19.1.4):
dependencies:
react: 19.1.4
@@ -4554,6 +4783,14 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
+ react-smooth@4.0.4(react-dom@19.1.4(react@19.1.4))(react@19.1.4):
+ dependencies:
+ fast-equals: 5.4.0
+ prop-types: 15.8.1
+ react: 19.1.4
+ react-dom: 19.1.4(react@19.1.4)
+ react-transition-group: 4.4.5(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
+
react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.1.4):
dependencies:
get-nonce: 1.0.1
@@ -4582,6 +4819,23 @@ snapshots:
react@19.1.4: {}
+ recharts-scale@0.4.5:
+ dependencies:
+ decimal.js-light: 2.5.1
+
+ recharts@2.15.4(react-dom@19.1.4(react@19.1.4))(react@19.1.4):
+ dependencies:
+ clsx: 2.1.1
+ eventemitter3: 4.0.7
+ lodash: 4.17.23
+ react: 19.1.4
+ react-dom: 19.1.4(react@19.1.4)
+ react-is: 18.3.1
+ react-smooth: 4.0.4(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
+ recharts-scale: 0.4.5
+ tiny-invariant: 1.3.3
+ victory-vendor: 36.9.2
+
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -4827,6 +5081,8 @@ snapshots:
tapable@2.3.0: {}
+ tiny-invariant@1.3.3: {}
+
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
@@ -4980,6 +5236,23 @@ snapshots:
util-deprecate@1.0.2: {}
+ victory-vendor@36.9.2:
+ dependencies:
+ '@types/d3-array': 3.2.2
+ '@types/d3-ease': 3.0.2
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-scale': 4.0.9
+ '@types/d3-shape': 3.1.8
+ '@types/d3-time': 3.0.4
+ '@types/d3-timer': 3.0.2
+ d3-array: 3.2.4
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-scale: 4.0.2
+ d3-shape: 3.2.0
+ d3-time: 3.1.0
+ d3-timer: 3.0.1
+
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
diff --git a/ui/src/core/api/client.ts b/ui/src/core/api/client.ts
index 850330ae..504bc774 100644
--- a/ui/src/core/api/client.ts
+++ b/ui/src/core/api/client.ts
@@ -116,8 +116,9 @@ export const api = {
observability: {
getStats: (params: {
agent_uuid: string;
- time_range?: "1m" | "5m" | "15m" | "1h" | "24h" | "7d";
+ time_range?: "1m" | "5m" | "15m" | "1h" | "24h" | "7d" | "30d" | "180d" | "365d";
control_id?: number | null;
+ include_timeseries?: boolean;
}) =>
apiClient.GET("/api/v1/observability/stats", {
params: { query: params },
diff --git a/ui/src/core/api/generated/api-types.ts b/ui/src/core/api/generated/api-types.ts
index 010b5158..ccd37b96 100644
--- a/ui/src/core/api/generated/api-types.ts
+++ b/ui/src/core/api/generated/api-types.ts
@@ -21,6 +21,7 @@ export interface paths {
* Args:
* cursor: Optional cursor for pagination (UUID of last agent from previous page)
* limit: Pagination limit (default 20, max 100)
+ * name: Optional name filter (case-insensitive partial match)
* db: Database session (injected)
*
* Returns:
@@ -783,19 +784,19 @@ export interface paths {
};
/**
* Get Stats
- * @description Get aggregated control execution statistics.
+ * @description Get agent-level aggregated statistics.
*
- * Statistics are computed at query time from raw events. This is fast
- * enough for most use cases (sub-200ms for 1-hour windows).
+ * Returns totals across all controls plus per-control breakdown.
+ * Use /stats/controls/{control_id} for single control stats.
*
* Args:
* agent_uuid: Agent to get stats for
- * time_range: Time range (1m, 5m, 15m, 1h, 24h, 7d)
- * control_id: Optional filter by specific control
+ * time_range: Time range (1m, 5m, 15m, 1h, 24h, 7d, 30d, 180d, 365d)
+ * include_timeseries: Include time-series data points for trend visualization
* store: Event store (injected)
*
* Returns:
- * StatsResponse with per-control statistics
+ * StatsResponse with agent-level totals and per-control breakdown
*/
get: operations["get_stats_api_v1_observability_stats_get"];
put?: never;
@@ -806,6 +807,38 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/observability/stats/controls/{control_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Control Stats
+ * @description Get statistics for a single control.
+ *
+ * Returns stats for the specified control with optional time-series.
+ *
+ * Args:
+ * control_id: Control ID to get stats for
+ * agent_uuid: Agent to get stats for
+ * time_range: Time range (1m, 5m, 15m, 1h, 24h, 7d, 30d, 180d, 365d)
+ * include_timeseries: Include time-series data points for trend visualization
+ * store: Event store (injected)
+ *
+ * Returns:
+ * ControlStatsResponse with control stats and optional timeseries
+ */
+ get: operations["get_control_stats_api_v1_observability_stats_controls__control_id__get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/observability/status": {
parameters: {
query?: never;
@@ -922,6 +955,22 @@ export interface components {
*/
controls: components["schemas"]["Control"][];
};
+ /**
+ * AgentRef
+ * @description Reference to an agent (for listing which agents use a control).
+ */
+ AgentRef: {
+ /**
+ * Agent Id
+ * @description Agent UUID
+ */
+ agent_id: string;
+ /**
+ * Agent Name
+ * @description Agent name
+ */
+ agent_name: string;
+ };
/**
* AgentSummary
* @description Summary of an agent for list responses.
@@ -1478,6 +1527,44 @@ export interface components {
*/
avg_duration_ms?: number | null;
};
+ /**
+ * ControlStatsResponse
+ * @description Response model for control-level statistics.
+ *
+ * Contains stats for a single control (with optional timeseries).
+ *
+ * Attributes:
+ * agent_uuid: Agent UUID
+ * time_range: Time range used
+ * control_id: Control ID
+ * control_name: Control name
+ * stats: Control statistics (includes timeseries when requested)
+ */
+ ControlStatsResponse: {
+ /**
+ * Agent Uuid
+ * Format: uuid
+ * @description Agent UUID
+ */
+ agent_uuid: string;
+ /**
+ * Time Range
+ * @description Time range used
+ */
+ time_range: string;
+ /**
+ * Control Id
+ * @description Control ID
+ */
+ control_id: number;
+ /**
+ * Control Name
+ * @description Control name
+ */
+ control_name: string;
+ /** @description Control statistics */
+ stats: components["schemas"]["StatsTotals"];
+ };
/**
* ControlSummary
* @description Summary of a control for list responses.
@@ -1524,6 +1611,8 @@ export interface components {
* @description Control tags
*/
tags?: string[];
+ /** @description Agent using this control */
+ used_by_agent?: components["schemas"]["AgentRef"] | null;
};
/** CreateControlRequest */
CreateControlRequest: {
@@ -2422,22 +2511,15 @@ export interface components {
};
/**
* StatsResponse
- * @description Response model for aggregated statistics.
+ * @description Response model for agent-level aggregated statistics.
*
- * Invariant: total_executions = total_matches + total_non_matches + total_errors
- *
- * Matches have actions (allow, deny, warn, log) tracked in action_counts.
- * sum(action_counts.values()) == total_matches
+ * Contains agent-level totals (with optional timeseries) and per-control breakdown.
*
* Attributes:
* agent_uuid: Agent UUID
* time_range: Time range used
- * stats: List of per-control statistics
- * total_executions: Total executions across all controls
- * total_matches: Total matches across all controls (evaluator matched)
- * total_non_matches: Total non-matches across all controls (evaluator didn't match)
- * total_errors: Total errors across all controls (evaluation failed)
- * action_counts: Breakdown of actions for matched executions
+ * totals: Agent-level aggregate statistics (includes timeseries)
+ * controls: Per-control breakdown for discovery and detail
*/
StatsResponse: {
/**
@@ -2451,34 +2533,55 @@ export interface components {
* @description Time range used
*/
time_range: string;
+ /** @description Agent-level aggregate statistics */
+ totals: components["schemas"]["StatsTotals"];
/**
- * Stats
- * @description Per-control statistics
+ * Controls
+ * @description Per-control breakdown
*/
- stats: components["schemas"]["ControlStats"][];
+ controls: components["schemas"]["ControlStats"][];
+ };
+ /**
+ * StatsTotals
+ * @description Agent-level aggregate statistics.
+ *
+ * Invariant: execution_count = match_count + non_match_count + error_count
+ *
+ * Matches have actions (allow, deny, warn, log) tracked in action_counts.
+ * sum(action_counts.values()) == match_count
+ *
+ * Attributes:
+ * execution_count: Total executions across all controls
+ * match_count: Total matches across all controls (evaluator matched)
+ * non_match_count: Total non-matches across all controls (evaluator didn't match)
+ * error_count: Total errors across all controls (evaluation failed)
+ * action_counts: Breakdown of actions for matched executions
+ * timeseries: Time-series data points (only when include_timeseries=true)
+ */
+ StatsTotals: {
/**
- * Total Executions
- * @description Total executions across all controls
+ * Execution Count
+ * @description Total executions
*/
- total_executions: number;
+ execution_count: number;
/**
- * Total Matches
- * @description Total matches across all controls
+ * Match Count
+ * @description Total matches
* @default 0
*/
- total_matches: number;
+ match_count: number;
/**
- * Total Non Matches
- * @description Total non-matches across all controls
+ * Non Match Count
+ * @description Total non-matches
* @default 0
*/
- total_non_matches: number;
+ non_match_count: number;
/**
- * Total Errors
- * @description Total errors across all controls
+ * Error Count
+ * @description Total errors
* @default 0
*/
- total_errors: number;
+ error_count: number;
/**
* Action Counts
* @description Action breakdown for matches: {allow, deny, warn, log}
@@ -2486,6 +2589,11 @@ export interface components {
action_counts?: {
[key: string]: number;
};
+ /**
+ * Timeseries
+ * @description Time-series data points (only when include_timeseries=true)
+ */
+ timeseries?: components["schemas"]["TimeseriesBucket"][] | null;
};
/**
* Step
@@ -2604,6 +2712,67 @@ export interface components {
[key: string]: unknown;
} | null;
};
+ /**
+ * TimeseriesBucket
+ * @description Single data point in a time-series.
+ *
+ * Represents aggregated metrics for a single time bucket.
+ *
+ * Attributes:
+ * timestamp: Start time of the bucket (UTC, always timezone-aware)
+ * execution_count: Total executions in this bucket
+ * match_count: Number of matches in this bucket
+ * non_match_count: Number of non-matches in this bucket
+ * error_count: Number of errors in this bucket
+ * action_counts: Breakdown of actions for matched executions
+ * avg_confidence: Average confidence score (None if no executions)
+ * avg_duration_ms: Average execution duration in milliseconds (None if no data)
+ */
+ TimeseriesBucket: {
+ /**
+ * Timestamp
+ * Format: date-time
+ * @description Start time of the bucket (UTC)
+ */
+ timestamp: string;
+ /**
+ * Execution Count
+ * @description Total executions in bucket
+ */
+ execution_count: number;
+ /**
+ * Match Count
+ * @description Matches in bucket
+ */
+ match_count: number;
+ /**
+ * Non Match Count
+ * @description Non-matches in bucket
+ */
+ non_match_count: number;
+ /**
+ * Error Count
+ * @description Errors in bucket
+ */
+ error_count: number;
+ /**
+ * Action Counts
+ * @description Action breakdown: {allow, deny, warn, log}
+ */
+ action_counts?: {
+ [key: string]: number;
+ };
+ /**
+ * Avg Confidence
+ * @description Average confidence score
+ */
+ avg_confidence?: number | null;
+ /**
+ * Avg Duration Ms
+ * @description Average duration (ms)
+ */
+ avg_duration_ms?: number | null;
+ };
/**
* UpdateEvaluatorConfigRequest
* @description Request to replace an evaluator config template.
@@ -2655,6 +2824,7 @@ export interface operations {
query?: {
cursor?: string | null;
limit?: number;
+ name?: string | null;
};
header?: never;
path?: never;
@@ -3639,8 +3809,8 @@ export interface operations {
parameters: {
query: {
agent_uuid: string;
- time_range?: "1m" | "5m" | "15m" | "1h" | "24h" | "7d";
- control_id?: number | null;
+ time_range?: "1m" | "5m" | "15m" | "1h" | "24h" | "7d" | "30d" | "180d" | "365d";
+ include_timeseries?: boolean;
};
header?: never;
path?: never;
@@ -3668,6 +3838,41 @@ export interface operations {
};
};
};
+ get_control_stats_api_v1_observability_stats_controls__control_id__get: {
+ parameters: {
+ query: {
+ agent_uuid: string;
+ time_range?: "1m" | "5m" | "15m" | "1h" | "24h" | "7d" | "30d" | "180d" | "365d";
+ include_timeseries?: boolean;
+ };
+ header?: never;
+ path: {
+ control_id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ControlStatsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_status_api_v1_observability_status_get: {
parameters: {
query?: never;
diff --git a/ui/src/core/components/label-with-tooltip.tsx b/ui/src/core/components/label-with-tooltip.tsx
new file mode 100644
index 00000000..a7f8cd8d
--- /dev/null
+++ b/ui/src/core/components/label-with-tooltip.tsx
@@ -0,0 +1,30 @@
+import { Group, Text, Tooltip } from "@mantine/core";
+import { IconInfoCircle } from "@tabler/icons-react";
+
+export interface LabelWithTooltipProps {
+ label: string;
+ tooltip: string;
+}
+
+/**
+ * Label with (i) icon that shows tooltip on hover.
+ * Use as the `label` prop of Mantine form inputs (Select, TextInput, etc.)
+ * so the input always uses the label prop and optional tooltip is consistent.
+ */
+export function LabelWithTooltip({ label, tooltip }: LabelWithTooltipProps) {
+ return (
+
+
+ {label}
+
+
+
+
+
+ );
+}
+
+/** Pass to labelProps on inputs so the label renders inline. */
+export const labelPropsInline = {
+ style: { display: "inline-block" as const },
+};
diff --git a/ui/src/core/evaluators/json/form.tsx b/ui/src/core/evaluators/json/form.tsx
index ec2fbf31..ae7c9e92 100644
--- a/ui/src/core/evaluators/json/form.tsx
+++ b/ui/src/core/evaluators/json/form.tsx
@@ -1,180 +1,177 @@
import {
- Box,
Checkbox,
Divider,
Select,
Stack,
- Text,
Textarea,
TextInput,
} from "@mantine/core";
+import {
+ labelPropsInline,
+ LabelWithTooltip,
+} from "@/core/components/label-with-tooltip";
+
import type { EvaluatorFormProps } from "../types";
import type { JsonFormValues } from "./types";
export const JsonForm = ({ form }: EvaluatorFormProps) => {
return (
-
-
-
-
-
- JSON Schema
-
-
-
- JSON Schema specification (Draft 7 or later) for structure validation
-
-
-
-
-
-
-
- Required fields
-
-
-
- Comma-separated list of required field paths (supports dot notation)
-
-
-
-
-
- Field types
-
-
-
- JSON mapping of field paths to expected types (string, number,
- integer, boolean, array, object, null)
-
-
-
-
-
- Field constraints
-
-
-
- JSON mapping of field paths to constraints (min/max for numbers, enum
- for allowed values, min_length/max_length for strings)
-
-
-
-
-
- Field patterns
-
-
-
- JSON mapping of field paths to regex patterns (RE2 syntax). Can be
- string or object with pattern and flags
-
-
-
-
-
-
-
-
- Allow fields not defined in field_types (if unchecked, extra fields
- cause validation failure)
-
-
-
-
-
-
- If unchecked, null values in required fields are treated as missing
-
-
-
-
-
- Pattern match logic
-
-
-
-
-
-
- If unchecked, enum matching is case-insensitive
-
-
-
-
-
-
-
-
- If checked, invalid JSON is treated as non-match (pass through). If
- unchecked, invalid JSON triggers the control
-
-
+
+
+
+
+ }
+ labelProps={labelPropsInline}
+ placeholder='{"type": "object", "properties": {...}}'
+ minRows={4}
+ maxRows={10}
+ autosize
+ size="sm"
+ styles={{ input: { fontFamily: "monospace" } }}
+ {...form.getInputProps("json_schema")}
+ />
+
+
+
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="field1, nested.field, data.items"
+ size="sm"
+ {...form.getInputProps("required_fields")}
+ />
+
+
+ }
+ labelProps={labelPropsInline}
+ placeholder='{"name": "string", "age": "integer", "active": "boolean"}'
+ minRows={3}
+ maxRows={8}
+ autosize
+ size="sm"
+ styles={{ input: { fontFamily: "monospace" } }}
+ {...form.getInputProps("field_types")}
+ />
+
+
+ }
+ labelProps={labelPropsInline}
+ placeholder='{"price": {"min": 0, "max": 10000}, "status": {"enum": ["active", "inactive"]}}'
+ minRows={3}
+ maxRows={8}
+ autosize
+ size="sm"
+ styles={{ input: { fontFamily: "monospace" } }}
+ {...form.getInputProps("field_constraints")}
+ />
+
+
+ }
+ labelProps={labelPropsInline}
+ placeholder='{"email": "^[a-z]+@[a-z]+\\.[a-z]+$", "phone": {"pattern": "^\\d{10}$", "flags": ["IGNORECASE"]}}'
+ minRows={3}
+ maxRows={8}
+ autosize
+ size="sm"
+ styles={{ input: { fontFamily: "monospace" } }}
+ {...form.getInputProps("field_patterns")}
+ />
+
+
+
+
+ }
+ size="sm"
+ {...form.getInputProps("allow_extra_fields", { type: "checkbox" })}
+ />
+
+
+ }
+ size="sm"
+ {...form.getInputProps("allow_null_required", { type: "checkbox" })}
+ />
+
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "all", label: "All (all patterns must match)" },
+ { value: "any", label: "Any (at least one pattern must match)" },
+ ]}
+ size="sm"
+ {...form.getInputProps("pattern_match_logic")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "pattern_match_logic",
+ (value as JsonFormValues["pattern_match_logic"]) || "all"
+ )
+ }
+ />
+
+
+ }
+ size="sm"
+ {...form.getInputProps("case_sensitive_enums", { type: "checkbox" })}
+ />
+
+
+
+
+ }
+ size="sm"
+ {...form.getInputProps("allow_invalid_json", { type: "checkbox" })}
+ />
);
};
diff --git a/ui/src/core/evaluators/list/form.tsx b/ui/src/core/evaluators/list/form.tsx
index 0509157c..36d458f2 100644
--- a/ui/src/core/evaluators/list/form.tsx
+++ b/ui/src/core/evaluators/list/form.tsx
@@ -1,94 +1,100 @@
-import { Box, Checkbox, Select, Stack, Text, Textarea } from "@mantine/core";
+import { Checkbox, Select, Stack, Textarea } from "@mantine/core";
+
+import {
+ labelPropsInline,
+ LabelWithTooltip,
+} from "@/core/components/label-with-tooltip";
import type { EvaluatorFormProps } from "../types";
import type { ListFormValues } from "./types";
export const ListForm = ({ form }: EvaluatorFormProps) => {
return (
-
-
-
- Values
-
-
-
- List of values to match against (one per line)
-
-
+
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="Enter values (one per line)"
+ minRows={4}
+ maxRows={8}
+ autosize
+ size="sm"
+ {...form.getInputProps("values")}
+ />
-
-
- Logic
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "any", label: "Any (match if any value matches)" },
+ { value: "all", label: "All (match if all values match)" },
+ ]}
+ size="sm"
+ {...form.getInputProps("logic")}
+ onChange={(value) =>
+ form.setFieldValue("logic", (value as ListFormValues["logic"]) || "any")
+ }
+ />
-
-
- Match on
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "match", label: "Match (trigger when matched)" },
+ { value: "no_match", label: "No match (trigger when not matched)" },
+ ]}
+ size="sm"
+ {...form.getInputProps("match_on")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "match_on",
+ (value as ListFormValues["match_on"]) || "match"
+ )
+ }
+ />
-
-
- Match mode
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "exact", label: "Exact (full string match)" },
+ { value: "contains", label: "Contains (substring match)" },
+ ]}
+ size="sm"
+ {...form.getInputProps("match_mode")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "match_mode",
+ (value as ListFormValues["match_mode"]) || "exact"
+ )
+ }
+ />
-
-
-
+
);
};
diff --git a/ui/src/core/evaluators/luna2/form.tsx b/ui/src/core/evaluators/luna2/form.tsx
index fd5d9568..96d9ad3b 100644
--- a/ui/src/core/evaluators/luna2/form.tsx
+++ b/ui/src/core/evaluators/luna2/form.tsx
@@ -1,14 +1,17 @@
import {
- Box,
Divider,
NumberInput,
Select,
Stack,
- Text,
Textarea,
TextInput,
} from "@mantine/core";
+import {
+ labelPropsInline,
+ LabelWithTooltip,
+} from "@/core/components/label-with-tooltip";
+
import type { EvaluatorFormProps } from "../types";
import type { Luna2FormValues } from "./types";
@@ -16,244 +19,235 @@ export const Luna2Form = ({ form }: EvaluatorFormProps) => {
const isLocalStage = form.values.stage_type === "local";
return (
-
-
-
- Stage type
-
-
+
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "local", label: "Local (define rules at runtime)" },
+ {
+ value: "central",
+ label: "Central (reference pre-defined stages)",
+ },
+ ]}
+ size="sm"
+ {...form.getInputProps("stage_type")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "stage_type",
+ (value as Luna2FormValues["stage_type"]) || "local"
+ )
+ }
+ />
{isLocalStage ? (
<>
-
+
-
-
- Metric
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "input_toxicity", label: "Input Toxicity" },
+ { value: "output_toxicity", label: "Output Toxicity" },
+ { value: "input_sexism", label: "Input Sexism" },
+ { value: "output_sexism", label: "Output Sexism" },
+ { value: "prompt_injection", label: "Prompt Injection" },
+ { value: "pii_detection", label: "PII Detection" },
+ { value: "hallucination", label: "Hallucination" },
+ { value: "tone", label: "Tone" },
+ ]}
+ size="sm"
+ placeholder="Select a metric"
+ {...form.getInputProps("metric")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "metric",
+ (value as Luna2FormValues["metric"]) || ""
+ )
+ }
+ />
-
-
- Operator
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "gt", label: "> (greater than)" },
+ { value: "gte", label: ">= (greater than or equal)" },
+ { value: "lt", label: "< (less than)" },
+ { value: "lte", label: "<= (less than or equal)" },
+ { value: "eq", label: "= (equal)" },
+ { value: "contains", label: "Contains" },
+ { value: "any", label: "Any" },
+ ]}
+ size="sm"
+ placeholder="Select an operator"
+ {...form.getInputProps("operator")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "operator",
+ (value as Luna2FormValues["operator"]) || ""
+ )
+ }
+ />
-
-
- Target value
-
-
-
- Threshold value for comparison. Can be a number (e.g., 0.5) or
- string depending on metric (required for local stage)
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="0.5"
+ size="sm"
+ {...form.getInputProps("target_value")}
+ />
>
) : (
<>
-
+
-
-
- Stage name
-
-
-
- Name of the pre-defined stage in Galileo (required for central
- stage)
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="production-guard"
+ size="sm"
+ {...form.getInputProps("stage_name")}
+ />
-
-
- Stage version
-
-
-
- Pin to a specific stage version (optional, defaults to latest)
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="Leave empty for latest"
+ min={1}
+ size="sm"
+ {...form.getInputProps("stage_version")}
+ />
>
)}
-
+
-
-
- Galileo project
-
-
-
- Galileo project name for logging/organization
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="my-project"
+ size="sm"
+ {...form.getInputProps("galileo_project")}
+ />
-
-
- Payload field
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "", label: "Auto-detect" },
+ { value: "input", label: "Input" },
+ { value: "output", label: "Output" },
+ ]}
+ size="sm"
+ clearable
+ {...form.getInputProps("payload_field")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "payload_field",
+ (value as Luna2FormValues["payload_field"]) || ""
+ )
+ }
+ />
-
-
- Timeout (ms)
-
-
-
- Request timeout in milliseconds (1-60 seconds)
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="10000"
+ min={1000}
+ max={60000}
+ step={1000}
+ size="sm"
+ {...form.getInputProps("timeout_ms")}
+ />
-
-
- On error
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ {
+ value: "allow",
+ label: "Allow (fail open - pass through on error)",
+ },
+ { value: "deny", label: "Deny (fail closed - block on error)" },
+ ]}
+ size="sm"
+ {...form.getInputProps("on_error")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "on_error",
+ (value as Luna2FormValues["on_error"]) || "allow"
+ )
+ }
+ />
-
-
- Metadata
-
-
-
- Additional metadata to send with the request (JSON format)
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder='{"key": "value"}'
+ minRows={2}
+ maxRows={6}
+ autosize
+ size="sm"
+ styles={{ input: { fontFamily: "monospace" } }}
+ {...form.getInputProps("metadata")}
+ />
);
};
diff --git a/ui/src/core/evaluators/regex/form.tsx b/ui/src/core/evaluators/regex/form.tsx
index cc16a4d3..1a0bb58a 100644
--- a/ui/src/core/evaluators/regex/form.tsx
+++ b/ui/src/core/evaluators/regex/form.tsx
@@ -1,24 +1,29 @@
-import { Box, Stack, Text, TextInput } from "@mantine/core";
+import { Stack, TextInput } from "@mantine/core";
+
+import {
+ labelPropsInline,
+ LabelWithTooltip,
+} from "@/core/components/label-with-tooltip";
import type { EvaluatorFormProps } from "../types";
import type { RegexFormValues } from "./types";
export const RegexForm = ({ form }: EvaluatorFormProps) => {
return (
-
-
-
- Pattern
-
-
-
- Regular expression pattern to match against
-
-
+
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="Enter regex pattern (e.g., ^.*$)"
+ size="sm"
+ styles={{ input: { fontFamily: "monospace" } }}
+ {...form.getInputProps("pattern")}
+ />
);
};
diff --git a/ui/src/core/evaluators/sql/form.tsx b/ui/src/core/evaluators/sql/form.tsx
index a94dcef1..991a2ae1 100644
--- a/ui/src/core/evaluators/sql/form.tsx
+++ b/ui/src/core/evaluators/sql/form.tsx
@@ -1,381 +1,381 @@
import {
- Box,
Checkbox,
Divider,
NumberInput,
Select,
Stack,
- Text,
TextInput,
} from "@mantine/core";
+import {
+ labelPropsInline,
+ LabelWithTooltip,
+} from "@/core/components/label-with-tooltip";
+
import type { EvaluatorFormProps } from "../types";
import type { SqlFormValues } from "./types";
export const SqlForm = ({ form }: EvaluatorFormProps) => {
return (
-
- {/* SQL Dialect */}
-
-
- SQL dialect
-
-
+
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "postgres", label: "PostgreSQL" },
+ { value: "mysql", label: "MySQL" },
+ { value: "tsql", label: "T-SQL (SQL Server)" },
+ { value: "oracle", label: "Oracle" },
+ { value: "sqlite", label: "SQLite" },
+ ]}
+ size="sm"
+ {...form.getInputProps("dialect")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "dialect",
+ (value as SqlFormValues["dialect"]) || "postgres"
+ )
+ }
+ />
-
+
-
- {
- form.setFieldValue("allow_multi_statements", event.target.checked);
- // Clear max_statements when disabling multi-statements
- if (!event.target.checked) {
- form.setFieldValue("max_statements", "");
- }
- }}
- />
-
- Allow queries like "SELECT x; DROP TABLE y"
-
-
+
+ }
+ size="sm"
+ {...form.getInputProps("allow_multi_statements", {
+ type: "checkbox",
+ })}
+ onChange={(event) => {
+ form.setFieldValue("allow_multi_statements", event.target.checked);
+ if (!event.target.checked) {
+ form.setFieldValue("max_statements", "");
+ }
+ }}
+ />
-
-
- Max statements
-
-
-
- Maximum statements allowed (only when multi-statements enabled)
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="Leave empty for no limit"
+ min={1}
+ max={100}
+ size="sm"
+ disabled={!form.values.allow_multi_statements}
+ {...form.getInputProps("max_statements")}
+ />
-
+
-
-
- Blocked operations
-
-
-
- Comma-separated SQL operations to block (blocklist mode)
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="DROP, DELETE, TRUNCATE"
+ size="sm"
+ disabled={!!form.values.allowed_operations}
+ {...form.getInputProps("blocked_operations")}
+ />
-
-
- Allowed operations
-
-
-
- Comma-separated SQL operations to allow (allowlist mode)
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="SELECT, INSERT, UPDATE"
+ size="sm"
+ disabled={!!form.values.blocked_operations}
+ {...form.getInputProps("allowed_operations")}
+ />
-
-
-
- Block CREATE, ALTER, DROP, TRUNCATE, RENAME, COMMENT
-
-
+
+ }
+ size="sm"
+ {...form.getInputProps("block_ddl", { type: "checkbox" })}
+ />
-
-
-
- Block GRANT, REVOKE statements
-
-
+
+ }
+ size="sm"
+ {...form.getInputProps("block_dcl", { type: "checkbox" })}
+ />
-
+
-
-
- Allowed tables
-
-
-
- Comma-separated tables to allow (allowlist mode)
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="users, orders, products"
+ size="sm"
+ disabled={!!form.values.blocked_tables}
+ {...form.getInputProps("allowed_tables")}
+ />
-
-
- Blocked tables
-
-
-
- Comma-separated tables to block (blocklist mode)
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="admin_users, secrets"
+ size="sm"
+ disabled={!!form.values.allowed_tables}
+ {...form.getInputProps("blocked_tables")}
+ />
-
-
- Allowed schemas
-
-
-
- Comma-separated schemas to allow (allowlist mode)
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="public, analytics"
+ size="sm"
+ disabled={!!form.values.blocked_schemas}
+ {...form.getInputProps("allowed_schemas")}
+ />
-
-
- Blocked schemas
-
-
-
- Comma-separated schemas to block (blocklist mode)
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="system, admin, internal"
+ size="sm"
+ disabled={!!form.values.allowed_schemas}
+ {...form.getInputProps("blocked_schemas")}
+ />
-
+
-
-
- Required columns
-
-
-
- Columns that must be present (e.g., for multi-tenant security)
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="tenant_id, user_id"
+ size="sm"
+ {...form.getInputProps("required_columns")}
+ />
-
-
- Column presence logic
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "any", label: "Any (at least one required column)" },
+ { value: "all", label: "All (all required columns)" },
+ ]}
+ size="sm"
+ {...form.getInputProps("column_presence_logic")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "column_presence_logic",
+ (value as SqlFormValues["column_presence_logic"]) || "any"
+ )
+ }
+ />
-
-
- Column context
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "", label: "Anywhere in query" },
+ { value: "where", label: "WHERE clause only" },
+ { value: "select", label: "SELECT clause only" },
+ ]}
+ size="sm"
+ clearable
+ {...form.getInputProps("column_context")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "column_context",
+ (value as SqlFormValues["column_context"]) || ""
+ )
+ }
+ />
-
-
- Column context scope
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "all", label: "All (including subqueries)" },
+ {
+ value: "top_level",
+ label: "Top level only (recommended for RLS)",
+ },
+ ]}
+ size="sm"
+ {...form.getInputProps("column_context_scope")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "column_context_scope",
+ (value as SqlFormValues["column_context_scope"]) || "all"
+ )
+ }
+ />
-
+
-
-
-
- Require SELECT queries to have a LIMIT clause
-
-
+
+ }
+ size="sm"
+ {...form.getInputProps("require_limit", { type: "checkbox" })}
+ />
-
-
- Max LIMIT value
-
-
-
- Maximum allowed LIMIT value
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="1000"
+ min={1}
+ size="sm"
+ {...form.getInputProps("max_limit")}
+ />
-
-
- Max result window (LIMIT + OFFSET)
-
-
-
- Maximum value of (LIMIT + OFFSET) for pagination control
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="10000"
+ min={1}
+ size="sm"
+ {...form.getInputProps("max_result_window")}
+ />
-
+
-
-
- Max subquery depth
-
-
-
- Maximum nesting depth for subqueries
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="5"
+ min={1}
+ max={100}
+ size="sm"
+ {...form.getInputProps("max_subquery_depth")}
+ />
-
-
- Max JOINs
-
-
-
- Maximum number of JOIN operations
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="10"
+ min={1}
+ max={100}
+ size="sm"
+ {...form.getInputProps("max_joins")}
+ />
-
-
- Max UNION/INTERSECT/EXCEPT
-
-
-
- Maximum number of set operations
-
-
+
+ }
+ labelProps={labelPropsInline}
+ placeholder="10"
+ min={1}
+ max={100}
+ size="sm"
+ {...form.getInputProps("max_union_count")}
+ />
-
+
-
-
-
- Case-sensitive table, column, and schema matching
-
-
+
+ }
+ size="sm"
+ {...form.getInputProps("case_sensitive", { type: "checkbox" })}
+ />
);
};
diff --git a/ui/src/core/hooks/query-hooks/use-agent-stats.ts b/ui/src/core/hooks/query-hooks/use-agent-monitor.ts
similarity index 63%
rename from ui/src/core/hooks/query-hooks/use-agent-stats.ts
rename to ui/src/core/hooks/query-hooks/use-agent-monitor.ts
index bfed9fe6..8e2db6d3 100644
--- a/ui/src/core/hooks/query-hooks/use-agent-stats.ts
+++ b/ui/src/core/hooks/query-hooks/use-agent-monitor.ts
@@ -1,26 +1,30 @@
-import { useQuery } from "@tanstack/react-query";
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api } from "@/core/api/client";
import type { components } from "@/core/api/generated/api-types";
-export type TimeRange = "1m" | "5m" | "15m" | "1h" | "24h" | "7d";
+export type TimeRange = "1m" | "5m" | "15m" | "1h" | "24h" | "7d" | "30d" | "180d" | "365d";
export type ControlStats = components["schemas"]["ControlStats"];
export type StatsResponse = components["schemas"]["StatsResponse"];
+export type TimeseriesBucket = components["schemas"]["TimeseriesBucket"];
+export type StatsTotals = components["schemas"]["StatsTotals"];
-export function useAgentStats(
+export function useAgentMonitor(
agentUuid: string,
timeRange: TimeRange = "1h",
options?: {
enabled?: boolean;
refetchInterval?: number;
+ includeTimeseries?: boolean;
}
) {
return useQuery({
- queryKey: ["agent-stats", agentUuid, timeRange],
+ queryKey: ["agent-monitor", agentUuid, timeRange, options?.includeTimeseries ?? false],
queryFn: async (): Promise => {
const { data, error } = await api.observability.getStats({
agent_uuid: agentUuid,
time_range: timeRange,
+ include_timeseries: options?.includeTimeseries ?? false,
});
if (error) {
@@ -32,6 +36,6 @@ export function useAgentStats(
enabled: options?.enabled !== false && !!agentUuid,
refetchInterval: options?.refetchInterval ?? 5000, // Default 5 seconds
refetchIntervalInBackground: false, // Pause polling when tab is not visible
+ placeholderData: keepPreviousData, // Keep showing previous data while loading new time range
});
}
-
diff --git a/ui/src/core/hooks/query-hooks/use-has-monitor-data.ts b/ui/src/core/hooks/query-hooks/use-has-monitor-data.ts
new file mode 100644
index 00000000..5ef31e03
--- /dev/null
+++ b/ui/src/core/hooks/query-hooks/use-has-monitor-data.ts
@@ -0,0 +1,68 @@
+import type { TimeRangeValue } from "@rungalileo/jupiter-ds";
+import { useQuery } from "@tanstack/react-query";
+
+import { api } from "@/core/api/client";
+import type { TimeRange } from "@/core/hooks/query-hooks/use-agent-monitor";
+import { mapTimeRangeTypeToTimeRange } from "@/core/page-components/agent-detail/monitor/utils";
+
+const TIME_RANGE_STORAGE_KEY = "agent-control-time-range-preference";
+
+/**
+ * Get stored time range from localStorage, defaulting to "7d"
+ */
+function getStoredTimeRange(): TimeRange {
+ if (typeof window === "undefined") return "7d";
+
+ try {
+ const stored = localStorage.getItem(TIME_RANGE_STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored) as TimeRangeValue;
+ if (parsed && typeof parsed.type === "string") {
+ return mapTimeRangeTypeToTimeRange(parsed.type);
+ }
+ }
+ } catch {
+ // Ignore parse errors
+ }
+ return "7d"; // Default
+}
+
+/**
+ * Lightweight hook to check if an agent has any monitoring data.
+ * Used to determine the default tab (monitor vs controls).
+ * Does NOT include timeseries data to minimize payload.
+ * Uses the stored time range preference from localStorage.
+ */
+export function useHasMonitorData(
+ agentUuid: string,
+ options?: {
+ enabled?: boolean;
+ }
+) {
+ return useQuery({
+ queryKey: ["has-monitor-data", agentUuid],
+ queryFn: async () => {
+ const timeRange = getStoredTimeRange();
+
+ const { data, error } = await api.observability.getStats({
+ agent_uuid: agentUuid,
+ time_range: timeRange,
+ include_timeseries: false, // Don't need timeseries, just totals
+ });
+
+ if (error) {
+ return false; // On error, default to no data
+ }
+
+ // Check if there's any meaningful data
+ const hasData =
+ (data.controls && data.controls.length > 0) ||
+ (data.totals?.execution_count && data.totals.execution_count > 0);
+
+ return hasData;
+ },
+ enabled: options?.enabled !== false && !!agentUuid,
+ staleTime: 30000, // Consider data fresh for 30 seconds
+ refetchOnWindowFocus: false, // Don't refetch on window focus
+ });
+}
diff --git a/ui/src/core/hooks/use-modal-route.ts b/ui/src/core/hooks/use-modal-route.ts
new file mode 100644
index 00000000..c34f2f78
--- /dev/null
+++ b/ui/src/core/hooks/use-modal-route.ts
@@ -0,0 +1,96 @@
+import { useRouter } from "next/router";
+import { useCallback, useMemo } from "react";
+
+/**
+ * Hook to manage modal state via URL query parameters
+ *
+ * URL structure:
+ * - ?modal=control-store - Opens Control Store modal
+ * - ?modal=control-store&submodal=add-new - Opens Control Store with Add New Control modal
+ * - ?modal=control-store&submodal=create&evaluator=regex - Opens Control Store with Create Control modal
+ * - ?modal=control-store&submodal=edit&controlId=123 - Opens Control Store with Edit Control modal
+ * - ?modal=edit&controlId=123 - Opens Edit Control modal directly (from agent detail page)
+ */
+export function useModalRoute() {
+ const router = useRouter();
+ const { modal, submodal, evaluator, controlId } = router.query;
+
+ const modalState = useMemo(() => {
+ return {
+ modal: typeof modal === "string" ? modal : null,
+ submodal: typeof submodal === "string" ? submodal : null,
+ evaluator: typeof evaluator === "string" ? evaluator : null,
+ controlId: typeof controlId === "string" ? controlId : null,
+ };
+ }, [modal, submodal, evaluator, controlId]);
+
+ const openModal = useCallback(
+ (modalName: string, params?: { submodal?: string; evaluator?: string; controlId?: string }) => {
+ const query: Record = { modal: modalName };
+ if (params?.submodal) query.submodal = params.submodal;
+ if (params?.evaluator) query.evaluator = params.evaluator;
+ if (params?.controlId) query.controlId = params.controlId;
+
+ router.push(
+ {
+ pathname: router.pathname,
+ query: { ...router.query, ...query },
+ },
+ undefined,
+ { shallow: true }
+ );
+ },
+ [router]
+ );
+
+ const closeModal = useCallback(() => {
+ // Remove all modal-related query parameters
+ const query = { ...router.query };
+ delete query.modal;
+ delete query.submodal;
+ delete query.evaluator;
+ delete query.controlId;
+
+ router.push(
+ {
+ pathname: router.pathname,
+ query,
+ },
+ undefined,
+ { shallow: true }
+ );
+ }, [router]);
+
+ const closeSubmodal = useCallback(() => {
+ const { submodal: currentSubmodal, evaluator: _evaluator, controlId: _controlId, ...rest } = router.query;
+
+ // If closing from "create", go back to "add-new" instead of closing everything
+ if (currentSubmodal === "create") {
+ router.push(
+ {
+ pathname: router.pathname,
+ query: { ...rest, modal: router.query.modal, submodal: "add-new" },
+ },
+ undefined,
+ { shallow: true }
+ );
+ } else {
+ // Otherwise, remove all submodal params
+ router.push(
+ {
+ pathname: router.pathname,
+ query: rest,
+ },
+ undefined,
+ { shallow: true }
+ );
+ }
+ }, [router]);
+
+ return {
+ ...modalState,
+ openModal,
+ closeModal,
+ closeSubmodal,
+ };
+}
diff --git a/ui/src/core/hooks/use-time-range-preference.ts b/ui/src/core/hooks/use-time-range-preference.ts
new file mode 100644
index 00000000..b30c9b23
--- /dev/null
+++ b/ui/src/core/hooks/use-time-range-preference.ts
@@ -0,0 +1,39 @@
+import type { TimeRangeValue } from "@rungalileo/jupiter-ds";
+import { useEffect, useState } from "react";
+
+const STORAGE_KEY = "agent-control-time-range-preference";
+
+export function useTimeRangePreference() {
+ const [timeRangeValue, setTimeRangeValue] = useState(() => {
+ // Initialize from localStorage or default to 1W
+ if (typeof window !== "undefined") {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ // Validate that it's a valid TimeRangeValue
+ if (parsed && typeof parsed.type === "string") {
+ return parsed as TimeRangeValue;
+ }
+ }
+ } catch (error) {
+ // If parsing fails, use default
+ console.warn("Failed to parse time range preference from localStorage", error);
+ }
+ }
+ return { type: "lastWeek" }; // Default to 1W
+ });
+
+ // Save to localStorage whenever it changes
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(timeRangeValue));
+ } catch (error) {
+ console.warn("Failed to save time range preference to localStorage", error);
+ }
+ }
+ }, [timeRangeValue]);
+
+ return [timeRangeValue, setTimeRangeValue] as const;
+}
diff --git a/ui/src/core/layouts/app-layout.tsx b/ui/src/core/layouts/app-layout.tsx
index 3a15636e..cdceb7ee 100644
--- a/ui/src/core/layouts/app-layout.tsx
+++ b/ui/src/core/layouts/app-layout.tsx
@@ -228,7 +228,7 @@ export function AppLayout({ children }: AppLayoutProps) {
{allAgents.map((agent) => (
{
- const [activeTab, setActiveTab] = useState("controls");
- const [editModalOpened, setEditModalOpened] = useState(false);
- const [controlStoreOpened, setControlStoreOpened] = useState(false);
+const AgentDetailPage = ({ agentId, defaultTab }: AgentDetailPageProps) => {
+ const router = useRouter();
+ const { modal, controlId, openModal, closeModal } = useModalRoute();
const [selectedControl, setSelectedControl] = useState(null);
// Get search value for filtering (SearchInput handles the UI and URL sync)
const [searchQuery] = useQueryParam("q");
- // Fetch agent details and controls
+ // Derive modal open state from URL
+ const controlStoreOpened = modal === "control-store";
+ const editModalOpened = modal === "edit";
+
+ // Fetch agent details, controls, and stats in parallel
const {
data: agent,
isLoading: agentLoading,
@@ -72,8 +80,46 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => {
isLoading: controlsLoading,
error: controlsError,
} = useAgentControls(agentId);
+
+ // Lightweight check to determine initial tab (when no defaultTab specified)
+ // Only checks if stats exist, doesn't fetch full data
+ const needsInitialTabCheck = !defaultTab;
+ const {
+ data: hasMonitorData,
+ isLoading: checkingMonitorData,
+ } = useHasMonitorData(agentId, {
+ enabled: needsInitialTabCheck,
+ });
+
const updateControl = useUpdateControl();
+ // Determine initial tab based on:
+ // 1. defaultTab prop (from route)
+ // 2. stats data (if no defaultTab and stats exist, show monitor)
+ // 3. Otherwise, show controls
+ const [activeTab, setActiveTab] = useState(() => {
+ if (defaultTab === "monitor") return "monitor";
+ if (defaultTab === "controls") return "controls";
+ return "controls"; // Default fallback
+ });
+
+ // Set initial tab based on monitor data check (only if no defaultTab specified)
+ const hasCheckedInitialTab = React.useRef(false);
+ React.useEffect(() => {
+ // Only check if no defaultTab is specified (i.e., accessing /agents/[id] directly)
+ if (!defaultTab && !hasCheckedInitialTab.current && !checkingMonitorData) {
+ hasCheckedInitialTab.current = true;
+
+ if (hasMonitorData) {
+ setActiveTab("monitor");
+ router.replace(`/agents/${agentId}/monitor`, undefined, { shallow: true });
+ } else {
+ setActiveTab("controls");
+ router.replace(`/agents/${agentId}/controls`, undefined, { shallow: true });
+ }
+ }
+ }, [defaultTab, checkingMonitorData, hasMonitorData, agentId, router]);
+
// Filter controls based on search query
const controls = useMemo(() => {
const allControls = controlsResponse?.controls || [];
@@ -86,6 +132,16 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => {
);
}, [controlsResponse, searchQuery]);
+ // Load control when controlId is in URL
+ React.useEffect(() => {
+ if (editModalOpened && controlId && controlsResponse?.controls) {
+ const control = controlsResponse.controls.find((c) => c.id.toString() === controlId);
+ if (control) {
+ setSelectedControl(control);
+ }
+ }
+ }, [editModalOpened, controlId, controlsResponse]);
+
// Loading state
if (agentLoading) {
return (
@@ -109,7 +165,20 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => {
title='Error loading agent'
color='red'
>
- Failed to fetch agent details. Please try again later.
+
+ Failed to fetch agent details. Please try again later.
+
+ Possible reasons:
+
+
+
+ • Check server for API errors
+
+
+ • The agent ID might be incorrect
+
+
+
);
@@ -121,23 +190,46 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => {
id: "enabled",
header: "",
size: 60,
- cell: ({ row }: { row: any }) => (
- {
- const control = row.original as Control;
- updateControl.mutate({
- agentId,
- controlId: control.id,
- definition: {
- ...control.control,
- enabled: e.currentTarget.checked,
- },
- });
- }}
- />
- ),
+ cell: ({ row }: { row: any }) => {
+ const control = row.original as Control;
+ const enabled = control.control?.enabled ?? false;
+ return (
+ {
+ const newEnabled = e.currentTarget.checked;
+ modals.openConfirmModal({
+ title: newEnabled ? "Enable control?" : "Disable control?",
+ children: (
+
+ {newEnabled
+ ? `Enable "${control.name}"?`
+ : `Disable "${control.name}"?`}
+
+ ),
+ labels: { confirm: "Confirm", cancel: "Cancel" },
+ confirmProps: {
+ variant: "filled",
+ color: "violet",
+ size: "sm",
+ className: "confirm-modal-confirm-btn",
+ },
+ cancelProps: { variant: "default", size: "sm" },
+ onConfirm: () =>
+ updateControl.mutate({
+ agentId,
+ controlId: control.id,
+ definition: {
+ ...control.control,
+ enabled: newEnabled,
+ },
+ }),
+ });
+ }}
+ />
+ );
+ },
},
{
id: "name",
@@ -229,21 +321,26 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => {
];
const handleEditControl = (control: Control) => {
- setSelectedControl(control);
- setEditModalOpened(true);
+ openModal("edit", { controlId: control.id.toString() });
};
const handleCloseEditModal = () => {
- setEditModalOpened(false);
+ closeModal();
setSelectedControl(null);
};
const handleEditControlSuccess = () => {
+ closeModal();
setSelectedControl(null);
};
return (
-
+
{/* Header */}
@@ -258,7 +355,18 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => {
{/* Tabs */}
-
+ {
+ setActiveTab(value);
+ // Update URL when tab changes
+ if (value === "monitor") {
+ router.push(`/agents/${agentId}/monitor`, undefined, { shallow: true });
+ } else if (value === "controls") {
+ router.push(`/agents/${agentId}/controls`, undefined, { shallow: true });
+ }
+ }}
+ >
@@ -269,32 +377,34 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => {
Controls
}
>
- Stats
+ Monitor
-
-
-
-
+ {activeTab === "controls" && (
+
+
+
+
+ )}
@@ -331,26 +441,35 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => {
variant='filled'
mt='md'
data-testid='add-control-button'
- onClick={() => setControlStoreOpened(true)}
+ onClick={() => openModal("control-store")}
>
Add Control
) : (
-
+
+
+
)}
-
+
- {agent?.agent.agent_id && (
-
+ {/* Only render AgentsMonitor when monitor tab is active to prevent polling on controls page */}
+ {agent?.agent.agent_id && activeTab === "monitor" && (
+
)}
@@ -360,7 +479,7 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => {
{/* Control Store Modal */}
setControlStoreOpened(false)}
+ onClose={closeModal}
agentId={agentId}
/>
@@ -368,11 +487,11 @@ const AgentDetailPage = ({ agentId }: AgentDetailPageProps) => {
diff --git a/ui/src/core/page-components/agent-detail/agent-stats.tsx b/ui/src/core/page-components/agent-detail/agent-stats.tsx
deleted file mode 100644
index 1352ad0e..00000000
--- a/ui/src/core/page-components/agent-detail/agent-stats.tsx
+++ /dev/null
@@ -1,531 +0,0 @@
-"use client";
-
-import {
- Badge,
- Box,
- Card,
- Group,
- Loader,
- Progress,
- RingProgress,
- Select,
- Stack,
- Table,
- Text,
- Title,
- Tooltip,
-} from "@mantine/core";
-import {
- IconAlertCircle,
- IconCheck,
- IconClock,
- IconX,
-} from "@tabler/icons-react";
-import React, { useMemo, useState } from "react";
-
-import { type TimeRange,useAgentStats } from "@/core/hooks/query-hooks/use-agent-stats";
-
-interface AgentStatsProps {
- agentUuid: string;
-}
-
-export function AgentStats({ agentUuid }: AgentStatsProps) {
- const [timeRange, setTimeRange] = useState("1h");
-
- const {
- data: stats,
- isLoading,
- error,
- } = useAgentStats(agentUuid, timeRange, {
- refetchInterval: 5000, // Poll every 5 seconds
- });
-
- // Calculate summary metrics
- const summary = useMemo(() => {
- if (!stats) return null;
-
- const actionCounts = stats.action_counts ?? {};
-
- return {
- totalExecutions: stats.total_executions,
- totalMatches: stats.total_matches,
- totalNonMatches: stats.total_non_matches,
- totalErrors: stats.total_errors,
- denyRate: stats.total_executions > 0
- ? ((actionCounts.deny || 0) / stats.total_executions) * 100
- : 0,
- matchRate: stats.total_executions > 0
- ? (stats.total_matches / stats.total_executions) * 100
- : 0,
- actionCounts,
- };
- }, [stats]);
-
- if (isLoading && !stats) {
- return (
-
-
-
- Loading stats...
-
-
- );
- }
-
- if (error) {
- return (
-
-
-
-
- Failed to load stats
-
-
- {error instanceof Error ? error.message : "Unknown error"}
-
-
-
- );
- }
-
- const isEmpty = !stats || stats.stats.length === 0;
-
- return (
-
- {/* Header with time range selector - always visible */}
-
-
- Control Statistics
-
-
-
- {/* Empty state */}
- {isEmpty && (
-
-
-
-
- No stats available
-
-
- Stats will appear here once controls are executed.
-
-
-
- )}
-
- {!isEmpty && (
- <>
-
- {/* Summary Cards - Compact Hierarchical View */}
- {summary && (
-
-
- {/* Left: Total Executions with breakdown */}
-
-
-
-
- Total Executions
-
-
- {summary.totalExecutions.toLocaleString()}
-
-
-
-
- Match Rate
-
-
- {summary.matchRate.toFixed(1)}%
-
-
-
-
- {/* Compact breakdown */}
-
-
-
- }
- >
- Non-Matches
-
-
- {summary.totalNonMatches}
-
-
-
-
-
-
- }
- >
- Matches
-
-
- {summary.totalMatches}
-
-
-
-
-
-
- }
- >
- Errors
-
- 0 ? "red" : "dimmed"}>
- {summary.totalErrors}
-
-
-
-
-
-
- {/* Right: Actions breakdown with visual chart */}
-
-
-
-
- Actions Distribution
-
-
- from {summary.totalMatches} matches
-
-
-
- {summary.totalMatches > 0 ? (
- <>
- {/* Donut Chart */}
-
-
- {summary.totalMatches}
-
- }
- />
-
-
- {/* Action Legend with percentages */}
-
- {summary.actionCounts.allow !== undefined && (
-
-
-
-
- Allow
-
-
-
-
- {summary.actionCounts.allow}
-
-
- ({((summary.actionCounts.allow / summary.totalMatches) * 100).toFixed(1)}%)
-
-
-
- )}
- {summary.actionCounts.deny !== undefined && (
-
-
-
-
- Deny
-
-
-
-
- {summary.actionCounts.deny}
-
-
- ({((summary.actionCounts.deny / summary.totalMatches) * 100).toFixed(1)}%)
-
-
-
- )}
- {summary.actionCounts.warn !== undefined && (
-
-
-
-
- Warn
-
-
-
-
- {summary.actionCounts.warn}
-
-
- ({((summary.actionCounts.warn / summary.totalMatches) * 100).toFixed(1)}%)
-
-
-
- )}
- {summary.actionCounts.log !== undefined && (
-
-
-
-
- Log
-
-
-
-
- {summary.actionCounts.log}
-
-
- ({((summary.actionCounts.log / summary.totalMatches) * 100).toFixed(1)}%)
-
-
-
- )}
-
- >
- ) : (
-
-
- No matches yet
-
-
- )}
-
-
-
-
- )}
-
- {/* Control Stats Table */}
-
-
- Per-Control Statistics
-
-
-
-
-
- Control
- Executions
- Matches
- Non-Matches
- Actions
- Errors
- Avg Confidence
-
-
-
- {stats.stats.map((control) => {
- const matchRate =
- control.execution_count > 0
- ? (control.match_count / control.execution_count) * 100
- : 0;
-
- return (
-
-
-
- {control.control_name}
-
-
-
- {control.execution_count.toLocaleString()}
-
-
-
-
-
- {control.match_count}
-
- {control.execution_count > 0 && (
-
- )}
-
-
-
-
-
- {control.non_match_count}
-
-
-
-
- {control.allow_count > 0 && (
-
- Allow: {control.allow_count}
-
- )}
- {control.deny_count > 0 && (
-
- Deny: {control.deny_count}
-
- )}
- {control.warn_count > 0 && (
-
- Warn: {control.warn_count}
-
- )}
- {control.log_count > 0 && (
-
- Log: {control.log_count}
-
- )}
- {control.allow_count === 0 &&
- control.deny_count === 0 &&
- control.warn_count === 0 &&
- control.log_count === 0 && (
-
- -
-
- )}
-
-
-
- {control.error_count > 0 ? (
-
- {control.error_count}
-
- ) : (
-
- 0
-
- )}
-
-
-
- = 0.9
- ? "green"
- : control.avg_confidence >= 0.7
- ? "yellow"
- : "red"
- }
- variant="light"
- size="sm"
- >
- {(control.avg_confidence * 100).toFixed(0)}%
-
-
-
-
- );
- })}
-
-
-
-
- >
- )}
-
- );
-}
-
diff --git a/ui/src/core/page-components/agent-detail/modals/add-new-control/index.tsx b/ui/src/core/page-components/agent-detail/modals/add-new-control/index.tsx
index 3fbd4d42..eca9589d 100644
--- a/ui/src/core/page-components/agent-detail/modals/add-new-control/index.tsx
+++ b/ui/src/core/page-components/agent-detail/modals/add-new-control/index.tsx
@@ -12,21 +12,18 @@ import {
Tooltip,
} from "@mantine/core";
import { Button, Table } from "@rungalileo/jupiter-ds";
-import {
- IconAlertCircle,
- IconSearch,
- IconSettings,
- IconSparkles,
- IconX,
-} from "@tabler/icons-react";
+import { IconAlertCircle, IconSearch, IconX } from "@tabler/icons-react";
import { type ColumnDef } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { ErrorBoundary } from "@/components/error-boundary";
import type { EvaluatorInfo } from "@/core/api/types";
+import { useAgent } from "@/core/hooks/query-hooks/use-agent";
import { useEvaluators } from "@/core/hooks/query-hooks/use-evaluators";
+import { useModalRoute } from "@/core/hooks/use-modal-route";
import { EditControlContent } from "../edit-control/edit-control-content";
+import { sanitizeControlNamePart } from "../edit-control/utils";
type EvaluatorWithId = EvaluatorInfo & { id: string };
@@ -64,28 +61,38 @@ export function AddNewControlModal({
onClose,
agentId,
}: AddNewControlModalProps) {
- const [selectedSource, setSelectedSource] = useState<"galileo" | "custom">(
- "galileo"
- );
const [searchQuery, setSearchQuery] = useState("");
- const [selectedEvaluator, setSelectedEvaluator] =
- useState(null);
- const [editModalOpened, setEditModalOpened] = useState(false);
+ const { submodal, evaluator, openModal, closeSubmodal, closeModal } = useModalRoute();
const { data: evaluatorsData, isLoading, error } = useEvaluators();
+ const { data: agent } = useAgent(agentId);
+ const agentName = agent?.agent?.agent_name ?? agentId;
+
+ // Derive submodal open state from URL
+ const editModalOpened = submodal === "create";
+
+ // Find selected evaluator from URL or state
+ const selectedEvaluator = useMemo(() => {
+ if (evaluator && evaluatorsData) {
+ const evaluatorData = evaluatorsData[evaluator];
+ if (evaluatorData) {
+ return { ...evaluatorData, id: evaluator };
+ }
+ }
+ return null;
+ }, [evaluator, evaluatorsData]);
const handleAddClick = (evaluator: EvaluatorWithId) => {
- setSelectedEvaluator(evaluator);
- setEditModalOpened(true);
+ openModal("control-store", { submodal: "create", evaluator: evaluator.id });
};
const handleEditModalClose = () => {
- setEditModalOpened(false);
- setSelectedEvaluator(null);
+ closeSubmodal();
};
const handleEditModalSuccess = () => {
- handleEditModalClose();
- onClose();
+ // Close all modals on successful create
+ // Use closeModal to close the entire modal stack (control-store + add-new + create)
+ closeModal();
};
// Transform evaluators record to array for table display
@@ -99,9 +106,10 @@ export function AddNewControlModal({
const draftControl = useMemo(() => {
if (!selectedEvaluator) return null;
+ const name = `${sanitizeControlNamePart(selectedEvaluator.name)}-control-for-${sanitizeControlNamePart(agentName)}`;
return {
id: 0,
- name: selectedEvaluator.name,
+ name,
control: {
description: selectedEvaluator.description,
enabled: true,
@@ -120,14 +128,14 @@ export function AddNewControlModal({
action: { decision: "deny" as const },
},
};
- }, [selectedEvaluator]);
+ }, [selectedEvaluator, agentName]);
const columns: ColumnDef[] = [
{
id: "name",
header: "Name",
accessorKey: "name",
- size: 80,
+ size: 150,
cell: ({ row }) => (
@@ -136,18 +144,11 @@ export function AddNewControlModal({
),
},
- {
- id: "version",
- header: "Version",
- accessorKey: "version",
- size: 80,
- cell: ({ row }) => {row.original.version},
- },
{
id: "description",
header: "Description",
accessorKey: "description",
- size: 200,
+ size: 400,
cell: ({ row }) => (
@@ -159,26 +160,27 @@ export function AddNewControlModal({
{
id: "actions",
header: "",
- size: 80,
+ size: 100,
cell: ({ row }) => (
-
+
+
+
+
+
),
},
];
- const filteredEvaluators =
- selectedSource === "galileo"
- ? evaluators.filter((evaluator) =>
- evaluator.name.toLowerCase().includes(searchQuery.toLowerCase())
- )
- : [];
+ const filteredEvaluators = evaluators.filter((evaluator) =>
+ evaluator.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
return (
- Control store
+ Create Control
- Browse and add controls to your agent
+ Select an evaluator to create a new control
{/* Content */}
-
- {/* Left Sidebar */}
-
-
-
-
- Source
-
-
- setSelectedSource("galileo")}
- w="100%"
- p="xs"
- radius="sm"
- withBorder
- bg={
- selectedSource === "galileo"
- ? "var(--mantine-color-blue-0)"
- : "transparent"
- }
- >
-
-
-
- OOB standard
-
-
-
- setSelectedSource("custom")}
- w="100%"
- p="xs"
- radius="sm"
- withBorder
- bg={
- selectedSource === "custom"
- ? "var(--mantine-color-blue-0)"
- : "transparent"
- }
- >
-
-
-
- Custom
-
-
-
-
-
-
-
-
-
- {/* Right Content */}
-
-
- {/* Search and Docs Link */}
-
- }
- flex={1}
- maw={250}
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
+
+
+ {/* Search and Docs Link */}
+
+ }
+ flex={1}
+ maw={250}
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
- Looking to add custom control?{" "}
+ Learn here on how to add new type of evaluator.{" "}
- Check our Docs ↗
+ Docs ↗
- {/* Table or Empty State */}
- {selectedSource === "galileo" ? (
- isLoading ? (
-
-
-
- ) : error ? (
-
-
-
- Failed to load evaluators
-
-
- ) : filteredEvaluators.length > 0 ? (
-
+
+
+ ) : error ? (
+
+
+
- ) : (
-
- No evaluators found
-
- )
- ) : (
-
-
-
-
- No custom controls yet
-
-
- Create your first custom control to get started
-
-
-
- )}
-
-
-
+ Failed to load evaluators
+
+
+ ) : filteredEvaluators.length > 0 ? (
+
+
+
+ ) : (
+
+ No evaluators found
+
+ )}
+
+
{/* Edit Control Modal */}
@@ -381,7 +288,7 @@ export function AddNewControlModal({
keepMounted={false}
styles={{
title: { fontSize: "18px", fontWeight: 600 },
- content: { maxWidth: "1200px", width: "90vw" },
+ content: { maxWidth: "1500px", width: "90vw" },
}}
>
diff --git a/ui/src/core/page-components/agent-detail/modals/control-store/index.tsx b/ui/src/core/page-components/agent-detail/modals/control-store/index.tsx
index 1cf968db..53185875 100644
--- a/ui/src/core/page-components/agent-detail/modals/control-store/index.tsx
+++ b/ui/src/core/page-components/agent-detail/modals/control-store/index.tsx
@@ -6,7 +6,6 @@ import {
Loader,
Modal,
Paper,
- ScrollArea,
Stack,
Text,
Title,
@@ -18,7 +17,7 @@ import { Button, Table } from "@rungalileo/jupiter-ds";
import { IconAlertCircle, IconX } from "@tabler/icons-react";
import { type ColumnDef } from "@tanstack/react-table";
import Link from "next/link";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { ErrorBoundary } from "@/components/error-boundary";
import { api } from "@/core/api/client";
@@ -26,10 +25,12 @@ import type { AgentRef, ControlDefinition, ControlSummary } from "@/core/api/typ
import { SearchInput } from "@/core/components/search-input";
import { useControlsInfinite } from "@/core/hooks/query-hooks/use-controls-infinite";
import { useInfiniteScroll } from "@/core/hooks/use-infinite-scroll";
+import { useModalRoute } from "@/core/hooks/use-modal-route";
import { useQueryParam } from "@/core/hooks/use-query-param";
import { AddNewControlModal } from "../add-new-control";
import { EditControlContent } from "../edit-control/edit-control-content";
+import { sanitizeControlNamePart } from "../edit-control/utils";
// Extended ControlSummary with used_by_agent (until API types are regenerated)
type ControlSummaryWithAgent = ControlSummary & {
@@ -50,13 +51,17 @@ export function ControlStoreModal({
// Get search value for debouncing (SearchInput handles the UI and URL sync)
const [searchQuery, setSearchQuery] = useQueryParam("store_q");
const [debouncedSearch] = useDebouncedValue(searchQuery, 300);
+ const { submodal, evaluator: _evaluator, controlId, openModal, closeSubmodal, closeModal } = useModalRoute();
const [selectedControl, setSelectedControl] = useState<{
summary: ControlSummary;
definition: ControlDefinition;
} | null>(null);
const [loadingControlId, setLoadingControlId] = useState(null);
- const [editModalOpened, setEditModalOpened] = useState(false);
- const [addNewModalOpened, setAddNewModalOpened] = useState(false);
+
+ // Derive submodal open state from URL
+ const editModalOpened = submodal === "edit";
+ // AddNewControlModal should be open when submodal is "add-new" OR "create" (create is nested inside add-new)
+ const addNewModalOpened = submodal === "add-new" || submodal === "create";
// Clear search query param when modal closes
useEffect(() => {
@@ -84,50 +89,94 @@ export function ControlStoreModal({
isFetchingNextPage,
fetchNextPage,
});
-
+
// Flatten paginated data
const controls = useMemo(() => {
return data?.pages.flatMap((page) => page.controls) ?? [];
}, [data]);
-
- const handleUseControl = async (control: ControlSummary) => {
- setLoadingControlId(control.id);
- try {
- const { data: controlData, error: fetchError } = await api.controls.getData(control.id);
- if (fetchError || !controlData) {
- notifications.show({
- title: "Error",
- message: "Failed to load control configuration",
- color: "red",
- });
- return;
+
+ // Ref callback to attach to Table's scroll container when maxHeight is set
+ const tableWrapperRef = useRef(null);
+
+ // Attach scroll container ref to the Table's scroll container
+ useEffect(() => {
+ if (tableWrapperRef.current && controls.length > 0) {
+ // Find the scrollable container (the div with overflow: auto from Table's maxHeight)
+ // The Table component wraps content in a div with the "root" class when maxHeight is set
+ const scrollContainer = tableWrapperRef.current.querySelector('[class*="root"]') as HTMLElement;
+ if (scrollContainer) {
+ // Check if it's scrollable (has overflow: auto)
+ const computedStyle = window.getComputedStyle(scrollContainer);
+ if (computedStyle.overflow === 'auto' || computedStyle.overflowY === 'auto') {
+ (scrollContainerRef as React.MutableRefObject).current = scrollContainer;
+
+ // Append sentinel inside the scroll container (after the table)
+ if (sentinelRef.current && sentinelRef.current.parentElement !== scrollContainer) {
+ scrollContainer.appendChild(sentinelRef.current);
+ }
+ }
}
- setSelectedControl({ summary: control, definition: controlData.data });
- setEditModalOpened(true);
- } finally {
- setLoadingControlId(null);
}
+ }, [controls.length, scrollContainerRef, sentinelRef]);
+
+ // Load control when controlId is in URL
+ useEffect(() => {
+ if (editModalOpened && controlId && !selectedControl) {
+ const loadControl = async () => {
+ const id = parseInt(controlId, 10);
+ if (isNaN(id)) return;
+
+ setLoadingControlId(id);
+ try {
+ const { data: controlData, error: fetchError } = await api.controls.getData(id);
+ if (fetchError || !controlData?.data) {
+ notifications.show({
+ title: "Error",
+ message: "Failed to load control configuration",
+ color: "red",
+ });
+ return;
+ }
+ // Find the control summary from the list
+ const controlSummary = controls.find((c) => c.id === id);
+ if (controlSummary) {
+ setSelectedControl({ summary: controlSummary, definition: controlData.data });
+ }
+ } finally {
+ setLoadingControlId(null);
+ }
+ };
+ loadControl();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [editModalOpened, controlId, selectedControl]);
+
+ // Clear selectedControl when edit modal closes
+ useEffect(() => {
+ if (!editModalOpened && selectedControl) {
+ setSelectedControl(null);
+ }
+ }, [editModalOpened, selectedControl]);
+
+ const handleCopyControl = async (control: ControlSummary) => {
+ openModal("control-store", { submodal: "edit", controlId: control.id.toString() });
};
const handleEditModalClose = () => {
- setEditModalOpened(false);
- setSelectedControl(null);
+ closeSubmodal();
};
const handleEditModalSuccess = () => {
- handleEditModalClose();
- onClose();
+ // Close all modals on successful create/edit
+ // Use closeModal to remove all modal query parameters from URL
+ closeModal();
};
- // Build a draft control for the edit modal with full evaluator config
+ // Build a draft control for the edit modal with full evaluator config (clone: append -copy to name)
const draftControl = useMemo(() => {
if (!selectedControl) return null;
const { summary, definition } = selectedControl;
- // Sanitize name to match pattern: ^[a-zA-Z0-9][a-zA-Z0-9_-]*$
- // Replace spaces with hyphens, remove invalid characters, append -copy
- const sanitizedName = summary.name
- .replace(/\s+/g, "-") // spaces -> hyphens
- .replace(/[^a-zA-Z0-9_-]/g, ""); // remove invalid chars
+ const sanitizedName = sanitizeControlNamePart(summary.name);
return {
id: 0,
name: `${sanitizedName}-copy`,
@@ -144,6 +193,28 @@ export function ControlStoreModal({
}, [selectedControl]);
const columns: ColumnDef[] = [
+ {
+ id: "enabled",
+ header: "",
+ accessorKey: "enabled",
+ size: 40,
+ cell: ({ row }) => (
+
+
+
+
+
+ ),
+ },
{
id: "name",
header: "Name",
@@ -168,20 +239,9 @@ export function ControlStoreModal({
),
},
- {
- id: "enabled",
- header: "Enabled",
- accessorKey: "enabled",
- size: 80,
- cell: ({ row }) => (
-
- {row.original.enabled ? "Yes" : "No"}
-
- ),
- },
{
id: "agent",
- header: "Used by",
+ header: "Agent",
size: 150,
cell: ({ row }) => {
const agent = (row.original as ControlSummaryWithAgent).used_by_agent;
@@ -189,8 +249,8 @@ export function ControlStoreModal({
if (!agent) {
return —;
}
- // Link to agent detail page with control name filter
- const href = `/agents/${agent.agent_id}?q=${encodeURIComponent(control.name)}`;
+ // Link to agent controls tab with control name filter
+ const href = `/agents/${agent.agent_id}/controls?q=${encodeURIComponent(control.name)}`;
return (
handleUseControl(row.original)}
+ onClick={() => handleCopyControl(row.original)}
>
- Use
+ Copy
),
@@ -228,53 +288,63 @@ export function ControlStoreModal({
];
return (
-
-
- {/* Header */}
-
-
-
- Control store
-
-
-
-
- Browse existing controls or create a new one
-
-
-
+ <>
+
+
+ {/* Header */}
+
+
+
+ Control store
+
+
+
+
+ Browse existing controls or create a new one
+
+
+
- {/* Search Bar */}
-
-
-
+ {/* Search Bar + Create Control */}
+
+
+
+
+
+
- {/* Scrollable Table Content */}
-
-
+ {/* Scrollable Table Content */}
+
{isLoading ? (
@@ -290,42 +360,32 @@ export function ControlStoreModal({
) : controls.length > 0 ? (
- <>
-
- {/* Load more sentinel for infinite scroll */}
+
+
+ {/* Load more sentinel for infinite scroll - will be moved inside table's scroll container by useEffect */}
{isFetchingNextPage && (
)}
- >
+
) : (
No controls found
)}
-
+
-
- {/* Footer CTA */}
-
-
-
-
- Can't find what you're looking for?
-
-
-
-
-
+
{/* Edit Control Modal */}
@@ -354,9 +414,9 @@ export function ControlStoreModal({
setAddNewModalOpened(false)}
+ onClose={closeSubmodal}
agentId={agentId}
/>
-
+ >
);
}
diff --git a/ui/src/core/page-components/agent-detail/modals/edit-control/control-definition-form.tsx b/ui/src/core/page-components/agent-detail/modals/edit-control/control-definition-form.tsx
index d7cd811b..e4fa50cf 100644
--- a/ui/src/core/page-components/agent-detail/modals/edit-control/control-definition-form.tsx
+++ b/ui/src/core/page-components/agent-detail/modals/edit-control/control-definition-form.tsx
@@ -1,197 +1,149 @@
import {
- Box,
- Group,
MultiSelect,
Select,
Stack,
Switch,
TagsInput,
- Text,
- TextInput,
- Tooltip,
} from "@mantine/core";
-import { IconInfoCircle } from "@tabler/icons-react";
import type {
ControlActionDecision,
ControlExecution,
ControlStage,
} from "@/core/api/types";
+import {
+ labelPropsInline,
+ LabelWithTooltip,
+} from "@/core/components/label-with-tooltip";
+import { StepNameInput } from "./step-name-input";
import type { ControlDefinitionFormProps } from "./types";
export const ControlDefinitionForm = ({ form }: ControlDefinitionFormProps) => {
return (
-
-
-
- Enabled
-
-
-
-
-
-
-
-
-
-
-
- Step types
-
-
-
-
-
- form.setFieldValue("step_types", value)}
- />
-
+
+ }
+ {...form.getInputProps("enabled", { type: "checkbox" })}
+ />
-
-
-
- Stages
-
-
-
-
-
-
- form.setFieldValue("stages", value as ControlStage[])
- }
- />
-
+
-
-
-
- Step names
-
-
-
-
-
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "pre", label: "Pre (before execution)" },
+ { value: "post", label: "Post (after execution)" },
+ ]}
+ size='sm'
+ placeholder='All stages'
+ clearable
+ value={form.values.stages}
+ onChange={(value) =>
+ form.setFieldValue("stages", value as ControlStage[])
+ }
+ />
-
-
-
- Step name regex
-
-
-
-
-
-
-
+
+ }
+ labelProps={labelPropsInline}
+ required
+ data={[
+ { value: "*", label: "* (entire payload)" },
+ { value: "input", label: "input" },
+ { value: "output", label: "output" },
+ { value: "context", label: "context" },
+ { value: "name", label: "name" },
+ { value: "type", label: "type" },
+ ]}
+ size="sm"
+ searchable
+ allowDeselect={false}
+ {...form.getInputProps("selector_path")}
+ onChange={(value) =>
+ form.setFieldValue("selector_path", value || "*")
+ }
+ />
-
-
-
- Selector path
-
-
-
-
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "allow", label: "Allow" },
+ { value: "deny", label: "Deny" },
+ { value: "warn", label: "Warn" },
+ { value: "log", label: "Log" },
+ ]}
+ size='sm'
+ {...form.getInputProps("action_decision")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "action_decision",
+ (value as ControlActionDecision) || "deny"
+ )
+ }
+ />
-
-
-
- Action
-
-
-
-
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={[
+ { value: "server", label: "Server" },
+ { value: "sdk", label: "SDK" },
+ ]}
+ size='sm'
+ {...form.getInputProps("execution")}
+ onChange={(value) =>
+ form.setFieldValue(
+ "execution",
+ (value as ControlExecution) || "server"
+ )
+ }
+ />
-
-
-
- Execution environment
-
-
-
-
-
-
+
+ }
+ labelProps={labelPropsInline}
+ data={["llm", "tool"]}
+ size='sm'
+ placeholder='All step types'
+ clearable
+ value={form.values.step_types}
+ onChange={(value) => form.setFieldValue("step_types", value)}
+ />
);
};
diff --git a/ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx b/ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx
index 511ace66..dc9cd6f5 100644
--- a/ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx
+++ b/ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx
@@ -1,5 +1,6 @@
import {
Anchor,
+ Box,
Divider,
Grid,
Group,
@@ -11,6 +12,7 @@ import {
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
+import { modals } from "@mantine/modals";
import { Button } from "@rungalileo/jupiter-ds";
import { IconExternalLink } from "@tabler/icons-react";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -32,7 +34,7 @@ import type {
} from "./types";
import { applyApiErrorsToForms } from "./utils";
-const EVALUATOR_CONFIG_HEIGHT = 400;
+const EVALUATOR_CONFIG_HEIGHT = 450;
export interface EditControlContentProps {
/** The control to edit/create template */
@@ -92,6 +94,7 @@ export const EditControlContent = ({
stages: ["post"],
step_names: "",
step_name_regex: "",
+ step_name_mode: "names",
selector_path: "*",
action_decision: "deny",
execution: "server",
@@ -191,13 +194,18 @@ export const EditControlContent = ({
useEffect(() => {
if (control && evaluator) {
const scope = control.control.scope ?? {};
+ const stepNamesValue = (scope.step_names ?? []).join(", ");
+ const stepRegexValue = scope.step_name_regex ?? "";
+ const stepNameMode =
+ stepRegexValue && !stepNamesValue ? "regex" : "names";
definitionForm.setValues({
name: control.name,
enabled: control.control.enabled,
step_types: scope.step_types ?? [],
stages: scope.stages ?? [],
- step_names: (scope.step_names ?? []).join(", "),
- step_name_regex: scope.step_name_regex ?? "",
+ step_names: stepNamesValue,
+ step_name_regex: stepRegexValue,
+ step_name_mode: stepNameMode,
selector_path: control.control.selector.path ?? "*",
action_decision: control.control.action.decision,
execution: control.control.execution ?? "server",
@@ -243,6 +251,7 @@ export const EditControlContent = ({
.map((value) => value.trim())
.filter(Boolean);
const stepNameRegex = values.step_name_regex.trim();
+ const isRegexMode = values.step_name_mode === "regex";
const definition = {
...control.control,
@@ -250,8 +259,8 @@ export const EditControlContent = ({
execution: values.execution,
scope: {
step_types: stepTypes.length > 0 ? stepTypes : undefined,
- step_names: stepNames.length > 0 ? stepNames : undefined,
- step_name_regex: stepNameRegex || undefined,
+ step_names: !isRegexMode && stepNames.length > 0 ? stepNames : undefined,
+ step_name_regex: isRegexMode ? stepNameRegex || undefined : undefined,
stages: values.stages.length > 0 ? values.stages : undefined,
},
selector: { ...control.control.selector, path: values.selector_path },
@@ -259,182 +268,221 @@ export const EditControlContent = ({
evaluator: { ...control.control.evaluator, config: finalConfig },
};
- try {
- if (isCreating) {
- // Create mode: use addControlToAgent
- await addControlToAgent.mutateAsync({
- agentId,
- controlName: values.name,
- definition,
- });
- } else {
- // Edit mode: use updateControl
- await updateControl.mutateAsync({
- agentId,
- controlId: control.id,
- definition,
- });
- }
- onSuccess?.();
- onClose();
- } catch (error) {
- if (isApiError(error)) {
- const problemDetail = error.problemDetail;
- setApiError(problemDetail);
-
- if (problemDetail.errors) {
- if (configViewMode === "form") {
- // Apply field-level errors to forms, capture unmapped ones
- const unmapped = applyApiErrorsToForms(
- problemDetail.errors,
- definitionForm,
- evaluatorForm
- );
- setUnmappedErrors(
- unmapped.map((e) => ({ field: e.field, message: e.message }))
- );
+ const runSave = async () => {
+ try {
+ if (isCreating) {
+ await addControlToAgent.mutateAsync({
+ agentId,
+ controlName: values.name,
+ definition,
+ });
+ } else {
+ await updateControl.mutateAsync({
+ agentId,
+ controlId: control.id,
+ definition,
+ });
+ }
+ // Call onSuccess first (which should close all modals)
+ // Only call onClose if onSuccess is not provided (for backward compatibility)
+ if (onSuccess) {
+ onSuccess();
+ } else {
+ onClose();
+ }
+ } catch (error) {
+ if (isApiError(error)) {
+ const problemDetail = error.problemDetail;
+
+ // Check if this is a "name already exists" error (409 Conflict or similar)
+ // and map it to the name field if it's not already in the errors array
+ const isNameExistsError =
+ (problemDetail.status === 409 ||
+ problemDetail.error_code === "CONTROL_NAME_EXISTS" ||
+ (problemDetail.detail?.toLowerCase().includes("name") &&
+ problemDetail.detail?.toLowerCase().includes("already exists"))) &&
+ !problemDetail.errors?.some(e => e.field === "name");
+
+ if (isNameExistsError) {
+ // Set error directly on the name field
+ definitionForm.setFieldError("name", problemDetail.detail || "Control name already exists");
+ // Don't show it in the alert since it's now on the field
+ setApiError(null);
+ setUnmappedErrors([]);
} else {
- // In JSON view, show all errors in the main alert
- setUnmappedErrors(
- problemDetail.errors.map((e) => ({
- field: e.field,
- message: e.message,
- }))
- );
+ setApiError(problemDetail);
+
+ if (problemDetail.errors) {
+ if (configViewMode === "form") {
+ const unmapped = applyApiErrorsToForms(
+ problemDetail.errors,
+ definitionForm,
+ evaluatorForm
+ );
+ setUnmappedErrors(
+ unmapped.map((e) => ({ field: e.field, message: e.message }))
+ );
+ } else {
+ setUnmappedErrors(
+ problemDetail.errors.map((e) => ({
+ field: e.field,
+ message: e.message,
+ }))
+ );
+ }
+ }
}
+ } else {
+ setApiError({
+ type: "about:blank",
+ title: "Error",
+ status: 500,
+ detail:
+ error instanceof Error
+ ? error.message
+ : "An unexpected error occurred",
+ error_code: "UNKNOWN_ERROR",
+ reason: "Unknown",
+ });
}
- } else {
- // Unexpected error
- setApiError({
- type: "about:blank",
- title: "Error",
- status: 500,
- detail:
- error instanceof Error
- ? error.message
- : "An unexpected error occurred",
- error_code: "UNKNOWN_ERROR",
- reason: "Unknown",
- });
}
- }
+ };
+
+ modals.openConfirmModal({
+ title: isCreating ? "Create control?" : "Save changes?",
+ children: (
+
+ {isCreating
+ ? "This will add the new control to the agent."
+ : "This will update the control configuration."}
+
+ ),
+ labels: { confirm: "Confirm", cancel: "Cancel" },
+ confirmProps: {
+ variant: "filled",
+ color: "violet",
+ size: "sm",
+ className: "confirm-modal-confirm-btn",
+ },
+ cancelProps: { variant: "default", size: "sm" },
+ onConfirm: runSave,
+ });
};
// Render the evaluator's form component
const FormComponent = evaluator?.FormComponent;
return (
- <>
+
- >
+
);
};
diff --git a/ui/src/core/page-components/agent-detail/modals/edit-control/step-name-input.tsx b/ui/src/core/page-components/agent-detail/modals/edit-control/step-name-input.tsx
new file mode 100644
index 00000000..3e27652f
--- /dev/null
+++ b/ui/src/core/page-components/agent-detail/modals/edit-control/step-name-input.tsx
@@ -0,0 +1,69 @@
+import {
+ Box,
+ Group,
+ Stack,
+ Switch,
+ Text,
+ TextInput,
+ Tooltip,
+} from "@mantine/core";
+import { IconInfoCircle } from "@tabler/icons-react";
+
+import type { ControlDefinitionFormProps } from "./types";
+
+export function StepNameInput({ form }: ControlDefinitionFormProps) {
+ const isRegexMode = form.values.step_name_mode === "regex";
+
+ const handleRegexToggle = (enabled: boolean) => {
+ form.setFieldValue("step_name_mode", enabled ? "regex" : "names");
+ };
+
+ return (
+
+
+
+
+ Step name
+
+
+
+ {isRegexMode
+ ? "Optional RE2 pattern to match step names."
+ : "Comma-separated step names to scope this control."}
+
+
+ {isRegexMode
+ ? "Toggle off to use comma-separated step names."
+ : "Toggle on to use a regex pattern instead."}
+
+
+ }
+ >
+
+
+
+ handleRegexToggle(e.currentTarget.checked)}
+ />
+
+ {isRegexMode ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/ui/src/core/page-components/agent-detail/modals/edit-control/types.ts b/ui/src/core/page-components/agent-detail/modals/edit-control/types.ts
index 05354cce..fb9fd3b6 100644
--- a/ui/src/core/page-components/agent-detail/modals/edit-control/types.ts
+++ b/ui/src/core/page-components/agent-detail/modals/edit-control/types.ts
@@ -30,6 +30,7 @@ export interface ControlDefinitionFormValues {
stages: ControlStage[];
step_names: string;
step_name_regex: string;
+ step_name_mode: "names" | "regex";
selector_path: string;
action_decision: ControlActionDecision;
execution: ControlExecution;
diff --git a/ui/src/core/page-components/agent-detail/modals/edit-control/utils.ts b/ui/src/core/page-components/agent-detail/modals/edit-control/utils.ts
index a1ab8ba0..3f7c863a 100644
--- a/ui/src/core/page-components/agent-detail/modals/edit-control/utils.ts
+++ b/ui/src/core/page-components/agent-detail/modals/edit-control/utils.ts
@@ -138,3 +138,16 @@ export function applyApiErrorsToForms(
return unmappedErrors;
}
+
+/**
+ * Sanitize a string for use in a control name segment.
+ * Control names must match ^[a-zA-Z0-9][a-zA-Z0-9_-]*$
+ */
+export function sanitizeControlNamePart(s: string): string {
+ const sanitized = s
+ .trim()
+ .replace(/\s+/g, "-")
+ .replace(/[^a-zA-Z0-9_-]/g, "")
+ .replace(/^[-_]+/, "");
+ return sanitized || "control";
+}
diff --git a/ui/src/core/page-components/agent-detail/monitor/control-stats-table.tsx b/ui/src/core/page-components/agent-detail/monitor/control-stats-table.tsx
new file mode 100644
index 00000000..cb138704
--- /dev/null
+++ b/ui/src/core/page-components/agent-detail/monitor/control-stats-table.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import {
+ Badge,
+ Box,
+ Card,
+ Group,
+ Stack,
+ Table,
+ Text,
+ Title,
+ Tooltip,
+} from "@mantine/core";
+
+import type { ControlStats } from "@/core/hooks/query-hooks/use-agent-monitor";
+
+interface ControlStatsTableProps {
+ stats: ControlStats[];
+}
+
+export function ControlStatsTable({ stats }: ControlStatsTableProps) {
+ return (
+
+
+ Per-Control Statistics
+
+
+
+
+
+ Control
+ Executions
+ Triggers
+ Non-Matches
+ Actions
+ Errors
+
+
+
+ {stats.map((control) => {
+ const triggerRate =
+ control.execution_count > 0
+ ? (control.match_count / control.execution_count) * 100
+ : 0;
+
+ // Calculate total actions for progress bar
+ const totalActions =
+ (control.allow_count || 0) +
+ (control.deny_count || 0) +
+ (control.warn_count || 0) +
+ (control.log_count || 0);
+
+ // Calculate percentages for each action type
+ const allowPercent =
+ totalActions > 0 ? ((control.allow_count || 0) / totalActions) * 100 : 0;
+ const denyPercent =
+ totalActions > 0 ? ((control.deny_count || 0) / totalActions) * 100 : 0;
+ const warnPercent =
+ totalActions > 0 ? ((control.warn_count || 0) / totalActions) * 100 : 0;
+ const logPercent =
+ totalActions > 0 ? ((control.log_count || 0) / totalActions) * 100 : 0;
+
+ return (
+
+
+
+ {control.control_name}
+
+
+
+
+ {control.execution_count.toLocaleString()}
+ {control.execution_count > 0 && (
+
+ ({triggerRate.toFixed(1)}%)
+
+ )}
+
+
+
+
+
+ {control.match_count}
+
+ {totalActions > 0 ? (
+
+ Actions Breakdown:
+ Allow: {control.allow_count || 0} ({allowPercent.toFixed(1)}%)
+ Deny: {control.deny_count || 0} ({denyPercent.toFixed(1)}%)
+ Warn: {control.warn_count || 0} ({warnPercent.toFixed(1)}%)
+ Log: {control.log_count || 0} ({logPercent.toFixed(1)}%)
+ Total: {totalActions}
+
+ }
+ >
+
+ {allowPercent > 0 && (
+
+ )}
+ {denyPercent > 0 && (
+
+ )}
+ {warnPercent > 0 && (
+
+ )}
+ {logPercent > 0 && (
+
+ )}
+
+
+ ) : (
+
+ -
+
+ )}
+
+
+
+
+ {control.non_match_count}
+
+
+
+ {totalActions > 0 ? (
+ {totalActions.toLocaleString()}
+ ) : (
+
+ -
+
+ )}
+
+
+ {control.error_count > 0 ? (
+
+ {control.error_count}
+
+ ) : (
+
+ 0
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/ui/src/core/page-components/agent-detail/monitor/index.tsx b/ui/src/core/page-components/agent-detail/monitor/index.tsx
new file mode 100644
index 00000000..6c64ba7b
--- /dev/null
+++ b/ui/src/core/page-components/agent-detail/monitor/index.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import {
+ Box,
+ Group,
+ Loader,
+ Stack,
+ Text,
+ Title,
+} from "@mantine/core";
+import { type TimeRangeOption,TimeRangeSwitch } from "@rungalileo/jupiter-ds";
+import { IconAlertCircle } from "@tabler/icons-react";
+import React, { useMemo } from "react";
+
+// Custom segment options from 5m to 1Y
+const TIME_RANGE_SEGMENTS: TimeRangeOption[] = [
+ { label: "5m", value: "last5Mins" },
+ { label: "15m", value: "last15Mins" },
+ { label: "1H", value: "lastHour" },
+ { label: "12H", value: "last12Hours" },
+ { label: "1D", value: "last24Hours" },
+ { label: "1W", value: "lastWeek" },
+ { label: "1M", value: "lastMonth" },
+ { label: "1Y", value: "lastYear" },
+];
+
+import type { StatsResponse } from "@/core/hooks/query-hooks/use-agent-monitor";
+import { useAgentMonitor } from "@/core/hooks/query-hooks/use-agent-monitor";
+import { useTimeRangePreference } from "@/core/hooks/use-time-range-preference";
+
+import { ControlStatsTable } from "./control-stats-table";
+import { SummaryCard } from "./summary-card";
+import type { SummaryMetrics } from "./types";
+import { mapTimeRangeTypeToTimeRange } from "./utils";
+
+interface AgentsMonitorProps {
+ agentUuid: string;
+}
+
+function calculateSummary(stats: StatsResponse | undefined): SummaryMetrics | null {
+ if (!stats) return null;
+
+ const actionCounts = stats.totals.action_counts ?? {};
+
+ return {
+ totalExecutions: stats.totals.execution_count,
+ totalMatches: stats.totals.match_count,
+ totalNonMatches: stats.totals.non_match_count,
+ totalErrors: stats.totals.error_count,
+ denyRate: stats.totals.execution_count > 0
+ ? ((actionCounts.deny || 0) / stats.totals.execution_count) * 100
+ : 0,
+ matchRate: stats.totals.execution_count > 0
+ ? (stats.totals.match_count / stats.totals.execution_count) * 100
+ : 0,
+ actionCounts,
+ };
+}
+
+export function AgentsMonitor({ agentUuid }: AgentsMonitorProps) {
+ // Use localStorage preference hook (defaults to 1W)
+ const [timeRangeValue, setTimeRangeValue] = useTimeRangePreference();
+
+ // Convert to API TimeRange only when calling the API
+ const apiTimeRange = useMemo(
+ () => mapTimeRangeTypeToTimeRange(timeRangeValue.type),
+ [timeRangeValue.type]
+ );
+
+ const {
+ data: stats,
+ isLoading,
+ error,
+ } = useAgentMonitor(agentUuid, apiTimeRange, {
+ refetchInterval: 5000, // Poll every 5 seconds
+ includeTimeseries: true, // Always fetch timeseries for trend chart
+ });
+
+ // Calculate summary metrics
+ const summary = useMemo(() => calculateSummary(stats), [stats]);
+
+ if (isLoading && !stats) {
+ return (
+
+
+
+ Loading stats...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ Failed to load stats
+
+
+ {error instanceof Error ? error.message : "Unknown error"}
+
+
+
+ );
+ }
+
+ // Create empty summary if no data
+ const displaySummary = summary || {
+ totalExecutions: 0,
+ totalMatches: 0,
+ totalNonMatches: 0,
+ totalErrors: 0,
+ denyRate: 0,
+ matchRate: 0,
+ actionCounts: {},
+ };
+
+ return (
+
+ {/* Header with time range selector - always visible */}
+
+
+ Control Statistics
+
+
+
+
+ {/* Always show the summary card - it handles empty state internally */}
+
+
+ {/* Show table only if there's data, otherwise show empty state message */}
+ {stats && stats.controls.length > 0 ? (
+
+ ) : (
+
+
+ Per-control statistics will appear here once controls are executed.
+
+
+ )}
+
+ );
+}
diff --git a/ui/src/core/page-components/agent-detail/monitor/summary-card.tsx b/ui/src/core/page-components/agent-detail/monitor/summary-card.tsx
new file mode 100644
index 00000000..762d2798
--- /dev/null
+++ b/ui/src/core/page-components/agent-detail/monitor/summary-card.tsx
@@ -0,0 +1,453 @@
+"use client";
+
+import { LineChart } from "@mantine/charts";
+import {
+ Box,
+ Card,
+ Grid,
+ Group,
+ RingProgress,
+ SimpleGrid,
+ Stack,
+ Text,
+ Tooltip,
+} from "@mantine/core";
+import {
+ IconActivity,
+ IconAlertTriangle,
+ IconCheck,
+ IconX,
+} from "@tabler/icons-react";
+import React, { useEffect, useMemo, useState } from "react";
+
+import type { TimeseriesBucket } from "@/core/hooks/query-hooks/use-agent-monitor";
+
+import type { SummaryMetrics } from "./types";
+
+interface SummaryCardProps {
+ summary: SummaryMetrics;
+ timeseries?: TimeseriesBucket[] | null;
+ timeRange: string;
+}
+
+// Format timestamp based on time range
+function formatTimestamp(timestamp: string, timeRange: string): string {
+ const date = new Date(timestamp);
+
+ if (["1m", "5m", "15m", "1h"].includes(timeRange)) {
+ return date.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit"
+ });
+ }
+
+ if (timeRange === "24h") {
+ return date.toLocaleString([], {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ }
+
+ return date.toLocaleDateString([], {
+ month: "short",
+ day: "numeric",
+ });
+}
+
+// Metric card component - uses neutral background with colored left border accent
+function MetricCard({
+ label,
+ value,
+ icon: Icon,
+ color,
+ tooltip,
+}: {
+ label: string;
+ value: number;
+ icon: React.ElementType;
+ color: string;
+ tooltip?: string;
+}) {
+ const content = (
+
+
+
+
+
+
+
+ {label}
+
+
+ {value.toLocaleString()}
+
+
+
+
+ );
+
+ if (tooltip) {
+ return {content};
+ }
+ return content;
+}
+
+// Custom hook for animating a value from 0 to 1
+function useAnimatedProgress(dependencies: unknown[], duration = 1000) {
+ const [progress, setProgress] = useState(0);
+ const animationRef = React.useRef(undefined);
+ const startTimeRef = React.useRef(undefined);
+
+ useEffect(() => {
+ // Start fresh animation
+ startTimeRef.current = Date.now();
+
+ const animate = () => {
+ const elapsed = Date.now() - (startTimeRef.current || Date.now());
+ const rawProgress = Math.min(elapsed / duration, 1);
+ // Ease-out cubic for smooth deceleration
+ const eased = 1 - Math.pow(1 - rawProgress, 3);
+ setProgress(eased);
+
+ if (rawProgress < 1) {
+ animationRef.current = requestAnimationFrame(animate);
+ }
+ };
+
+ // Start from 0 by immediately setting and then animating
+ setProgress(0);
+ // Use setTimeout to ensure the 0 is rendered before animating
+ const timeoutId = setTimeout(() => {
+ animationRef.current = requestAnimationFrame(animate);
+ }, 16);
+
+ return () => {
+ clearTimeout(timeoutId);
+ if (animationRef.current) {
+ cancelAnimationFrame(animationRef.current);
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, dependencies);
+
+ return progress;
+}
+
+export function SummaryCard({ summary, timeseries, timeRange }: SummaryCardProps) {
+ // Animation progress for RingProgress (0 to 1)
+ const ringAnimationProgress = useAnimatedProgress(
+ [timeRange, summary.totalMatches],
+ 1000
+ );
+
+ // Transform timeseries data for Mantine charts
+ const chartData = useMemo(() => {
+ if (!timeseries) return [];
+ return timeseries.map((bucket) => ({
+ timestamp: formatTimestamp(bucket.timestamp, timeRange),
+ Triggers: bucket.match_count,
+ "Non-Matches": bucket.non_match_count,
+ Errors: bucket.error_count,
+ }));
+ }, [timeseries, timeRange]);
+
+ // Check if there's any chart data to display
+ const hasChartData = timeseries && timeseries.some(
+ (bucket) =>
+ bucket.match_count > 0 ||
+ bucket.non_match_count > 0 ||
+ bucket.error_count > 0
+ );
+
+ return (
+
+
+ {/* Top: Metrics Row */}
+
+
+
+
+
+
+
+ {/* Bottom: Chart + Actions Distribution */}
+
+ {/* Left: Trend Chart */}
+
+
+
+
+
+ Activity Trend
+
+ {/* Compact Legend */}
+
+
+
+ Triggers
+
+
+
+ Non-Matches
+
+
+
+ Errors
+
+
+
+
+ {hasChartData ? (
+
+ ) : (
+
+
+ No data available
+
+
+ Try adjusting the time range or wait for controls to be executed
+
+
+ )}
+
+
+
+
+ {/* Right: Actions Distribution */}
+
+
+
+ Actions Distribution
+
+
+ {summary.totalMatches > 0 ? (
+
+ {/* Donut Chart - larger, animated on data change */}
+
+
+ {summary.totalMatches}
+
+ triggers
+
+ }
+ />
+
+ {/* Legend - horizontal layout */}
+
+ {[
+ { key: "allow", label: "Allow", color: "green" },
+ { key: "deny", label: "Deny", color: "red" },
+ { key: "warn", label: "Warn", color: "yellow" },
+ { key: "log", label: "Log", color: "blue" },
+ ].map(({ key, label, color }) => {
+ const count = summary.actionCounts?.[key] ?? 0;
+ if (count === 0) return null;
+ const percentage = ((count / summary.totalMatches) * 100).toFixed(0);
+ return (
+
+
+ {label}
+
+ {count} ({percentage}%)
+
+
+ );
+ })}
+
+
+ ) : (
+
+
+ No triggers yet
+
+
+ Actions will appear when controls match
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/core/page-components/agent-detail/monitor/types.ts b/ui/src/core/page-components/agent-detail/monitor/types.ts
new file mode 100644
index 00000000..09451fd6
--- /dev/null
+++ b/ui/src/core/page-components/agent-detail/monitor/types.ts
@@ -0,0 +1,11 @@
+import type { StatsTotals } from "@/core/hooks/query-hooks/use-agent-monitor";
+
+export type SummaryMetrics = {
+ totalExecutions: number;
+ totalMatches: number;
+ totalNonMatches: number;
+ totalErrors: number;
+ denyRate: number;
+ matchRate: number;
+ actionCounts: NonNullable;
+};
diff --git a/ui/src/core/page-components/agent-detail/monitor/utils.ts b/ui/src/core/page-components/agent-detail/monitor/utils.ts
new file mode 100644
index 00000000..47caf35f
--- /dev/null
+++ b/ui/src/core/page-components/agent-detail/monitor/utils.ts
@@ -0,0 +1,25 @@
+import type { TimeRangeType } from "@rungalileo/jupiter-ds";
+
+import type { TimeRange } from "@/core/hooks/query-hooks/use-agent-monitor";
+
+// Map Jupiter DS TimeRangeType to API TimeRange
+// API supports: 1m, 5m, 15m, 1h, 24h, 7d, 30d, 180d, 365d
+export function mapTimeRangeTypeToTimeRange(type: TimeRangeType): TimeRange {
+ const mapping: Record = {
+ last5Mins: "5m",
+ last15Mins: "15m",
+ last30Mins: "1h", // closest: 1h
+ lastHour: "1h",
+ last3Hours: "1h", // closest: 1h (no 3h in API)
+ last6Hours: "24h", // closest: 24h (no 6h in API)
+ last12Hours: "24h",
+ last24Hours: "24h",
+ last2Days: "7d", // closest: 7d (no 2d in API)
+ lastWeek: "7d",
+ lastMonth: "30d",
+ last6Months: "180d",
+ lastYear: "365d",
+ custom: "24h", // Default for custom ranges
+ };
+ return mapping[type];
+}
diff --git a/ui/src/core/page-components/home/home.tsx b/ui/src/core/page-components/home/home.tsx
index 52398c8e..1bac58f5 100644
--- a/ui/src/core/page-components/home/home.tsx
+++ b/ui/src/core/page-components/home/home.tsx
@@ -1,9 +1,9 @@
import {
Alert,
+ Box,
Center,
Group,
Loader,
- ScrollArea,
Stack,
Text,
Title,
@@ -43,7 +43,7 @@ const HomePage = () => {
});
// Infinite scroll setup
- const { sentinelRef, scrollContainerRef } = useInfiniteScroll({
+ const { sentinelRef } = useInfiniteScroll({
hasNextPage: hasNextPage ?? false,
isFetchingNextPage,
fetchNextPage,
@@ -106,7 +106,7 @@ const HomePage = () => {
{/* Scrollable Table Container */}
-
+
{isLoading ? (
@@ -130,6 +130,7 @@ const HomePage = () => {
onRowClick={handleRowClick}
highlightOnHover
withColumnBorders
+ maxHeight='calc(100dvh - 270px)'
/>
{/* Intersection observer trigger for infinite scroll */}
@@ -143,7 +144,7 @@ const HomePage = () => {
)}
>
)}
-
+
);
};
diff --git a/ui/src/pages/_app.tsx b/ui/src/pages/_app.tsx
index 5f4829bf..7a896d99 100644
--- a/ui/src/pages/_app.tsx
+++ b/ui/src/pages/_app.tsx
@@ -1,5 +1,7 @@
// Import Mantine styles
import "@mantine/core/styles.css";
+import "@mantine/dates/styles.css";
+import "@mantine/charts/styles.css";
// Import jupiter-ds styles
import "@rungalileo/jupiter-ds/styles.css";
// Import rungalileo icons styles
@@ -8,6 +10,8 @@ import "@rungalileo/icons/styles.css";
import "@/styles/globals.css";
import { MantineProvider } from "@mantine/core";
+import { DatesProvider } from "@mantine/dates";
+import { ModalsProvider } from "@mantine/modals";
import { JupiterThemeProvider } from "@rungalileo/jupiter-ds";
import type { AppProps } from "next/app";
import Head from "next/head";
@@ -104,9 +108,13 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
-
- {getLayout()}
-
+
+
+
+ {getLayout()}
+
+
+
diff --git a/ui/src/pages/agents/[id].tsx b/ui/src/pages/agents/[id].tsx
index ce93c6fe..a30d9957 100644
--- a/ui/src/pages/agents/[id].tsx
+++ b/ui/src/pages/agents/[id].tsx
@@ -3,7 +3,7 @@ import { useRouter } from "next/router";
import type { ReactElement } from "react";
import { AppLayout } from "@/core/layouts/app-layout";
-import AgentDetailPage from "@/core/page-components/agent-detail/agent-detail";
+import AgentDetailPage from "@/core/page-components/agent-detail/agent-detail";
import type { NextPageWithLayout } from "@/core/types/page";
const AgentPage: NextPageWithLayout = () => {
@@ -29,6 +29,8 @@ const AgentPage: NextPageWithLayout = () => {
throw new Error("Invalid agent ID");
}
+ // Let the component determine the default tab based on stats data
+ // It will check stats and redirect to the appropriate tab (monitor if data exists, controls otherwise)
return ;
};
diff --git a/ui/src/pages/agents/[id]/controls.tsx b/ui/src/pages/agents/[id]/controls.tsx
new file mode 100644
index 00000000..ef39bc62
--- /dev/null
+++ b/ui/src/pages/agents/[id]/controls.tsx
@@ -0,0 +1,40 @@
+import { Box, Center, Loader, Stack, Text } from "@mantine/core";
+import { useRouter } from "next/router";
+import { type ReactElement } from "react";
+
+import { AppLayout } from "@/core/layouts/app-layout";
+import AgentDetailPage from "@/core/page-components/agent-detail/agent-detail";
+import type { NextPageWithLayout } from "@/core/types/page";
+
+const AgentControlsPage: NextPageWithLayout = () => {
+ const router = useRouter();
+ const { id } = router.query;
+
+ // Show loading while router is initializing
+ if (!id) {
+ return (
+
+
+
+
+ Loading...
+
+
+
+ );
+ }
+
+ // TODO: This is a temporary fix to ensure the agent ID is a string.
+ if (typeof id !== "string") {
+ throw new Error("Invalid agent ID");
+ }
+
+ return ;
+};
+
+// Attach layout to page
+AgentControlsPage.getLayout = (page: ReactElement) => {
+ return {page};
+};
+
+export default AgentControlsPage;
diff --git a/ui/src/pages/agents/[id]/monitor.tsx b/ui/src/pages/agents/[id]/monitor.tsx
new file mode 100644
index 00000000..19f51726
--- /dev/null
+++ b/ui/src/pages/agents/[id]/monitor.tsx
@@ -0,0 +1,40 @@
+import { Box, Center, Loader, Stack, Text } from "@mantine/core";
+import { useRouter } from "next/router";
+import { type ReactElement } from "react";
+
+import { AppLayout } from "@/core/layouts/app-layout";
+import AgentDetailPage from "@/core/page-components/agent-detail/agent-detail";
+import type { NextPageWithLayout } from "@/core/types/page";
+
+const AgentMonitorPage: NextPageWithLayout = () => {
+ const router = useRouter();
+ const { id } = router.query;
+
+ // Show loading while router is initializing
+ if (!id) {
+ return (
+
+
+
+
+ Loading...
+
+
+
+ );
+ }
+
+ // TODO: This is a temporary fix to ensure the agent ID is a string.
+ if (typeof id !== "string") {
+ throw new Error("Invalid agent ID");
+ }
+
+ return ;
+};
+
+// Attach layout to page
+AgentMonitorPage.getLayout = (page: ReactElement) => {
+ return {page};
+};
+
+export default AgentMonitorPage;
diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css
index f02a82f3..cc32908e 100644
--- a/ui/src/styles/globals.css
+++ b/ui/src/styles/globals.css
@@ -24,3 +24,11 @@ a {
color: inherit;
text-decoration: none;
}
+
+/* Confirm modal button: white text in light mode, black in dark mode */
+[data-mantine-color-scheme="light"] .confirm-modal-confirm-btn {
+ color: var(--mantine-color-white);
+}
+[data-mantine-color-scheme="dark"] .confirm-modal-confirm-btn {
+ color: var(--mantine-color-black);
+}
diff --git a/ui/tests/agent-detail.spec.ts b/ui/tests/agent-detail.spec.ts
index b65f6b61..1841944e 100644
--- a/ui/tests/agent-detail.spec.ts
+++ b/ui/tests/agent-detail.spec.ts
@@ -1,10 +1,10 @@
import type { AgentControlsResponse, GetAgentResponse } from "@/core/api/types";
-import { expect, mockData, test } from "./fixtures";
+import { expect, mockData, mockRoutes, test } from "./fixtures";
test.describe("Agent Detail Page", () => {
const agentId = "agent-1";
- const agentUrl = `/agents/${agentId}`;
+ const agentUrl = `/agents/${agentId}/controls`;
// Type-safe access to mock agent data
const agentData: GetAgentResponse = mockData.agent;
@@ -26,20 +26,52 @@ test.describe("Agent Detail Page", () => {
// Check all tabs are present
await expect(mockedPage.getByRole("tab", { name: /Controls/i })).toBeVisible();
- await expect(mockedPage.getByRole("tab", { name: /Stats/i })).toBeVisible();
+ await expect(mockedPage.getByRole("tab", { name: /Monitor/i })).toBeVisible();
});
- test("controls tab is active by default", async ({ mockedPage }) => {
- await mockedPage.goto(agentUrl);
+ test("controls tab is active by default when no stats data", async ({ mockedPage }) => {
+ // Set up mocks with empty stats to ensure Controls tab is shown
+ await mockRoutes.agents(mockedPage);
+ await mockRoutes.agent(mockedPage);
+ await mockRoutes.stats(mockedPage, { data: mockData.emptyStats });
+
+ await mockedPage.goto(`/agents/${agentId}/controls`);
- // Controls tab should be selected
+ // Controls tab should be selected when no stats data exists
const controlsTab = mockedPage.getByRole("tab", { name: /Controls/i });
await expect(controlsTab).toHaveAttribute("aria-selected", "true");
+
+ // Monitor tab should not be selected
+ const monitorTab = mockedPage.getByRole("tab", { name: /Monitor/i });
+ await expect(monitorTab).toHaveAttribute("aria-selected", "false");
+ });
+
+ test("monitor tab is active by default when stats data exists", async ({ mockedPage }) => {
+ // Set up mocks with stats data to ensure Monitor tab is shown
+ await mockRoutes.agents(mockedPage);
+ await mockRoutes.agent(mockedPage);
+ await mockRoutes.stats(mockedPage, { data: mockData.stats });
+
+ await mockedPage.goto(`/agents/${agentId}/monitor`);
+
+ // Wait for stats to load and tab to be set
+ await mockedPage.waitForTimeout(100);
+
+ // Monitor tab should be selected when stats data exists
+ const monitorTab = mockedPage.getByRole("tab", { name: /Monitor/i });
+ await expect(monitorTab).toHaveAttribute("aria-selected", "true");
+
+ // Controls tab should not be selected
+ const controlsTab = mockedPage.getByRole("tab", { name: /Controls/i });
+ await expect(controlsTab).toHaveAttribute("aria-selected", "false");
});
test("displays controls table with data", async ({ mockedPage }) => {
await mockedPage.goto(agentUrl);
+ // Click Controls tab (monitor might be shown by default if stats exist)
+ await mockedPage.getByRole("tab", { name: "Controls" }).click();
+
// Wait for controls to load - scope to the Controls tab panel
const controlsPanel = mockedPage.getByRole("tabpanel", { name: /Controls/i });
await expect(controlsPanel.getByRole("table")).toBeVisible();
@@ -53,6 +85,9 @@ test.describe("Agent Detail Page", () => {
test("filters controls when searching", async ({ mockedPage }) => {
await mockedPage.goto(agentUrl);
+ // Click Controls tab (monitor might be shown by default if stats exist)
+ await mockedPage.getByRole("tab", { name: "Controls" }).click();
+
// Wait for controls to load
const controlsPanel = mockedPage.getByRole("tabpanel", { name: /Controls/i });
await expect(controlsPanel.getByRole("table")).toBeVisible();
@@ -83,16 +118,20 @@ test.describe("Agent Detail Page", () => {
test("displays control badges for step types and stages", async ({ mockedPage }) => {
await mockedPage.goto(agentUrl);
+ // Click Controls tab (monitor might be shown by default if stats exist)
+ await mockedPage.getByRole("tab", { name: "Controls" }).click();
+
// Wait for controls to load
- await expect(mockedPage.getByRole("table")).toBeVisible();
+ const controlsPanel = mockedPage.getByRole("tabpanel", { name: /Controls/i });
+ await expect(controlsPanel.getByRole("table")).toBeVisible();
- // Check that badges are displayed (LLM or Tool) - use first() since multiple rows may have same badge
- await expect(mockedPage.getByText("LLM").first()).toBeVisible();
- await expect(mockedPage.getByText("Tool").first()).toBeVisible();
+ // Check that badges are displayed (LLM or Tool) - scope to controls panel
+ await expect(controlsPanel.getByText("LLM").first()).toBeVisible();
+ await expect(controlsPanel.getByText("Tool").first()).toBeVisible();
- // Check stage badges (Pre or Post) - use first() since multiple rows may have same badge
- await expect(mockedPage.getByText("Pre").first()).toBeVisible();
- await expect(mockedPage.getByText("Post").first()).toBeVisible();
+ // Check stage badges (Pre or Post) - scope to controls panel
+ await expect(controlsPanel.getByText("Pre").first()).toBeVisible();
+ await expect(controlsPanel.getByText("Post").first()).toBeVisible();
});
test("shows Add Control button", async ({ mockedPage }) => {
@@ -110,6 +149,68 @@ test.describe("Agent Detail Page", () => {
// Control store modal should be visible
await expect(mockedPage.getByRole("heading", { name: "Control store" })).toBeVisible();
+
+ // URL should contain modal parameter
+ await expect(mockedPage).toHaveURL(/.*\?modal=control-store/);
+ });
+
+ test("closing edit modal removes query parameters", async ({ mockedPage }) => {
+ // Mock empty stats to ensure controls tab is shown
+ await mockRoutes.stats(mockedPage, { data: mockData.emptyStats });
+
+ // Open edit modal via URL
+ await mockedPage.goto(`${agentUrl}?modal=edit&controlId=1`);
+
+ const editModal = mockedPage.getByRole("dialog", { name: "Edit Control" });
+ await expect(editModal).toBeVisible();
+
+ // Close the modal (press Escape)
+ await mockedPage.keyboard.press("Escape");
+
+ // Modal should be closed
+ await expect(editModal).not.toBeVisible();
+
+ // URL should not contain modal parameters
+ await expect(mockedPage).not.toHaveURL(/.*\?modal=/);
+ });
+
+ test("closes edit modal when control is successfully updated", async ({ mockedPage }) => {
+ // Mock empty stats to ensure controls tab is shown
+ await mockRoutes.stats(mockedPage, { data: mockData.emptyStats });
+
+ // Open edit modal via URL
+ await mockedPage.goto(`${agentUrl}?modal=edit&controlId=1`);
+
+ const editModal = mockedPage.getByRole("dialog", { name: "Edit Control" });
+ await expect(editModal).toBeVisible();
+
+ // Mock successful API response for control update
+ await mockedPage.route("**/api/v1/controls/*/data", async (route, request) => {
+ if (request.method() === "PUT") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({}),
+ });
+ } else {
+ await route.continue();
+ }
+ });
+
+ // Submit the form
+ const saveButton = editModal.getByRole("button", { name: /Save/i });
+ await saveButton.click();
+
+ // Wait for confirmation modal and confirm
+ await mockedPage.waitForTimeout(300); // Wait for modal animation
+ const confirmButton = mockedPage.getByRole("button", { name: /Confirm/i });
+ await confirmButton.click({ force: true });
+
+ // Wait for modal to close
+ await expect(editModal).not.toBeVisible({ timeout: 5000 });
+
+ // URL should not contain any modal parameters
+ await expect(mockedPage).not.toHaveURL(/.*\?modal=/);
});
test("shows loading state while fetching controls", async ({ page }) => {
@@ -178,7 +279,7 @@ test.describe("Agent Detail Page", () => {
await mockedPage.goto(agentUrl);
// Click Stats tab
- await mockedPage.getByRole("tab", { name: /Stats/i }).click();
+ await mockedPage.getByRole("tab", { name: /Monitor/i }).click();
await expect(
mockedPage.getByRole("heading", { name: "Control Statistics", exact: true })
).toBeVisible();
@@ -188,15 +289,37 @@ test.describe("Agent Detail Page", () => {
await expect(mockedPage.getByRole("table")).toBeVisible();
});
+ test("opens edit control modal via URL query parameter", async ({ mockedPage }) => {
+ // Mock empty stats to ensure controls tab is shown
+ await mockRoutes.stats(mockedPage, { data: mockData.emptyStats });
+
+ await mockedPage.goto(`${agentUrl}?modal=edit&controlId=1`);
+
+ // Edit modal should be visible
+ const editModal = mockedPage.getByRole("dialog", { name: "Edit Control" });
+ await expect(editModal).toBeVisible();
+
+ // URL should contain modal and controlId parameters
+ await expect(mockedPage).toHaveURL(/.*\?modal=edit&controlId=1/);
+ });
+
test("opens edit control modal when edit button is clicked", async ({ mockedPage }) => {
await mockedPage.goto(agentUrl);
+ // Click Controls tab (monitor might be shown by default if stats exist)
+ await mockedPage.getByRole("tab", { name: "Controls" }).click();
+
// Wait for controls to load
- await expect(mockedPage.getByRole("table")).toBeVisible();
+ const controlsPanel = mockedPage.getByRole("tabpanel", { name: /Controls/i });
+ await expect(controlsPanel.getByRole("table")).toBeVisible();
- // Find and click the first edit button in a row
- const rows = mockedPage.locator("tbody tr");
+ // Find and click the first edit button in a row (scope to controls panel)
+ const rows = controlsPanel.locator("tbody tr");
const firstRow = rows.first();
+
+ // Scroll to the row to ensure it's in view
+ await firstRow.scrollIntoViewIfNeeded();
+
const editButton = firstRow.locator('button:has(svg[class*="icon-pencil"])');
// If that doesn't work, try clicking any action button in the row
@@ -204,55 +327,63 @@ test.describe("Agent Detail Page", () => {
const actionButtons = firstRow.locator("button").last();
await actionButtons.click();
} else {
- await editButton.click();
+ // Force click if button exists but might be hidden due to CSS
+ await editButton.click({ force: true });
}
// Edit modal should be visible
- await expect(mockedPage.getByRole("heading", { name: "Configure Control" })).toBeVisible();
+ await expect(mockedPage.getByRole("dialog", { name: "Edit Control" })).toBeVisible();
+
+ // URL should contain modal and controlId parameters
+ await expect(mockedPage).toHaveURL(/.*\?modal=edit&controlId=\d+/);
});
test("edit control modal pre-fills scope and execution fields", async ({ mockedPage }) => {
await mockedPage.goto(agentUrl);
+ // Click Controls tab (monitor might be shown by default if stats exist)
+ await mockedPage.getByRole("tab", { name: "Controls" }).click();
+
// Wait for controls to load
- await expect(mockedPage.getByRole("table")).toBeVisible();
+ const controlsPanel = mockedPage.getByRole("tabpanel", { name: /Controls/i });
+ await expect(controlsPanel.getByRole("table")).toBeVisible();
- const targetRow = mockedPage.locator("tr", { hasText: "SQL Injection Guard" });
+ // Find the row for "SQL Injection Guard" and click its edit button (scope to controls panel)
+ const targetRow = controlsPanel.locator("tr", { hasText: "SQL Injection Guard" });
+
+ // Scroll to the row to ensure it's in view
+ await targetRow.scrollIntoViewIfNeeded();
+
const editButton = targetRow.locator('button:has(svg[class*="icon-pencil"])');
if ((await editButton.count()) === 0) {
await targetRow.locator("button").last().click();
} else {
- await editButton.click();
+ // Force click if button exists but might be hidden due to CSS
+ await editButton.click({ force: true });
}
- await expect(
- mockedPage.getByRole("heading", { name: "Configure Control" })
- ).toBeVisible();
-
- const modal = mockedPage.getByRole("dialog");
+ const modal = mockedPage.getByRole("dialog", { name: "Edit Control" });
+ await expect(modal).toBeVisible();
await expect(modal.getByText("Step types")).toBeVisible();
await expect(modal.getByText("Stages")).toBeVisible();
- await expect(modal.getByText("Step names")).toBeVisible();
- await expect(modal.getByText("Step name regex")).toBeVisible();
+ await expect(modal.getByText("Step name")).toBeVisible();
+ await expect(modal.getByText("Regex")).toBeVisible();
await expect(modal.getByText("Execution environment")).toBeVisible();
await expect(modal.getByText("tool", { exact: true })).toBeVisible();
await expect(
modal.getByText("Pre (before execution)", { exact: true })
).toBeVisible();
+ // Step name: mock has both step_names and step_name_regex; form shows one (names mode when both set)
await expect(modal.getByPlaceholder("search_db, fetch_user")).toHaveValue(
"database_query"
);
- await expect(modal.getByPlaceholder("^db_.*")).toHaveValue("^db_.*");
+ // Execution environment is a Select; assert label is visible (selected value may be in closed dropdown)
const executionLabel = modal.getByText("Execution environment", { exact: true });
await executionLabel.scrollIntoViewIfNeeded();
await expect(executionLabel).toBeVisible();
-
- const executionField = executionLabel.locator("..").locator("..");
- const executionInput = executionField.getByRole("textbox");
- await expect(executionInput).toHaveValue("Server");
});
});
@@ -279,7 +410,7 @@ test.describe("Agent Detail - Empty State", () => {
});
});
- await page.goto("/agents/agent-1");
+ await page.goto("/agents/agent-1/controls");
// Check for empty state message
await expect(page.getByText("No controls configured")).toBeVisible();
diff --git a/ui/tests/agent-stats.spec.ts b/ui/tests/agent-stats.spec.ts
index 30986422..dfe055a3 100644
--- a/ui/tests/agent-stats.spec.ts
+++ b/ui/tests/agent-stats.spec.ts
@@ -1,16 +1,16 @@
import { expect, mockData, mockRoutes, test } from "./fixtures";
-test.describe("Agent Stats Tab", () => {
+test.describe("Agent Monitor Tab", () => {
test.beforeEach(async ({ mockedPage }) => {
// Navigate to agent detail page
- await mockedPage.goto("/agents/agent-1");
+ await mockedPage.goto("/agents/agent-1/monitor");
// Wait for the page to load
await expect(mockedPage.getByText("Customer Support Bot")).toBeVisible();
});
test("should display stats tab and navigate to it", async ({ mockedPage }) => {
// Stats tab should be visible
- const statsTab = mockedPage.getByRole("tab", { name: "Stats" });
+ const statsTab = mockedPage.getByRole("tab", { name: "Monitor" });
await expect(statsTab).toBeVisible();
// Click on stats tab
@@ -26,32 +26,33 @@ test.describe("Agent Stats Tab", () => {
mockedPage,
}) => {
// Navigate to stats tab
- await mockedPage.getByRole("tab", { name: "Stats" }).click();
+ await mockedPage.getByRole("tab", { name: "Monitor" }).click();
- // Time range selector should be visible with default "Last 1 hour"
- const timeRangeSelect = mockedPage.getByRole("textbox", { name: "Time Range" });
- await expect(timeRangeSelect).toBeVisible();
- await expect(timeRangeSelect).toHaveValue("Last 1 hour");
+ // Time range selector should be visible (TimeRangeSwitch component)
+ // Look for the component by finding the segment buttons or menu button
+ const timeRangeSwitch = mockedPage.locator('[class*="TimeRangeSwitch"]').first();
+ await expect(timeRangeSwitch).toBeVisible();
});
test("should display summary statistics", async ({ mockedPage }) => {
// Navigate to stats tab
- await mockedPage.getByRole("tab", { name: "Stats" }).click();
+ await mockedPage.getByRole("tab", { name: "Monitor" }).click();
// Check total executions
await expect(
- mockedPage.getByText(mockData.stats.total_executions.toLocaleString())
+ mockedPage.getByText(mockData.stats.totals.execution_count.toLocaleString())
).toBeVisible();
// Check for badges showing matches and non-matches (use first() to get badge, not table header)
await expect(mockedPage.getByText("Non-Matches").first()).toBeVisible();
+ // Note: "Matches" badge text is still "Matches" in the summary card, but table column is "Triggers"
await expect(mockedPage.getByText("Matches").first()).toBeVisible();
await expect(mockedPage.getByText("Errors").first()).toBeVisible();
});
test("should display actions distribution section", async ({ mockedPage }) => {
// Navigate to stats tab
- await mockedPage.getByRole("tab", { name: "Stats" }).click();
+ await mockedPage.getByRole("tab", { name: "Monitor" }).click();
// Check actions distribution header
await expect(mockedPage.getByText("Actions Distribution")).toBeVisible();
@@ -65,7 +66,7 @@ test.describe("Agent Stats Tab", () => {
test("should display per-control statistics table", async ({ mockedPage }) => {
// Navigate to stats tab
- await mockedPage.getByRole("tab", { name: "Stats" }).click();
+ await mockedPage.getByRole("tab", { name: "Monitor" }).click();
// Check table header
await expect(
@@ -76,47 +77,45 @@ test.describe("Agent Stats Tab", () => {
await expect(mockedPage.getByRole("columnheader", { name: "Control" })).toBeVisible();
await expect(mockedPage.getByRole("columnheader", { name: "Executions" })).toBeVisible();
await expect(
- mockedPage.getByRole("columnheader", { name: "Matches", exact: true })
+ mockedPage.getByRole("columnheader", { name: "Triggers", exact: true })
).toBeVisible();
await expect(mockedPage.getByRole("columnheader", { name: "Non-Matches" })).toBeVisible();
await expect(mockedPage.getByRole("columnheader", { name: "Actions" })).toBeVisible();
await expect(mockedPage.getByRole("columnheader", { name: "Errors" })).toBeVisible();
- await expect(
- mockedPage.getByRole("columnheader", { name: "Avg Confidence" })
- ).toBeVisible();
});
test("should display control names in the table", async ({ mockedPage }) => {
// Navigate to stats tab
- await mockedPage.getByRole("tab", { name: "Stats" }).click();
+ await mockedPage.getByRole("tab", { name: "Monitor" }).click();
// Check control names from mock data - scope to Stats panel table
- const statsTable = mockedPage.getByRole("tabpanel", { name: /Stats/i }).getByRole("table");
- for (const stat of mockData.stats.stats) {
+ const statsTable = mockedPage.getByRole("tabpanel", { name: /Monitor/i }).getByRole("table");
+ for (const stat of mockData.stats.controls) {
await expect(statsTable.getByText(stat.control_name)).toBeVisible();
}
});
test("should allow changing time range", async ({ mockedPage }) => {
// Navigate to stats tab
- await mockedPage.getByRole("tab", { name: "Stats" }).click();
-
- // Open time range selector
- const timeRangeSelect = mockedPage.getByRole("textbox", { name: "Time Range" });
- await timeRangeSelect.click();
-
- // Select a different time range
- await mockedPage.getByRole("option", { name: "Last 24 hours" }).click();
-
- // Verify the selection changed
- await expect(timeRangeSelect).toHaveValue("Last 24 hours");
+ await mockedPage.getByRole("tab", { name: "Monitor" }).click();
+
+ // TimeRangeSwitch should be visible and allow changing time range
+ // The component has segment buttons for quick selection
+ const timeRangeSwitch = mockedPage.locator('[class*="TimeRangeSwitch"]').first();
+ await expect(timeRangeSwitch).toBeVisible();
+
+ // Try clicking on a segment button (e.g., "1D" for 24 hours)
+ const oneDayButton = mockedPage.getByRole("button", { name: /1D/i }).first();
+ if (await oneDayButton.isVisible()) {
+ await oneDayButton.click();
+ }
});
test("should show error badges for controls with errors", async ({
mockedPage,
}) => {
// Navigate to stats tab
- await mockedPage.getByRole("tab", { name: "Stats" }).click();
+ await mockedPage.getByRole("tab", { name: "Monitor" }).click();
// SQL Injection Guard has 2 errors in mock data
// Find the row and check for error count
@@ -124,23 +123,9 @@ test.describe("Agent Stats Tab", () => {
await expect(errorBadge).toBeVisible();
});
- test("should show confidence badges with appropriate colors", async ({
- mockedPage,
- }) => {
- // Navigate to stats tab
- await mockedPage.getByRole("tab", { name: "Stats" }).click();
-
- // Check that confidence percentages are displayed
- // PII Detection has 92% confidence
- await expect(mockedPage.getByText("92%")).toBeVisible();
- // SQL Injection Guard has 88% confidence
- await expect(mockedPage.getByText("88%")).toBeVisible();
- // Rate Limiter has 95% confidence
- await expect(mockedPage.getByText("95%")).toBeVisible();
- });
});
-test.describe("Agent Stats Tab - Empty State", () => {
+test.describe("Agent Monitor Tab - Empty State", () => {
test("should show empty state when no stats available", async ({ page }) => {
// Set up mocks with empty stats
await mockRoutes.agents(page);
@@ -148,33 +133,35 @@ test.describe("Agent Stats Tab - Empty State", () => {
await mockRoutes.stats(page, { data: mockData.emptyStats });
// Navigate to agent detail page
- await page.goto("/agents/agent-1");
+ await page.goto("/agents/agent-1/monitor");
await expect(page.getByText("Customer Support Bot")).toBeVisible();
// Navigate to stats tab
- await page.getByRole("tab", { name: "Stats" }).click();
+ await page.getByRole("tab", { name: "Monitor" }).click();
- // Time range selector should still be visible in empty state
- await expect(page.getByRole("textbox", { name: "Time Range" })).toBeVisible();
+ // Time range selector should still be visible in empty state (TimeRangeSwitch)
+ const timeRangeSwitch = page.locator('[class*="TimeRangeSwitch"]').first();
+ await expect(timeRangeSwitch).toBeVisible();
- // Should show empty state message
- await expect(page.getByText("No stats available")).toBeVisible();
- await expect(
- page.getByText("Stats will appear here once controls are executed.")
- ).toBeVisible();
+ // Should show empty state messages in the charts
+ await expect(page.getByText("No data available")).toBeVisible();
+ await expect(page.getByText("No triggers yet")).toBeVisible();
});
});
-test.describe("Agent Stats Tab - Refetch Flow", () => {
+test.describe("Agent Monitor Tab - Refetch Flow", () => {
test("should update values when data is refetched", async ({ page }) => {
let requestCount = 0;
// Initial stats data
const initialStats: typeof mockData.stats = {
...mockData.stats,
- total_executions: 100,
- total_matches: 10,
- stats: [
+ totals: {
+ ...mockData.stats.totals,
+ execution_count: 100,
+ match_count: 10,
+ },
+ controls: [
{
control_id: 1,
control_name: "PII Detection",
@@ -195,9 +182,12 @@ test.describe("Agent Stats Tab - Refetch Flow", () => {
// Updated stats data (returned after first request)
const updatedStats: typeof mockData.stats = {
...mockData.stats,
- total_executions: 250,
- total_matches: 35,
- stats: [
+ totals: {
+ ...mockData.stats.totals,
+ execution_count: 250,
+ match_count: 35,
+ },
+ controls: [
{
control_id: 1,
control_name: "PII Detection",
@@ -228,29 +218,31 @@ test.describe("Agent Stats Tab - Refetch Flow", () => {
});
// Navigate to agent detail page
- await page.goto("/agents/agent-1");
+ await page.goto("/agents/agent-1/monitor");
await expect(page.getByText("Customer Support Bot")).toBeVisible();
// Navigate to stats tab
- await page.getByRole("tab", { name: "Stats" }).click();
+ await page.getByRole("tab", { name: "Monitor" }).click();
// Verify initial values are displayed (use first() to get summary stat, not table cell)
+ // Initial: 100 executions, 10 matches = 10% match rate
await expect(page.getByText("100", { exact: true }).first()).toBeVisible();
- await expect(page.getByText("85%")).toBeVisible();
+ await expect(page.getByText("10.0%").first()).toBeVisible();
// Wait for refetch (component polls every 5 seconds)
// We wait for the updated values to appear
+ // Updated: 250 executions, 35 matches = 14% match rate
await expect(page.getByText("250", { exact: true }).first()).toBeVisible({
timeout: 10000,
});
- await expect(page.getByText("91%")).toBeVisible();
+ await expect(page.getByText("14.0%").first()).toBeVisible();
// Verify the request was made multiple times
expect(requestCount).toBeGreaterThan(1);
});
});
-test.describe("Agent Stats Tab - Error State", () => {
+test.describe("Agent Monitor Tab - Error State", () => {
test("should show error state when API fails", async ({ page }) => {
// Set up mocks with failing stats endpoint
await mockRoutes.agents(page);
@@ -258,11 +250,11 @@ test.describe("Agent Stats Tab - Error State", () => {
await mockRoutes.stats(page, { error: "Internal server error", status: 500 });
// Navigate to agent detail page
- await page.goto("/agents/agent-1");
+ await page.goto("/agents/agent-1/monitor");
await expect(page.getByText("Customer Support Bot")).toBeVisible();
// Navigate to stats tab
- await page.getByRole("tab", { name: "Stats" }).click();
+ await page.getByRole("tab", { name: "Monitor" }).click();
// Should show error state
await expect(page.getByText("Failed to load stats")).toBeVisible();
diff --git a/ui/tests/control-store.spec.ts b/ui/tests/control-store.spec.ts
index d5f3bbbb..80c8ea22 100644
--- a/ui/tests/control-store.spec.ts
+++ b/ui/tests/control-store.spec.ts
@@ -2,7 +2,7 @@ import type { Page } from "@playwright/test";
import { expect, mockData, test } from "./fixtures";
-const agentUrl = "/agents/agent-1";
+const agentUrl = "/agents/agent-1/controls";
async function openControlStoreModal(page: Page) {
await page.goto(agentUrl);
@@ -19,7 +19,7 @@ async function openAddNewControlModal(page: Page) {
await controlStoreModal.getByTestId("footer-new-control-button").click();
const modal = page
.getByRole("dialog")
- .filter({ hasText: "Browse and add controls to your agent" });
+ .filter({ hasText: "Select an evaluator to create a new control" });
await expect(modal).toBeVisible();
return modal;
}
@@ -40,22 +40,22 @@ test.describe("Control Store Modal", () => {
await expect(modal.getByRole("columnheader", { name: "Name" })).toBeVisible();
await expect(modal.getByRole("columnheader", { name: "Description" })).toBeVisible();
- await expect(modal.getByRole("columnheader", { name: "Enabled" })).toBeVisible();
- await expect(modal.getByRole("columnheader", { name: "Used by" })).toBeVisible();
+ // Status dot column has no header text, so we skip checking for it
+ await expect(modal.getByRole("columnheader", { name: "Agent" })).toBeVisible();
for (const control of mockData.listControls.controls) {
await expect(modal.getByText(control.name, { exact: true })).toBeVisible();
}
});
- test("displays agent links in Used by column", async ({ mockedPage }) => {
+ test("displays agent links in Agent column", async ({ mockedPage }) => {
const modal = await openControlStoreModal(mockedPage);
// PII Detection is used by Customer Support Bot
const agentLink = modal.getByRole("link", { name: "Customer Support Bot" }).first();
await expect(agentLink).toBeVisible();
// Link includes query param to filter by control name
- await expect(agentLink).toHaveAttribute("href", "/agents/agent-1?q=PII%20Detection");
+ await expect(agentLink).toHaveAttribute("href", "/agents/agent-1/controls?q=PII%20Detection");
});
test("can search for controls", async ({ mockedPage }) => {
@@ -97,25 +97,25 @@ test.describe("Control Store Modal", () => {
).not.toBeVisible();
});
- test("Use button opens create control modal", async ({ mockedPage }) => {
+ test("Copy button opens create control modal", async ({ mockedPage }) => {
const modal = await openControlStoreModal(mockedPage);
const tableRow = modal.locator("tbody tr").first();
- await tableRow.getByTestId("use-control-button").click();
+ await tableRow.getByTestId("copy-control-button").click();
- await expect(mockedPage.getByRole("heading", { name: "Create Control" })).toBeVisible();
+ await expect(mockedPage.getByRole("dialog", { name: "Create Control" })).toBeVisible();
});
- test("Use button pre-fills control name and evaluator config", async ({ mockedPage }) => {
+ test("Copy button pre-fills control name and evaluator config", async ({ mockedPage }) => {
const modal = await openControlStoreModal(mockedPage);
const targetRow = modal.locator("tr", { hasText: "PII Detection" });
- await targetRow.getByTestId("use-control-button").click();
+ await targetRow.getByTestId("copy-control-button").click();
- const createControlModal = mockedPage
- .getByRole("dialog")
- .filter({ hasText: "Create Control" });
+ const createControlModal = mockedPage.getByRole("dialog", {
+ name: "Create Control",
+ });
await expect(createControlModal).toBeVisible();
- // Check control name is pre-filled with -copy suffix (sanitized)
+ // Check control name is pre-filled with -copy appended (sanitized)
const controlNameInput = createControlModal.getByPlaceholder("Enter control name");
await expect(controlNameInput).toHaveValue("PII-Detection-copy");
@@ -124,12 +124,12 @@ test.describe("Control Store Modal", () => {
await expect(patternInput).toHaveValue("\\b\\d{3}-\\d{2}-\\d{4}\\b");
});
- test("Footer 'Create new control' button opens add-new-control modal", async ({ mockedPage }) => {
+ test("Create Control button opens add-new-control modal", async ({ mockedPage }) => {
const modal = await openControlStoreModal(mockedPage);
await modal.getByTestId("footer-new-control-button").click();
await expect(
- mockedPage.getByText("Browse and add controls to your agent")
+ mockedPage.getByText("Select an evaluator to create a new control")
).toBeVisible();
});
});
@@ -137,19 +137,8 @@ test.describe("Control Store Modal", () => {
test.describe("Add New Control Modal", () => {
test("displays modal header and description", async ({ mockedPage }) => {
const modal = await openAddNewControlModal(mockedPage);
- await expect(modal.getByRole("heading", { name: "Control store" })).toBeVisible();
- await expect(modal.getByText("Browse and add controls to your agent")).toBeVisible();
- });
-
- test("displays source selection sidebar", async ({ mockedPage }) => {
- const modal = await openAddNewControlModal(mockedPage);
- await expect(modal.getByRole("button", { name: "OOB standard" })).toBeVisible();
- await expect(modal.getByRole("button", { name: "Custom" })).toBeVisible();
- });
-
- test("OOB standard is selected by default", async ({ mockedPage }) => {
- const modal = await openAddNewControlModal(mockedPage);
- await expect(modal.getByText("OOB standard")).toBeVisible();
+ await expect(modal.getByRole("heading", { name: "Create Control" })).toBeVisible();
+ await expect(modal.getByText("Select an evaluator to create a new control")).toBeVisible();
});
test("displays evaluators table with available evaluators", async ({
@@ -157,7 +146,6 @@ test.describe("Add New Control Modal", () => {
}) => {
const modal = await openAddNewControlModal(mockedPage);
await expect(modal.getByRole("columnheader", { name: "Name" })).toBeVisible();
- await expect(modal.getByRole("columnheader", { name: "Version" })).toBeVisible();
await expect(modal.getByRole("columnheader", { name: "Description" })).toBeVisible();
const evaluators = Object.values(mockData.evaluators);
@@ -168,7 +156,7 @@ test.describe("Add New Control Modal", () => {
test("can search for evaluators", async ({ mockedPage }) => {
const modal = await openAddNewControlModal(mockedPage);
- const searchInput = modal.getByPlaceholder("Search or apply filter...");
+ const searchInput = modal.getByPlaceholder("Search evaluators...");
await searchInput.fill("Regex");
await expect(modal.getByRole("cell", { name: "Regex" })).toBeVisible();
@@ -177,34 +165,403 @@ test.describe("Add New Control Modal", () => {
test("shows empty state when search has no results", async ({ mockedPage }) => {
const modal = await openAddNewControlModal(mockedPage);
- const searchInput = modal.getByPlaceholder("Search or apply filter...");
+ const searchInput = modal.getByPlaceholder("Search evaluators...");
await searchInput.fill("NonexistentEvaluator");
await expect(modal.getByText("No evaluators found")).toBeVisible();
});
- test("shows empty state for Custom source", async ({ mockedPage }) => {
+ test("Use button opens create control modal", async ({ mockedPage }) => {
const modal = await openAddNewControlModal(mockedPage);
- await modal.getByRole("button", { name: "Custom" }).click();
+ const tableRow = modal.locator("tbody tr").first();
+ await tableRow.getByRole("button", { name: "Use" }).click();
- await expect(modal.getByText("No custom controls yet")).toBeVisible();
- await expect(
- modal.getByText("Create your first custom control to get started")
- ).toBeVisible();
+ await expect(mockedPage.getByRole("dialog", { name: "Create Control" })).toBeVisible();
});
- test("Add button opens create control modal", async ({ mockedPage }) => {
+ test("displays docs link", async ({ mockedPage }) => {
const modal = await openAddNewControlModal(mockedPage);
- const tableRow = modal.locator("tbody tr").first();
- await tableRow.getByRole("button", { name: "Add" }).click();
+ await expect(modal.getByText("Learn here on how to add new type of evaluator.")).toBeVisible();
+ await expect(modal.getByText("Docs ↗")).toBeVisible();
+ });
+});
- await expect(mockedPage.getByRole("heading", { name: "Create Control" })).toBeVisible();
+test.describe("Modal Routing", () => {
+ test("opens control store modal via URL query parameter", async ({ mockedPage }) => {
+ await mockedPage.goto(`${agentUrl}?modal=control-store`);
+
+ const modal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Browse existing controls or create a new one" });
+ await expect(modal).toBeVisible();
+
+ // URL should contain modal parameter
+ await expect(mockedPage).toHaveURL(/.*\?modal=control-store/);
});
- test("displays docs link", async ({ mockedPage }) => {
- const modal = await openAddNewControlModal(mockedPage);
- await expect(modal.getByText("Looking to add custom control?")).toBeVisible();
- await expect(modal.getByText("Check our Docs ↗")).toBeVisible();
+ test("opens add-new-control modal via URL query parameters", async ({ mockedPage }) => {
+ await mockedPage.goto(`${agentUrl}?modal=control-store&submodal=add-new`);
+
+ // Both modals should be visible
+ const controlStoreModal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Browse existing controls or create a new one" });
+ await expect(controlStoreModal).toBeVisible();
+
+ const addNewModal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Select an evaluator to create a new control" });
+ await expect(addNewModal).toBeVisible();
+
+ // URL should contain both parameters
+ await expect(mockedPage).toHaveURL(/.*\?modal=control-store&submodal=add-new/);
+ });
+
+ test("opens create control modal via URL query parameters", async ({ mockedPage }) => {
+ await mockedPage.goto(`${agentUrl}?modal=control-store&submodal=create&evaluator=list`);
+
+ // Control store and add-new modals should be visible (create is nested inside add-new)
+ const controlStoreModal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Browse existing controls or create a new one" });
+ await expect(controlStoreModal).toBeVisible();
+
+ const addNewModal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Select an evaluator to create a new control" });
+ await expect(addNewModal).toBeVisible();
+
+ // Create control modal should be visible
+ const createModal = mockedPage.getByRole("dialog", { name: "Create Control" });
+ await expect(createModal).toBeVisible();
+
+ // URL should contain all parameters
+ await expect(mockedPage).toHaveURL(/.*\?modal=control-store&submodal=create&evaluator=list/);
+ });
+
+ test("opens edit control modal via URL query parameters (Copy flow)", async ({ mockedPage }) => {
+ // First, we need to get a control ID from the list
+ await mockedPage.goto(`${agentUrl}?modal=control-store`);
+ const modal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Browse existing controls or create a new one" });
+ await expect(modal).toBeVisible();
+
+ // Get the first control's ID (we'll use PII Detection which has ID 1 in mock data)
+ const targetRow = modal.locator("tr", { hasText: "PII Detection" });
+ await targetRow.getByTestId("copy-control-button").click();
+
+ // Wait for the edit modal to open
+ const editModal = mockedPage.getByRole("dialog", { name: "Create Control" });
+ await expect(editModal).toBeVisible();
+
+ // URL should contain edit submodal and controlId
+ await expect(mockedPage).toHaveURL(/.*\?modal=control-store&submodal=edit&controlId=\d+/);
+ });
+
+ test("closing create modal returns to add-new modal", async ({ mockedPage }) => {
+ await mockedPage.goto(`${agentUrl}?modal=control-store&submodal=create&evaluator=list`);
+
+ // Verify create modal is open
+ const createModal = mockedPage.getByRole("dialog", { name: "Create Control" });
+ await expect(createModal).toBeVisible();
+
+ // Close the create modal (press Escape or click close)
+ await mockedPage.keyboard.press("Escape");
+
+ // Wait for create modal to close
+ await expect(createModal).not.toBeVisible();
+
+ // Add-new modal should still be visible
+ const addNewModal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Select an evaluator to create a new control" });
+ await expect(addNewModal).toBeVisible();
+
+ // URL should be back to add-new
+ await expect(mockedPage).toHaveURL(/.*\?modal=control-store&submodal=add-new/);
+ });
+
+ test("closing edit modal (Copy flow) closes completely", async ({ mockedPage }) => {
+ // Open control store and click Copy
+ await mockedPage.goto(`${agentUrl}?modal=control-store`);
+ const modal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Browse existing controls or create a new one" });
+ await expect(modal).toBeVisible();
+
+ const targetRow = modal.locator("tr", { hasText: "PII Detection" });
+ await targetRow.getByTestId("copy-control-button").click();
+
+ // Wait for edit modal to open
+ const editModal = mockedPage.getByRole("dialog", { name: "Create Control" });
+ await expect(editModal).toBeVisible();
+
+ // Close the edit modal (press Escape)
+ await mockedPage.keyboard.press("Escape");
+
+ // Wait for edit modal to close
+ await expect(editModal).not.toBeVisible();
+
+ // Control store modal should still be visible
+ await expect(modal).toBeVisible();
+
+ // URL should only have modal parameter (no submodal)
+ await expect(mockedPage).toHaveURL(/.*\?modal=control-store(?!.*submodal)/);
+ });
+
+ test("modal state persists on page refresh", async ({ mockedPage }) => {
+ // Open modals via URL
+ await mockedPage.goto(`${agentUrl}?modal=control-store&submodal=add-new`);
+
+ // Verify modals are open
+ const controlStoreModal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Browse existing controls or create a new one" });
+ await expect(controlStoreModal).toBeVisible();
+
+ const addNewModal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Select an evaluator to create a new control" });
+ await expect(addNewModal).toBeVisible();
+
+ // Refresh the page
+ await mockedPage.reload();
+
+ // Modals should still be open after refresh
+ await expect(controlStoreModal).toBeVisible();
+ await expect(addNewModal).toBeVisible();
+
+ // URL should still have the parameters
+ await expect(mockedPage).toHaveURL(/.*\?modal=control-store&submodal=add-new/);
+ });
+
+ test("navigating through modal flow updates URL correctly", async ({ mockedPage }) => {
+ // Start at control store
+ await mockedPage.goto(`${agentUrl}?modal=control-store`);
+ await expect(mockedPage).toHaveURL(/.*\?modal=control-store(?!.*submodal)/);
+
+ // Click "Create Control" button
+ const controlStoreModal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Browse existing controls or create a new one" });
+ await controlStoreModal.getByTestId("footer-new-control-button").click();
+
+ // URL should update to include submodal=add-new
+ await expect(mockedPage).toHaveURL(/.*\?modal=control-store&submodal=add-new/);
+
+ // Click "Use" on an evaluator
+ const addNewModal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Select an evaluator to create a new control" });
+ const tableRow = addNewModal.locator("tbody tr").first();
+ await tableRow.getByRole("button", { name: "Use" }).click();
+
+ // URL should update to include submodal=create and evaluator
+ await expect(mockedPage).toHaveURL(/.*\?modal=control-store&submodal=create&evaluator=\w+/);
+
+ // Create modal should be visible
+ const createModal = mockedPage.getByRole("dialog", { name: "Create Control" });
+ await expect(createModal).toBeVisible();
+ });
+
+ test("closes all modals when control is successfully created", async ({ mockedPage }) => {
+ // Navigate to create modal via URL (simulating the full flow)
+ await mockedPage.goto(`${agentUrl}?modal=control-store&submodal=create&evaluator=list`);
+
+ // Verify all modals are open
+ const controlStoreModal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Browse existing controls or create a new one" });
+ await expect(controlStoreModal).toBeVisible();
+
+ const addNewModal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Select an evaluator to create a new control" });
+ await expect(addNewModal).toBeVisible();
+
+ const createModal = mockedPage.getByRole("dialog", { name: "Create Control" });
+ await expect(createModal).toBeVisible();
+
+ // Mock successful API response for control creation
+ // Agent already has a policy (return 200 with policy_id)
+ await mockedPage.route("**/api/v1/agents/*/policy", async (route, request) => {
+ if (request.method() === "GET") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ policy_id: 1 }),
+ });
+ } else if (request.method() === "POST") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({}),
+ });
+ } else {
+ await route.continue();
+ }
+ });
+
+ await mockedPage.route("**/api/v1/controls", async (route, request) => {
+ if (request.method() === "PUT") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ control_id: 100 }),
+ });
+ } else {
+ await route.continue();
+ }
+ });
+
+ await mockedPage.route("**/api/v1/controls/*/data", async (route, request) => {
+ if (request.method() === "PUT") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({}),
+ });
+ } else {
+ await route.continue();
+ }
+ });
+
+ await mockedPage.route("**/api/v1/policies/*/controls/*", async (route, request) => {
+ if (request.method() === "POST") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({}),
+ });
+ } else {
+ await route.continue();
+ }
+ });
+
+ // Fill out the form and submit
+ const controlNameInput = createModal.getByPlaceholder("Enter control name");
+ await controlNameInput.fill("Test Control");
+
+ // Fill in the required "Values" field for the list evaluator (at least one value required)
+ const valuesTextarea = createModal.getByPlaceholder("Enter values (one per line)");
+ await valuesTextarea.fill("test-value");
+
+ // Submit the form
+ const saveButton = createModal.getByRole("button", { name: /Save|Create/i });
+ await saveButton.click();
+
+ // Wait for confirmation modal to appear
+ await mockedPage.waitForTimeout(300); // Wait for modal animation
+
+ // Find the confirm button by text - use locator to find button containing "Confirm"
+ const confirmButton = mockedPage.locator("button:has-text('Confirm')");
+ await expect(confirmButton).toBeVisible({ timeout: 5000 });
+
+ // Start waiting for API response before clicking (must be set up before the action)
+ const responsePromise = mockedPage.waitForResponse("**/api/v1/policies/*/controls/*", { timeout: 10000 });
+ await confirmButton.click();
+
+ // Wait for API call to complete
+ await responsePromise;
+
+ // Wait for all modals to close
+ await expect(controlStoreModal).not.toBeVisible({ timeout: 5000 });
+ await expect(addNewModal).not.toBeVisible();
+ await expect(createModal).not.toBeVisible();
+
+ // URL should not contain any modal parameters
+ await expect(mockedPage).not.toHaveURL(/.*\?modal=/);
+ });
+
+ test("closes all modals when control is successfully copied", async ({ mockedPage }) => {
+ // Open control store and click Copy
+ await mockedPage.goto(`${agentUrl}?modal=control-store`);
+ const controlStoreModal = mockedPage
+ .getByRole("dialog")
+ .filter({ hasText: "Browse existing controls or create a new one" });
+ await expect(controlStoreModal).toBeVisible();
+
+ const targetRow = controlStoreModal.locator("tr", { hasText: "PII Detection" });
+ await targetRow.getByTestId("copy-control-button").click();
+
+ // Wait for edit modal to open
+ const editModal = mockedPage.getByRole("dialog", { name: "Create Control" });
+ await expect(editModal).toBeVisible();
+
+ // Wait for form to be initialized with control data (control name should contain "-copy")
+ const controlNameInput = editModal.getByPlaceholder("Enter control name");
+ await expect(controlNameInput).toHaveValue(/.*-copy$/, { timeout: 5000 });
+
+ // Set up mock routes for control creation flow (copying creates a new control)
+ await mockedPage.route("**/api/v1/agents/*/policy", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ policy_id: 1 }),
+ });
+ });
+
+ await mockedPage.route("**/api/v1/controls", async (route, request) => {
+ if (request.method() === "PUT") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ control_id: 100 }),
+ });
+ } else {
+ await route.continue();
+ }
+ });
+
+ await mockedPage.route("**/api/v1/controls/*/data", async (route, request) => {
+ if (request.method() === "PUT") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({}),
+ });
+ } else {
+ await route.continue();
+ }
+ });
+
+ await mockedPage.route("**/api/v1/policies/*/controls/*", async (route, request) => {
+ if (request.method() === "POST") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({}),
+ });
+ } else {
+ await route.continue();
+ }
+ });
+
+ // Submit the form
+ const saveButton = editModal.getByRole("button", { name: /Save|Create/i });
+ await saveButton.click();
+
+ // Wait for confirmation modal to appear
+ await mockedPage.waitForTimeout(300); // Wait for modal animation
+
+ // Find the confirm button
+ const confirmButton = mockedPage.locator("button:has-text('Confirm')");
+ await expect(confirmButton).toBeVisible({ timeout: 5000 });
+
+ // Start waiting for API response before clicking (must be set up before the action)
+ const responsePromise = mockedPage.waitForResponse("**/api/v1/policies/*/controls/*", { timeout: 10000 });
+ await confirmButton.click();
+
+ // Wait for API call to complete
+ await responsePromise;
+
+ // Wait for all modals to close
+ await expect(controlStoreModal).not.toBeVisible({ timeout: 5000 });
+ await expect(editModal).not.toBeVisible();
+
+ // URL should not contain any modal parameters
+ await expect(mockedPage).not.toHaveURL(/.*\?modal=/);
});
});
@@ -237,7 +594,7 @@ test.describe("Control Store - Loading States", () => {
});
});
- await page.goto("/agents/agent-1");
+ await page.goto("/agents/agent-1/controls");
// Open the control store modal
await page.getByTestId("add-control-button").first().click();
diff --git a/ui/tests/evaluators/helpers.ts b/ui/tests/evaluators/helpers.ts
index da01a94e..29a5216b 100644
--- a/ui/tests/evaluators/helpers.ts
+++ b/ui/tests/evaluators/helpers.ts
@@ -4,7 +4,7 @@
import { expect, type Page } from "@playwright/test";
-const AGENT_URL = "/agents/agent-1";
+const AGENT_URL = "/agents/agent-1/controls";
/**
* Opens the control store and selects an evaluator to create a new control
@@ -23,14 +23,14 @@ export async function openEvaluatorForm(page: Page, evaluatorName: string) {
await controlStoreModal.getByTestId("footer-new-control-button").click();
const addNewModal = page
.getByRole("dialog")
- .filter({ hasText: "Browse and add controls to your agent" });
+ .filter({ hasText: "Select an evaluator to create a new control" });
await expect(addNewModal).toBeVisible();
// Find and click Add button for the evaluator
const evaluatorRow = addNewModal.locator("tr", { hasText: evaluatorName });
- await evaluatorRow.getByRole("button", { name: "Add" }).click();
+ await evaluatorRow.getByRole("button", { name: "Use" }).click();
- // Wait for the create control modal
- await expect(page.getByRole("heading", { name: "Create Control" })).toBeVisible();
+ // Wait for the create control modal (scope to dialog to avoid multiple headings)
+ await expect(page.getByRole("dialog", { name: "Create Control" })).toBeVisible();
}
diff --git a/ui/tests/evaluators/regex.spec.ts b/ui/tests/evaluators/regex.spec.ts
index e6b81134..473f232a 100644
--- a/ui/tests/evaluators/regex.spec.ts
+++ b/ui/tests/evaluators/regex.spec.ts
@@ -14,11 +14,6 @@ test.describe("Regex Evaluator", () => {
await expect(
mockedPage.getByPlaceholder("Enter regex pattern (e.g., ^.*$)")
).toBeVisible();
-
- // Check helper text
- await expect(
- mockedPage.getByText("Regular expression pattern to match against")
- ).toBeVisible();
});
test("pattern field has default value", async ({ mockedPage }) => {
diff --git a/ui/tests/fixtures.ts b/ui/tests/fixtures.ts
index 5e268ef9..1c2685da 100644
--- a/ui/tests/fixtures.ts
+++ b/ui/tests/fixtures.ts
@@ -10,7 +10,7 @@ import type {
ListAgentsResponse,
ListControlsResponse,
} from "@/core/api/types";
-import type { StatsResponse } from "@/core/hooks/query-hooks/use-agent-stats";
+import type { StatsResponse } from "@/core/hooks/query-hooks/use-agent-monitor";
/**
* Mock data for API responses
@@ -258,7 +258,19 @@ const evaluatorsResponse: EvaluatorsResponse = {
const statsResponse: StatsResponse = {
agent_uuid: "agent-1",
time_range: "1h",
- stats: [
+ totals: {
+ execution_count: 430,
+ match_count: 40,
+ non_match_count: 390,
+ error_count: 2,
+ action_counts: {
+ allow: 10,
+ deny: 25,
+ warn: 3,
+ log: 2,
+ },
+ },
+ controls: [
{
control_id: 1,
control_name: "PII Detection",
@@ -302,27 +314,19 @@ const statsResponse: StatsResponse = {
avg_duration_ms: 12,
},
],
- total_executions: 430,
- total_matches: 40,
- total_non_matches: 390,
- total_errors: 2,
- action_counts: {
- allow: 10,
- deny: 25,
- warn: 3,
- log: 2,
- },
};
const emptyStatsResponse: StatsResponse = {
agent_uuid: "agent-1",
time_range: "1h",
- stats: [],
- total_executions: 0,
- total_matches: 0,
- total_non_matches: 0,
- total_errors: 0,
- action_counts: {},
+ totals: {
+ execution_count: 0,
+ match_count: 0,
+ non_match_count: 0,
+ error_count: 0,
+ action_counts: {},
+ },
+ controls: [],
};
/**
diff --git a/ui/tests/home.spec.ts b/ui/tests/home.spec.ts
index 4028a7b2..27bbbe74 100644
--- a/ui/tests/home.spec.ts
+++ b/ui/tests/home.spec.ts
@@ -113,7 +113,8 @@ test.describe("Home Page - Agents Overview", () => {
await mockedPage.getByText(firstAgent.agent_name).click();
// Verify navigation to agent detail page
- await expect(mockedPage).toHaveURL(`/agents/${firstAgent.agent_id}`);
+ // Since stats mock returns data, it will redirect to monitor tab
+ await expect(mockedPage).toHaveURL(`/agents/${firstAgent.agent_id}/monitor`);
});
test("displays correct active controls count for each agent", async ({ mockedPage }) => {
diff --git a/ui/tests/search-input.spec.ts b/ui/tests/search-input.spec.ts
index c6a01cc7..016eb4a9 100644
--- a/ui/tests/search-input.spec.ts
+++ b/ui/tests/search-input.spec.ts
@@ -106,7 +106,7 @@ test.describe("SearchInput - Query Param Syncing", () => {
test.describe("SearchInput - Agent Detail Page", () => {
test("syncs search value to URL query param (q)", async ({ mockedPage }) => {
- await mockedPage.goto("/agents/agent-1");
+ await mockedPage.goto("/agents/agent-1/controls");
const searchInput = mockedPage.getByPlaceholder("Search controls...");
await searchInput.fill("PII");
@@ -119,7 +119,7 @@ test.describe("SearchInput - Agent Detail Page", () => {
});
test("reads search value from URL on page load", async ({ mockedPage }) => {
- await mockedPage.goto("/agents/agent-1?q=PII");
+ await mockedPage.goto("/agents/agent-1/controls?q=PII");
// Wait for page to load
await expect(mockedPage.getByRole("table")).toBeVisible();
diff --git a/ui/tests/step-name-input.spec.ts b/ui/tests/step-name-input.spec.ts
new file mode 100644
index 00000000..30325c24
--- /dev/null
+++ b/ui/tests/step-name-input.spec.ts
@@ -0,0 +1,90 @@
+/**
+ * Integration tests for the StepNameInput component (step name / regex mode toggle).
+ * The component is exercised within the Create Control modal.
+ */
+
+import { openEvaluatorForm } from "./evaluators/helpers";
+import { expect, test } from "./fixtures";
+
+test.describe("Step Name Input", () => {
+ test("displays Step name label and Regex toggle", async ({ mockedPage }) => {
+ await openEvaluatorForm(mockedPage, "Regex");
+
+ const modal = mockedPage.getByRole("dialog", { name: "Create Control" });
+ await expect(modal.getByText("Step name")).toBeVisible();
+ await expect(modal.getByText("Regex")).toBeVisible();
+ });
+
+ test("defaults to names mode with step names placeholder", async ({
+ mockedPage,
+ }) => {
+ await openEvaluatorForm(mockedPage, "Regex");
+
+ const modal = mockedPage.getByRole("dialog", { name: "Create Control" });
+ const namesInput = modal.getByPlaceholder("search_db, fetch_user");
+ await expect(namesInput).toBeVisible();
+ await expect(modal.getByPlaceholder("^db_.*")).not.toBeVisible();
+ });
+
+ test("toggling Regex on shows regex input and hides names input", async ({
+ mockedPage,
+ }) => {
+ await openEvaluatorForm(mockedPage, "Regex");
+
+ const modal = mockedPage.getByRole("dialog", { name: "Create Control" });
+ await expect(modal.getByPlaceholder("search_db, fetch_user")).toBeVisible();
+
+ // Click the visible "Regex" label (switch is in ScrollArea and can be out of viewport)
+ await modal.getByText("Regex", { exact: true }).click();
+
+ await expect(modal.getByPlaceholder("^db_.*")).toBeVisible();
+ await expect(modal.getByPlaceholder("search_db, fetch_user")).not.toBeVisible();
+ });
+
+ test("can type in regex field and value persists when toggling", async ({
+ mockedPage,
+ }) => {
+ await openEvaluatorForm(mockedPage, "Regex");
+
+ const modal = mockedPage.getByRole("dialog", { name: "Create Control" });
+ await modal.getByText("Regex", { exact: true }).click();
+
+ const regexInput = modal.getByPlaceholder("^db_.*");
+ await regexInput.fill("^db_.*");
+
+ await expect(regexInput).toHaveValue("^db_.*");
+
+ // Toggle off to names mode
+ await modal.getByText("Regex", { exact: true }).click();
+ await expect(modal.getByPlaceholder("search_db, fetch_user")).toBeVisible();
+
+ // Toggle back to regex mode – value should still be there
+ await modal.getByText("Regex", { exact: true }).click();
+ await expect(modal.getByPlaceholder("^db_.*")).toHaveValue("^db_.*");
+ });
+
+ test("can type in names field when in names mode", async ({ mockedPage }) => {
+ await openEvaluatorForm(mockedPage, "Regex");
+
+ const modal = mockedPage.getByRole("dialog", { name: "Create Control" });
+ const namesInput = modal.getByPlaceholder("search_db, fetch_user");
+ await namesInput.fill("search_db, fetch_user");
+
+ await expect(namesInput).toHaveValue("search_db, fetch_user");
+ });
+
+ test("toggling Regex off shows names input and hides regex input", async ({
+ mockedPage,
+ }) => {
+ await openEvaluatorForm(mockedPage, "Regex");
+
+ const modal = mockedPage.getByRole("dialog", { name: "Create Control" });
+ await modal.getByText("Regex", { exact: true }).click();
+ await expect(modal.getByPlaceholder("^db_.*")).toBeVisible();
+
+ await modal.getByText("Regex", { exact: true }).click();
+
+ await expect(modal.getByPlaceholder("search_db, fetch_user")).toBeVisible();
+ await expect(modal.getByPlaceholder("^db_.*")).not.toBeVisible();
+ });
+});