mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
Merge remote-tracking branch 'origin/main' into litellm_shared_rust_pagination
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
commit
9c9bc4ecd3
58 changed files with 2513 additions and 1029 deletions
|
|
@ -99,6 +99,11 @@ const eslintConfig = [
|
|||
],
|
||||
rules: { "local/no-ad-hoc-z-index": ["error", { allowPopupLayer: true }] },
|
||||
},
|
||||
{
|
||||
files: ["src/components/view_logs/TraceView/**/*.tsx", "src/components/lens/**/*.tsx"],
|
||||
ignores: ["src/**/*.test.tsx"],
|
||||
rules: { "local/no-arbitrary-design-value": "error" },
|
||||
},
|
||||
{
|
||||
files: ["tests/eslint-rules/**/*.{ts,tsx}"],
|
||||
rules: { "local/no-noop-hover-variant": "off", "local/no-ad-hoc-z-index": "off" },
|
||||
|
|
|
|||
|
|
@ -12,7 +12,14 @@ const nextConfig = {
|
|||
? {
|
||||
async rewrites() {
|
||||
return {
|
||||
beforeFiles: [{ source: "/ui/:path*", destination: "/:path*" }],
|
||||
beforeFiles: [
|
||||
{
|
||||
source: "/:path*",
|
||||
has: [{ type: "header", key: "content-type", value: "application/json.*" }],
|
||||
destination: `${devProxyUrl}/:path*`,
|
||||
},
|
||||
{ source: "/ui/:path*", destination: "/:path*" },
|
||||
],
|
||||
fallback: [{ source: "/:path*", destination: `${devProxyUrl}/:path*` }],
|
||||
};
|
||||
},
|
||||
|
|
|
|||
172
ui/litellm-dashboard/package-lock.json
generated
172
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.92.0",
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@handlewithcare/react-prosemirror": "3.2.9",
|
||||
"@headlessui/tailwindcss": "0.2.2",
|
||||
"@heroicons/react": "1.0.6",
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
"@tanstack/react-pacer": "0.22.1",
|
||||
"@tanstack/react-query": "5.100.7",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.14.13",
|
||||
"@types/papaparse": "5.5.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -32,18 +34,24 @@
|
|||
"openapi-fetch": "^0.17.0",
|
||||
"openapi-react-query": "^0.5.4",
|
||||
"papaparse": "5.5.3",
|
||||
"prosemirror-model": "1.25.12",
|
||||
"prosemirror-state": "1.4.4",
|
||||
"prosemirror-view": "1.42.3",
|
||||
"react": "19.2.8",
|
||||
"react-copy-to-clipboard": "5.1.1",
|
||||
"react-dom": "19.2.8",
|
||||
"react-error-boundary": "6.1.6",
|
||||
"react-hook-form": "7.82.0",
|
||||
"react-intersection-observer": "11.0.1",
|
||||
"react-json-view-lite": "2.5.0",
|
||||
"react-markdown": "9.1.0",
|
||||
"react-reconciler": "0.33.0",
|
||||
"react-syntax-highlighter": "15.6.6",
|
||||
"recharts": "3.9.2",
|
||||
"remark-gfm": "4.0.1",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"uuid": "14.0.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
|
|
@ -1344,6 +1352,40 @@
|
|||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@handlewithcare/react-prosemirror": {
|
||||
"version": "3.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@handlewithcare/react-prosemirror/-/react-prosemirror-3.2.9.tgz",
|
||||
"integrity": "sha512-zNGR4BDAvXQGY0ph2ZVK/wDdOr3nL3kTjtXv9Bwwmvzy39Gtooc+vQHSHRtWg0AoN33csJgg0ev78dtoScAR9g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.0",
|
||||
"@tiptap/pm": "^3.0.0",
|
||||
"@tiptap/react": "^3.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "1.42.3",
|
||||
"react": ">=17 <20",
|
||||
"react-dom": ">=17 <20",
|
||||
"react-reconciler": ">=0.26.1 <=0.33.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@tiptap/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@tiptap/pm": {
|
||||
"optional": true
|
||||
},
|
||||
"@tiptap/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@headlessui/tailwindcss": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz",
|
||||
|
|
@ -3499,6 +3541,23 @@
|
|||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.14.13",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.13.tgz",
|
||||
"integrity": "sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.17.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/store": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz",
|
||||
|
|
@ -3522,6 +3581,16 @@
|
|||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.17.11",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.11.tgz",
|
||||
"integrity": "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
|
|
@ -5161,6 +5230,12 @@
|
|||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
|
|
@ -8450,6 +8525,12 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
|
|
@ -9966,6 +10047,12 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||
|
|
@ -10314,6 +10401,46 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.12",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
|
||||
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
|
||||
"integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.42.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz",
|
||||
"integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.8",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
|
@ -10379,6 +10506,21 @@
|
|||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-boundary": {
|
||||
"version": "6.1.6",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.6.tgz",
|
||||
"integrity": "sha512-CDXPnXDGyFIbkwaaJ6u+xgsRmJhSi6YdgUDW1vnyKHfXp1a9pfAlM+ZET2CDu80/A+8iRcmXN0NSY9BCCoWP5A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.82.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.82.0.tgz",
|
||||
|
|
@ -10455,6 +10597,21 @@
|
|||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-reconciler": {
|
||||
"version": "0.33.0",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
|
||||
"integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
|
|
@ -12117,6 +12274,21 @@
|
|||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/usehooks-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.debounce": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.15.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.92.0",
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@handlewithcare/react-prosemirror": "3.2.9",
|
||||
"@headlessui/tailwindcss": "0.2.2",
|
||||
"@heroicons/react": "1.0.6",
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
|
|
@ -34,6 +35,7 @@
|
|||
"@tanstack/react-pacer": "0.22.1",
|
||||
"@tanstack/react-query": "5.100.7",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.14.13",
|
||||
"@types/papaparse": "5.5.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -49,18 +51,24 @@
|
|||
"openapi-fetch": "^0.17.0",
|
||||
"openapi-react-query": "^0.5.4",
|
||||
"papaparse": "5.5.3",
|
||||
"prosemirror-model": "1.25.12",
|
||||
"prosemirror-state": "1.4.4",
|
||||
"prosemirror-view": "1.42.3",
|
||||
"react": "19.2.8",
|
||||
"react-copy-to-clipboard": "5.1.1",
|
||||
"react-dom": "19.2.8",
|
||||
"react-error-boundary": "6.1.6",
|
||||
"react-hook-form": "7.82.0",
|
||||
"react-intersection-observer": "11.0.1",
|
||||
"react-json-view-lite": "2.5.0",
|
||||
"react-markdown": "9.1.0",
|
||||
"react-reconciler": "0.33.0",
|
||||
"react-syntax-highlighter": "15.6.6",
|
||||
"recharts": "3.9.2",
|
||||
"remark-gfm": "4.0.1",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"uuid": "14.0.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import noComplexJsxArrow from "./no-complex-jsx-arrow.mjs";
|
|||
import filenamePascalCase from "./filename-pascal-case.mjs";
|
||||
import noNoopHoverVariant from "./no-noop-hover-variant.mjs";
|
||||
import noAdHocZIndex from "./no-ad-hoc-z-index.mjs";
|
||||
import noArbitraryDesignValue from "./no-arbitrary-design-value.mjs";
|
||||
|
||||
const plugin = {
|
||||
rules: {
|
||||
|
|
@ -13,6 +14,7 @@ const plugin = {
|
|||
"filename-pascal-case": filenamePascalCase,
|
||||
"no-noop-hover-variant": noNoopHoverVariant,
|
||||
"no-ad-hoc-z-index": noAdHocZIndex,
|
||||
"no-arbitrary-design-value": noArbitraryDesignValue,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +1,7 @@
|
|||
import { utilityOf } from "./tailwind-utility.mjs";
|
||||
|
||||
const AD_HOC_Z = /^-?z-(?:\d+|\[[^\]]*\]|\([^)]*\))$/;
|
||||
|
||||
const OPENERS = { "[": "]", "(": ")" };
|
||||
|
||||
const utilityOf = (token) => {
|
||||
const closers = [];
|
||||
const lastTopLevelColon = [...token].reduce((found, ch, i) => {
|
||||
if (closers.length > 0 && ch === closers[closers.length - 1]) {
|
||||
closers.pop();
|
||||
return found;
|
||||
}
|
||||
if (ch in OPENERS) {
|
||||
closers.push(OPENERS[ch]);
|
||||
return found;
|
||||
}
|
||||
return ch === ":" && closers.length === 0 ? i : found;
|
||||
}, -1);
|
||||
return token
|
||||
.slice(lastTopLevelColon + 1)
|
||||
.replace(/^!/, "")
|
||||
.replace(/!$/, "");
|
||||
};
|
||||
|
||||
const classify = (token, allowPopupLayer) => {
|
||||
const utility = utilityOf(token);
|
||||
if (AD_HOC_Z.test(utility)) return "adHoc";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { utilityOf } from "./tailwind-utility.mjs";
|
||||
|
||||
const ARBITRARY_SCALE = /^(?:text|tracking|leading|rounded(?:-[a-z]+)?|border(?:-[a-z]+)?)-\[/;
|
||||
const ARBITRARY_PROPERTY = /^\[[a-z-]+:/;
|
||||
|
||||
const isOffending = (token) => {
|
||||
const utility = utilityOf(token);
|
||||
return ARBITRARY_SCALE.test(utility) || ARBITRARY_PROPERTY.test(utility);
|
||||
};
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow arbitrary font size, tracking, leading, radius and border values, and arbitrary CSS properties. Use the theme scale so surfaces share one type and shape system.",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
arbitrary:
|
||||
"`{{token}}` bypasses the theme scale. Use a scale utility (text-xs/sm, leading-*, tracking-*, rounded-sm/md/lg, border/border-2) instead.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const check = (node, value) => {
|
||||
if (typeof value !== "string" || !value.includes("[")) return;
|
||||
for (const token of value.split(/\s+/).filter(isOffending)) {
|
||||
context.report({ node, messageId: "arbitrary", data: { token } });
|
||||
}
|
||||
};
|
||||
return {
|
||||
Literal(node) {
|
||||
check(node, node.value);
|
||||
},
|
||||
TemplateElement(node) {
|
||||
check(node, node.value.cooked);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default rule;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
const OPENERS = { "[": "]", "(": ")" };
|
||||
|
||||
export const utilityOf = (token) => {
|
||||
const closers = [];
|
||||
const lastTopLevelColon = [...token].reduce((found, ch, i) => {
|
||||
if (closers.length > 0 && ch === closers[closers.length - 1]) {
|
||||
closers.pop();
|
||||
return found;
|
||||
}
|
||||
if (ch in OPENERS) {
|
||||
closers.push(OPENERS[ch]);
|
||||
return found;
|
||||
}
|
||||
return ch === ":" && closers.length === 0 ? i : found;
|
||||
}, -1);
|
||||
return token
|
||||
.slice(lastTopLevelColon + 1)
|
||||
.replace(/^!/, "")
|
||||
.replace(/!$/, "");
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { chooseSelectOption, renderWithProviders, testQueryClient } from "@/../tests/test-utils";
|
||||
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
|
||||
import { LensWorkspace } from "./LensWorkspace";
|
||||
import { createLensDemoData } from "./demo/createLensDemo";
|
||||
|
||||
|
|
@ -33,29 +33,27 @@ describe("Lens interactive demo", () => {
|
|||
expect(await screen.findByText("Enable tracing")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Preview sample" }));
|
||||
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
|
||||
expect(screen.getByText("You’re viewing demo data")).toBeVisible();
|
||||
expect(screen.getByRole("switch", { name: "Demo data" })).toBeChecked();
|
||||
await expectUrl(onUrlUpdate, (url) => expect(url.get("demo")).toBe("true"));
|
||||
expect(screen.queryByRole("button", { name: "Set up tracing" })).not.toBeInTheDocument();
|
||||
network.mockClear();
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(screen.getByText("Where is order #1042?")).toBeVisible();
|
||||
fireEvent.change(screen.getByPlaceholderText("Search input or trace ID"), { target: { value: "headphones" } });
|
||||
const search = screen.getByRole("combobox", { name: "Search runs" });
|
||||
await user.type(search, "headphones");
|
||||
await waitFor(() => expect(screen.queryByText("Where is order #1042?")).not.toBeInTheDocument());
|
||||
expect(screen.getByText("Can I return my headphones?")).toBeVisible();
|
||||
expect(screen.queryByText("Where is order #1042?")).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search runs" }), { target: { value: "" } });
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by agent" }), "support_agent");
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by status" }), "Failed");
|
||||
expect(within(screen.getByRole("table", { name: "Agent runs" })).getAllByRole("row")).toHaveLength(4);
|
||||
await expectUrl(onUrlUpdate, (url) => expect(url.get("agent")).toBe("support_agent"));
|
||||
await expectUrl(onUrlUpdate, (url) => expect(url.get("status")).toBe("error"));
|
||||
await user.clear(search);
|
||||
await user.type(search, "agent:support_agent status:error");
|
||||
const table = screen.getByRole("table", { name: "Agent runs" });
|
||||
await waitFor(() => expect(within(table).getAllByRole("row")).toHaveLength(4));
|
||||
await expectUrl(onUrlUpdate, (url) => expect(url.get("q")).toBe("agent:support_agent status:error"));
|
||||
await user.click(screen.getByRole("tab", { name: "Investigations" }));
|
||||
expect(await screen.findByRole("row", { name: /Support quality/ })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "New investigation" })).not.toBeInTheDocument();
|
||||
expect(network).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("switch", { name: "Demo data" }));
|
||||
expect(await screen.findByText(/Investigations require proxy administrator access/)).toBeVisible();
|
||||
expect(screen.queryByText("You’re viewing demo data")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Can I return my headphones?")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Demo data" })).not.toBeChecked();
|
||||
await expectUrl(onUrlUpdate, (url) => expect([...url.entries()]).toEqual([["tab", "investigations"]]));
|
||||
|
|
@ -68,7 +66,7 @@ describe("Lens interactive demo", () => {
|
|||
searchParams: "?demo=true",
|
||||
onUrlUpdate,
|
||||
});
|
||||
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
|
||||
expect(await screen.findByRole("switch", { name: "Demo data" })).toBeChecked();
|
||||
await user.click(await screen.findByText("Where is order #1042?"));
|
||||
const drawer = await screen.findByRole("complementary", { name: "Trace details" });
|
||||
await expectUrl(onUrlUpdate, (url) => expect(url.get("trace")).toBeTruthy());
|
||||
|
|
@ -185,9 +183,7 @@ describe("Lens interactive demo", () => {
|
|||
await user.click(within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Traces" }));
|
||||
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Set up tracing" }));
|
||||
expect(await screen.findByRole("heading", { name: "Connect another agent" })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Set up tracing" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the header preview only for the active tab that still needs setup", async () => {
|
||||
|
|
@ -211,4 +207,25 @@ describe("Lens interactive demo", () => {
|
|||
expect(await screen.findByRole("table", { name: "Agent runs" })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the Investigations tab while a scan runs and clears it once the scan finishes", async () => {
|
||||
const saved = createLensDemoData().lenses[0];
|
||||
const withJob = (status: (typeof saved.jobs)[number]["status"]) => ({
|
||||
...saved,
|
||||
jobs: [{ ...saved.jobs[0], status }, ...saved.jobs.slice(1)],
|
||||
});
|
||||
const lenses = vi.fn(() => [withJob("running")]);
|
||||
network.mockImplementation(async (input) => {
|
||||
const path = new URL(String(input), "http://localhost").pathname;
|
||||
if (path === "/lens") return Response.json({ lenses: lenses(), workers: [], tracing_enabled: false });
|
||||
if (path === "/v1/traces") return Response.json({ detail: "Tracing is not enabled" }, { status: 501 });
|
||||
return Response.json({ data: [], traces: false, requests: false });
|
||||
});
|
||||
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />);
|
||||
const tab = within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Investigations" });
|
||||
await waitFor(() => expect(tab).toHaveAccessibleDescription("An investigation is running"));
|
||||
lenses.mockReturnValue([withJob("completed")]);
|
||||
await testQueryClient.refetchQueries({ queryKey: ["lens", "list"] });
|
||||
await waitFor(() => expect(tab).toHaveAccessibleDescription(""));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
|
||||
import { Activity, Aperture, Info, ScanSearch } from "lucide-react";
|
||||
import { Activity, Aperture, ArrowUpRight, ScanSearch } from "lucide-react";
|
||||
import AgentTracesPage from "@/components/view_logs/TraceView/AgentTracesPage";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
|
|
@ -11,6 +12,10 @@ import { LensPreviewContext } from "./LensPreviewButton";
|
|||
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
|
||||
import { InvestigationsView } from "./investigations/InvestigationsView";
|
||||
import { createLensDemo } from "./demo/createLensDemo";
|
||||
import { lensQueries } from "./api/queries";
|
||||
import { useLensApi } from "./services";
|
||||
import { investigationActivity, type InvestigationActivity } from "./model/status";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { LENS_TABS, useLensRoute, type LensTab } from "./route";
|
||||
|
||||
type WorkspaceProps = { accessToken: string; userRole: string; readOnly: boolean };
|
||||
|
|
@ -32,94 +37,177 @@ function SampleSession() {
|
|||
function DemoToggle({ demo, onChange }: { demo: boolean; onChange: (demo: boolean) => void }) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{demo && (
|
||||
<p role="status" className="flex items-center gap-1.5 font-medium text-info">
|
||||
<Info aria-hidden="true" className="size-3.5 shrink-0" />
|
||||
You’re viewing demo data
|
||||
</p>
|
||||
)}
|
||||
<div className={cn("flex items-center gap-2 text-xs", demo ? "font-medium text-info" : "text-muted-foreground")}>
|
||||
<label htmlFor={id}>Demo data</label>
|
||||
<Switch id={id} size="sm" checked={demo} onCheckedChange={onChange} />
|
||||
<Switch id={id} size="sm" checked={demo} onCheckedChange={onChange} className="data-checked:bg-info" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MODE_ICONS = { traces: Activity, investigations: ScanSearch } as const;
|
||||
|
||||
function LensModeSwitch() {
|
||||
const ACTIVITY_DOT: Record<Exclude<InvestigationActivity, "idle">, { className: string; label: string }> = {
|
||||
running: { className: "bg-info motion-safe:animate-pulse", label: "An investigation is running" },
|
||||
queued: { className: "bg-muted-foreground/60", label: "An investigation is queued" },
|
||||
};
|
||||
|
||||
function ActivityDot({ activity }: { activity: InvestigationActivity }) {
|
||||
if (activity === "idle") return null;
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
aria-label="Lens"
|
||||
className="relative inline-flex h-8 items-center rounded-full border border-border bg-muted/60 p-0.5"
|
||||
>
|
||||
<TabsPrimitive.Indicator className="absolute top-0.5 bottom-0.5 left-(--active-tab-left) w-(--active-tab-width) rounded-full bg-background shadow-sm ring-1 ring-border transition-[left,width] duration-200 ease-out motion-reduce:transition-none" />
|
||||
{Object.entries(LENS_TABS).map(([view, label]) => {
|
||||
const Icon = MODE_ICONS[view as LensTab];
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
key={view}
|
||||
value={view}
|
||||
className="relative z-raised inline-flex h-full items-center gap-1.5 rounded-full px-3 text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-active:text-foreground"
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-3.5" />
|
||||
{label}
|
||||
</TabsPrimitive.Tab>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-1.5 rounded-full animate-in fade-in-0 zoom-in-50 duration-300",
|
||||
ACTIVITY_DOT[activity].className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const FRAME = {
|
||||
live: {
|
||||
card: "border border-foreground/15",
|
||||
tab: "-mb-px border-x border-t border-foreground/15",
|
||||
corner: { left: "border-r border-b", right: "border-l border-b", tone: "border-foreground/15" },
|
||||
},
|
||||
demo: {
|
||||
card: "border-2 border-info",
|
||||
tab: "-mb-0.5 border-x-2 border-t-2 border-info",
|
||||
corner: { left: "border-r-2 border-b-2", right: "border-l-2 border-b-2", tone: "border-info" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const frameOf = (demo: boolean) => FRAME[demo ? "demo" : "live"];
|
||||
|
||||
/** Inverted corner joining the tab's side border to the card's top border; plain CSS borders so both snap to the same pixels. */
|
||||
function NotchCorner({ side, demo }: { side: "left" | "right"; demo: boolean }) {
|
||||
const { corner } = frameOf(demo);
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 size-3 overflow-hidden",
|
||||
side === "left" ? "-left-3" : "-right-3",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block size-full shadow-[0_0_0_12px_var(--card)]",
|
||||
side === "left" ? "rounded-br-xl" : "rounded-bl-xl",
|
||||
corner[side],
|
||||
corner.tone,
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LensModeSwitch({ activity, demo }: { activity: InvestigationActivity; demo: boolean }) {
|
||||
return (
|
||||
<div className={cn("relative z-raised rounded-t-2xl bg-card px-1.5 pt-1.5 pb-[7px]", frameOf(demo).tab)}>
|
||||
<NotchCorner side="left" demo={demo} />
|
||||
<NotchCorner side="right" demo={demo} />
|
||||
<TabsPrimitive.List
|
||||
aria-label="Lens"
|
||||
className="relative inline-flex h-9 items-center rounded-full bg-muted/70 p-1"
|
||||
>
|
||||
<TabsPrimitive.Indicator className="absolute top-1 bottom-1 left-(--active-tab-left) w-(--active-tab-width) rounded-full bg-background shadow-sm ring-1 ring-border transition-[left,width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none" />
|
||||
{Object.entries(LENS_TABS).map(([view, label]) => {
|
||||
const Icon = MODE_ICONS[view as LensTab];
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
key={view}
|
||||
value={view}
|
||||
aria-description={
|
||||
view === "investigations" && activity !== "idle" ? ACTIVITY_DOT[activity].label : undefined
|
||||
}
|
||||
className="relative z-raised inline-flex h-full items-center gap-2 rounded-full px-4 text-sm font-medium text-muted-foreground outline-none transition-colors duration-200 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-active:text-foreground"
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-4" />
|
||||
{label}
|
||||
{view === "investigations" && <ActivityDot activity={activity} />}
|
||||
</TabsPrimitive.Tab>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useInvestigationActivity(accessToken: string, enabled: boolean): InvestigationActivity {
|
||||
const api = useLensApi(accessToken);
|
||||
const { data } = useQuery({ ...lensQueries.list(api, false), enabled });
|
||||
return investigationActivity(data?.lenses ?? []);
|
||||
}
|
||||
|
||||
const PANEL =
|
||||
"flex min-h-0 flex-1 flex-col overflow-y-auto animate-in fade-in-0 duration-300 motion-reduce:animate-none";
|
||||
|
||||
function LensContent({ accessToken, userRole, readOnly }: WorkspaceProps) {
|
||||
const { tab, lensId, demo, setTab, setDemo } = useLensRoute();
|
||||
const [previewTarget, setPreviewTarget] = useState<HTMLDivElement | null>(null);
|
||||
const activeTab = tab ?? (lensId ? "investigations" : "traces");
|
||||
const canInvestigate = isProxyAdminTierRole(userRole);
|
||||
const activity = useInvestigationActivity(accessToken, canInvestigate);
|
||||
const preview = (view: LensTab) => ({
|
||||
target: previewTarget,
|
||||
open: !demo && activeTab === view ? () => setDemo(true) : undefined,
|
||||
});
|
||||
return (
|
||||
<main className="flex h-full w-full min-w-0 flex-1 flex-col gap-2 px-3 pt-2 pb-3">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setTab(value as LensTab)} className="min-h-0 flex-1 gap-2">
|
||||
<div className="flex min-h-8 flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="flex items-center gap-1.5 text-sm font-semibold tracking-tight">
|
||||
<Aperture aria-hidden="true" className="size-4" strokeWidth={2} />
|
||||
<main className="flex h-full w-full min-w-0 flex-1 flex-col px-4 pt-3 pb-4">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setTab(value as LensTab)} className="min-h-0 flex-1 gap-0">
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-end gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1 pb-3">
|
||||
<h1 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
|
||||
<Aperture aria-hidden="true" className="size-5" strokeWidth={2} />
|
||||
Lens
|
||||
</h1>
|
||||
<LensModeSwitch />
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
Trace your agents and investigate what goes wrong.{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/lens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 font-medium text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Docs
|
||||
<ArrowUpRight aria-hidden="true" className="size-3" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<LensModeSwitch activity={activity} demo={demo} />
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 pb-3">
|
||||
<div ref={setPreviewTarget} />
|
||||
<DemoToggle demo={demo} onChange={setDemo} />
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="traces" keepMounted className="flex min-h-0 flex-col overflow-y-auto">
|
||||
<LensPreviewContext.Provider value={preview("traces")}>
|
||||
<AgentTracesPage
|
||||
accessToken={accessToken}
|
||||
isActive={activeTab === "traces"}
|
||||
readOnly={readOnly}
|
||||
canMintTracingKey={isProxyAdminRole(userRole)}
|
||||
/>
|
||||
</LensPreviewContext.Provider>
|
||||
</TabsContent>
|
||||
<TabsContent value="investigations" className="flex min-h-0 flex-col overflow-y-auto">
|
||||
<LensPreviewContext.Provider value={preview("investigations")}>
|
||||
{isProxyAdminTierRole(userRole) ? (
|
||||
<InvestigationsView
|
||||
active={activeTab === "investigations"}
|
||||
<div className={cn("flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-card", frameOf(demo).card)}>
|
||||
<TabsContent value="traces" keepMounted className={PANEL}>
|
||||
<LensPreviewContext.Provider value={preview("traces")}>
|
||||
<AgentTracesPage
|
||||
accessToken={accessToken}
|
||||
readOnly={readOnly || !isProxyAdminRole(userRole)}
|
||||
isActive={activeTab === "traces"}
|
||||
readOnly={readOnly}
|
||||
canMintTracingKey={isProxyAdminRole(userRole)}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-6 text-sm text-muted-foreground">
|
||||
Investigations require proxy administrator access. You can still view your traces.
|
||||
</p>
|
||||
)}
|
||||
</LensPreviewContext.Provider>
|
||||
</TabsContent>
|
||||
</LensPreviewContext.Provider>
|
||||
</TabsContent>
|
||||
<TabsContent value="investigations" className={cn(PANEL, "p-4")}>
|
||||
<LensPreviewContext.Provider value={preview("investigations")}>
|
||||
{canInvestigate ? (
|
||||
<InvestigationsView
|
||||
active={activeTab === "investigations"}
|
||||
accessToken={accessToken}
|
||||
readOnly={readOnly || !isProxyAdminRole(userRole)}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-6 text-sm text-muted-foreground">
|
||||
Investigations require proxy administrator access. You can still view your traces.
|
||||
</p>
|
||||
)}
|
||||
</LensPreviewContext.Provider>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Fragment, useState, type ReactNode } from "react";
|
||||
import { Fragment, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import {
|
||||
ChevronRight,
|
||||
Circle,
|
||||
|
|
@ -26,16 +26,22 @@ import { scopeLabel } from "../model/format";
|
|||
import { type Finding, type Lens } from "../model/types";
|
||||
import { useListSearchRoute } from "../route";
|
||||
|
||||
const PRIORITY_COLOR = { high: "text-[#e5484d]", medium: "text-amber-500", low: "text-muted-foreground" } as const;
|
||||
const PRIORITY_COLOR = { high: "text-destructive", medium: "text-amber-500", low: "text-muted-foreground" } as const;
|
||||
const ROW =
|
||||
"border-b border-border/60 transition-colors duration-150 hover:bg-trace-row-hover motion-reduce:transition-none";
|
||||
const META = "truncate text-[11px] text-muted-foreground";
|
||||
const META = "truncate text-xs text-muted-foreground";
|
||||
|
||||
function openOnRowKeyDown(event: KeyboardEvent<HTMLTableRowElement>, open: () => void) {
|
||||
if (event.target !== event.currentTarget || (event.key !== "Enter" && event.key !== " ")) return;
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
|
||||
function JobIcon({ lens }: { lens: Lens }) {
|
||||
const status = lens.jobs[0]?.status;
|
||||
const className = "size-4 shrink-0";
|
||||
if (status === "queued" || status === "running")
|
||||
return <CircleDashed aria-hidden="true" className={cn(className, "text-[#3b5bfd]")} />;
|
||||
return <CircleDashed aria-hidden="true" className={cn(className, "text-info")} />;
|
||||
if (status === "failed") return <CircleX aria-hidden="true" className={cn(className, "text-destructive")} />;
|
||||
if (status === "cancelled")
|
||||
return <CircleSlash aria-hidden="true" className={cn(className, "text-muted-foreground")} />;
|
||||
|
|
@ -47,7 +53,7 @@ function TwoLine({ meta, title, className }: { meta: ReactNode; title: ReactNode
|
|||
return (
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className={META}>{meta}</span>
|
||||
<span className={cn("truncate text-[13px] text-foreground", className)}>{title}</span>
|
||||
<span className={cn("truncate text-sm text-foreground", className)}>{title}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -56,6 +62,7 @@ export function InvestigationList({
|
|||
lenses,
|
||||
connected,
|
||||
readOnly = false,
|
||||
onOpen,
|
||||
onEdit,
|
||||
onRunNow,
|
||||
onOpenFinding,
|
||||
|
|
@ -63,6 +70,7 @@ export function InvestigationList({
|
|||
lenses: Lens[];
|
||||
connected: boolean;
|
||||
readOnly?: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
onRunNow: (id: string) => void;
|
||||
onOpenFinding: (lens: Lens, finding: Finding) => void;
|
||||
|
|
@ -87,7 +95,7 @@ export function InvestigationList({
|
|||
<Input
|
||||
aria-label="Search investigations"
|
||||
placeholder="Search investigations"
|
||||
className="h-7 bg-background pl-8 text-[12px]"
|
||||
className="h-7 bg-background pl-8 text-xs"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
|
@ -112,9 +120,11 @@ export function InvestigationList({
|
|||
return (
|
||||
<Fragment key={lens.id}>
|
||||
<tr
|
||||
onClick={readOnly ? undefined : () => onEdit(lens.id)}
|
||||
onClick={() => onOpen(lens.id)}
|
||||
onKeyDown={(event) => openOnRowKeyDown(event, () => onOpen(lens.id))}
|
||||
tabIndex={0}
|
||||
aria-label={lens.settings.name}
|
||||
className={cn(ROW, "group h-14", !readOnly && "cursor-pointer")}
|
||||
className={cn(ROW, "group h-14 cursor-pointer focus-visible:outline-2 focus-visible:outline-ring")}
|
||||
>
|
||||
<td className="pl-2">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
|
|
@ -149,7 +159,7 @@ export function InvestigationList({
|
|||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"w-[180px] truncate px-3 text-right text-[12px]",
|
||||
"w-[180px] truncate px-3 text-right text-xs",
|
||||
latest?.status === "failed" ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
|
@ -159,14 +169,14 @@ export function InvestigationList({
|
|||
{findings.length > 0 && (
|
||||
<span
|
||||
title={`${findings.length} open ${findings.length === 1 ? "finding" : "findings"}`}
|
||||
className="inline-flex min-w-5 justify-center rounded-full bg-muted px-1.5 font-mono text-[11px] tabular-nums text-foreground"
|
||||
className="inline-flex min-w-5 justify-center rounded-full bg-muted px-1.5 font-mono text-xs tabular-nums text-foreground"
|
||||
>
|
||||
{findings.length}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className="w-[120px] px-3 text-right text-[12px] text-muted-foreground"
|
||||
className="w-[120px] px-3 text-right text-xs text-muted-foreground"
|
||||
title={latest ? formatActivityTimestamp(latest.created_at) : undefined}
|
||||
>
|
||||
{latest ? agoLabel(Date.parse(latest.created_at), now) : "never run"}
|
||||
|
|
@ -186,12 +196,18 @@ export function InvestigationList({
|
|||
>
|
||||
<Play className="size-3.5" />
|
||||
</button>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex size-7 items-center justify-center text-muted-foreground/60 group-hover:text-muted-foreground"
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Edit ${lens.settings.name}`}
|
||||
title="Edit"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit(lens.id);
|
||||
}}
|
||||
className="inline-flex size-7 items-center justify-center rounded text-muted-foreground/60 hover:bg-muted hover:text-foreground group-hover:text-muted-foreground"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
|
@ -205,8 +221,10 @@ export function InvestigationList({
|
|||
<tr
|
||||
key={finding.id}
|
||||
onClick={() => onOpenFinding(lens, finding)}
|
||||
onKeyDown={(event) => openOnRowKeyDown(event, () => onOpenFinding(lens, finding))}
|
||||
tabIndex={0}
|
||||
aria-label={finding.title}
|
||||
className={cn(ROW, "h-12 cursor-pointer")}
|
||||
className={cn(ROW, "h-12 cursor-pointer focus-visible:outline-2 focus-visible:outline-ring")}
|
||||
>
|
||||
<td className="pl-2" title={finding.suggestion ? `Fix: ${finding.suggestion}` : undefined}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
|
|
@ -227,12 +245,12 @@ export function InvestigationList({
|
|||
/>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 text-right text-[12px] text-muted-foreground">
|
||||
<td className="px-3 text-right text-xs text-muted-foreground">
|
||||
{runs} {runs === 1 ? "run" : "runs"}
|
||||
</td>
|
||||
<td />
|
||||
<td
|
||||
className="px-3 text-right text-[12px] text-muted-foreground"
|
||||
className="px-3 text-right text-xs text-muted-foreground"
|
||||
title={formatActivityTimestamp(finding.last_seen)}
|
||||
>
|
||||
{agoLabel(Date.parse(finding.last_seen), now)}
|
||||
|
|
@ -249,12 +267,10 @@ export function InvestigationList({
|
|||
</tbody>
|
||||
</table>
|
||||
{!shown.length && (
|
||||
<div className="py-16 text-center text-[12px] text-muted-foreground">
|
||||
No investigations match your search.
|
||||
</div>
|
||||
<div className="py-16 text-center text-xs text-muted-foreground">No investigations match your search.</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-[11px] text-muted-foreground">
|
||||
<footer className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-xs text-muted-foreground">
|
||||
{shown.length} {shown.length === 1 ? "investigation" : "investigations"} ·{" "}
|
||||
{lenses.filter((l) => l.settings.enabled).length} watching
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -1,45 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowUpRight, Loader2, SearchX, TriangleAlert } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
|
||||
export function InvestigationError({
|
||||
error,
|
||||
queryError,
|
||||
refresh,
|
||||
const DOCS_URL = "https://docs.litellm.ai/docs/proxy/lens";
|
||||
|
||||
function CenteredState({
|
||||
role,
|
||||
icon,
|
||||
tone = "muted",
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
error: string;
|
||||
queryError: unknown;
|
||||
refresh: () => void;
|
||||
role: "status" | "alert";
|
||||
icon: ReactNode;
|
||||
tone?: "muted" | "destructive";
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const unavailable = queryError instanceof ApiError && queryError.status === 404;
|
||||
const queryMessage = queryError instanceof Error ? queryError.message : undefined;
|
||||
return (
|
||||
<div role="alert" className="rounded-lg border border-destructive/30 p-4 text-sm text-destructive">
|
||||
{unavailable
|
||||
? "The Lens API is unavailable. Reload this page to use the current dashboard; if it persists, check the proxy deployment."
|
||||
: error || queryMessage}
|
||||
<Button variant="ghost" size="sm" onClick={() => (unavailable ? window.location.reload() : refresh())}>
|
||||
{unavailable ? "Reload page" : "Retry"}
|
||||
<div
|
||||
role={role}
|
||||
className="m-auto flex max-w-sm flex-col items-center gap-3 py-16 text-center animate-in fade-in-0 duration-300 motion-reduce:animate-none"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex size-10 items-center justify-center rounded-full",
|
||||
tone === "destructive" ? "bg-destructive/10 text-destructive" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
{children && <div className="mt-1 flex items-center gap-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocsLink() {
|
||||
return (
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonVariants({ variant: "ghost", size: "sm" })}
|
||||
>
|
||||
Lens docs
|
||||
<ArrowUpRight aria-hidden="true" className="size-3.5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function loadFailureMessage(queryError: unknown, unavailable: boolean): string {
|
||||
if (unavailable)
|
||||
return "This dashboard may be newer than the proxy. Reload the page, and if it persists, check the proxy deployment.";
|
||||
if (queryError instanceof Error) return queryError.message;
|
||||
return "Something went wrong while contacting the proxy.";
|
||||
}
|
||||
|
||||
export function InvestigationsLoadFailed({ queryError, refresh }: { queryError: unknown; refresh: () => void }) {
|
||||
const unavailable = queryError instanceof ApiError && queryError.status === 404;
|
||||
return (
|
||||
<CenteredState
|
||||
role="alert"
|
||||
tone="destructive"
|
||||
icon={<TriangleAlert className="size-5" />}
|
||||
title={unavailable ? "Lens API is unavailable" : "Couldn't load investigations"}
|
||||
description={loadFailureMessage(queryError, unavailable)}
|
||||
>
|
||||
<Button size="sm" onClick={() => (unavailable ? window.location.reload() : refresh())}>
|
||||
{unavailable ? "Reload page" : "Try again"}
|
||||
</Button>
|
||||
<DocsLink />
|
||||
</CenteredState>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvestigationError({ message, refresh }: { message: string; refresh: () => void }) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
<span className="min-w-0">{message}</span>
|
||||
<Button variant="ghost" size="sm" onClick={refresh}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvestigationsLoading() {
|
||||
return (
|
||||
<p role="status" className="py-8 text-sm text-muted-foreground">
|
||||
Loading investigations…
|
||||
</p>
|
||||
<CenteredState
|
||||
role="status"
|
||||
icon={<Loader2 className="size-5 animate-spin motion-reduce:animate-none" />}
|
||||
title="Loading investigations…"
|
||||
description="Fetching your investigations and their latest findings."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvestigationMissing({ selectLens }: { selectLens: (id: string | null) => void }) {
|
||||
return (
|
||||
<div role="alert" className="text-sm">
|
||||
This investigation was not found.{" "}
|
||||
<Button variant="link" onClick={() => selectLens(null)}>
|
||||
<CenteredState
|
||||
role="alert"
|
||||
icon={<SearchX className="size-5" />}
|
||||
title="Investigation not found"
|
||||
description="It may have been deleted, or the link points to a different proxy."
|
||||
>
|
||||
<Button size="sm" onClick={() => selectLens(null)}>
|
||||
View all investigations
|
||||
</Button>
|
||||
</div>
|
||||
</CenteredState>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -482,6 +482,24 @@ it("allows retrying a failed trace readiness check without treating it as an emp
|
|||
expect(await screen.findByRole("link", { name: "Set up traces" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows a centered failure with a retry when investigations cannot load, then recovers", async () => {
|
||||
window.history.replaceState({}, "", "/lens/");
|
||||
testQueryClient.clear();
|
||||
const list = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new ApiError("Proxy timed out", 504, {}))
|
||||
.mockResolvedValue({ lenses: [], workers: [], tracing_enabled: true });
|
||||
vi.mocked(apiClient.get).mockImplementation(async (path) => (path === "/lens" ? list() : { data: [] }));
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<InvestigationsView accessToken="test" />);
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Couldn't load investigations");
|
||||
expect(alert).toHaveTextContent("Proxy timed out");
|
||||
await user.click(within(alert).getByRole("button", { name: "Try again" }));
|
||||
expect(await screen.findByRole("heading", { name: "Find what needs attention" })).toBeVisible();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps saved investigations accessible when tracing is disabled", async () => {
|
||||
testQueryClient.clear();
|
||||
vi.mocked(apiClient.get).mockImplementation(async (path) => {
|
||||
|
|
@ -585,6 +603,8 @@ it("lists investigations without edit or run controls for read-only viewers", as
|
|||
vi.mocked(apiClient.get).mockImplementation(async (path) => {
|
||||
if (path === "/lens") return { lenses: [lens], tracing_enabled: true, workers: [] };
|
||||
if (path === "/lens/activity/available") return { traces: true, requests: false };
|
||||
if (path === "/lens/lens/runs") return [];
|
||||
if (path === "/lens/agents") return [];
|
||||
return { data: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
|
@ -592,6 +612,67 @@ it("lists investigations without edit or run controls for read-only viewers", as
|
|||
const row = await screen.findByRole("row", { name: lens.settings.name });
|
||||
expect(within(row).queryByRole("button", { name: /now/ })).not.toBeInTheDocument();
|
||||
await user.click(row);
|
||||
expect(await screen.findByRole("heading", { level: 2, name: lens.settings.name })).toBeVisible();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens investigations from the keyboard without treating nested edit keys as row activation", async () => {
|
||||
window.history.replaceState({}, "", "/lens/");
|
||||
testQueryClient.clear();
|
||||
vi.mocked(apiClient.get).mockImplementation(async (path) => {
|
||||
if (path === "/lens") return { lenses: [lens], tracing_enabled: true, workers: [] };
|
||||
if (path === "/lens/activity/available") return { traces: true, requests: false };
|
||||
if (path === "/lens/lens/runs") return [];
|
||||
if (path === "/lens/agents") return [];
|
||||
return { data: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<InvestigationsView accessToken="test" />);
|
||||
const row = await screen.findByRole("row", { name: lens.settings.name });
|
||||
row.focus();
|
||||
expect(row).toHaveFocus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(await screen.findByRole("heading", { level: 2, name: lens.settings.name })).toBeVisible();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
const editButton = await screen.findByRole("button", { name: `Edit ${lens.settings.name}` });
|
||||
editButton.focus();
|
||||
expect(editButton).toHaveFocus();
|
||||
await user.keyboard("{Enter}");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByDisplayValue(lens.settings.name)).toBeVisible();
|
||||
expect(screen.queryByRole("heading", { level: 2, name: lens.settings.name })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a failed investigation's details from its row and edits only from the pencil", async () => {
|
||||
window.history.replaceState({}, "", "/lens/");
|
||||
testQueryClient.clear();
|
||||
const job = {
|
||||
...lens.jobs[0],
|
||||
id: "failed-run",
|
||||
status: "failed" as const,
|
||||
stage: "Failed",
|
||||
error: "boom",
|
||||
findings: [],
|
||||
};
|
||||
vi.mocked(apiClient.get).mockImplementation(async (path) => {
|
||||
if (path === "/lens") return { lenses: [{ ...lens, jobs: [job] }], workers: [], tracing_enabled: true };
|
||||
if (path === "/lens/activity/available") return { traces: true, requests: false };
|
||||
if (path === "/lens/lens/runs") return [job];
|
||||
if (path === "/lens/lens/runs/failed-run") return job;
|
||||
if (path === "/lens/agents") return [];
|
||||
return { data: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<InvestigationsView accessToken="test" />);
|
||||
await user.click(await screen.findByRole("button", { name: `Edit ${lens.settings.name}` }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByDisplayValue(lens.settings.name)).toBeVisible();
|
||||
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
await user.click(screen.getByRole("row", { name: lens.settings.name }));
|
||||
expect(within(await screen.findByRole("alert")).getByLabelText("Investigation error")).toHaveTextContent("boom");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
"use client";
|
||||
import { InvestigationMissing, InvestigationsLoading, InvestigationError } from "./InvestigationStatus";
|
||||
import {
|
||||
InvestigationMissing,
|
||||
InvestigationsLoadFailed,
|
||||
InvestigationsLoading,
|
||||
InvestigationError,
|
||||
} from "./InvestigationStatus";
|
||||
|
||||
import { InvestigationNavigation } from "./InvestigationNavigation";
|
||||
import { useInvestigationResults } from "./useInvestigationResults";
|
||||
|
|
@ -175,7 +180,11 @@ export function InvestigationsView({
|
|||
setEditing={setEditing}
|
||||
/>
|
||||
)}
|
||||
{(error || query.error) && <InvestigationError error={error} queryError={query.error} refresh={refresh} />}
|
||||
{query.error && !query.data ? (
|
||||
<InvestigationsLoadFailed queryError={query.error} refresh={refresh} />
|
||||
) : (
|
||||
(error || query.error) && <InvestigationError message={error || query.error?.message || ""} refresh={refresh} />
|
||||
)}
|
||||
{query.isLoading && <InvestigationsLoading />}
|
||||
{loaded && showEmpty && (
|
||||
<InvestigationsWelcome
|
||||
|
|
@ -228,6 +237,7 @@ export function InvestigationsView({
|
|||
lenses={lenses}
|
||||
connected={connected}
|
||||
readOnly={readOnly}
|
||||
onOpen={selectLens}
|
||||
onEdit={(id) => openDialog("edit", id)}
|
||||
onRunNow={(id) => openDialog("run_now", id)}
|
||||
onOpenFinding={openFinding}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function InvestigationsWelcome({
|
|||
className: "col-start-2 w-fit sm:col-start-auto",
|
||||
});
|
||||
return (
|
||||
<section aria-labelledby="lens-welcome" className="max-w-3xl pb-6">
|
||||
<section aria-labelledby="lens-welcome" className="m-auto w-full max-w-2xl py-10">
|
||||
{showPreview && <LensPreviewButton />}
|
||||
<h2 id="lens-welcome" className="text-xl font-semibold tracking-tight">
|
||||
Find what needs attention
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import type { Job } from "../model/types";
|
|||
const STEP_MARK = { model: "✓", stage: "▸", error: "!" } as const;
|
||||
const STEP_TONE = {
|
||||
model: "text-foreground",
|
||||
stage: "text-[#0011b3] dark:text-[#8b9bff]",
|
||||
error: "text-[#e5484d]",
|
||||
stage: "text-info",
|
||||
error: "text-destructive",
|
||||
} as const;
|
||||
|
||||
export function StepFeed({ job }: { job: Job }) {
|
||||
|
|
@ -21,7 +21,7 @@ export function StepFeed({ job }: { job: Job }) {
|
|||
const models = modelsUsed(steps);
|
||||
const calls = steps.filter((s) => s.kind === "model").length;
|
||||
return (
|
||||
<section aria-label="Live investigation steps" className="rounded-md border bg-background font-mono text-[11px]">
|
||||
<section aria-label="Live investigation steps" className="rounded-md border bg-background font-mono text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { nextCheckStatus, workerConnected } from "./status";
|
||||
import { investigationActivity, nextCheckStatus, workerConnected } from "./status";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Job, Lens, LensList } from "./types";
|
||||
|
|
@ -102,3 +102,12 @@ it("shows the actual next schedule and avoids a stale countdown during active sc
|
|||
);
|
||||
expect(nextCheckStatus(lens, now)).toBeNull();
|
||||
});
|
||||
|
||||
it("summarizes activity across investigations, preferring a running scan over a queued one", () => {
|
||||
const withStatus = (status: Job["status"]): Lens => ({ ...lens, jobs: [{ ...job, status }] });
|
||||
expect(investigationActivity([])).toBe("idle");
|
||||
expect(investigationActivity([lens, withStatus("failed")])).toBe("idle");
|
||||
expect(investigationActivity([lens, withStatus("queued")])).toBe("queued");
|
||||
expect(investigationActivity([withStatus("queued"), withStatus("running")])).toBe("running");
|
||||
expect(investigationActivity([{ ...lens, jobs: [lens.jobs[0], { ...job, status: "running" }] }])).toBe("running");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,3 +43,12 @@ export function readiness(
|
|||
const ready = activityReady && connected && !listError;
|
||||
return { tracesReady, requestsReady, activityReady, ready };
|
||||
}
|
||||
|
||||
export type InvestigationActivity = "running" | "queued" | "idle";
|
||||
|
||||
export function investigationActivity(lenses: readonly Lens[]): InvestigationActivity {
|
||||
const statuses = new Set(lenses.flatMap((lens) => lens.jobs.map((job) => job.status)));
|
||||
if (statuses.has("running")) return "running";
|
||||
if (statuses.has("queued")) return "queued";
|
||||
return "idle";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { runs } from "./__fixtures__/runs";
|
||||
import { RunSearch } from "./RunSearch";
|
||||
|
||||
function Harness({ initial = "" }: { initial?: string }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<>
|
||||
<RunSearch value={value} onChange={setValue} runs={runs} />
|
||||
<button type="button" onClick={() => setValue("status:ok")}>
|
||||
Load saved query
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Delivers each emitted value only when asked, like a throttled URL. */
|
||||
function LaggingHarness() {
|
||||
const [value, setValue] = useState("");
|
||||
const [queue, setQueue] = useState<string[]>([]);
|
||||
return (
|
||||
<>
|
||||
<RunSearch value={value} onChange={(next) => setQueue((q) => [...q, next])} runs={runs} />
|
||||
<output aria-label="undelivered">{queue.join("|")}</output>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValue(queue[0]);
|
||||
setQueue(queue.slice(1));
|
||||
}}
|
||||
>
|
||||
Deliver next
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const box = () => screen.getByRole("combobox", { name: "Search runs" });
|
||||
const undelivered = () => screen.getByRole("status", { name: "undelivered" });
|
||||
const listbox = () => screen.getByRole("listbox", { name: "Search suggestions" });
|
||||
|
||||
describe("RunSearch", () => {
|
||||
it("builds a filter from the keyboard: field, then value", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
await user.click(box());
|
||||
expect(box()).toHaveAttribute("aria-expanded", "true");
|
||||
await user.keyboard("-sta");
|
||||
expect(
|
||||
within(listbox())
|
||||
.getAllByRole("option")
|
||||
.map((o) => o.textContent),
|
||||
).toEqual(["status"]);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(box()).toHaveTextContent(/^-status:$/, { normalizeWhitespace: false });
|
||||
expect(within(listbox()).getByRole("group", { name: "status" })).toBeVisible();
|
||||
expect(within(listbox()).getByText("does not contain")).toBeVisible();
|
||||
|
||||
await user.keyboard("{ArrowDown}");
|
||||
const ok = within(listbox()).getByRole("option", { name: "ok" });
|
||||
expect(ok).toHaveAttribute("aria-selected", "true");
|
||||
expect(box()).toHaveAttribute("aria-activedescendant", ok.id);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(box()).toHaveTextContent(/^-status:ok $/, { normalizeWhitespace: false });
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
expect(box()).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("wraps arrow navigation and picks with Tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
await user.click(box());
|
||||
await user.keyboard("{ArrowUp}");
|
||||
expect(within(listbox()).getByRole("option", { name: "trace_id" })).toHaveAttribute("aria-selected", "true");
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}{Tab}");
|
||||
expect(box()).toHaveTextContent(/^agent:$/, { normalizeWhitespace: false });
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}e");
|
||||
expect(within(listbox()).getByRole("option", { name: "billing-agent" })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("picks a suggestion with the mouse without losing focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
await user.click(box());
|
||||
await user.keyboard("agent:");
|
||||
await user.click(within(listbox()).getByRole("option", { name: "researcher" }));
|
||||
expect(box()).toHaveTextContent(/^agent:researcher $/, { normalizeWhitespace: false });
|
||||
expect(box()).toHaveFocus();
|
||||
await user.keyboard("refund");
|
||||
expect(box()).toHaveTextContent(/^agent:researcher refund$/, { normalizeWhitespace: false });
|
||||
});
|
||||
|
||||
it("stays one line: Enter with no menu adds nothing, and Escape closes the menu until ArrowDown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
await user.click(box());
|
||||
await user.keyboard("refund{Enter}");
|
||||
expect(box()).toHaveTextContent(/^refund$/, { normalizeWhitespace: false });
|
||||
await user.keyboard(" {Escape}");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(listbox()).toBeVisible();
|
||||
});
|
||||
|
||||
it("emits the query once typing pauses, not per keystroke", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<RunSearch value="" onChange={onChange} runs={runs} />);
|
||||
await user.click(box());
|
||||
await user.keyboard("refund");
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("refund"));
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps typing intact while the value it emitted arrives late", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaggingHarness />);
|
||||
await user.click(box());
|
||||
await user.keyboard("ab");
|
||||
await waitFor(() => expect(undelivered()).toHaveTextContent(/^ab$/, { normalizeWhitespace: false }));
|
||||
await user.keyboard("c");
|
||||
await user.click(screen.getByRole("button", { name: "Deliver next" }));
|
||||
await user.click(box());
|
||||
await user.keyboard("d");
|
||||
expect(box()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false });
|
||||
await waitFor(() => expect(undelivered()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false }));
|
||||
});
|
||||
|
||||
it("shows a query set from outside, such as the URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness initial="agent:triage" />);
|
||||
expect(box()).toHaveTextContent("agent:triage");
|
||||
await user.click(screen.getByRole("button", { name: "Load saved query" }));
|
||||
expect(box()).toHaveTextContent(/^status:ok$/, { normalizeWhitespace: false });
|
||||
});
|
||||
});
|
||||
257
ui/litellm-dashboard/src/components/lens/runSearch/RunSearch.tsx
Normal file
257
ui/litellm-dashboard/src/components/lens/runSearch/RunSearch.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
"use client";
|
||||
|
||||
import "prosemirror-view/style/prosemirror.css";
|
||||
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { ProseMirror, ProseMirrorDoc, reactKeys, useEditorEventCallback } from "@handlewithcare/react-prosemirror";
|
||||
import {
|
||||
Bot,
|
||||
Box,
|
||||
Braces,
|
||||
CircleDashed,
|
||||
CornerDownLeft,
|
||||
Hash,
|
||||
type LucideIcon,
|
||||
Search,
|
||||
SquareChevronRight,
|
||||
} from "lucide-react";
|
||||
import { Schema } from "prosemirror-model";
|
||||
import { EditorState, TextSelection, type Transaction } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
|
||||
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
|
||||
|
||||
import { parseRunQuery, type RunField } from "./runQuery";
|
||||
import { suggest, type Suggestion, type SuggestionMenu } from "./suggestions";
|
||||
|
||||
const schema = new Schema({ nodes: { doc: { content: "text*" }, text: {} } });
|
||||
|
||||
const createState = (text: string) =>
|
||||
EditorState.create({ schema, doc: schema.node("doc", null, text ? schema.text(text) : []), plugins: [reactKeys()] });
|
||||
|
||||
const EMIT_WAIT_MS = 150;
|
||||
const KEEP_MENU_CLOSED = "runSearchKeepMenuClosed";
|
||||
|
||||
const FIELD_ICONS: Record<RunField, LucideIcon> = {
|
||||
name: SquareChevronRight,
|
||||
agent: Bot,
|
||||
status: CircleDashed,
|
||||
model: Box,
|
||||
input: Braces,
|
||||
trace_id: Hash,
|
||||
};
|
||||
|
||||
const OPERATORS = [
|
||||
{ label: "equals", example: "foo:bar" },
|
||||
{ label: "not equals", example: "-foo:bar" },
|
||||
{ label: "wildcard match", example: "foo:*bar*" },
|
||||
{ label: "does not contain", example: "-foo:*bar*" },
|
||||
];
|
||||
|
||||
function highlight(state: EditorState): DecorationSet {
|
||||
const marks = parseRunQuery(state.doc.textContent).flatMap((clause) =>
|
||||
clause.kind === "field"
|
||||
? [
|
||||
Decoration.inline(clause.from, clause.keyTo, { class: "text-info" }),
|
||||
Decoration.inline(clause.keyTo, clause.keyTo + 1, { class: "text-muted-foreground" }),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
return DecorationSet.create(state.doc, marks);
|
||||
}
|
||||
|
||||
function applySuggestion(view: EditorView, item: Suggestion) {
|
||||
const tr = view.state.tr.insertText(item.insert, item.from, item.to);
|
||||
tr.setSelection(TextSelection.create(tr.doc, item.from + item.insert.length));
|
||||
if (item.completesClause) tr.setMeta(KEEP_MENU_CLOSED, true);
|
||||
view.dispatch(tr);
|
||||
}
|
||||
|
||||
interface RunSearchProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Loaded runs, the source of value suggestions. */
|
||||
runs: readonly TraceSummary[];
|
||||
}
|
||||
|
||||
/** One query box for the runs list: free text plus `key:value` filters, with autocomplete for both. */
|
||||
export function RunSearch({ value, onChange, runs }: RunSearchProps) {
|
||||
const listId = useId();
|
||||
const [state, setState] = useState(() => createState(value));
|
||||
const [seenValue, setSeenValue] = useState(value);
|
||||
const [unechoed, setUnechoed] = useState<readonly string[]>([]);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(true);
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
// `value` can trail the editor (the URL updates are throttled), so an echo of text we emitted is not an edit.
|
||||
if (value !== seenValue) {
|
||||
setSeenValue(value);
|
||||
const echo = unechoed.indexOf(value);
|
||||
if (echo >= 0) setUnechoed(unechoed.slice(echo + 1));
|
||||
else if (value !== state.doc.textContent) {
|
||||
setUnechoed([]);
|
||||
setState(createState(value));
|
||||
}
|
||||
}
|
||||
|
||||
const { selection } = state;
|
||||
const text = state.doc.textContent;
|
||||
const menu = useMemo(() => (selection.empty ? suggest(text, selection.head, runs) : null), [text, selection, runs]);
|
||||
const items = menu?.groups.flatMap((group) => group.items) ?? [];
|
||||
const shownMenu = focused && menuOpen ? menu : null;
|
||||
const visible = shownMenu !== null;
|
||||
const activeItem = visible ? items[Math.min(active, items.length - 1)] : undefined;
|
||||
|
||||
const emit = useDebouncedCallback(
|
||||
(text: string) => {
|
||||
setUnechoed((current) => [...current, text]);
|
||||
onChange(text);
|
||||
},
|
||||
{ wait: EMIT_WAIT_MS },
|
||||
);
|
||||
|
||||
const dispatchTransaction = (tr: Transaction) => {
|
||||
setState((current) => current.apply(tr));
|
||||
if (tr.selectionSet || tr.docChanged) setActive(0);
|
||||
if (!tr.docChanged) return;
|
||||
setMenuOpen(!tr.getMeta(KEEP_MENU_CLOSED));
|
||||
emit(tr.doc.textContent);
|
||||
};
|
||||
|
||||
const handleKeyDown = (view: EditorView, event: KeyboardEvent): boolean => {
|
||||
if (event.key === "Escape" && visible) {
|
||||
setMenuOpen(false);
|
||||
return true;
|
||||
}
|
||||
if (event.key === "ArrowDown" && !visible) {
|
||||
setMenuOpen(true);
|
||||
return true;
|
||||
}
|
||||
if ((event.key === "ArrowDown" || event.key === "ArrowUp") && items.length > 0) {
|
||||
const step = event.key === "ArrowDown" ? 1 : -1;
|
||||
setActive((Math.min(active, items.length - 1) + step + items.length) % items.length);
|
||||
return true;
|
||||
}
|
||||
if ((event.key === "Enter" || event.key === "Tab") && activeItem) {
|
||||
applySuggestion(view, activeItem);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<ProseMirror
|
||||
state={state}
|
||||
dispatchTransaction={dispatchTransaction}
|
||||
decorations={highlight}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleDOMEvents={{
|
||||
focus: () => {
|
||||
setFocused(true);
|
||||
setMenuOpen(true);
|
||||
return false;
|
||||
},
|
||||
blur: () => {
|
||||
setFocused(false);
|
||||
return false;
|
||||
},
|
||||
}}
|
||||
transformPastedText={(pasted) => pasted.replace(/\s+/g, " ")}
|
||||
attributes={{
|
||||
role: "combobox",
|
||||
"aria-label": "Search runs",
|
||||
"aria-autocomplete": "list",
|
||||
"aria-expanded": String(visible),
|
||||
"aria-controls": listId,
|
||||
...(activeItem && { "aria-activedescendant": `${listId}-${activeItem.id}` }),
|
||||
spellcheck: "false",
|
||||
class: "min-w-0 flex-1 overflow-x-auto py-1.5 font-mono text-xs whitespace-pre outline-none",
|
||||
}}
|
||||
>
|
||||
<div className="relative w-full flex-1">
|
||||
<div className="flex h-8 items-center gap-2 rounded-md border border-input bg-transparent px-2.5 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30">
|
||||
<Search className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="relative flex min-w-0 flex-1">
|
||||
<ProseMirrorDoc />
|
||||
{!text && (
|
||||
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center font-mono text-xs text-muted-foreground">
|
||||
Search runs, or filter like agent:researcher status:error
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{shownMenu && <SuggestionPanel id={listId} menu={shownMenu} activeId={activeItem?.id} />}
|
||||
</div>
|
||||
</ProseMirror>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionPanel({ id, menu, activeId }: { id: string; menu: SuggestionMenu; activeId: string | undefined }) {
|
||||
const pick = useEditorEventCallback((view, item: Suggestion) => applySuggestion(view, item));
|
||||
return (
|
||||
<div className="absolute top-full left-0 z-floating mt-1 w-full max-w-xl overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md">
|
||||
<div id={id} role="listbox" aria-label="Search suggestions" className="max-h-80 overflow-y-auto p-1">
|
||||
{menu.groups.map((group) => (
|
||||
<div key={group.heading} role="group" aria-label={group.heading}>
|
||||
<div className="px-2 pt-2 pb-1 text-xs text-muted-foreground">{group.heading}</div>
|
||||
{group.items.map((item) => {
|
||||
const Icon = FIELD_ICONS[item.field];
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
id={`${id}-${item.id}`}
|
||||
role="option"
|
||||
aria-selected={item.id === activeId}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => pick(item)}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 font-mono text-xs aria-selected:bg-accent aria-selected:text-accent-foreground"
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{menu.showOperators && <OperatorHints />}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 border-t border-border px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>↓</Kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Kbd>
|
||||
<CornerDownLeft className="size-3" />
|
||||
</Kbd>
|
||||
Select
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OperatorHints() {
|
||||
return (
|
||||
<div className="pb-1">
|
||||
<div className="px-2 pt-2 pb-1 text-xs text-muted-foreground">Comparison operators</div>
|
||||
{OPERATORS.map((op) => (
|
||||
<div key={op.label} className="flex items-center justify-between px-2 py-1 font-mono text-xs">
|
||||
<span className="text-warning">{op.label}</span>
|
||||
<code className="rounded bg-muted px-1.5 text-muted-foreground">{op.example}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="inline-flex h-5 min-w-5 items-center justify-center rounded border border-border px-1 font-sans">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
|
||||
|
||||
import { RunSearch } from "./RunSearch";
|
||||
|
||||
interface RunsToolbarProps {
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
runs: readonly TraceSummary[];
|
||||
/** Extra controls (time range, live tail) rendered on the right. */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RunsToolbar({ query, onQueryChange, runs, children }: RunsToolbarProps) {
|
||||
return (
|
||||
<div className="flex min-h-11 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-card px-3 py-2">
|
||||
<RunSearch value={query} onChange={onQueryChange} runs={runs} />
|
||||
{children && <div className="ml-auto flex items-center gap-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
|
||||
|
||||
export const run = (overrides: Partial<TraceSummary>): TraceSummary => ({
|
||||
agent_count: 1,
|
||||
agent_invocations: 1,
|
||||
duration_ms: 10,
|
||||
error_count: 0,
|
||||
input_preview: "",
|
||||
input_tokens: 0,
|
||||
llm_calls: 1,
|
||||
models: [],
|
||||
name: "run",
|
||||
output_tokens: 0,
|
||||
service: "svc",
|
||||
span_count: 1,
|
||||
spend: null,
|
||||
start_time: "2026-10-01T00:00:00Z",
|
||||
status: "ok",
|
||||
tool_calls: 0,
|
||||
trace_id: "trace",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const refundOverrides = {
|
||||
trace_id: "aaa111",
|
||||
name: "support",
|
||||
input_preview: "Where is my refund?",
|
||||
agent_names: ["billing-agent", "triage"],
|
||||
models: ["gpt-5"],
|
||||
};
|
||||
const refund = run(refundOverrides);
|
||||
const researchOverrides = {
|
||||
trace_id: "bbb222",
|
||||
name: "research_lead",
|
||||
input_preview: "Compare vector stores",
|
||||
agent_names: ["researcher"],
|
||||
models: ["claude-opus"],
|
||||
error_count: 2,
|
||||
};
|
||||
const research = run(researchOverrides);
|
||||
const plain = run({ trace_id: "ccc333", name: "health", service: "cron" });
|
||||
export const runs = [refund, research, plain];
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { run, runs } from "./__fixtures__/runs";
|
||||
import { fieldValues, filterRuns } from "./runQuery";
|
||||
|
||||
const ids = (query: string) => filterRuns(runs, query).map((r) => r.trace_id);
|
||||
|
||||
describe("filterRuns", () => {
|
||||
it("returns every run for an empty or blank query", () => {
|
||||
expect(filterRuns(runs, " ")).toBe(runs);
|
||||
});
|
||||
|
||||
it("matches free text against input, name and trace id, ignoring case, with every term required", () => {
|
||||
expect(ids("REFUND")).toEqual(["aaa111"]);
|
||||
expect(ids("lead")).toEqual(["bbb222"]);
|
||||
expect(ids("ccc3")).toEqual(["ccc333"]);
|
||||
expect(ids("vector research")).toEqual(["bbb222"]);
|
||||
expect(ids("vector refund")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a quoted phrase together", () => {
|
||||
expect(ids('"my refund"')).toEqual(["aaa111"]);
|
||||
expect(ids('"refund my"')).toEqual([]);
|
||||
});
|
||||
|
||||
it("splits runs by status, judged by recorded errors", () => {
|
||||
expect(ids("status:error")).toEqual(["bbb222"]);
|
||||
expect(ids("-status:error")).toEqual(["aaa111", "ccc333"]);
|
||||
expect(ids("status:OK")).toEqual(["aaa111", "ccc333"]);
|
||||
});
|
||||
|
||||
it("matches a multi-valued field when any value matches, and negation when none does", () => {
|
||||
expect(ids("agent:triage")).toEqual(["aaa111"]);
|
||||
expect(ids("-agent:triage")).toEqual(["bbb222", "ccc333"]);
|
||||
expect(ids("agent:cron")).toEqual(["ccc333"]);
|
||||
expect(ids("model:gpt-5")).toEqual(["aaa111"]);
|
||||
});
|
||||
|
||||
it("requires a whole-value match unless the value has wildcards", () => {
|
||||
expect(ids("agent:billing")).toEqual([]);
|
||||
expect(ids("agent:*bill*")).toEqual(["aaa111"]);
|
||||
expect(ids("agent:billing*")).toEqual(["aaa111"]);
|
||||
expect(ids("agent:*agent")).toEqual(["aaa111"]);
|
||||
expect(ids("-agent:*search*")).toEqual(["aaa111", "ccc333"]);
|
||||
expect(ids("name:res.arch_lead")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches glob segments case-insensitively without overlapping exact anchors", () => {
|
||||
const billing = run({ trace_id: "billing", agent_names: ["BILLING-agent"] });
|
||||
const research = run({ trace_id: "research", agent_names: ["research-billing-agent"] });
|
||||
const doubled = run({ trace_id: "doubled", agent_names: ["agent"] });
|
||||
const globRuns = [billing, research, doubled];
|
||||
|
||||
expect(filterRuns(globRuns, "agent:*bill*")).toEqual([billing, research]);
|
||||
expect(filterRuns(globRuns, "agent:billing*")).toEqual([billing]);
|
||||
expect(filterRuns(globRuns, "agent:*agent")).toEqual(globRuns);
|
||||
expect(filterRuns([run({ name: "aa" })], "name:a*a")).toHaveLength(1);
|
||||
expect(filterRuns([run({ name: "a" })], "name:a*a")).toHaveLength(0);
|
||||
expect(filterRuns(globRuns, "agent:**")).toEqual(filterRuns(globRuns, "agent:*"));
|
||||
});
|
||||
|
||||
it("matches adversarial globs without catastrophic backtracking", () => {
|
||||
const longValue = run({ agent_names: ["a".repeat(5000)] });
|
||||
const pattern = `agent:${"a*".repeat(30)}b`;
|
||||
const startedAt = performance.now();
|
||||
const matches = filterRuns([longValue], pattern);
|
||||
const elapsed = performance.now() - startedAt;
|
||||
|
||||
expect(matches).toEqual([]);
|
||||
expect(elapsed).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("searches the readable input text and quoted values with spaces", () => {
|
||||
expect(ids('input:"*vector stores"')).toEqual(["bbb222"]);
|
||||
expect(ids("trace_id:bbb222")).toEqual(["bbb222"]);
|
||||
});
|
||||
|
||||
it("combines field clauses with free text", () => {
|
||||
expect(ids("agent:*e* -status:error refund")).toEqual(["aaa111"]);
|
||||
});
|
||||
|
||||
it("ignores a key still waiting for its value and treats unknown keys as text", () => {
|
||||
expect(ids("agent:")).toEqual(["aaa111", "bbb222", "ccc333"]);
|
||||
expect(ids("refund agent:")).toEqual(["aaa111"]);
|
||||
expect(ids("foo:bar")).toEqual([]);
|
||||
expect(filterRuns([run({ name: "foo:bar" })], "foo:bar")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fieldValues", () => {
|
||||
it("lists each distinct non-empty value once, sorted", () => {
|
||||
expect(fieldValues(runs, "agent")).toEqual(["billing-agent", "cron", "researcher", "triage"]);
|
||||
expect(fieldValues(runs, "status")).toEqual(["error", "ok"]);
|
||||
expect(fieldValues([run({ models: [] })], "model")).toEqual([]);
|
||||
});
|
||||
});
|
||||
111
ui/litellm-dashboard/src/components/lens/runSearch/runQuery.ts
Normal file
111
ui/litellm-dashboard/src/components/lens/runSearch/runQuery.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
|
||||
import { previewText, traceAgentNames } from "@/components/view_logs/TraceView/traceUtils";
|
||||
|
||||
type RunFieldGroup = "Run attributes" | "Content" | "Identity";
|
||||
|
||||
interface RunFieldSpec {
|
||||
group: RunFieldGroup;
|
||||
read: (run: TraceSummary) => readonly string[];
|
||||
suggestValues: boolean;
|
||||
}
|
||||
|
||||
const runStatus = (run: TraceSummary): string => (run.error_count > 0 ? "error" : "ok");
|
||||
|
||||
export const RUN_FIELDS = {
|
||||
name: { group: "Run attributes", read: (run) => [run.name], suggestValues: true },
|
||||
agent: { group: "Run attributes", read: traceAgentNames, suggestValues: true },
|
||||
status: { group: "Run attributes", read: (run) => [runStatus(run)], suggestValues: true },
|
||||
model: { group: "Run attributes", read: (run) => run.models, suggestValues: true },
|
||||
input: { group: "Content", read: (run) => [previewText(run.input_preview)], suggestValues: false },
|
||||
trace_id: { group: "Identity", read: (run) => [run.trace_id], suggestValues: false },
|
||||
} as const satisfies Record<string, RunFieldSpec>;
|
||||
|
||||
export type RunField = keyof typeof RUN_FIELDS;
|
||||
|
||||
const isRunField = (key: string): key is RunField => Object.hasOwn(RUN_FIELDS, key);
|
||||
|
||||
interface Span {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export type QueryClause =
|
||||
| (Span & { kind: "text"; value: string })
|
||||
| (Span & { kind: "field"; field: RunField; negated: boolean; keyTo: number; value: string });
|
||||
|
||||
const unquote = (raw: string): string => raw.replace(/^"([^"]*)"?$/, "$1");
|
||||
|
||||
/** Whitespace-separated tokens; a double-quoted stretch keeps its spaces. */
|
||||
function tokenize(text: string): (Span & { raw: string })[] {
|
||||
return Array.from(text.matchAll(/(?:"[^"]*"?|\S)+/g), (match) => ({
|
||||
raw: match[0],
|
||||
from: match.index,
|
||||
to: match.index + match[0].length,
|
||||
}));
|
||||
}
|
||||
|
||||
function parseToken({ raw, from, to }: Span & { raw: string }): QueryClause {
|
||||
const match = /^(-?)([A-Za-z_]+):(.*)$/.exec(raw);
|
||||
const key = match?.[2].toLowerCase() ?? "";
|
||||
if (!match || !isRunField(key)) return { kind: "text", value: unquote(raw), from, to };
|
||||
const negated = match[1] === "-";
|
||||
return {
|
||||
kind: "field",
|
||||
field: key,
|
||||
negated,
|
||||
keyTo: from + match[1].length + match[2].length,
|
||||
value: unquote(match[3]),
|
||||
from,
|
||||
to,
|
||||
};
|
||||
}
|
||||
|
||||
export const parseRunQuery = (text: string): QueryClause[] => tokenize(text).map(parseToken);
|
||||
|
||||
/** `bar` matches the whole value, `*bar*` is a glob; both ignore case. */
|
||||
function valueMatcher(pattern: string): (value: string) => boolean {
|
||||
const needle = pattern.toLowerCase();
|
||||
if (!needle.includes("*")) return (value) => value.toLowerCase() === needle;
|
||||
const segments = needle.split("*");
|
||||
const first = segments[0];
|
||||
const last = segments.at(-1);
|
||||
const middle = segments.slice(1, -1).filter(Boolean);
|
||||
return (value) => {
|
||||
const candidate = value.toLowerCase();
|
||||
if (first && !candidate.startsWith(first)) return false;
|
||||
if (last && !candidate.endsWith(last)) return false;
|
||||
const suffixStart = last ? candidate.length - last.length : candidate.length;
|
||||
let cursor = first ? first.length : 0;
|
||||
if (suffixStart < cursor) return false;
|
||||
for (const segment of middle) {
|
||||
const found = candidate.indexOf(segment, cursor);
|
||||
if (found < 0 || found + segment.length > suffixStart) return false;
|
||||
cursor = found + segment.length;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function matchesClause(run: TraceSummary, clause: QueryClause): boolean {
|
||||
if (clause.kind === "text") {
|
||||
const needle = clause.value.toLowerCase();
|
||||
return [run.trace_id, previewText(run.input_preview), run.name].some((text) => text.toLowerCase().includes(needle));
|
||||
}
|
||||
// A key typed without a value yet narrows nothing, so the list does not blank out mid-typing.
|
||||
if (!clause.value) return true;
|
||||
const matches = RUN_FIELDS[clause.field].read(run).some(valueMatcher(clause.value));
|
||||
return clause.negated ? !matches : matches;
|
||||
}
|
||||
|
||||
/** Every clause must hold: free text searches input, name and trace id; `key:value` clauses target one field. */
|
||||
export function filterRuns(runs: TraceSummary[], query: string): TraceSummary[] {
|
||||
const clauses = parseRunQuery(query);
|
||||
if (clauses.length === 0) return runs;
|
||||
return runs.filter((run) => clauses.every((clause) => matchesClause(run, clause)));
|
||||
}
|
||||
|
||||
export function fieldValues(runs: readonly TraceSummary[], field: RunField): string[] {
|
||||
return Array.from(new Set(runs.flatMap((run) => RUN_FIELDS[field].read(run))))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { run, runs } from "./__fixtures__/runs";
|
||||
import { suggest, type Suggestion, type SuggestionMenu } from "./suggestions";
|
||||
|
||||
const atEnd = (text: string, source = runs) => suggest(text, text.length, source);
|
||||
const items = (menu: SuggestionMenu | null): Suggestion[] => menu?.groups.flatMap((g) => g.items) ?? [];
|
||||
const labels = (menu: SuggestionMenu | null) => items(menu).map((item) => item.label);
|
||||
const apply = (text: string, item: Suggestion) => text.slice(0, item.from) + item.insert + text.slice(item.to);
|
||||
|
||||
describe("suggest", () => {
|
||||
it("offers every field, grouped, in an empty box", () => {
|
||||
const menu = atEnd("");
|
||||
expect(menu?.groups.map((g) => g.heading)).toEqual(["Run attributes", "Content", "Identity"]);
|
||||
expect(labels(menu)).toEqual(["name", "agent", "status", "model", "input", "trace_id"]);
|
||||
expect(menu?.showOperators).toBe(false);
|
||||
});
|
||||
|
||||
it("narrows fields by the typed prefix and completes the key with a colon", () => {
|
||||
const menu = atEnd("refund AG");
|
||||
expect(labels(menu)).toEqual(["agent"]);
|
||||
expect(apply("refund AG", items(menu)[0])).toBe("refund agent:");
|
||||
expect(items(menu)[0].completesClause).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a leading minus when completing a negated key", () => {
|
||||
expect(apply("-st", items(atEnd("-st"))[0])).toBe("-status:");
|
||||
});
|
||||
|
||||
it("offers nothing for free text that names no field", () => {
|
||||
expect(atEnd("refund")).toBeNull();
|
||||
expect(atEnd("foo:bar")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the loaded values and the operator help once a key has its colon", () => {
|
||||
const menu = atEnd("agent:");
|
||||
expect(menu?.groups.map((g) => g.heading)).toEqual(["agent"]);
|
||||
expect(labels(menu)).toEqual(["billing-agent", "cron", "researcher", "triage"]);
|
||||
expect(menu?.showOperators).toBe(true);
|
||||
});
|
||||
|
||||
it("narrows values by substring, ignoring wildcards, and drops operator help once a value is typed", () => {
|
||||
const menu = atEnd("-agent:*AR*");
|
||||
expect(labels(menu)).toEqual(["researcher"]);
|
||||
expect(menu?.showOperators).toBe(false);
|
||||
expect(atEnd("agent:zzz")).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces only the value, adds a separating space at the end, and finishes the clause", () => {
|
||||
const item = items(atEnd("-agent:bil"))[0];
|
||||
expect(apply("-agent:bil", item)).toBe("-agent:billing-agent ");
|
||||
expect(item.completesClause).toBe(true);
|
||||
const text = "agent:tri refund";
|
||||
expect(apply(text, items(suggest(text, 9, runs))[0])).toBe("agent:triage refund");
|
||||
});
|
||||
|
||||
it("quotes a value that contains spaces", () => {
|
||||
const spaced = [run({ name: "Order lookup" })];
|
||||
expect(apply("name:", items(atEnd("name:", spaced))[0])).toBe('name:"Order lookup" ');
|
||||
});
|
||||
|
||||
it("does not list values for free-form fields but still explains the operators", () => {
|
||||
const menu = atEnd("input:");
|
||||
expect(menu?.groups).toEqual([]);
|
||||
expect(menu?.showOperators).toBe(true);
|
||||
expect(atEnd("input:x")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers fields in the gap between tokens, but nothing mid-token", () => {
|
||||
const text = "refund status:ok";
|
||||
expect(apply(text, items(suggest(text, 7, runs))[1])).toBe("refund agent: status:ok");
|
||||
expect(suggest(text, 3, runs)).toBeNull();
|
||||
expect(suggest("status:ok", 0, runs)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
|
||||
|
||||
import { fieldValues, parseRunQuery, RUN_FIELDS, type QueryClause, type RunField } from "./runQuery";
|
||||
|
||||
export interface Suggestion {
|
||||
id: string;
|
||||
label: string;
|
||||
field: RunField;
|
||||
/** Replaces `text.slice(from, to)`. */
|
||||
from: number;
|
||||
to: number;
|
||||
insert: string;
|
||||
/** A value finishes the clause and closes the menu; a field keeps it open for its values. */
|
||||
completesClause: boolean;
|
||||
}
|
||||
|
||||
export interface SuggestionGroup {
|
||||
heading: string;
|
||||
items: Suggestion[];
|
||||
}
|
||||
|
||||
export interface SuggestionMenu {
|
||||
groups: SuggestionGroup[];
|
||||
showOperators: boolean;
|
||||
}
|
||||
|
||||
const MAX_VALUES = 50;
|
||||
const FIELDS = Object.keys(RUN_FIELDS) as RunField[];
|
||||
const isSpace = (char: string | undefined) => char === undefined || /\s/.test(char);
|
||||
const quoteIfNeeded = (value: string) => (/[\s"]/.test(value) ? `"${value.replaceAll('"', "")}"` : value);
|
||||
|
||||
function fieldMenu(prefix: string, negated: boolean, from: number, to: number): SuggestionMenu | null {
|
||||
const fields = FIELDS.filter((field) => field.startsWith(prefix.toLowerCase()));
|
||||
if (fields.length === 0) return null;
|
||||
const groups = Array.from(new Set(fields.map((field) => RUN_FIELDS[field].group)), (heading) => ({
|
||||
heading,
|
||||
items: fields
|
||||
.filter((field) => RUN_FIELDS[field].group === heading)
|
||||
.map((field) => ({
|
||||
id: `field:${field}`,
|
||||
label: field,
|
||||
field,
|
||||
from,
|
||||
to,
|
||||
insert: `${negated ? "-" : ""}${field}:`,
|
||||
completesClause: false,
|
||||
})),
|
||||
}));
|
||||
return { groups, showOperators: false };
|
||||
}
|
||||
|
||||
function valueMenu(
|
||||
clause: Extract<QueryClause, { kind: "field" }>,
|
||||
text: string,
|
||||
runs: readonly TraceSummary[],
|
||||
): SuggestionMenu | null {
|
||||
const from = clause.keyTo + 1;
|
||||
const needle = clause.value.replaceAll("*", "").toLowerCase();
|
||||
const trailing = clause.to === text.length ? " " : "";
|
||||
const values = RUN_FIELDS[clause.field].suggestValues ? fieldValues(runs, clause.field) : [];
|
||||
const items = values
|
||||
.filter((value) => value.toLowerCase().includes(needle))
|
||||
.slice(0, MAX_VALUES)
|
||||
.map((value) => ({
|
||||
id: `value:${value}`,
|
||||
label: value,
|
||||
field: clause.field,
|
||||
from,
|
||||
to: clause.to,
|
||||
insert: quoteIfNeeded(value) + trailing,
|
||||
completesClause: true,
|
||||
}));
|
||||
const showOperators = clause.value === "";
|
||||
if (items.length === 0 && !showOperators) return null;
|
||||
return { groups: items.length ? [{ heading: clause.field, items }] : [], showOperators };
|
||||
}
|
||||
|
||||
/** What to offer at the cursor: fields while a key is being typed, values once it has a colon. */
|
||||
export function suggest(text: string, cursor: number, runs: readonly TraceSummary[]): SuggestionMenu | null {
|
||||
const clause = parseRunQuery(text).find((c) => c.from < cursor && c.to === cursor);
|
||||
if (!clause) {
|
||||
const betweenTokens = isSpace(text[cursor - 1]) && isSpace(text[cursor]);
|
||||
return betweenTokens ? fieldMenu("", false, cursor, cursor) : null;
|
||||
}
|
||||
if (clause.kind === "field") return valueMenu(clause, text, runs);
|
||||
const raw = text.slice(clause.from, clause.to);
|
||||
const negated = raw.startsWith("-");
|
||||
const prefix = negated ? raw.slice(1) : raw;
|
||||
if (/[:"]/.test(prefix)) return null;
|
||||
return fieldMenu(prefix, negated, clause.from, clause.to);
|
||||
}
|
||||
239
ui/litellm-dashboard/src/components/shared/SidePanel.tsx
Normal file
239
ui/litellm-dashboard/src/components/shared/SidePanel.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, ChevronDown, ChevronsRight, ChevronUp, Maximize2, Minimize2, X } from "lucide-react";
|
||||
import { useCallback, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { useEventListener, useLocalStorage, useMediaQuery, useOnClickOutside, useWindowSize } from "usehooks-ts";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { ignoresLetterShortcut } from "@/components/view_logs/letterShortcut";
|
||||
|
||||
const WIDTH_KEY = "litellm.agentTraces.drawerWidth";
|
||||
const MIN_WIDTH = 700;
|
||||
const MIN_LEFT_GAP = 100;
|
||||
const DEFAULT_FRACTION = 0.56;
|
||||
const KEY_STEP = 32;
|
||||
|
||||
export const clampDrawerWidth = (width: number, viewport: number): number => {
|
||||
const max = Math.max(Math.min(MIN_WIDTH, viewport), viewport - MIN_LEFT_GAP);
|
||||
return Math.round(Math.min(Math.max(width, Math.min(MIN_WIDTH, max)), max));
|
||||
};
|
||||
|
||||
export const PANEL_TRIGGER = { "data-side-panel-trigger": "" } as const;
|
||||
|
||||
const KEEPS_PANEL_OPEN =
|
||||
"[data-side-panel-trigger], [role='dialog'], [role='menu'], [role='listbox'], [data-radix-popper-content-wrapper]";
|
||||
|
||||
const OWNS_ITS_KEYS = "[role='dialog'], [role='menu'], [role='listbox']";
|
||||
|
||||
const insideKeyOwner = (event: KeyboardEvent): boolean =>
|
||||
event.target instanceof Element && event.target.closest(OWNS_ITS_KEYS) !== null;
|
||||
|
||||
function useDrawerWidth() {
|
||||
const { width: viewport } = useWindowSize();
|
||||
const [stored, setStored] = useLocalStorage<number | null>(WIDTH_KEY, null, {
|
||||
serializer: String,
|
||||
deserializer: (raw) => (Number.isFinite(Number(raw)) ? Number(raw) : null),
|
||||
});
|
||||
const width = clampDrawerWidth(stored ?? viewport * DEFAULT_FRACTION, viewport);
|
||||
const update = useCallback((next: number) => setStored(clampDrawerWidth(next, window.innerWidth)), [setStored]);
|
||||
return [width, update] as const;
|
||||
}
|
||||
|
||||
function useExitPresence<T>(item: T | null, itemKey: (item: T) => string) {
|
||||
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
|
||||
const [lastShown, setLastShown] = useState<T | null>(item);
|
||||
const [exitedKey, setExitedKey] = useState<string | null>(null);
|
||||
if (item !== null && item !== lastShown) setLastShown(item);
|
||||
if (item !== null && exitedKey !== null) setExitedKey(null);
|
||||
const shown = item ?? lastShown;
|
||||
const closing = item === null && shown !== null;
|
||||
const exited = closing && (reduceMotion || exitedKey === itemKey(shown));
|
||||
const onExited = () => {
|
||||
if (closing) setExitedKey(itemKey(shown));
|
||||
};
|
||||
return { shown: exited ? null : shown, closing, onExited };
|
||||
}
|
||||
|
||||
function ResizeHandle({ noun, width, onResize }: { noun: string; width: number; onResize: (width: number) => void }) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDragging(true);
|
||||
};
|
||||
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (dragging) onResize(window.innerWidth - event.clientX);
|
||||
};
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") onResize(width + KEY_STEP);
|
||||
else if (event.key === "ArrowRight") onResize(width - KEY_STEP);
|
||||
else return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`Resize ${noun} panel`}
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={MIN_WIDTH}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={() => setDragging(false)}
|
||||
onPointerCancel={() => setDragging(false)}
|
||||
onKeyDown={onKeyDown}
|
||||
className="group/handle absolute inset-y-0 -left-2.5 z-raised flex w-5 cursor-col-resize justify-center outline-none"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-full w-[0.67px] bg-border transition-[width,background-color] duration-150 group-hover/handle:w-0.5 group-focus-visible/handle:w-0.5 group-focus-visible/handle:bg-trace-brand motion-reduce:transition-none",
|
||||
dragging && "w-0.5 bg-trace-brand",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderButton({
|
||||
label,
|
||||
disabled = false,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="grid size-7 place-items-center rounded-[4px] text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SidePanelProps<T> {
|
||||
item: T | null;
|
||||
itemKey: (item: T) => string;
|
||||
noun: string;
|
||||
label: string;
|
||||
testId?: string;
|
||||
index: number;
|
||||
total: number;
|
||||
onStep: (delta: number) => void;
|
||||
onClose: () => void;
|
||||
fullScreen: boolean;
|
||||
onFullScreenChange: (fullScreen: boolean) => void;
|
||||
children: (item: T) => ReactNode;
|
||||
}
|
||||
|
||||
export function SidePanel<T>({
|
||||
item,
|
||||
itemKey,
|
||||
noun,
|
||||
label,
|
||||
testId,
|
||||
index,
|
||||
total,
|
||||
onStep,
|
||||
onClose,
|
||||
fullScreen,
|
||||
onFullScreenChange,
|
||||
children,
|
||||
}: SidePanelProps<T>) {
|
||||
const [width, setWidth] = useDrawerWidth();
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const { shown, closing, onExited } = useExitPresence(item, itemKey);
|
||||
|
||||
useEventListener("keydown", (event) => {
|
||||
if (item === null || ignoresLetterShortcut(event) || insideKeyOwner(event)) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
} else if (event.key === "j") {
|
||||
event.preventDefault();
|
||||
onStep(1);
|
||||
} else if (event.key === "k") {
|
||||
event.preventDefault();
|
||||
onStep(-1);
|
||||
}
|
||||
});
|
||||
|
||||
useOnClickOutside(panelRef as RefObject<HTMLElement>, (event) => {
|
||||
if (item === null || (event instanceof MouseEvent && event.button !== 0)) return;
|
||||
if (event.target instanceof Element && event.target.closest(KEEPS_PANEL_OPEN) !== null) return;
|
||||
onClose();
|
||||
});
|
||||
|
||||
if (shown === null) return null;
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
style={{ width: fullScreen ? "100%" : width }}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.target === event.currentTarget) onExited();
|
||||
}}
|
||||
className={cn(
|
||||
"fixed inset-y-0 right-0 z-overlay flex origin-right flex-col bg-background shadow-[0_10px_15px_-3px_rgba(16,24,40,0.1),0_4px_6px_-4px_rgba(16,24,40,0.1)] motion-reduce:animate-none",
|
||||
closing ? "animate-trace-drawer-out" : "animate-trace-drawer-in",
|
||||
)}
|
||||
>
|
||||
{!fullScreen && <ResizeHandle noun={noun} width={width} onResize={setWidth} />}
|
||||
<div className="flex h-[37px] shrink-0 items-center gap-1 border-b border-border px-2">
|
||||
<HeaderButton label="Close (Esc)" onClick={onClose}>
|
||||
<ChevronsRight className="size-4" />
|
||||
</HeaderButton>
|
||||
<span className="mx-1 h-4 w-px bg-border" />
|
||||
<HeaderButton label={`Next ${noun} (J)`} disabled={index < 0 || index >= total - 1} onClick={() => onStep(1)}>
|
||||
<ChevronDown className="size-4" />
|
||||
</HeaderButton>
|
||||
<HeaderButton label={`Previous ${noun} (K)`} disabled={index <= 0} onClick={() => onStep(-1)}>
|
||||
<ChevronUp className="size-4" />
|
||||
</HeaderButton>
|
||||
{index >= 0 && (
|
||||
<span className="ml-1 font-mono text-[11px] text-muted-foreground tabular-nums">
|
||||
{index + 1} / {total}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<HeaderButton
|
||||
label={fullScreen ? "Exit full screen" : "Enter full screen"}
|
||||
onClick={() => onFullScreenChange(!fullScreen)}
|
||||
>
|
||||
{fullScreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||
</HeaderButton>
|
||||
<HeaderButton label={`Close ${noun} (Esc)`} onClick={onClose}>
|
||||
<X className="size-4" />
|
||||
</HeaderButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col">{children(shown)}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelBackLink({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="-ml-1 inline-flex max-w-full items-center gap-1 rounded px-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-3 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,11 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
|
||||
import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
|
||||
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
|
||||
import traceList from "./__fixtures__/trace_list.json";
|
||||
import { LensPreviewContext } from "@/components/lens/LensPreviewButton";
|
||||
import AgentTracesPage from "./AgentTracesPage";
|
||||
import { AgentTracesSection, filterRuns, type TimeControls } from "./AgentTracesSection";
|
||||
import { filterRuns } from "@/components/lens/runSearch/runQuery";
|
||||
import { AgentTracesSection, type TimeControls } from "./AgentTracesSection";
|
||||
import type { TracePage, TraceSummary } from "./traceTypes";
|
||||
import { RESULTS_CHANGED_MESSAGE } from "./useAgentTraces";
|
||||
|
||||
|
|
@ -143,7 +144,8 @@ describe("AgentTracesSection", () => {
|
|||
.mockResolvedValueOnce({ ...(traceList as TracePage), items: runs.slice(1, 2), next_cursor: "later" });
|
||||
renderSection();
|
||||
expect(await screen.findByTestId("agent-trace-row")).toBeVisible();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search runs" }), { target: { value: "no-such-run" } });
|
||||
await user.type(screen.getByRole("combobox", { name: "Search runs" }), "no-such-run");
|
||||
await waitFor(() => expect(screen.queryAllByTestId("agent-trace-row")).toHaveLength(0));
|
||||
act(() => mockAllIsIntersecting(true));
|
||||
expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument();
|
||||
expect(agentTraceListCall).toHaveBeenCalledOnce();
|
||||
|
|
@ -204,7 +206,6 @@ describe("AgentTracesSection", () => {
|
|||
expect(screen.getByText(`Could not load runs: ${message}`)).toBeVisible();
|
||||
expect(screen.queryByText(/Private token details/)).not.toBeInTheDocument();
|
||||
expect(agentTraceListCall).toHaveBeenCalledOnce();
|
||||
expect(screen.getByTestId("runs-footer")).toHaveTextContent("Update failed");
|
||||
});
|
||||
|
||||
it("renders the setup snippet when the proxy answers 501", async () => {
|
||||
|
|
@ -239,10 +240,12 @@ describe("AgentTracesSection", () => {
|
|||
vi.mocked(agentTraceListCall).mockResolvedValue({ ...(traceList as TracePage), items: [] });
|
||||
vi.mocked(apiClient.get).mockResolvedValue(traceList);
|
||||
renderSection();
|
||||
expect(await screen.findByText("No runs match these filters.")).toBeVisible();
|
||||
expect(await screen.findByText("No runs in this time range")).toBeVisible();
|
||||
expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/v1/traces", { accessToken: "sk-test", query: { start_ms: 0 } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set up tracing" }));
|
||||
expect(await screen.findByRole("heading", { name: "Connect another agent" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("checks proxy readiness, waits for an agent, and confirms receipt using actual query results", async () => {
|
||||
|
|
@ -301,7 +304,7 @@ describe("AgentTracesSection", () => {
|
|||
await act(async () => {
|
||||
void testQueryClient.invalidateQueries({ queryKey: ["agentTraces"] });
|
||||
});
|
||||
expect(await screen.findByText("Updating…")).toBeVisible();
|
||||
await waitFor(() => expect(screen.getByRole("table", { name: "Agent runs" })).toHaveAttribute("aria-busy", "true"));
|
||||
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length);
|
||||
expect(screen.getByRole("complementary", { name: "Trace details" })).toBe(drawer);
|
||||
expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument();
|
||||
|
|
@ -313,13 +316,13 @@ describe("AgentTracesSection", () => {
|
|||
vi.mocked(apiClient.get).mockRejectedValue(new ApiError("History unavailable", 503, {}));
|
||||
renderSection();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Could not check earlier traces. History unavailable");
|
||||
expect(screen.getByText("No runs match these filters.")).toBeVisible();
|
||||
expect(screen.getByText("No runs in this time range")).toBeVisible();
|
||||
expect(screen.queryByText(/Could not load runs/)).not.toBeInTheDocument();
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue(traceList);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry trace check" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
|
||||
expect(screen.getByText("No runs match these filters.")).toBeVisible();
|
||||
expect(screen.getByText("No runs in this time range")).toBeVisible();
|
||||
expect(agentTraceListCall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
|
@ -342,7 +345,6 @@ describe("AgentTracesSection", () => {
|
|||
expect(lead).toBeDefined();
|
||||
const failed = rows.find((row) => row.textContent?.includes("acme-404")) as HTMLElement;
|
||||
expect(within(failed).getByLabelText("2 errors")).toBeInTheDocument();
|
||||
expect(screen.getByText(`${runs.length} runs`)).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Cost" })).toBeInTheDocument();
|
||||
expect(within(failed).getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -359,22 +361,24 @@ describe("AgentTracesSection", () => {
|
|||
});
|
||||
|
||||
it("filters by input text and by trace id", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
|
||||
renderSection();
|
||||
await screen.findAllByTestId("agent-trace-row");
|
||||
|
||||
const search = screen.getByLabelText("Search runs");
|
||||
fireEvent.change(search, { target: { value: "acme-404" } });
|
||||
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1);
|
||||
const search = screen.getByRole("combobox", { name: "Search runs" });
|
||||
await user.type(search, "acme-404");
|
||||
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1));
|
||||
|
||||
const lead = runs.find((r) => r.name === "research_lead") as TraceSummary;
|
||||
fireEvent.change(search, { target: { value: lead.trace_id.slice(0, 10) } });
|
||||
const rows = screen.getAllByTestId("agent-trace-row");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toHaveTextContent("Should we store OTEL agent spans");
|
||||
await user.clear(search);
|
||||
await user.type(search, lead.trace_id.slice(0, 10));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("agent-trace-row")).toHaveTextContent("Should we store OTEL agent spans"),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses recorded agent names for the column and filter even when services are shared", async () => {
|
||||
it("uses recorded agent names for the column, suggestions and filter even when services are shared", async () => {
|
||||
vi.mocked(agentTraceListCall).mockResolvedValue({
|
||||
...(traceList as TracePage),
|
||||
items: [
|
||||
|
|
@ -387,20 +391,19 @@ describe("AgentTracesSection", () => {
|
|||
await screen.findAllByTestId("agent-trace-row");
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: "Agent" })).toBeInTheDocument();
|
||||
const agentFilter = screen.getByRole("combobox", { name: "Filter by agent" });
|
||||
expect(agentFilter).toHaveTextContent("All agents");
|
||||
await user.type(screen.getByRole("combobox", { name: "Search runs" }), "agent:");
|
||||
const suggestions = screen.getByRole("listbox", { name: "Search suggestions" });
|
||||
expect(
|
||||
within(suggestions)
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent),
|
||||
).toEqual(["billing-agent", "research-agent", "review-agent"]);
|
||||
|
||||
await chooseSelectOption(user, agentFilter, "billing-agent");
|
||||
const rows = screen.getAllByTestId("agent-trace-row");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toHaveTextContent("billing-agent");
|
||||
expect(rows[0]).not.toHaveTextContent("shared-app");
|
||||
|
||||
await chooseSelectOption(user, agentFilter, "review-agent");
|
||||
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1);
|
||||
|
||||
await chooseSelectOption(user, agentFilter, "All agents");
|
||||
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length);
|
||||
await user.click(within(suggestions).getByRole("option", { name: "review-agent" }));
|
||||
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1));
|
||||
const row = screen.getByTestId("agent-trace-row");
|
||||
expect(row).toHaveTextContent("billing-agent");
|
||||
expect(row).not.toHaveTextContent("shared-app");
|
||||
});
|
||||
|
||||
it("shows each run's agent name with the logo of the SDK that produced it", async () => {
|
||||
|
|
@ -528,11 +531,11 @@ describe("AgentTracesSection", () => {
|
|||
expect(rows[1]).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
it("reads the search, agent and status filters from the URL and writes changes back", async () => {
|
||||
it("reads the query from the URL and writes typed changes back", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
|
||||
const onUrlUpdate = vi.fn();
|
||||
const failed = filterRuns(runs, "", "all", "error");
|
||||
const failed = filterRuns(runs, "status:error");
|
||||
renderWithProviders(
|
||||
<AgentTracesSection
|
||||
accessToken="sk-test"
|
||||
|
|
@ -542,18 +545,18 @@ describe("AgentTracesSection", () => {
|
|||
isCustomDate={false}
|
||||
isLiveTail={false}
|
||||
/>,
|
||||
{ searchParams: "?status=error", onUrlUpdate },
|
||||
{ searchParams: "?q=status:error", onUrlUpdate },
|
||||
);
|
||||
expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(failed.length);
|
||||
expect(screen.getByRole("combobox", { name: "Filter by status" })).toHaveTextContent("Failed");
|
||||
expect(failed.length).toBeLessThan(runs.length);
|
||||
const search = screen.getByRole("combobox", { name: "Search runs" });
|
||||
expect(search).toHaveTextContent("status:error");
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search runs" }), { target: { value: failed[0].trace_id } });
|
||||
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1);
|
||||
await waitFor(() => expect(lastUrl(onUrlUpdate).get("q")).toBe(failed[0].trace_id));
|
||||
expect(lastUrl(onUrlUpdate).get("status")).toBe("error");
|
||||
|
||||
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by status" }), "All status");
|
||||
await waitFor(() => expect(lastUrl(onUrlUpdate).has("status")).toBe(false));
|
||||
await user.clear(search);
|
||||
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length));
|
||||
await user.type(search, "-status:error");
|
||||
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length - failed.length));
|
||||
await waitFor(() => expect(lastUrl(onUrlUpdate).get("q")).toBe("-status:error"));
|
||||
});
|
||||
|
||||
it("plots every loaded run on the timeline", async () => {
|
||||
|
|
@ -636,13 +639,13 @@ describe("AgentTracesPage", () => {
|
|||
|
||||
const trigger = screen.getByRole("button", { name: "Time range" });
|
||||
expect(trigger).toHaveTextContent(/ to /);
|
||||
expect(screen.getByTestId("traces-timeline")).toHaveTextContent("Total 1d");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last 7 days" }));
|
||||
expect(await screen.findByText("Total 7d")).toBeInTheDocument();
|
||||
const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0];
|
||||
expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000);
|
||||
await waitFor(() => {
|
||||
const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0];
|
||||
expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000);
|
||||
});
|
||||
|
||||
const live = screen.getByRole("button", { name: "Live" });
|
||||
expect(live).toHaveAttribute("aria-pressed", "true");
|
||||
|
|
@ -671,7 +674,7 @@ describe("AgentTracesPage", () => {
|
|||
fireEvent.click(screen.getByRole("button", { name: "Time range" }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last hour" }));
|
||||
|
||||
expect(await screen.findByText("No runs match these filters.")).toBeInTheDocument();
|
||||
expect(await screen.findByText("No runs in this time range")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Time range" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -681,13 +684,15 @@ describe("AgentTracesPage", () => {
|
|||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<AgentTracesPage accessToken="sk-test" />, { searchParams: "?hours=168", onUrlUpdate });
|
||||
await screen.findByTestId("runs-table");
|
||||
expect(screen.getByTestId("traces-timeline")).toHaveTextContent("Total 7d");
|
||||
const { startMs, endMs } = vi.mocked(agentTraceListCall).mock.calls[0][0];
|
||||
expect(endMs - startMs).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Time range" }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last hour" }));
|
||||
expect(await screen.findByText("Total 1h")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0];
|
||||
expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeLessThanOrEqual(3600 * 1000 + 60_000);
|
||||
});
|
||||
await waitFor(() => expect(lastUrl(onUrlUpdate).get("hours")).toBe("1"));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { filterRuns } from "./AgentTracesSection";
|
||||
import traceList from "./__fixtures__/trace_list.json";
|
||||
import type { TracePage, TraceSummary } from "./traceTypes";
|
||||
|
||||
const runs = (traceList as TracePage).items as TraceSummary[];
|
||||
|
||||
describe("filterRuns", () => {
|
||||
it("status filter 'Failed' keeps only runs with errors", () => {
|
||||
const failed = filterRuns(runs, "", "all", "error");
|
||||
expect(failed.length).toBeGreaterThan(0);
|
||||
expect(failed.every((r) => r.error_count > 0)).toBe(true);
|
||||
const ok = filterRuns(runs, "", "all", "ok");
|
||||
expect(ok.every((r) => r.error_count === 0)).toBe(true);
|
||||
expect(failed.length + ok.length).toBe(runs.length);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,12 +3,12 @@
|
|||
import moment from "moment";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { HeaderActions } from "@/components/lens/HeaderActions";
|
||||
import { filterRuns } from "@/components/lens/runSearch/runQuery";
|
||||
import { RunsToolbar } from "@/components/lens/runSearch/RunsToolbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { AgentTracesTable } from "./AgentTracesTable";
|
||||
import { RunDrawer } from "./RunDrawer";
|
||||
import { ALL_AGENTS, RunsToolbar, type RunStatusFilter } from "./RunsToolbar";
|
||||
import {
|
||||
type TraceRef,
|
||||
traceKey,
|
||||
|
|
@ -18,32 +18,12 @@ import {
|
|||
useZoomRouting,
|
||||
} from "./traceRouting";
|
||||
import type { TraceSummary } from "./traceTypes";
|
||||
import { previewText, traceAgentNames } from "./traceUtils";
|
||||
import { TimeRangeControls } from "./TimeRangeControls";
|
||||
import { TracesTimeline, type TimeWindow } from "./TracesTimeline";
|
||||
import { ActiveDot } from "./ActiveDot";
|
||||
import { TracingSetupCard } from "./TracingSetupCard";
|
||||
import { useTracesLive } from "./tracesApi";
|
||||
import { type AgentTracesResult, traceWindowStartMs, useAgentTraces, useTraceAvailability } from "./useAgentTraces";
|
||||
|
||||
/** Client-side search (input text or trace id) plus agent / status filters over the loaded runs. */
|
||||
export function filterRuns(
|
||||
runs: TraceSummary[],
|
||||
query: string,
|
||||
agent: string,
|
||||
status: RunStatusFilter,
|
||||
): TraceSummary[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
return runs.filter((run) => {
|
||||
const haystack = [run.trace_id, previewText(run.input_preview), run.name].map((s) => s.toLowerCase());
|
||||
const matchesQuery = !q || haystack.some((text) => text.includes(q));
|
||||
const matchesAgent = agent === ALL_AGENTS || traceAgentNames(run).includes(agent);
|
||||
const failed = run.error_count > 0;
|
||||
const matchesStatus = status === "all" || (status === "error" ? failed : !failed);
|
||||
return matchesQuery && matchesAgent && matchesStatus;
|
||||
});
|
||||
}
|
||||
|
||||
const filterByWindow = (runs: TraceSummary[], range: TimeWindow): TraceSummary[] =>
|
||||
runs.filter((run) => {
|
||||
const t = moment(run.start_time).valueOf();
|
||||
|
|
@ -96,7 +76,7 @@ function TraceHistoryError({ history }: { history: ReturnType<typeof useTraceAva
|
|||
);
|
||||
}
|
||||
|
||||
/** The Runs view: filters, the runs table and footer — or one run, in place, once a row is clicked. */
|
||||
/** The Runs view: filters and the runs table — or one run, in place, once a row is clicked. */
|
||||
export function AgentTracesSection({
|
||||
accessToken,
|
||||
isActive,
|
||||
|
|
@ -110,7 +90,7 @@ export function AgentTracesSection({
|
|||
}: AgentTracesSectionProps) {
|
||||
const live = useTracesLive();
|
||||
const { trace: openTrace, openTrace: openRun, selection, fullScreen, setFullScreen } = useOpenTraceRouting();
|
||||
const { query, agent, status, setQuery, setAgent, setStatus } = useRunFilterRouting();
|
||||
const { query, setQuery } = useRunFilterRouting();
|
||||
const [showSetup, setShowSetup] = useState(false);
|
||||
const [zoom, setZoom] = useZoomRouting();
|
||||
const [rangeChanged, setRangeChanged] = useState(false);
|
||||
|
|
@ -125,17 +105,13 @@ export function AgentTracesSection({
|
|||
if (setup.disabledDetail == null) void history.refetch();
|
||||
};
|
||||
|
||||
const agents = useMemo(() => Array.from(new Set(traces.traces.flatMap(traceAgentNames))).sort(), [traces.traces]);
|
||||
// Relative ranges end "now" (the list query uses Date.now() too); round to the minute so the histogram is stable.
|
||||
const endMs = isCustomDate ? moment(endTime).valueOf() : moment().endOf("minute").valueOf();
|
||||
const range = useMemo(
|
||||
() => ({ startMs: traceWindowStartMs(startTime, endTime, isCustomDate, endMs), endMs }),
|
||||
[startTime, endTime, isCustomDate, endMs],
|
||||
);
|
||||
const filtered = useMemo(
|
||||
() => filterRuns(traces.traces, query, agent, status),
|
||||
[traces.traces, query, agent, status],
|
||||
);
|
||||
const filtered = useMemo(() => filterRuns(traces.traces, query), [traces.traces, query]);
|
||||
const runs = useMemo(() => (zoom ? filterByWindow(filtered, zoom) : filtered), [filtered, zoom]);
|
||||
|
||||
const changeRange = (hours: number, apply: (hours: number) => void) => {
|
||||
|
|
@ -169,7 +145,7 @@ export function AgentTracesSection({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSetup(false)}
|
||||
className="mb-3 text-[13px] text-muted-foreground hover:text-foreground"
|
||||
className="mb-3 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back to traces
|
||||
</button>
|
||||
|
|
@ -184,10 +160,9 @@ export function AgentTracesSection({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[560px] flex-1 flex-col overflow-hidden border-y border-border bg-card">
|
||||
<div className="flex min-h-[560px] flex-1 flex-col overflow-hidden bg-card">
|
||||
{checkHistory && <TraceHistoryError history={history} />}
|
||||
<TracesReceived received={setup.received} />
|
||||
<TracingSetupAction live={live} isActive={isActive} onClick={() => setShowSetup(true)} />
|
||||
<RunDrawer
|
||||
trace={openTrace}
|
||||
runs={runs}
|
||||
|
|
@ -197,15 +172,7 @@ export function AgentTracesSection({
|
|||
fullScreen={fullScreen}
|
||||
onFullScreenChange={setFullScreen}
|
||||
/>
|
||||
<RunsToolbar
|
||||
query={query}
|
||||
agent={agent}
|
||||
status={status}
|
||||
agents={agents}
|
||||
onQueryChange={setQuery}
|
||||
onAgentChange={setAgent}
|
||||
onStatusChange={setStatus}
|
||||
>
|
||||
<RunsToolbar query={query} onQueryChange={setQuery} runs={traces.traces}>
|
||||
{timeControls && (
|
||||
<TimeRangeControls
|
||||
range={zoom ?? range}
|
||||
|
|
@ -234,30 +201,13 @@ export function AgentTracesSection({
|
|||
onLoadMore={traces.loadMore}
|
||||
onOpenTrace={toggleRun}
|
||||
selectedKey={openTrace === null ? null : traceKey(openTrace)}
|
||||
/>
|
||||
<RunsFooter
|
||||
count={runs.length}
|
||||
zoom={zoom}
|
||||
isFetching={traces.isFetching}
|
||||
failed={!!traces.error}
|
||||
onResetZoom={() => setZoom(null)}
|
||||
rangeEmpty={traces.traces.length === 0}
|
||||
onSetUpTracing={() => setShowSetup(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TracingSetupAction({ live, isActive, onClick }: { live: boolean; isActive: boolean; onClick: () => void }) {
|
||||
if (!live || !isActive) return null;
|
||||
return (
|
||||
<HeaderActions>
|
||||
<Button variant="outline" size="sm" onClick={onClick} className="shrink-0 gap-1.5">
|
||||
<ActiveDot />
|
||||
Set up tracing
|
||||
</Button>
|
||||
</HeaderActions>
|
||||
);
|
||||
}
|
||||
|
||||
function TracesReceived({ received }: { received: boolean }) {
|
||||
if (!received) return null;
|
||||
return (
|
||||
|
|
@ -266,39 +216,3 @@ function TracesReceived({ received }: { received: boolean }) {
|
|||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function RunsFooter({
|
||||
count,
|
||||
zoom,
|
||||
isFetching,
|
||||
failed,
|
||||
onResetZoom,
|
||||
}: {
|
||||
count: number;
|
||||
zoom: TimeWindow | null;
|
||||
isFetching: boolean;
|
||||
failed: boolean;
|
||||
onResetZoom: () => void;
|
||||
}) {
|
||||
const settled = failed ? "Update failed" : "Updated just now";
|
||||
const status = isFetching ? "Updating…" : settled;
|
||||
return (
|
||||
<footer
|
||||
data-testid="runs-footer"
|
||||
className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-[11px] text-muted-foreground"
|
||||
>
|
||||
{count} {count === 1 ? "run" : "runs"}
|
||||
{zoom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResetZoom()}
|
||||
aria-label="Clear time zoom"
|
||||
className="ml-3 rounded border border-info/40 bg-info/10 px-1.5 text-info hover:bg-info/20"
|
||||
>
|
||||
{moment(zoom.startMs).format("MMM DD, HH:mm")} to {moment(zoom.endMs).format("MMM DD, HH:mm")} ×
|
||||
</button>
|
||||
)}
|
||||
<span className="ml-auto">{status}</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "../../../../tests/test-utils";
|
||||
import traceList from "./__fixtures__/trace_list.json";
|
||||
import { AgentTracesTable } from "./AgentTracesTable";
|
||||
import type { TracePage, TraceSummary } from "./traceTypes";
|
||||
|
||||
const renderEmpty = (rangeEmpty: boolean) => {
|
||||
const onSetUpTracing = vi.fn();
|
||||
render(
|
||||
<AgentTracesTable
|
||||
traces={[]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
hasMore={false}
|
||||
onLoadMore={vi.fn()}
|
||||
onOpenTrace={vi.fn()}
|
||||
rangeEmpty={rangeEmpty}
|
||||
onSetUpTracing={onSetUpTracing}
|
||||
/>,
|
||||
);
|
||||
return onSetUpTracing;
|
||||
};
|
||||
|
||||
describe("AgentTracesTable empty state", () => {
|
||||
it.each([
|
||||
{ rangeEmpty: true, message: "No runs in this time range" },
|
||||
{ rangeEmpty: false, message: "No runs match these filters." },
|
||||
])("offers tracing setup when rangeEmpty is $rangeEmpty", ({ rangeEmpty, message }) => {
|
||||
const onSetUpTracing = renderEmpty(rangeEmpty);
|
||||
expect(screen.getByText(message)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set up tracing" }));
|
||||
expect(onSetUpTracing).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentTracesTable virtualization", () => {
|
||||
const template = (traceList as TracePage).items[0] as TraceSummary;
|
||||
const manyRuns: TraceSummary[] = Array.from({ length: 500 }, (_, i) => ({
|
||||
...template,
|
||||
trace_id: `trace-${i}`,
|
||||
input_preview: `question ${i}`,
|
||||
}));
|
||||
|
||||
it("renders only the rows near the viewport as the list scrolls", () => {
|
||||
renderWithProviders(
|
||||
<AgentTracesTable
|
||||
traces={manyRuns}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
hasMore={false}
|
||||
onLoadMore={vi.fn()}
|
||||
onOpenTrace={vi.fn()}
|
||||
onSetUpTracing={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByTestId("agent-trace-row").length).toBeLessThan(50);
|
||||
expect(screen.getByText("question 0")).toBeInTheDocument();
|
||||
|
||||
const scroller = screen.getByTestId("runs-table");
|
||||
scroller.scrollTop = 36 * 480;
|
||||
fireEvent.scroll(scroller);
|
||||
|
||||
expect(screen.getAllByTestId("agent-trace-row").length).toBeLessThan(50);
|
||||
expect(screen.getByText("question 499")).toBeInTheDocument();
|
||||
expect(screen.queryByText("question 0")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowDown, ChevronRight } from "lucide-react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import { PANEL_TRIGGER } from "@/components/shared/SidePanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation } from "@/utils/activityTimestamp";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
|
@ -25,6 +27,8 @@ interface AgentTracesTableProps {
|
|||
onLoadMore: () => void;
|
||||
onOpenTrace: (trace: TraceSummary) => void;
|
||||
selectedKey?: string | null;
|
||||
rangeEmpty?: boolean;
|
||||
onSetUpTracing: () => void;
|
||||
}
|
||||
|
||||
export const formatCost = (cost: number): string => {
|
||||
|
|
@ -34,10 +38,13 @@ export const formatCost = (cost: number): string => {
|
|||
};
|
||||
|
||||
const firstLine = (text: string): string => text.split("\n")[0] ?? text;
|
||||
const runKey = (run: TraceSummary): string => run.trace_ref || run.trace_id;
|
||||
|
||||
const TH = "px-3 font-medium";
|
||||
const PREFETCH_MARGIN = "0px 0px 480px 0px";
|
||||
const PLACEHOLDER_ROWS = [0, 1, 2];
|
||||
const ROW_HEIGHT = 36;
|
||||
const OVERSCAN_ROWS = 12;
|
||||
const TH_NUM = "px-3 text-right font-medium";
|
||||
const TD_NUM = "px-3 text-right font-mono tabular-nums text-muted-foreground";
|
||||
|
||||
|
|
@ -71,6 +78,106 @@ function PlaceholderRow({ rowRef }: { rowRef?: (node: Element | null) => void })
|
|||
);
|
||||
}
|
||||
|
||||
function EmptyRuns({ rangeEmpty, onSetUpTracing }: { rangeEmpty: boolean; onSetUpTracing: () => void }) {
|
||||
if (!rangeEmpty)
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 py-16 text-xs text-muted-foreground">
|
||||
<span>No runs match these filters.</span>
|
||||
<Button size="xs" variant="outline" onClick={onSetUpTracing}>
|
||||
Set up tracing
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-16 text-center">
|
||||
<p className="text-sm font-medium">No runs in this time range</p>
|
||||
<p className="text-xs text-muted-foreground">Connect an agent to start sending traces.</p>
|
||||
<Button size="sm" className="mt-3" onClick={onSetUpTracing}>
|
||||
Set up tracing
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunRow({
|
||||
run,
|
||||
selected,
|
||||
onOpen,
|
||||
}: {
|
||||
run: TraceSummary;
|
||||
selected: boolean;
|
||||
onOpen: (run: TraceSummary) => void;
|
||||
}) {
|
||||
return (
|
||||
<tr
|
||||
data-testid="agent-trace-row"
|
||||
{...PANEL_TRIGGER}
|
||||
onClick={() => onOpen(run)}
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
"h-9 cursor-pointer border-b border-border/60 text-xs transition-colors duration-150 motion-reduce:transition-none",
|
||||
selected ? "bg-trace-row-selected shadow-[inset_2px_0_0_var(--trace-brand)]" : "hover:bg-trace-row-hover",
|
||||
)}
|
||||
>
|
||||
<td
|
||||
className="px-3 font-mono text-xs whitespace-nowrap tabular-nums text-muted-foreground"
|
||||
title={formatActivityTimestamp(run.start_time)}
|
||||
>
|
||||
{formatRunTimestamp(run.start_time)}
|
||||
</td>
|
||||
<AgentCell run={run} />
|
||||
<td className="px-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<StatusMark status={run.error_count > 0 ? "error" : "ok"} subtle />
|
||||
<span className="truncate text-foreground">
|
||||
{firstLine(previewText(run.input_preview)) || traceDisplayName(run)}
|
||||
</span>
|
||||
{run.resolution_limited && (
|
||||
<span
|
||||
className="shrink-0 text-xs text-muted-foreground"
|
||||
title="This run is too large to calculate all totals in this view"
|
||||
>
|
||||
Partial totals
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden shrink-0 font-mono text-xs text-muted-foreground 2xl:inline">{run.trace_id}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={TD_NUM}>{run.agent_count.toLocaleString()}</td>
|
||||
<td className={TD_NUM}>{run.span_count.toLocaleString()}</td>
|
||||
<td className="px-3 text-right font-mono tabular-nums text-foreground">{fmtMs(run.duration_ms)}</td>
|
||||
<td className="px-3 text-right font-mono tabular-nums text-foreground">
|
||||
{run.spend == null ? "—" : formatCost(run.spend)}
|
||||
</td>
|
||||
<td className="px-3 text-right">
|
||||
{run.error_count > 0 ? (
|
||||
<StatusMark status="error" count={run.error_count} />
|
||||
) : (
|
||||
<span className="font-mono text-xs text-muted-foreground/60">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<ChevronRight className="size-3 text-muted-foreground/60" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function useVirtualRows(traces: TraceSummary[], scroller: HTMLDivElement | null) {
|
||||
const options = {
|
||||
count: traces.length,
|
||||
getScrollElement: () => scroller,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: OVERSCAN_ROWS,
|
||||
getItemKey: (index: number) => runKey(traces[index]),
|
||||
};
|
||||
const virtualizer = useVirtualizer(options);
|
||||
const rows = virtualizer.getVirtualItems();
|
||||
const padTop = rows[0]?.start ?? 0;
|
||||
const padBottom = virtualizer.getTotalSize() - (rows.at(-1)?.end ?? 0);
|
||||
return { rows, padTop, padBottom };
|
||||
}
|
||||
|
||||
/** Devtool-dense runs list: one row per agent run, newest first. */
|
||||
export function AgentTracesTable({
|
||||
traces,
|
||||
|
|
@ -83,6 +190,8 @@ export function AgentTracesTable({
|
|||
onLoadMore,
|
||||
onOpenTrace,
|
||||
selectedKey = null,
|
||||
rangeEmpty = false,
|
||||
onSetUpTracing,
|
||||
}: AgentTracesTableProps) {
|
||||
const settled = !isLoading && !error;
|
||||
const isEmpty = settled && !hasMore && traces.length === 0;
|
||||
|
|
@ -93,6 +202,7 @@ export function AgentTracesTable({
|
|||
useEffect(() => {
|
||||
if (nearTail && autoContinue && !isFetching) onLoadMore();
|
||||
}, [nearTail, autoContinue, isFetching, onLoadMore]);
|
||||
const { rows, padTop, padBottom } = useVirtualRows(traces, scroller);
|
||||
return (
|
||||
<div ref={setScroller} className="min-h-0 flex-1 overflow-auto" data-testid="runs-table">
|
||||
<table
|
||||
|
|
@ -101,7 +211,7 @@ export function AgentTracesTable({
|
|||
className="w-full min-w-[900px] table-fixed border-collapse text-left"
|
||||
>
|
||||
<thead className="sticky top-0 z-sticky bg-[color-mix(in_oklab,var(--muted)_40%,var(--card))]">
|
||||
<tr className="h-8 border-b border-border text-[10px] tracking-[0.08em] text-muted-foreground uppercase">
|
||||
<tr className="h-8 border-b border-border text-xs tracking-wider text-muted-foreground uppercase">
|
||||
<th className={`w-[170px] ${TH}`}>
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||
Time <ArrowDown className="size-2.5" />
|
||||
|
|
@ -121,70 +231,18 @@ export function AgentTracesTable({
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{traces.map((run) => (
|
||||
<tr
|
||||
key={run.trace_ref || run.trace_id}
|
||||
data-testid="agent-trace-row"
|
||||
onClick={() => onOpenTrace(run)}
|
||||
aria-selected={selectedKey === (run.trace_ref || run.trace_id)}
|
||||
className={cn(
|
||||
"h-9 cursor-pointer border-b border-border/60 text-[12px] transition-colors duration-150 motion-reduce:transition-none",
|
||||
selectedKey === (run.trace_ref || run.trace_id)
|
||||
? "bg-trace-row-selected shadow-[inset_2px_0_0_var(--trace-brand)]"
|
||||
: "hover:bg-trace-row-hover",
|
||||
)}
|
||||
>
|
||||
<td
|
||||
className="px-3 font-mono text-[11px] whitespace-nowrap tabular-nums text-muted-foreground"
|
||||
title={formatActivityTimestamp(run.start_time)}
|
||||
>
|
||||
{formatRunTimestamp(run.start_time)}
|
||||
</td>
|
||||
<AgentCell run={run} />
|
||||
<td className="px-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<StatusMark status={run.error_count > 0 ? "error" : "ok"} subtle />
|
||||
<span className="truncate text-foreground">
|
||||
{firstLine(previewText(run.input_preview)) || traceDisplayName(run)}
|
||||
</span>
|
||||
{run.resolution_limited && (
|
||||
<span
|
||||
className="shrink-0 text-[10px] text-muted-foreground"
|
||||
title="This run is too large to calculate all totals in this view"
|
||||
>
|
||||
Partial totals
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden shrink-0 font-mono text-[10px] text-muted-foreground 2xl:inline">
|
||||
{run.trace_id}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={TD_NUM}>{run.agent_count.toLocaleString()}</td>
|
||||
<td className={TD_NUM}>{run.span_count.toLocaleString()}</td>
|
||||
<td className="px-3 text-right font-mono tabular-nums text-foreground">{fmtMs(run.duration_ms)}</td>
|
||||
<td className="px-3 text-right font-mono tabular-nums text-foreground">
|
||||
{run.spend == null ? "—" : formatCost(run.spend)}
|
||||
</td>
|
||||
<td className="px-3 text-right">
|
||||
{run.error_count > 0 ? (
|
||||
<StatusMark status="error" count={run.error_count} />
|
||||
) : (
|
||||
<span className="font-mono text-[11px] text-muted-foreground/60">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<ChevronRight className="size-3 text-muted-foreground/60" />
|
||||
</td>
|
||||
</tr>
|
||||
{padTop > 0 && <tr aria-hidden style={{ height: padTop }} />}
|
||||
{rows.map(({ index, key }) => (
|
||||
<RunRow key={key} run={traces[index]} selected={selectedKey === key} onOpen={onOpenTrace} />
|
||||
))}
|
||||
{padBottom > 0 && <tr aria-hidden style={{ height: padBottom }} />}
|
||||
{autoContinue &&
|
||||
PLACEHOLDER_ROWS.map((row) => <PlaceholderRow key={row} rowRef={row === 0 ? tailRef : undefined} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
{isLoading && <div className="py-16 text-center text-[12px] text-muted-foreground">Loading runs…</div>}
|
||||
{isLoading && <div className="py-16 text-center text-xs text-muted-foreground">Loading runs…</div>}
|
||||
{error && (
|
||||
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-[12px] text-muted-foreground">
|
||||
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{traces.length ? "Could not load more runs" : "Could not load runs"}: {error.message}
|
||||
</span>
|
||||
|
|
@ -196,16 +254,14 @@ export function AgentTracesTable({
|
|||
</div>
|
||||
)}
|
||||
{canContinue && traces.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-3 py-16 text-[12px] text-muted-foreground">
|
||||
<div className="flex items-center justify-center gap-3 py-16 text-xs text-muted-foreground">
|
||||
<span>No loaded runs match these filters.</span>
|
||||
<Button size="xs" variant="outline" disabled={isFetching} onClick={onLoadMore}>
|
||||
Load older runs
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && (
|
||||
<div className="py-16 text-center text-[12px] text-muted-foreground">No runs match these filters.</div>
|
||||
)}
|
||||
{isEmpty && <EmptyRuns rangeEmpty={rangeEmpty} onSetUpTracing={onSetUpTracing} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ interface AttributesDetailProps {
|
|||
export function DetailGroup({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section aria-label={title} className="flex flex-col gap-2">
|
||||
<h3 className="py-1 text-[13px] font-medium tracking-[-0.26px] text-trace-text">{title}</h3>
|
||||
<h3 className="py-1 text-sm font-medium text-trace-text">{title}</h3>
|
||||
<Card className="px-3 py-2.5">{children}</Card>
|
||||
</section>
|
||||
);
|
||||
|
|
@ -38,7 +38,7 @@ export function AttributesDetail({ traceId, span, attributes, isLoading }: Attri
|
|||
<KeyValueRows entries={attributeEntries} />
|
||||
</DetailGroup>
|
||||
)}
|
||||
{isLoading && <div className="text-[13px] text-trace-duration">Loading attributes…</div>}
|
||||
{isLoading && <div className="text-sm text-trace-duration">Loading attributes…</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,8 @@ import { errorSource, parseJson, parseMessages, prettyPayload } from "./traceUti
|
|||
|
||||
const ERROR_SOURCE_LABEL: Record<ErrorSource, string> = { tool: "Tool", model: "Model", litellm: "LiteLLM" };
|
||||
const TRACEBACK_MARKER = "Traceback (most recent call last):";
|
||||
const STATUS_TEXT = "px-5 py-2 text-[13px] tracking-[-0.26px] text-muted-foreground";
|
||||
const PAYLOAD_PRE =
|
||||
"font-mono text-[13px] leading-[1.5] tracking-[-0.26px] break-words whitespace-pre-wrap text-foreground";
|
||||
const STATUS_TEXT = "px-3 py-2 text-sm text-muted-foreground";
|
||||
const PAYLOAD_PRE = "font-mono text-sm leading-normal break-words whitespace-pre-wrap text-foreground";
|
||||
|
||||
/** Exporters record `repr(exc)` + traceback with no separator; keep the exception line. */
|
||||
export const errorHeadline = (error: string): string =>
|
||||
|
|
@ -46,9 +45,9 @@ export function ErrorBlock({ span }: { span: Span }) {
|
|||
return (
|
||||
<section
|
||||
aria-label="Error"
|
||||
className="mx-3 mb-3 rounded-[4px] border-[0.67px] border-destructive/40 bg-destructive/5 px-3 py-2.5"
|
||||
className="mx-3 mb-3 rounded-sm border border-destructive/40 bg-destructive/5 px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[13px] leading-[1.2] font-medium tracking-[-0.26px] text-destructive">
|
||||
<div className="flex items-center gap-2 text-sm leading-tight font-medium text-destructive">
|
||||
<AlertTriangle className="size-3.5" />
|
||||
{ERROR_SOURCE_LABEL[source]} · {errorReason(headline)}
|
||||
</div>
|
||||
|
|
@ -210,7 +209,7 @@ export function DetailContent({ accessToken, traceId, traceRef, span }: DetailCo
|
|||
: parseMessages(detail?.input ?? "")?.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-2 pt-1 pb-4">
|
||||
<div className="flex flex-col pt-1 pb-4">
|
||||
<ErrorBlock span={span} />
|
||||
{span.error && (
|
||||
<DiagnosticContent
|
||||
|
|
@ -239,9 +238,7 @@ export function DetailContent({ accessToken, traceId, traceRef, span }: DetailCo
|
|||
</Section>
|
||||
) : null}
|
||||
{empty && span.status !== "error" && (
|
||||
<div className="py-12 text-center text-[13px] tracking-[-0.26px] text-muted-foreground">
|
||||
No content recorded for this span.
|
||||
</div>
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">No content recorded for this span.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { AttributesDetail } from "./AttributesDetail";
|
|||
import { CopyButton } from "./CopyButton";
|
||||
import { DetailContent, errorHeadline, useSpanDetail } from "./DetailContent";
|
||||
import { IdChip } from "./IdChip";
|
||||
import { PaneBar } from "./PaneBar";
|
||||
import { RequestDetail } from "./RequestDetail";
|
||||
import { SpanIcon } from "./SpanIcon";
|
||||
import { useTracesApi } from "./tracesApi";
|
||||
|
|
@ -47,7 +48,7 @@ function PaneHeader({
|
|||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-7 shrink-0 items-center justify-between gap-3 px-5">
|
||||
<PaneBar className="justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<span className="mr-2 shrink-0">
|
||||
<SpanIcon type={type} model={model} error={failed} size="md" />
|
||||
|
|
@ -64,20 +65,16 @@ function PaneHeader({
|
|||
size="icon-xs"
|
||||
onClick={onClose}
|
||||
aria-label="Close details"
|
||||
className="size-6 shrink-0 rounded-[4px] text-muted-foreground"
|
||||
className="size-6 shrink-0 rounded-sm text-muted-foreground"
|
||||
>
|
||||
<PanelRightClose className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</PaneBar>
|
||||
);
|
||||
}
|
||||
|
||||
function PaneFooter({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-10 shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-t bg-background px-5 py-2">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return <div className="flex h-10 shrink-0 items-center gap-3 border-t bg-background px-3">{children}</div>;
|
||||
}
|
||||
|
||||
function Meta({ label, value }: { label: string; value: string }) {
|
||||
|
|
@ -112,10 +109,7 @@ function SpanPane({
|
|||
);
|
||||
const tokens = span.input_tokens + span.output_tokens;
|
||||
return (
|
||||
<aside
|
||||
className="flex h-full min-w-0 flex-col bg-background pt-4 text-[13px] text-foreground"
|
||||
aria-label="Span details"
|
||||
>
|
||||
<aside className="flex h-full min-w-0 flex-col bg-background text-sm text-foreground" aria-label="Span details">
|
||||
<PaneHeader
|
||||
type={span.type}
|
||||
model={span.model}
|
||||
|
|
@ -125,15 +119,15 @@ function SpanPane({
|
|||
onClose={onClose}
|
||||
/>
|
||||
<Tabs value={tab} onValueChange={(value) => onSpanTabChange(value as SpanTab)} className="min-h-0 flex-1 gap-0">
|
||||
<div className="shrink-0 border-b px-5">
|
||||
<TabsList variant="line" aria-label="Span detail sections" className="h-10 gap-4 px-0">
|
||||
<PaneBar>
|
||||
<TabsList variant="line" aria-label="Span detail sections" className="h-full gap-4 px-0">
|
||||
{SPAN_TABS.map((id) => (
|
||||
<TabsTrigger key={id} value={id} className="flex-none px-0 text-xs">
|
||||
{TAB_LABELS[id]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
</PaneBar>
|
||||
<TabsContent value="content" className="min-h-0 overflow-auto pt-3">
|
||||
<DetailContent accessToken={accessToken} traceId={traceId} traceRef={trace.summary.trace_ref} span={span} />
|
||||
</TabsContent>
|
||||
|
|
@ -163,8 +157,8 @@ function SpanPane({
|
|||
function GroupMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="border-b border-border/60 py-3">
|
||||
<div className="text-[13px] font-medium text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-[13px] text-foreground tabular-nums">{value}</div>
|
||||
<div className="text-sm font-medium text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-sm text-foreground tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -186,10 +180,7 @@ function GroupPane({
|
|||
const sample = (firstFailure ?? row.members[0]).span_id;
|
||||
const handoff = useTracesApi(accessToken).handoff(trace.summary.trace_id, sample, trace.summary.trace_ref);
|
||||
return (
|
||||
<aside
|
||||
className="flex h-full min-w-0 flex-col bg-background pt-4 text-[13px] text-foreground"
|
||||
aria-label="Group details"
|
||||
>
|
||||
<aside className="flex h-full min-w-0 flex-col bg-background text-sm text-foreground" aria-label="Group details">
|
||||
<PaneHeader
|
||||
type={row.type}
|
||||
model={row.members[0]?.model ?? null}
|
||||
|
|
@ -201,7 +192,7 @@ function GroupPane({
|
|||
}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-auto px-5 py-3">
|
||||
<div className="min-h-0 flex-1 overflow-auto px-3 py-3">
|
||||
<div className="grid grid-cols-2 gap-x-5">
|
||||
<GroupMetric label="Invocations" value={row.members.length.toLocaleString()} />
|
||||
<GroupMetric label="Failed" value={row.failedCount.toLocaleString()} />
|
||||
|
|
@ -211,9 +202,9 @@ function GroupPane({
|
|||
<GroupMetric label="Type" value={row.type} />
|
||||
</div>
|
||||
{firstFailure?.error && (
|
||||
<section className="mt-3 rounded-[4px] border border-destructive/30 px-3.5 py-3">
|
||||
<div className="text-[12px] font-medium text-destructive">Failure pattern</div>
|
||||
<p className="mt-2 font-mono text-[12px] leading-5 text-foreground">{errorHeadline(firstFailure.error)}</p>
|
||||
<section className="mt-3 rounded-sm border border-destructive/30 px-3.5 py-3">
|
||||
<div className="text-xs font-medium text-destructive">Failure pattern</div>
|
||||
<p className="mt-2 font-mono text-xs leading-5 text-foreground">{errorHeadline(firstFailure.error)}</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -228,7 +219,7 @@ function GroupPane({
|
|||
export function DetailPane({ trace, row, accessToken, spanTab, onSpanTabChange, onClose }: DetailPaneProps) {
|
||||
if (!row || row.kind === "load-more") {
|
||||
return (
|
||||
<div className="grid h-full place-items-center bg-background text-[13px] text-muted-foreground">
|
||||
<div className="grid h-full place-items-center bg-background text-sm text-muted-foreground">
|
||||
Select a span to inspect it.
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ function KeyValueRow({ entry, mono }: { entry: KeyValue; mono: boolean }) {
|
|||
const [key, value] = entry;
|
||||
const [open, setOpen] = useState(false);
|
||||
const valueClass = cn(
|
||||
"min-w-0 flex-1 break-words text-left text-[13px] leading-5 text-foreground",
|
||||
"min-w-0 flex-1 break-words text-left text-sm leading-5 text-foreground",
|
||||
mono || ID_KEY.test(key) ? "font-mono" : "font-sans",
|
||||
);
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ function KeyValueRow({ entry, mono }: { entry: KeyValue; mono: boolean }) {
|
|||
<button {...toggle} className="flex shrink-0 cursor-pointer items-baseline gap-2.5 text-left">
|
||||
{head}
|
||||
</button>
|
||||
<pre className={cn(valueClass, "leading-[1.5] break-words whitespace-pre-wrap")}>{value}</pre>
|
||||
<pre className={cn(valueClass, "leading-normal break-words whitespace-pre-wrap")}>{value}</pre>
|
||||
</>
|
||||
) : (
|
||||
<button {...toggle} className="flex min-w-0 flex-1 cursor-pointer items-baseline gap-2.5 text-left">
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const markdownComponents: Components = {
|
|||
<pre
|
||||
className={cn(
|
||||
BLOCK_GAP,
|
||||
"max-w-full overflow-x-auto rounded-[4px] bg-muted/40 p-2.5 font-mono text-[13px] leading-[1.5] tracking-normal",
|
||||
"max-w-full overflow-x-auto rounded-sm bg-muted/40 p-2.5 font-mono text-sm leading-normal tracking-normal",
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -55,7 +55,7 @@ export function Card({ children, className }: { children: React.ReactNode; class
|
|||
return <section className={cn("rounded-md border border-border bg-background", className)}>{children}</section>;
|
||||
}
|
||||
|
||||
const TILE = "grid size-4 shrink-0 place-items-center rounded-[2px] p-0.5";
|
||||
const TILE = "grid size-4 shrink-0 place-items-center rounded-sm p-0.5";
|
||||
|
||||
function RoleTile({ failed = false }: { role: string; failed?: boolean }) {
|
||||
return <Wrench className={cn("size-3.5 shrink-0", failed ? "text-destructive" : "text-muted-foreground")} />;
|
||||
|
|
@ -83,8 +83,8 @@ const HEADER =
|
|||
"group/header flex min-w-0 items-center gap-2 border border-border bg-background py-2 pr-2 pl-3 transition-colors hover:bg-muted/30";
|
||||
const LABEL = "min-w-0 truncate text-xs leading-5 font-medium text-foreground";
|
||||
const CARD_COPY =
|
||||
"ml-auto size-5 shrink-0 rounded-[3px] text-muted-foreground opacity-60 transition-opacity duration-150 group-hover/header:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [&_svg]:size-4";
|
||||
const INLINE_COPY = "size-4 shrink-0 rounded-[3px] text-muted-foreground";
|
||||
"ml-auto size-5 shrink-0 rounded-sm text-muted-foreground opacity-60 transition-opacity duration-150 group-hover/header:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [&_svg]:size-4";
|
||||
const INLINE_COPY = "size-4 shrink-0 rounded-sm text-muted-foreground";
|
||||
|
||||
export function ToolCallBlock({ call }: { call: TraceToolCall }) {
|
||||
const argEntries = objectEntries(call.args);
|
||||
|
|
@ -111,7 +111,7 @@ export function MessageCard({ message }: { message: TraceMessage; model: string
|
|||
const expanded = open && hasBody;
|
||||
return (
|
||||
<article className="rounded-md">
|
||||
<div className={cn(HEADER, "sticky top-10 z-sticky", expanded ? "rounded-t-[4px] border-b-0" : "rounded-[4px]")}>
|
||||
<div className={cn(HEADER, "sticky top-10 z-sticky", expanded ? "rounded-t-sm border-b-0" : "rounded-sm")}>
|
||||
<FoldTile label={label} open={open} onToggle={() => setOpen((v) => !v)} />
|
||||
<span className={LABEL}>{label}</span>
|
||||
<CopyButton value={copyValue} label={`Copy ${label}`} iconOnly className={CARD_COPY} />
|
||||
|
|
@ -142,7 +142,7 @@ export function ToolResultCard({ name, result, failed = false }: { name: string;
|
|||
const tone = failed ? "text-destructive" : "text-muted-foreground";
|
||||
return (
|
||||
<article className="flex flex-col">
|
||||
<div className={cn(HEADER, open ? "rounded-t-[4px]" : "rounded-[4px]")}>
|
||||
<div className={cn(HEADER, open ? "rounded-t-sm" : "rounded-sm")}>
|
||||
<RoleTile role="tool" failed={failed} />
|
||||
<span className="flex min-w-0 max-w-[50%] shrink-0 items-center gap-1.5">
|
||||
<span className={cn(LABEL, failed && "text-destructive")}>{name}</span>
|
||||
|
|
@ -157,17 +157,17 @@ export function ToolResultCard({ name, result, failed = false }: { name: string;
|
|||
className={cn("flex min-w-0 cursor-pointer items-center gap-1 text-left", tone)}
|
||||
>
|
||||
<FoldChevron open={open} className="size-3 shrink-0" />
|
||||
{!open && <span className="min-w-0 truncate text-[13px] leading-[1.2]">{result}</span>}
|
||||
{!open && <span className="min-w-0 truncate text-sm leading-tight">{result}</span>}
|
||||
</button>
|
||||
) : (
|
||||
<span className={cn("min-w-0 break-words text-[13px] leading-5", tone)}>{result || "No output"}</span>
|
||||
<span className={cn("min-w-0 break-words text-sm leading-5", tone)}>{result || "No output"}</span>
|
||||
)}
|
||||
<CopyButton value={result} label={`Copy ${name} result`} iconOnly className={CARD_COPY} />
|
||||
</div>
|
||||
{open && (
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-80 overflow-auto rounded-b-[4px] border-[0.67px] border-t-0 border-border bg-muted/40 px-3 py-2 font-mono text-[12px] leading-[1.5] break-words whitespace-pre-wrap",
|
||||
"max-h-80 overflow-auto rounded-b-sm border border-t-0 border-border bg-muted/40 px-3 py-2 font-mono text-xs leading-normal break-words whitespace-pre-wrap",
|
||||
tone,
|
||||
)}
|
||||
>
|
||||
|
|
@ -197,10 +197,10 @@ export function Section({
|
|||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="sticky top-0 z-sticky-pinned mb-2 flex h-10 w-full cursor-pointer items-center gap-2.5 bg-background py-1 pr-3 pl-2 text-left transition-colors duration-150 hover:bg-muted/40 motion-reduce:transition-none"
|
||||
className="sticky top-0 z-sticky-pinned mb-2 flex h-10 w-full cursor-pointer items-center gap-2.5 bg-background px-3 text-left transition-colors duration-150 hover:bg-muted/40 motion-reduce:transition-none"
|
||||
>
|
||||
<FoldChevron open={open} className="size-3 shrink-0 text-foreground" />
|
||||
<span className="text-[13px] leading-[1.2] font-medium tracking-[-0.26px] text-foreground">{title}</span>{" "}
|
||||
<span className="text-sm leading-tight font-medium text-foreground">{title}</span>{" "}
|
||||
{count !== undefined && (
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{count} {count === 1 ? "message" : "messages"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export function PaneBar({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <div className={cn("flex h-10 shrink-0 items-center gap-2 border-b px-3", className)}>{children}</div>;
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ export function RequestDetail({ span, accessToken, traceStartMs }: RequestDetail
|
|||
const logNotFound = lookupDone && logQuery.data === null;
|
||||
|
||||
if (span.type !== "llm") {
|
||||
return <div className="py-16 text-center text-[13px] text-trace-duration">This span is not a model request.</div>;
|
||||
return <div className="py-16 text-center text-sm text-trace-duration">This span is not a model request.</div>;
|
||||
}
|
||||
|
||||
const usage: KeyValue[] = [
|
||||
|
|
@ -57,15 +57,15 @@ export function RequestDetail({ span, accessToken, traceStartMs }: RequestDetail
|
|||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="self-start border-trace-border text-[13px] text-trace-text-2 shadow-trace-xs"
|
||||
className="self-start border-trace-border text-sm text-trace-text-2 shadow-trace-xs"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
Open request log <ArrowUpRight className="size-3.5" />
|
||||
</Button>
|
||||
{logNotFound && <p className="text-[13px] text-trace-duration">No request log found for this call.</p>}
|
||||
{logNotFound && <p className="text-sm text-trace-duration">No request log found for this call.</p>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[13px] text-trace-duration">Not linked to a LiteLLM request</p>
|
||||
<p className="text-sm text-trace-duration">Not linked to a LiteLLM request</p>
|
||||
)}
|
||||
</DetailGroup>
|
||||
<LogDetailsDrawer
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "../../../../tests/test-utils";
|
||||
import { clampDrawerWidth, RunDrawer } from "./RunDrawer";
|
||||
import { clampDrawerWidth, PANEL_TRIGGER } from "@/components/shared/SidePanel";
|
||||
import { RunDrawer } from "./RunDrawer";
|
||||
import { type RunSelection, traceRefOf, useOpenTraceRouting } from "./traceRouting";
|
||||
import type { TraceSummary } from "./traceTypes";
|
||||
|
||||
|
|
@ -62,11 +63,15 @@ const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
|
|||
new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
|
||||
|
||||
const mockReducedMotion = (reduce: boolean) =>
|
||||
vi
|
||||
.spyOn(window, "matchMedia")
|
||||
.mockImplementation(
|
||||
(query: string) => ({ matches: reduce && query.includes("reduce"), media: query }) as MediaQueryList,
|
||||
);
|
||||
vi.spyOn(window, "matchMedia").mockImplementation(
|
||||
(query: string) =>
|
||||
({
|
||||
matches: reduce && query.includes("reduce"),
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}) as unknown as MediaQueryList,
|
||||
);
|
||||
|
||||
describe("clampDrawerWidth", () => {
|
||||
it("keeps at least 700px and a 100px strip of list on wide screens", () => {
|
||||
|
|
@ -156,6 +161,70 @@ describe("RunDrawer", () => {
|
|||
expect(onSelect).toHaveBeenCalledExactlyOnceWith(null);
|
||||
});
|
||||
|
||||
it("leaves J, K and Escape to an open menu or listbox instead of stepping or closing the trace", () => {
|
||||
const runs = [run("a"), run("b"), run("c")];
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(
|
||||
<>
|
||||
<div role="menu">
|
||||
<button type="button">menu item</button>
|
||||
</div>
|
||||
<div role="listbox">
|
||||
<button type="button">option</button>
|
||||
</div>
|
||||
<RunDrawer
|
||||
trace={traceRefOf(runs[1])}
|
||||
runs={runs}
|
||||
accessToken="sk"
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
fullScreen={false}
|
||||
onFullScreenChange={vi.fn()}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
for (const target of [screen.getByText("menu item"), screen.getByText("option")]) {
|
||||
for (const key of ["j", "k", "Escape"]) fireEvent.keyDown(target, { key });
|
||||
}
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(window, { key: "j" });
|
||||
expect(onSelect).toHaveBeenCalledExactlyOnceWith(traceRefOf(runs[2]));
|
||||
});
|
||||
|
||||
it("closes on a press outside the panel but not inside it, on a trigger row, or in a dialog", () => {
|
||||
const runs = [run("a")];
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(
|
||||
<>
|
||||
<button type="button">outside</button>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr {...PANEL_TRIGGER}>
|
||||
<td>row</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div role="dialog">dialog</div>
|
||||
<RunDrawer
|
||||
trace={traceRefOf(runs[0])}
|
||||
runs={runs}
|
||||
accessToken="sk"
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
fullScreen={false}
|
||||
onFullScreenChange={vi.fn()}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
fireEvent.mouseDown(screen.getByTestId("run-view"));
|
||||
fireEvent.mouseDown(screen.getByText("row"));
|
||||
fireEvent.mouseDown(screen.getByText("dialog"));
|
||||
fireEvent.mouseDown(screen.getByText("outside"), { button: 2 });
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
fireEvent.mouseDown(screen.getByText("outside"));
|
||||
expect(onSelect).toHaveBeenCalledExactlyOnceWith(null);
|
||||
});
|
||||
|
||||
it("unmounts right away on close when the user prefers reduced motion", () => {
|
||||
mockReducedMotion(true);
|
||||
const runs = [run("a")];
|
||||
|
|
|
|||
|
|
@ -1,143 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronsRight, ChevronUp, Maximize2, Minimize2, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { SidePanel } from "@/components/shared/SidePanel";
|
||||
|
||||
import { RunView } from "./TraceDrawer";
|
||||
import { type RunSelection, type TraceRef, traceKey, traceRefOf } from "./traceRouting";
|
||||
import type { TraceSummary } from "./traceTypes";
|
||||
import { ignoresLetterShortcut } from "../letterShortcut";
|
||||
|
||||
const WIDTH_KEY = "litellm.agentTraces.drawerWidth";
|
||||
const MIN_WIDTH = 700;
|
||||
const MIN_LEFT_GAP = 100;
|
||||
const DEFAULT_FRACTION = 0.56;
|
||||
const KEY_STEP = 32;
|
||||
|
||||
const viewportWidth = (): number => (typeof window === "undefined" ? 1440 : window.innerWidth);
|
||||
|
||||
/** At least 700px when the screen allows, never wider than the screen minus a 100px strip of list (or the full screen). */
|
||||
export const clampDrawerWidth = (width: number, viewport: number): number => {
|
||||
const max = Math.max(Math.min(MIN_WIDTH, viewport), viewport - MIN_LEFT_GAP);
|
||||
return Math.round(Math.min(Math.max(width, Math.min(MIN_WIDTH, max)), max));
|
||||
};
|
||||
|
||||
const prefersReducedMotion = (): boolean =>
|
||||
typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
||||
|
||||
const readStoredWidth = (): number | null => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(WIDTH_KEY);
|
||||
const parsed = raw === null ? Number.NaN : Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const storeWidth = (width: number): void => {
|
||||
try {
|
||||
window.localStorage.setItem(WIDTH_KEY, String(width));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
function useDrawerWidth() {
|
||||
const [width, setWidth] = useState(() =>
|
||||
clampDrawerWidth(readStoredWidth() ?? viewportWidth() * DEFAULT_FRACTION, viewportWidth()),
|
||||
);
|
||||
const update = useCallback((next: number) => {
|
||||
const clamped = clampDrawerWidth(next, viewportWidth());
|
||||
setWidth(clamped);
|
||||
storeWidth(clamped);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const onResize = () => setWidth((w) => clampDrawerWidth(w, viewportWidth()));
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
return [width, update] as const;
|
||||
}
|
||||
|
||||
function ResizeHandle({ width, onResize }: { width: number; onResize: (width: number) => void }) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDragging(true);
|
||||
};
|
||||
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (dragging) onResize(viewportWidth() - event.clientX);
|
||||
};
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") onResize(width + KEY_STEP);
|
||||
else if (event.key === "ArrowRight") onResize(width - KEY_STEP);
|
||||
else return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize trace panel"
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={MIN_WIDTH}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={() => setDragging(false)}
|
||||
onPointerCancel={() => setDragging(false)}
|
||||
onKeyDown={onKeyDown}
|
||||
className="group/handle absolute inset-y-0 -left-2.5 z-raised flex w-5 cursor-col-resize justify-center outline-none"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-full w-[0.67px] bg-border transition-[width,background-color] duration-150 group-hover/handle:w-0.5 group-focus-visible/handle:w-0.5 group-focus-visible/handle:bg-trace-brand motion-reduce:transition-none",
|
||||
dragging && "w-0.5 bg-trace-brand",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderButton({
|
||||
label,
|
||||
disabled = false,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="grid size-7 place-items-center rounded-[4px] text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FullScreenButton({ fullScreen, onToggle }: { fullScreen: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<HeaderButton label={fullScreen ? "Exit full screen" : "Enter full screen"} onClick={onToggle}>
|
||||
{fullScreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||
</HeaderButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface RunDrawerProps {
|
||||
trace: TraceRef | null;
|
||||
|
|
@ -159,15 +28,6 @@ export function RunDrawer({
|
|||
fullScreen,
|
||||
onFullScreenChange,
|
||||
}: RunDrawerProps) {
|
||||
const [width, setWidth] = useDrawerWidth();
|
||||
const [lastShown, setLastShown] = useState<TraceRef | null>(trace);
|
||||
const [exitedKey, setExitedKey] = useState<string | null>(null);
|
||||
if (trace !== null && trace !== lastShown) setLastShown(trace);
|
||||
const shown = trace ?? lastShown;
|
||||
const closing = trace === null && shown !== null;
|
||||
if (trace !== null && exitedKey !== null) setExitedKey(null);
|
||||
if (closing && exitedKey !== traceKey(shown) && prefersReducedMotion()) setExitedKey(traceKey(shown));
|
||||
|
||||
const index = trace === null ? -1 : runs.findIndex((run) => traceKey(traceRefOf(run)) === traceKey(trace));
|
||||
const step = useCallback(
|
||||
(delta: number) => {
|
||||
|
|
@ -176,75 +36,31 @@ export function RunDrawer({
|
|||
},
|
||||
[runs, index, onSelect],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (trace === null) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (ignoresLetterShortcut(event)) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onSelect(null);
|
||||
} else if (event.key === "j") {
|
||||
event.preventDefault();
|
||||
step(1);
|
||||
} else if (event.key === "k") {
|
||||
event.preventDefault();
|
||||
step(-1);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [trace, step, onSelect]);
|
||||
|
||||
if (shown === null || (closing && exitedKey === traceKey(shown))) return null;
|
||||
const close = useCallback(() => onSelect(null), [onSelect]);
|
||||
return (
|
||||
<aside
|
||||
aria-label="Trace details"
|
||||
data-testid="run-drawer"
|
||||
style={{ width: fullScreen ? "100%" : width }}
|
||||
onAnimationEnd={(event) => {
|
||||
if (closing && event.target === event.currentTarget) setExitedKey(traceKey(shown));
|
||||
}}
|
||||
className={cn(
|
||||
"fixed inset-y-0 right-0 z-overlay flex origin-right flex-col bg-background shadow-[0_10px_15px_-3px_rgba(16,24,40,0.1),0_4px_6px_-4px_rgba(16,24,40,0.1)] motion-reduce:animate-none",
|
||||
closing ? "animate-trace-drawer-out" : "animate-trace-drawer-in",
|
||||
)}
|
||||
<SidePanel
|
||||
item={trace}
|
||||
itemKey={traceKey}
|
||||
noun="trace"
|
||||
label="Trace details"
|
||||
testId="run-drawer"
|
||||
index={index}
|
||||
total={runs.length}
|
||||
onStep={step}
|
||||
onClose={close}
|
||||
fullScreen={fullScreen}
|
||||
onFullScreenChange={onFullScreenChange}
|
||||
>
|
||||
{!fullScreen && <ResizeHandle width={width} onResize={setWidth} />}
|
||||
<div className="flex h-[37px] shrink-0 items-center gap-1 border-b border-border px-2">
|
||||
<HeaderButton label="Close (Esc)" onClick={() => onSelect(null)}>
|
||||
<ChevronsRight className="size-4" />
|
||||
</HeaderButton>
|
||||
<span className="mx-1 h-4 w-px bg-border" />
|
||||
<HeaderButton label="Next trace (J)" disabled={index < 0 || index >= runs.length - 1} onClick={() => step(1)}>
|
||||
<ChevronDown className="size-4" />
|
||||
</HeaderButton>
|
||||
<HeaderButton label="Previous trace (K)" disabled={index <= 0} onClick={() => step(-1)}>
|
||||
<ChevronUp className="size-4" />
|
||||
</HeaderButton>
|
||||
{index >= 0 && (
|
||||
<span className="ml-1 font-mono text-[11px] text-muted-foreground tabular-nums">
|
||||
{index + 1} / {runs.length}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<FullScreenButton fullScreen={fullScreen} onToggle={() => onFullScreenChange(!fullScreen)} />
|
||||
<HeaderButton label="Close trace (Esc)" onClick={() => onSelect(null)}>
|
||||
<X className="size-4" />
|
||||
</HeaderButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{(shown) => (
|
||||
<RunView
|
||||
key={traceKey(shown)}
|
||||
traceId={shown.traceId}
|
||||
traceRef={shown.traceRef}
|
||||
selection={selection}
|
||||
accessToken={accessToken}
|
||||
onBack={() => onSelect(null)}
|
||||
onBack={close}
|
||||
embedded
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</SidePanel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
export type RunStatusFilter = "all" | "ok" | "error";
|
||||
|
||||
export const ALL_AGENTS = "all";
|
||||
|
||||
interface RunsToolbarProps {
|
||||
query: string;
|
||||
agent: string;
|
||||
status: RunStatusFilter;
|
||||
agents: string[];
|
||||
onQueryChange: (value: string) => void;
|
||||
onAgentChange: (value: string) => void;
|
||||
onStatusChange: (value: RunStatusFilter) => void;
|
||||
/** Extra controls (time range, live tail) rendered on the right. */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const STATUS_ITEMS: { value: RunStatusFilter; label: string }[] = [
|
||||
{ value: "all", label: "All status" },
|
||||
{ value: "ok", label: "Succeeded" },
|
||||
{ value: "error", label: "Failed" },
|
||||
];
|
||||
|
||||
/** Search + agent / status filters for the Runs table. Filtering is client-side over the loaded page. */
|
||||
export function RunsToolbar({
|
||||
query,
|
||||
agent,
|
||||
status,
|
||||
agents,
|
||||
onQueryChange,
|
||||
onAgentChange,
|
||||
onStatusChange,
|
||||
children,
|
||||
}: RunsToolbarProps) {
|
||||
const agentItems = [{ value: ALL_AGENTS, label: "All agents" }, ...agents.map((s) => ({ value: s, label: s }))];
|
||||
return (
|
||||
<div className="flex min-h-11 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-card px-3 py-2">
|
||||
<div className="relative w-full max-w-[380px]">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder="Search input or trace ID"
|
||||
aria-label="Search runs"
|
||||
className="h-7 pl-8 text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
items={agentItems}
|
||||
value={agent}
|
||||
onValueChange={(value: string | null) => value !== null && onAgentChange(value)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-7 min-w-[130px] text-[12px]" aria-label="Filter by agent">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agentItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
items={STATUS_ITEMS}
|
||||
value={status}
|
||||
onValueChange={(value: RunStatusFilter | null) => value !== null && onStatusChange(value)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-7 min-w-[110px] text-[12px]" aria-label="Filter by status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{children && <div className="ml-auto flex items-center gap-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -125,7 +125,7 @@ function HoverCardBody({ facts, traceStartMs }: { facts: HoverFacts; traceStartM
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[12px] text-trace-duration">
|
||||
<span className="text-xs text-trace-duration">
|
||||
{TYPE_LABEL[facts.type]}
|
||||
{facts.model ? ` · ${facts.model}` : ""}
|
||||
</span>
|
||||
|
|
@ -150,7 +150,7 @@ function HoverCardBody({ facts, traceStartMs }: { facts: HoverFacts; traceStartM
|
|||
<FactSection title="Tags">
|
||||
<ul className="flex flex-wrap gap-1">
|
||||
{facts.tags.map((tag) => (
|
||||
<li key={tag} className="rounded-[3px] bg-trace-tag px-1 py-0.5 text-trace-duration">
|
||||
<li key={tag} className="rounded-sm bg-trace-tag px-1 py-0.5 text-trace-duration">
|
||||
{tag}
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -158,7 +158,7 @@ function HoverCardBody({ facts, traceStartMs }: { facts: HoverFacts; traceStartM
|
|||
</FactSection>
|
||||
)}
|
||||
{facts.error && (
|
||||
<div className="rounded-[4px] bg-destructive/10 px-2 py-1.5 font-mono text-[12px] break-words text-destructive">
|
||||
<div className="rounded-sm bg-destructive/10 px-2 py-1.5 font-mono text-xs break-words text-destructive">
|
||||
{errorHeadline(facts.error)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -183,7 +183,7 @@ export function SpanHoverCard({ facts, traceStartMs, children }: SpanHoverCardPr
|
|||
sideOffset={0}
|
||||
alignOffset={0}
|
||||
data-testid="span-hover-card"
|
||||
className="max-h-[min(520px,calc(100vh-16px))] w-auto max-w-[420px] overflow-y-auto rounded-[6px] border border-trace-border bg-trace-surface p-3 text-[13px] leading-[1.2] tracking-[-0.26px] text-trace-text shadow-trace-md ring-0 transition-opacity duration-150 ease-[cubic-bezier(0,0,0.2,1)] data-closed:animate-none data-ending-style:opacity-0 data-open:animate-none motion-reduce:transition-none"
|
||||
className="max-h-[min(520px,calc(100vh-16px))] w-auto max-w-[420px] overflow-y-auto rounded-md border border-trace-border bg-trace-surface p-3 text-sm leading-tight text-trace-text shadow-trace-md ring-0 transition-opacity duration-150 ease-[cubic-bezier(0,0,0.2,1)] data-closed:animate-none data-ending-style:opacity-0 data-open:animate-none motion-reduce:transition-none"
|
||||
>
|
||||
<HoverCardBody facts={facts} traceStartMs={traceStartMs} />
|
||||
</HoverCardContent>
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ import { useSpanProvider } from "./spanProvider";
|
|||
import type { SpanType } from "./traceTypes";
|
||||
|
||||
const SIZE = {
|
||||
sm: "size-4 rounded-[2px]",
|
||||
card: "size-[18px] rounded-[3px]",
|
||||
sm: "size-4 rounded-sm",
|
||||
card: "size-[18px] rounded-sm",
|
||||
md: "size-5 rounded-full",
|
||||
lg: "size-5 rounded-full",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { FoldChevron } from "./Collapse";
|
||||
import { PaneBar } from "./PaneBar";
|
||||
import { groupFacts, SpanHoverCard, spanFacts } from "./SpanHoverCard";
|
||||
import { SpanIcon } from "./SpanIcon";
|
||||
import type { GroupRowData, SpanRowData, TreeRow } from "./traceTree";
|
||||
|
|
@ -50,9 +51,9 @@ const rowClass = (selected: boolean): string =>
|
|||
selected ? "border-l-primary bg-accent" : "border-l-transparent hover:bg-muted/60",
|
||||
);
|
||||
|
||||
const LINE = "border-dashed border-border [border-width:0] [border-left-width:1px]";
|
||||
const ELBOW = "border-dashed border-border [border-width:0] [border-left-width:1px] [border-bottom-width:1px]";
|
||||
const NAME = "line-clamp-2 break-words text-[13px] leading-[1.2] font-medium";
|
||||
const LINE = "border-l border-dashed border-border";
|
||||
const ELBOW = "border-b border-l border-dashed border-border";
|
||||
const NAME = "line-clamp-2 break-words text-sm leading-tight font-medium";
|
||||
const META = "text-xs leading-4 text-muted-foreground tabular-nums";
|
||||
|
||||
/** One 20px column per ancestor: pass-through rails for open branches, an elbow into this row's tile. */
|
||||
|
|
@ -99,7 +100,7 @@ function Caret({ open, onToggle, label }: { open: boolean; onToggle: () => void;
|
|||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
className="grid size-6 shrink-0 place-items-center self-start rounded-[4px] p-0.5 text-muted-foreground transition-colors duration-150 hover:bg-muted motion-reduce:transition-none"
|
||||
className="grid size-6 shrink-0 place-items-center self-start rounded-sm p-0.5 text-muted-foreground transition-colors duration-150 hover:bg-muted motion-reduce:transition-none"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggle();
|
||||
|
|
@ -295,79 +296,78 @@ export function SpanTree({
|
|||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 min-w-0 flex-col border-r border-border bg-card" aria-label="Run spans">
|
||||
<div className="shrink-0 space-y-3 border-b px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Steps{" "}
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
{summary.span_count.toLocaleString()}
|
||||
</span>
|
||||
<PaneBar className="justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
Steps{" "}
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
{filtering && `${rows.length.toLocaleString()} of `}
|
||||
{summary.span_count.toLocaleString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{onOpenDetails && (
|
||||
<Button variant="ghost" size="icon-xs" onClick={onOpenDetails} aria-label="Show details">
|
||||
<PanelRightOpen className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-xs" aria-label="Step display options" />}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuCheckboxItem checked={hideFramework} onCheckedChange={onToggleHideFramework}>
|
||||
Hide framework spans
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onCollapseAll}>Collapse branches</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{onOpenDetails && (
|
||||
<Button variant="ghost" size="icon-xs" onClick={onOpenDetails} aria-label="Show details">
|
||||
<PanelRightOpen className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-xs" aria-label="Step display options" />}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuCheckboxItem checked={hideFramework} onCheckedChange={onToggleHideFramework}>
|
||||
Hide framework spans
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onCollapseAll}>Collapse branches</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-2.5 left-2.5 size-3.5 text-muted-foreground" />
|
||||
</PaneBar>
|
||||
<PaneBar>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search steps"
|
||||
placeholder="Search steps"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
className="h-8 pr-8 pl-8 text-xs shadow-none md:text-xs"
|
||||
className="h-7 pr-7 pl-7 text-xs shadow-none md:text-xs"
|
||||
/>
|
||||
{query && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Clear step search"
|
||||
className="absolute top-1 right-1"
|
||||
className="absolute top-1/2 right-0.5 -translate-y-1/2"
|
||||
onClick={() => onQueryChange("")}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={!errorsOnly ? "secondary" : "ghost"}
|
||||
size="xs"
|
||||
aria-pressed={!errorsOnly}
|
||||
onClick={() => onErrorsOnlyChange(false)}
|
||||
>
|
||||
All steps
|
||||
</Button>
|
||||
<Button
|
||||
variant={errorsOnly ? "secondary" : "ghost"}
|
||||
size="xs"
|
||||
aria-pressed={errorsOnly}
|
||||
onClick={() => onErrorsOnlyChange(true)}
|
||||
>
|
||||
Errors <span className="ml-1 text-muted-foreground">{summary.error_count}</span>
|
||||
</Button>
|
||||
{filtering && (
|
||||
<span className="ml-auto text-xs text-muted-foreground" role="status">
|
||||
{rows.length} found
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={!errorsOnly ? "secondary" : "ghost"}
|
||||
size="xs"
|
||||
aria-pressed={!errorsOnly}
|
||||
onClick={() => onErrorsOnlyChange(false)}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant={errorsOnly ? "secondary" : "ghost"}
|
||||
size="xs"
|
||||
aria-pressed={errorsOnly}
|
||||
onClick={() => onErrorsOnlyChange(true)}
|
||||
>
|
||||
Errors <span className="ml-1 text-muted-foreground">{summary.error_count}</span>
|
||||
</Button>
|
||||
{filtering && (
|
||||
<span role="status" className="sr-only">
|
||||
{rows.length} found
|
||||
</span>
|
||||
)}
|
||||
</PaneBar>
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto py-2">
|
||||
{rows.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
|
|
@ -385,7 +385,7 @@ export function SpanTree({
|
|||
key={row.id}
|
||||
type="button"
|
||||
onClick={() => onLoadMore(row.groupId)}
|
||||
className={cn(rowClass(false), "h-8 text-[13px] tracking-[-0.26px] text-muted-foreground")}
|
||||
className={cn(rowClass(false), "h-8 text-sm text-muted-foreground")}
|
||||
>
|
||||
<Gutters depth={row.depth} guide={guides[i]} />
|
||||
<span className="ml-0.5 flex items-center gap-2">
|
||||
|
|
@ -413,7 +413,7 @@ export function SpanTree({
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-8 shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
|
||||
<div className="flex min-h-10 shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-t border-border px-3 py-1.5 text-xs text-muted-foreground">
|
||||
{embedded ? (
|
||||
<>
|
||||
<span className="whitespace-nowrap">
|
||||
|
|
@ -441,7 +441,7 @@ export function SpanTree({
|
|||
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="rounded-[3px] border border-border border-b-2 bg-muted px-[3px] font-mono text-muted-foreground">
|
||||
<kbd className="rounded-sm border border-border border-b-2 bg-muted px-[3px] font-mono text-muted-foreground">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export function StatusMark({ status, count, subtle = false }: StatusMarkProps) {
|
|||
if (status === "error") {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[11px] text-destructive"
|
||||
className="inline-flex items-center gap-1 text-xs text-destructive"
|
||||
aria-label={`${count ?? 1} error${count === 1 ? "" : "s"}`}
|
||||
>
|
||||
<AlertCircle className="size-3" />
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const RANGE_LABEL_FORMAT = "MMM D, h:mm A";
|
|||
export const rangeLabel = (range: TimeWindow): string =>
|
||||
`${moment(range.startMs).format(RANGE_LABEL_FORMAT)} to ${moment(range.endMs).format(RANGE_LABEL_FORMAT)}`;
|
||||
|
||||
const SEGMENT = "inline-flex h-7 items-center gap-1.5 px-2.5 text-[13px] outline-none focus-visible:bg-accent";
|
||||
const SEGMENT = "inline-flex h-7 items-center gap-1.5 px-2.5 text-sm outline-none focus-visible:bg-accent";
|
||||
|
||||
interface TimeRangeControlsProps {
|
||||
range: TimeWindow;
|
||||
|
|
@ -79,7 +79,7 @@ export function TimeRangeControls({
|
|||
onValueChange={(value: string) => onRangeHoursChange(Number(value))}
|
||||
>
|
||||
{RANGE_PRESETS.map((preset) => (
|
||||
<DropdownMenuRadioItem key={preset.hours} value={String(preset.hours)} className="text-[13px]">
|
||||
<DropdownMenuRadioItem key={preset.hours} value={String(preset.hours)} className="text-sm">
|
||||
{preset.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,36 @@ describe("RunView", () => {
|
|||
expect(screen.queryByRole("button", { name: "Back to runs" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the current run on screen, inert, while an unvisited run loads in the drawer", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveSwarm: (trace: Trace) => void = () => {};
|
||||
vi.mocked(agentTraceCall).mockImplementation((_token, traceId) =>
|
||||
traceId === swarm.summary.trace_id
|
||||
? new Promise<Trace>((resolve) => {
|
||||
resolveSwarm = resolve;
|
||||
})
|
||||
: Promise.resolve(research),
|
||||
);
|
||||
const props = { accessToken: "sk-test", onBack: vi.fn(), embedded: true };
|
||||
const { rerender } = renderWithProviders(<RoutedRunView traceId={research.summary.trace_id} {...props} />);
|
||||
const root = rootSpanId(research);
|
||||
expect(await screen.findByTestId("detail-pane")).toHaveAttribute("data-row-id", root);
|
||||
|
||||
rerender(<RoutedRunView traceId={swarm.summary.trace_id} {...props} />);
|
||||
await waitFor(() => expect(screen.getByTestId("run-view")).toHaveAttribute("aria-busy", "true"));
|
||||
expect(screen.queryByRole("status", { name: "Loading trace" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary));
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", root);
|
||||
|
||||
act(() => resolveSwarm(swarm));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(swarm.summary)),
|
||||
);
|
||||
expect(screen.getByTestId("run-view")).toHaveAttribute("aria-busy", "false");
|
||||
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", initialRunSelection(swarm).selectedId);
|
||||
});
|
||||
|
||||
it("moves the selection with J / K and closes the detail pane with Esc", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRun(research);
|
||||
|
|
@ -297,6 +327,15 @@ describe("RunView", () => {
|
|||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("loads the run on retry after a failed load", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(agentTraceCall).mockRejectedValueOnce(new Error("Traces are temporarily unavailable"));
|
||||
renderRun(research);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary));
|
||||
});
|
||||
|
||||
it("finds a step beyond a folded group's first page and reveals it after search clears", async () => {
|
||||
const user = userEvent.setup();
|
||||
const root = research.spans.find((span) => span.parent_span_id === null)!;
|
||||
|
|
@ -342,12 +381,14 @@ describe("RunView", () => {
|
|||
});
|
||||
|
||||
it("does not navigate steps while typing or moving the search cursor", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRun(research);
|
||||
const search = await screen.findByRole("textbox", { name: "Search steps" });
|
||||
const selected = screen.getByTestId("detail-pane").getAttribute("data-row-id");
|
||||
await user.type(search, "jk{ArrowDown}{ArrowUp}");
|
||||
fireEvent.change(search, { target: { value: "jk" } });
|
||||
expect(search).toHaveValue("jk");
|
||||
for (const key of ["j", "k", "ArrowDown", "ArrowUp"]) {
|
||||
fireEvent.keyDown(search, { key });
|
||||
}
|
||||
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", selected);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
import { type TraceHandoff, useTracesApi } from "./tracesApi";
|
||||
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { QueryErrorResetBoundary, useQueryClient, useSuspenseInfiniteQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Check, Copy } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { useEventListener, useTimeout } from "usehooks-ts";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
|
@ -19,7 +21,7 @@ import { restartsTraversal } from "./readFailure";
|
|||
import { SpanTree } from "./SpanTree";
|
||||
import { TraceConversation } from "./TraceConversation";
|
||||
import { FrameworkLogo, traceFramework } from "./TraceFramework";
|
||||
import type { RunSelection } from "./traceRouting";
|
||||
import { type RunSelection, traceKey } from "./traceRouting";
|
||||
import type { SpanTreeState, TreeRow } from "./traceTree";
|
||||
import type { Trace } from "./traceTypes";
|
||||
import {
|
||||
|
|
@ -81,16 +83,12 @@ const toggle = (set: ReadonlySet<string>, id: string): Set<string> => {
|
|||
|
||||
function CopyForAgent({ handoff }: { handoff: TraceHandoff }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timeout = window.setTimeout(() => setCopied(false), 1600);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [copied]);
|
||||
useTimeout(() => setCopied(false), copied ? 1600 : null);
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 shrink-0 gap-1.5 rounded-md text-[12px] shadow-none"
|
||||
className="h-7 shrink-0 gap-1.5 rounded-md text-xs shadow-none"
|
||||
onClick={async () => setCopied(await copyToClipboard(handoff.text, handoff.copied))}
|
||||
>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
|
|
@ -193,9 +191,10 @@ interface RunBodyProps {
|
|||
accessToken: string;
|
||||
selection: RunSelection;
|
||||
embedded: boolean;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
function RunBody({ trace, accessToken, selection, embedded }: RunBodyProps) {
|
||||
function RunBody({ trace, accessToken, selection, embedded, stale }: RunBodyProps) {
|
||||
const spanKeys = embedded ? EMBEDDED_SPAN_KEYS : SPAN_KEYS;
|
||||
const { view, selectSpan, setView, stepQuery: query, setStepQuery: setQuery, errorsOnly, setErrorsOnly } = selection;
|
||||
const [initial] = useState(() => initialRunSelection(trace, selection.spanId ?? undefined));
|
||||
|
|
@ -250,14 +249,14 @@ function RunBody({ trace, accessToken, selection, embedded }: RunBodyProps) {
|
|||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "steps") return;
|
||||
const setRowExpanded = (row: TreeRow, expand: boolean) => {
|
||||
if (row.kind === "span" && row.hasChildren && row.collapsed === expand) toggleSpan(row.id);
|
||||
if (row.kind === "group" && row.expanded !== expand) toggleGroup(row.id);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (ignoreStepKey(event)) return;
|
||||
const setRowExpanded = (row: TreeRow, expand: boolean) => {
|
||||
if (row.kind === "span" && row.hasChildren && row.collapsed === expand) toggleSpan(row.id);
|
||||
if (row.kind === "group" && row.expanded !== expand) toggleGroup(row.id);
|
||||
};
|
||||
useEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (stale || view !== "steps" || ignoreStepKey(event)) return;
|
||||
const index = rows.findIndex((row) => row.id === selectedRow?.id);
|
||||
const row = rows[index];
|
||||
if (event.key === "Escape" && detailOpen) {
|
||||
|
|
@ -279,10 +278,10 @@ function RunBody({ trace, accessToken, selection, embedded }: RunBodyProps) {
|
|||
} else if (event.key === "ArrowRight" && row) {
|
||||
setRowExpanded(row, true);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", onKeyDown, true);
|
||||
}, [rows, selectedRow, detailOpen, select, toggleSpan, toggleGroup, spanKeys, view]);
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
if (view === "conversation")
|
||||
return (
|
||||
|
|
@ -365,8 +364,8 @@ interface RunViewProps {
|
|||
embedded?: boolean;
|
||||
}
|
||||
|
||||
function selectedSpanMissing(trace: Trace | undefined, spanId: string | null): boolean {
|
||||
return Boolean(spanId && trace && !trace.spans.some((span) => span.span_id === spanId));
|
||||
function selectedSpanMissing(trace: Trace, spanId: string | null): boolean {
|
||||
return Boolean(spanId && !trace.spans.some((span) => span.span_id === spanId));
|
||||
}
|
||||
|
||||
const loadMoreStatus = (trace: Trace, failed: boolean, mustRestart: boolean): string => {
|
||||
|
|
@ -375,11 +374,82 @@ const loadMoreStatus = (trace: Trace, failed: boolean, mustRestart: boolean): st
|
|||
return `Showing ${trace.spans.length.toLocaleString()} of ${trace.summary.span_count.toLocaleString()} steps`;
|
||||
};
|
||||
|
||||
export function RunView({ traceId, traceRef, selection, accessToken, onBack, embedded = false }: RunViewProps) {
|
||||
function RunLoading({ embedded }: { embedded: boolean }) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Loading trace"
|
||||
className={embedded ? "flex flex-col gap-3 p-4" : "flex h-[60vh] items-center justify-center"}
|
||||
>
|
||||
{embedded ? (
|
||||
[72, 48, 88, 60, 80].map((w) => (
|
||||
<div key={w} className="h-4 animate-pulse rounded bg-trace-row-hover" style={{ width: `${w}%` }} />
|
||||
))
|
||||
) : (
|
||||
<UiLoadingSpinner className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunLoadError({ error, onBack, onRetry }: { error: unknown; onBack: () => void; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="p-6 text-xs" data-testid="run-view-error">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" /> Back to traces
|
||||
</button>
|
||||
<h1 className="mb-2 text-sm font-medium">Could not load trace</h1>
|
||||
<span className="text-muted-foreground">{error instanceof Error ? error.message : "Unknown error"}</span>
|
||||
<Button variant="outline" size="sm" className="ml-3" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps the shown run on screen, inert, while the next one loads. */
|
||||
export function RunView(props: RunViewProps) {
|
||||
const traceId = useDeferredValue(props.traceId);
|
||||
const traceRef = useDeferredValue(props.traceRef);
|
||||
const switching = traceId !== props.traceId || traceRef !== props.traceRef;
|
||||
const shownKey = traceKey({ traceId, traceRef });
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
resetKeys={[shownKey]}
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<RunLoadError error={error} onBack={props.onBack} onRetry={resetErrorBoundary} />
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<RunLoading embedded={props.embedded ?? false} />}>
|
||||
<LoadedRun key={shownKey} {...props} traceId={traceId} traceRef={traceRef} switching={switching} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadedRun({
|
||||
traceId,
|
||||
traceRef,
|
||||
selection,
|
||||
accessToken,
|
||||
onBack,
|
||||
embedded = false,
|
||||
switching,
|
||||
}: RunViewProps & { switching: boolean }) {
|
||||
const traces = useTracesApi(accessToken);
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ["agentTrace", traceId, traceRef, accessToken];
|
||||
const traceQueryOptions = {
|
||||
queryKey: ["agentTrace", traceId, traceRef, accessToken],
|
||||
queryKey,
|
||||
queryFn: ({ pageParam }: { pageParam: string | null }) => traces.trace(traceId, traceRef, pageParam),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (lastPage: Trace) => lastPage.next_cursor ?? undefined,
|
||||
|
|
@ -389,14 +459,13 @@ export function RunView({ traceId, traceRef, selection, accessToken, onBack, emb
|
|||
refetchOnMount: false,
|
||||
retry: false,
|
||||
};
|
||||
const traceQuery = useInfiniteQuery(traceQueryOptions);
|
||||
const refreshTrace = () => queryClient.resetQueries({ queryKey: traceQueryOptions.queryKey, exact: true });
|
||||
const traceQuery = useSuspenseInfiniteQuery(traceQueryOptions);
|
||||
const refreshTrace = () => queryClient.resetQueries({ queryKey, exact: true });
|
||||
const trace = useMemo(() => {
|
||||
const pages = traceQuery.data?.pages;
|
||||
if (!pages?.length) return undefined;
|
||||
return { ...pages[0], spans: pages.flatMap((page) => page.spans) };
|
||||
const [first, ...rest] = traceQuery.data.pages;
|
||||
return { ...first, spans: [first, ...rest].flatMap((page) => page.spans) };
|
||||
}, [traceQuery.data]);
|
||||
const seekingSpan = selectedSpanMissing(trace, selection.spanId);
|
||||
const seekingSpan = !switching && selectedSpanMissing(trace, selection.spanId);
|
||||
const { hasNextPage, isFetching, isError, fetchNextPage } = traceQuery;
|
||||
const canSeek = seekingSpan && hasNextPage;
|
||||
useEffect(() => {
|
||||
|
|
@ -405,41 +474,6 @@ export function RunView({ traceId, traceRef, selection, accessToken, onBack, emb
|
|||
const mustRestart = restartsTraversal(traceQuery.error);
|
||||
const pageAction = isError ? "Retry" : "Load more steps";
|
||||
|
||||
if (traceQuery.isLoading) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Loading trace"
|
||||
className={embedded ? "flex flex-col gap-3 p-4" : "flex h-[60vh] items-center justify-center"}
|
||||
>
|
||||
{embedded ? (
|
||||
[72, 48, 88, 60, 80].map((w) => (
|
||||
<div key={w} className="h-4 animate-pulse rounded bg-trace-row-hover" style={{ width: `${w}%` }} />
|
||||
))
|
||||
) : (
|
||||
<UiLoadingSpinner className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!trace) {
|
||||
return (
|
||||
<div className="p-6 text-[12px]" data-testid="run-view-error">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" /> Back to traces
|
||||
</button>
|
||||
<h1 className="mb-2 text-[13px] font-medium">Could not load trace</h1>
|
||||
<span className="text-muted-foreground">{traceQuery.error?.message ?? "Unknown error"}</span>
|
||||
<Button variant="outline" size="sm" className="ml-3" onClick={() => void refreshTrace()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tabs
|
||||
value={selection.view}
|
||||
|
|
@ -447,7 +481,10 @@ export function RunView({ traceId, traceRef, selection, accessToken, onBack, emb
|
|||
className={cn(
|
||||
"@container/trace flex flex-1 flex-col gap-0 overflow-hidden bg-background",
|
||||
embedded ? "min-h-0" : "min-h-[560px] border-y border-border",
|
||||
switching && "opacity-60 transition-opacity delay-150 duration-150 motion-reduce:transition-none",
|
||||
)}
|
||||
aria-busy={switching}
|
||||
inert={switching}
|
||||
data-testid="run-view"
|
||||
>
|
||||
<RunHeader
|
||||
|
|
@ -487,6 +524,7 @@ export function RunView({ traceId, traceRef, selection, accessToken, onBack, emb
|
|||
accessToken={accessToken}
|
||||
selection={selection}
|
||||
embedded={embedded}
|
||||
stale={switching}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { useMediaQuery, useResizeObserver } from "usehooks-ts";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { FIELD_COLS, FIELD_ROWS, agentDotColor, agoLabel, columnDots, isReceiving } from "./lensField";
|
||||
import { FIELD_COLS, FIELD_ROWS, columnDots } from "./lensField";
|
||||
import type { TraceSummary } from "./traceTypes";
|
||||
import { traceAgentNames } from "./traceUtils";
|
||||
|
||||
|
|
@ -136,9 +138,10 @@ function drawField(canvas: HTMLCanvasElement, { buckets, max, band, hover, progr
|
|||
}
|
||||
|
||||
function useRiseIn(): number {
|
||||
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
|
||||
const [progress, setProgress] = useState(0);
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
if (reduceMotion) {
|
||||
setProgress(1);
|
||||
return;
|
||||
}
|
||||
|
|
@ -149,7 +152,7 @@ function useRiseIn(): number {
|
|||
if (t < 1) frame = requestAnimationFrame(rise);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
}, [reduceMotion]);
|
||||
return progress;
|
||||
}
|
||||
|
||||
|
|
@ -166,17 +169,13 @@ function DotField({
|
|||
}) {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const progress = useRiseIn();
|
||||
const { width, height } = useResizeObserver({ ref: canvas as RefObject<HTMLCanvasElement> });
|
||||
useEffect(() => {
|
||||
const node = canvas.current;
|
||||
if (!node) return;
|
||||
const frame: FieldFrame = { buckets, max, band, hover, progress };
|
||||
const paint = () => drawField(node, frame);
|
||||
paint();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(paint);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [buckets, max, band, hover, progress]);
|
||||
if (canvas.current) {
|
||||
const fieldFrame = { buckets, max, band, hover, progress };
|
||||
drawField(canvas.current, fieldFrame);
|
||||
}
|
||||
}, [buckets, max, band, hover, progress, width, height]);
|
||||
return (
|
||||
<canvas
|
||||
ref={canvas}
|
||||
|
|
@ -191,74 +190,6 @@ function BucketBar({ bucket }: { bucket: Bucket }) {
|
|||
return <div className="pointer-events-none h-full flex-1" data-testid="timeline-bucket" data-runs={bucket.runs} />;
|
||||
}
|
||||
|
||||
function useNow(intervalMs: number): number {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), intervalMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
function LivePulse({ live }: { live: boolean }) {
|
||||
return (
|
||||
<span aria-hidden="true" className="relative flex size-2">
|
||||
{live && (
|
||||
<span className="absolute inline-flex size-full rounded-full bg-[#3b5bfd] opacity-60 motion-safe:animate-ping" />
|
||||
)}
|
||||
<span
|
||||
className={cn("relative inline-flex size-2 rounded-full", live ? "bg-[#3b5bfd]" : "bg-muted-foreground/50")}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function latestStart(runs: readonly TraceSummary[]): number | null {
|
||||
return runs.reduce<number | null>((latest, run) => {
|
||||
const t = moment(run.start_time).valueOf();
|
||||
return latest === null || t > latest ? t : latest;
|
||||
}, null);
|
||||
}
|
||||
|
||||
function FieldHeader({ runs, range }: { runs: readonly TraceSummary[]; range: TimeWindow }) {
|
||||
const now = useNow(1000);
|
||||
const agents = Array.from(new Set(runs.flatMap(traceAgentNames)));
|
||||
const failed = runs.filter((run) => run.error_count > 0).length;
|
||||
const lastMs = latestStart(runs);
|
||||
const live = isReceiving(lastMs, now);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-1 font-mono text-[11px] leading-none">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground tabular-nums">
|
||||
<span className="flex items-center gap-2" role="status" data-testid="traces-last-seen">
|
||||
<LivePulse live={live} />
|
||||
<span className={cn("font-semibold", live ? "text-[#0011b3] dark:text-[#8b9bff]" : "text-foreground")}>
|
||||
{live ? "receiving" : "idle"}
|
||||
</span>
|
||||
{lastMs !== null && <span>last trace {agoLabel(lastMs, now)}</span>}
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold text-foreground">{runs.length.toLocaleString()}</span>{" "}
|
||||
{runs.length === 1 ? "run" : "runs"} from{" "}
|
||||
<span className="font-semibold text-foreground">{agents.length}</span>{" "}
|
||||
{agents.length === 1 ? "agent" : "agents"}
|
||||
{failed > 0 && <span className="text-[#e5484d]"> · {failed} failed</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 text-muted-foreground">
|
||||
{agents.slice(0, 6).map((agent) => (
|
||||
<span key={agent} className="flex items-center gap-1.5">
|
||||
<span className="size-[6px] rounded-full" style={{ backgroundColor: agentDotColor(agent) }} />
|
||||
{agent}
|
||||
</span>
|
||||
))}
|
||||
<span className="rounded border border-border bg-muted/50 px-1.5 py-px">
|
||||
Total {formatSpan(range.endMs - range.startMs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NowEdge() {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -277,7 +208,7 @@ function NowEdge() {
|
|||
function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute top-full z-floating mt-1 rounded-md border border-border bg-popover px-2.5 py-1.5 font-mono text-[11px] text-popover-foreground shadow-md"
|
||||
className="pointer-events-none absolute top-full z-floating mt-1 rounded-md border border-border bg-popover px-2.5 py-1.5 font-mono text-xs text-popover-foreground shadow-md"
|
||||
style={{ left: pct(Math.min(0.8, index / BUCKETS)) }}
|
||||
role="tooltip"
|
||||
>
|
||||
|
|
@ -288,7 +219,7 @@ function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
|
|||
{bucket.runs} {bucket.runs === 1 ? "run" : "runs"}
|
||||
{bucket.failed > 0 && `, ${bucket.failed} failed`}
|
||||
</div>
|
||||
<div className="text-[#0011b3] dark:text-[#8b9bff]">drag to zoom</div>
|
||||
<div className="text-info">drag to zoom</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -351,8 +282,7 @@ function SelectionBracket({
|
|||
const handle = "absolute inset-y-0 w-[2px] cursor-ew-resize bg-[#0011b3] dark:bg-[#8b9bff]";
|
||||
const tick =
|
||||
"before:absolute before:top-0 before:h-[2px] before:w-2 before:bg-inherit after:absolute after:bottom-0 after:h-[2px] after:w-2 after:bg-inherit";
|
||||
const edgeLabel =
|
||||
"pointer-events-none absolute -bottom-5 font-mono text-[10px] whitespace-nowrap text-[#0011b3] tabular-nums dark:text-[#8b9bff]";
|
||||
const edgeLabel = "pointer-events-none absolute -bottom-5 font-mono text-xs whitespace-nowrap text-info tabular-nums";
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -372,7 +302,7 @@ function SelectionBracket({
|
|||
onPointerDown={onHandleDown("resize-hi")}
|
||||
/>
|
||||
{widthPx >= MIN_DURATION_LABEL_PX && (
|
||||
<span className="pointer-events-none absolute inset-x-0 top-2 text-center font-mono text-[11px] font-semibold text-[#0011b3] dark:text-[#8b9bff]">
|
||||
<span className="pointer-events-none absolute inset-x-0 top-2 text-center font-mono text-xs font-semibold text-info">
|
||||
{formatSpan(window.endMs - window.startMs)}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -404,7 +334,7 @@ function TickAxis({ range }: { range: TimeWindow }) {
|
|||
style={{ left: pct(t), transform: tickShift(t) }}
|
||||
>
|
||||
<span className="h-1 w-px bg-border" />
|
||||
<span className="font-mono text-[10px] whitespace-nowrap text-muted-foreground tabular-nums">
|
||||
<span className="font-mono text-xs whitespace-nowrap text-muted-foreground tabular-nums">
|
||||
{moment(range.startMs + (range.endMs - range.startMs) * t).format(format)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -421,18 +351,10 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
|
|||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
const [draft, setDraft] = useState<Band | null>(null);
|
||||
const areaRef = useRef<HTMLDivElement>(null);
|
||||
const [stripWidth, setStripWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = areaRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setStripWidth(el.getBoundingClientRect().width);
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const { width: stripWidth = 0 } = useResizeObserver({
|
||||
ref: areaRef as RefObject<HTMLDivElement>,
|
||||
box: "border-box",
|
||||
});
|
||||
|
||||
const committed = bandForWindow(buckets, selection);
|
||||
const band = drag ? draft : committed;
|
||||
|
|
@ -486,12 +408,11 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
|
|||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<FieldHeader runs={runs} range={range} />
|
||||
<div
|
||||
ref={areaRef}
|
||||
role="presentation"
|
||||
data-testid="timeline-area"
|
||||
className="relative mt-2 flex cursor-crosshair touch-none items-end"
|
||||
className="relative flex cursor-crosshair touch-none items-end"
|
||||
style={{ height: FIELD_HEIGHT }}
|
||||
onPointerDown={(e) => begin("select", e)}
|
||||
onPointerMove={onMove}
|
||||
|
|
@ -525,6 +446,16 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
|
|||
<TickAxis range={range} />
|
||||
</div>
|
||||
{hover !== null && !drag && <BucketTooltip bucket={buckets[hover]} index={hover} />}
|
||||
{selection && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(null)}
|
||||
aria-label="Clear time zoom"
|
||||
className="absolute top-1 right-3 inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { ArrowRight, ArrowUpRight, Check, Copy, KeyRound, Loader2, Send } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTimeout } from "usehooks-ts";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { LensPreviewButton } from "@/components/lens/LensPreviewButton";
|
||||
|
|
@ -99,12 +100,8 @@ function CodeBlock({
|
|||
wrap?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
if (await copyToClipboard(code)) {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
|
||||
}
|
||||
};
|
||||
useTimeout(() => setCopied(false), copied ? COPIED_RESET_MS : null);
|
||||
const copy = async () => setCopied(await copyToClipboard(code));
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-border bg-muted/30">
|
||||
<div className="flex h-9 items-center border-b border-border px-3">
|
||||
|
|
@ -131,7 +128,7 @@ function CodeBlock({
|
|||
}
|
||||
|
||||
function FileLabel({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-[12.5px] text-foreground">{children}</span>;
|
||||
return <span className="text-xs text-foreground">{children}</span>;
|
||||
}
|
||||
|
||||
function LineTabs<T extends string>({
|
||||
|
|
@ -155,7 +152,7 @@ function LineTabs<T extends string>({
|
|||
aria-selected={value === option}
|
||||
onClick={() => onChange(option)}
|
||||
className={cn(
|
||||
"-mb-px inline-flex h-9 items-center gap-1.5 border-b-2 text-[12.5px]",
|
||||
"-mb-px inline-flex h-9 items-center gap-1.5 border-b-2 text-xs",
|
||||
value === option
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
|
|
@ -351,18 +348,14 @@ function TracingKey({
|
|||
|
||||
function EndpointValue({ value }: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
if (await copyToClipboard(value)) {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
|
||||
}
|
||||
};
|
||||
useTimeout(() => setCopied(false), copied ? COPIED_RESET_MS : null);
|
||||
const copy = async () => setCopied(await copyToClipboard(value));
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy()}
|
||||
aria-label={`Copy ${value}`}
|
||||
className="group inline-flex min-w-0 items-center gap-2 text-left font-mono text-[12.5px] text-foreground"
|
||||
className="group inline-flex min-w-0 items-center gap-2 text-left font-mono text-xs text-foreground"
|
||||
>
|
||||
<span className="break-all">{value}</span>
|
||||
{copied ? (
|
||||
|
|
@ -387,9 +380,9 @@ function Endpoints({ proxyUrl, children }: { proxyUrl: string; children?: React.
|
|||
<dl className="mt-3 grid gap-x-6 gap-y-3 rounded-md border bg-muted/30 p-4 md:grid-cols-2">
|
||||
{otlpEndpoints(proxyUrl).map(([label, value, copyable]) => (
|
||||
<div key={label} className={cn("min-w-0", label === "Traces endpoint" && "md:col-span-2")}>
|
||||
<dt className="text-[12px] text-muted-foreground">{label}</dt>
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-0.5">
|
||||
{copyable ? <EndpointValue value={value} /> : <span className="text-[12.5px]">{value}</span>}
|
||||
{copyable ? <EndpointValue value={value} /> : <span className="text-xs">{value}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -456,11 +449,9 @@ function CodingAgentSetup({ proxyUrl, guide, model }: { proxyUrl: string; guide:
|
|||
const [codingAgent, setCodingAgent] = useState<CodingAgent>("Claude Code");
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const command = codingAgentCommand(codingAgent, codingAgentPrompt(proxyUrl, guide, model));
|
||||
useTimeout(() => setCopied(null), copied === null ? null : COPIED_RESET_MS);
|
||||
const copy = async () => {
|
||||
if (await copyToClipboard(command)) {
|
||||
setCopied(command);
|
||||
window.setTimeout(() => setCopied(null), COPIED_RESET_MS);
|
||||
}
|
||||
if (await copyToClipboard(command)) setCopied(command);
|
||||
};
|
||||
return (
|
||||
<section className="mt-6" aria-labelledby="connect-project">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
FIELD_ROWS,
|
||||
UNNAMED_AGENT_COLOR,
|
||||
agoLabel,
|
||||
isReceiving,
|
||||
agentDotColor,
|
||||
columnDots,
|
||||
columnTop,
|
||||
|
|
@ -102,13 +101,3 @@ describe("agoLabel", () => {
|
|||
expect(agoLabel(now + 10_000, now)).toBe("just now");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReceiving", () => {
|
||||
const now = Date.UTC(2026, 9, 3, 12, 0, 0);
|
||||
|
||||
it("is live only when a trace landed in the last five minutes", () => {
|
||||
expect(isReceiving(now - 4 * 60_000, now)).toBe(true);
|
||||
expect(isReceiving(now - 6 * 60_000, now)).toBe(false);
|
||||
expect(isReceiving(null, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,9 +59,3 @@ export function agoLabel(thenMs: number, nowMs: number): string {
|
|||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
}
|
||||
|
||||
export const RECEIVING_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
export function isReceiving(lastMs: number | null, nowMs: number): boolean {
|
||||
return lastMs !== null && nowMs - lastMs < RECEIVING_WINDOW_MS;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
} from "nuqs";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { ALL_AGENTS, type RunStatusFilter } from "./RunsToolbar";
|
||||
import { RANGE_PRESETS } from "./TimeRangeControls";
|
||||
import type { TimeWindow } from "./TracesTimeline";
|
||||
import type { TraceSummary } from "./traceTypes";
|
||||
|
|
@ -20,7 +19,6 @@ export type TraceView = (typeof TRACE_VIEWS)[number];
|
|||
export const SPAN_TABS = ["content", "request", "attributes"] as const;
|
||||
export type SpanTab = (typeof SPAN_TABS)[number];
|
||||
|
||||
const STATUS_FILTERS = ["all", "ok", "error"] as const satisfies readonly RunStatusFilter[];
|
||||
const RANGE_HOURS = RANGE_PRESETS.map((preset) => preset.hours);
|
||||
type RangeHours = (typeof RANGE_PRESETS)[number]["hours"];
|
||||
const isRangeHours = (hours: number): hours is RangeHours => RANGE_HOURS.includes(hours as RangeHours);
|
||||
|
|
@ -61,8 +59,6 @@ export const OPEN_TRACE_PARSERS = {
|
|||
|
||||
export const RUN_FILTER_PARSERS = {
|
||||
q: parseAsString.withDefault(""),
|
||||
agent: parseAsString.withDefault(ALL_AGENTS),
|
||||
status: parseAsStringLiteral(STATUS_FILTERS).withDefault("all"),
|
||||
hours: parseAsNumberLiteral(RANGE_HOURS).withDefault(DEFAULT_RANGE_HOURS),
|
||||
from: parseAsInteger,
|
||||
to: parseAsInteger,
|
||||
|
|
@ -143,25 +139,9 @@ export function useLocalRunSelection(initialSpanId: string | null): RunSelection
|
|||
return { spanId, view, spanTab, stepQuery, errorsOnly, selectSpan, setView, setSpanTab, setStepQuery, setErrorsOnly };
|
||||
}
|
||||
|
||||
export interface RunFilterRouting {
|
||||
query: string;
|
||||
agent: string;
|
||||
status: RunStatusFilter;
|
||||
setQuery: (query: string) => void;
|
||||
setAgent: (agent: string) => void;
|
||||
setStatus: (status: RunStatusFilter) => void;
|
||||
}
|
||||
|
||||
export function useRunFilterRouting(): RunFilterRouting {
|
||||
const [{ q, agent, status }, setParams] = useQueryStates(RUN_FILTER_PARSERS);
|
||||
return {
|
||||
query: q,
|
||||
agent,
|
||||
status,
|
||||
setQuery: useCallback((q: string) => void setParams({ q }), [setParams]),
|
||||
setAgent: useCallback((agent: string) => void setParams({ agent }), [setParams]),
|
||||
setStatus: useCallback((status: RunStatusFilter) => void setParams({ status }), [setParams]),
|
||||
};
|
||||
export function useRunFilterRouting(): { query: string; setQuery: (query: string) => void } {
|
||||
const [query, setQuery] = useQueryState("q", RUN_FILTER_PARSERS.q);
|
||||
return { query, setQuery: useCallback((q: string) => void setQuery(q), [setQuery]) };
|
||||
}
|
||||
|
||||
export function useRangeHoursRouting(): [number, (hours: number) => void] {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,16 @@ describe("createApiClient", () => {
|
|||
expect(init.body).toBe(JSON.stringify({ model_name: "gpt" }));
|
||||
});
|
||||
|
||||
it("reports a non-JSON success body as an ApiError naming the path instead of a parse error", async () => {
|
||||
const html = new Response("<!DOCTYPE html><html></html>", { headers: { "Content-Type": "text/html" } });
|
||||
const client = createApiClient({ getBaseUrl: () => "", fetchImpl: vi.fn(async () => html) });
|
||||
|
||||
const error = await client.get("/lens").catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect((error as ApiError).message).toBe("Expected JSON from /lens but the server returned text/html");
|
||||
});
|
||||
|
||||
it("throws ApiError with the derived message and invokes onError on a non-2xx response", async () => {
|
||||
const fetchImpl = vi.fn(async () => errorResponse(403, { error: { message: "no access" } }));
|
||||
const onError = vi.fn();
|
||||
|
|
|
|||
|
|
@ -184,7 +184,13 @@ export function createApiClient(config: ApiClientConfig): ApiClient {
|
|||
async function request<T = any>(method: HttpMethod, path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const response = await fetchChecked(method, path, options);
|
||||
const text = await response.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
if (!text) return undefined as T;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
const type = response.headers.get("content-type") ?? "an unknown content type";
|
||||
throw new ApiError(`Expected JSON from ${path} but the server returned ${type}`, response.status, text);
|
||||
}
|
||||
}
|
||||
|
||||
async function getBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { RuleTester } from "eslint";
|
||||
import rule from "../../scripts/eslint-rules/no-arbitrary-design-value.mjs";
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const arbitrary = (code: string, token: string) => ({ code, errors: [{ messageId: "arbitrary", data: { token } }] });
|
||||
|
||||
ruleTester.run("no-arbitrary-design-value", rule as never, {
|
||||
valid: [
|
||||
'const c = "text-xs leading-tight tracking-wider rounded-sm border";',
|
||||
'const c = "grid-cols-[minmax(0,1fr)_auto] w-[180px] left-[calc(50%-0.5px)]";',
|
||||
'const c = "data-[side=right]:sm:max-w-[90vw] [&>p]:mb-0";',
|
||||
'const c = "ease-[cubic-bezier(0.4,0,0.2,1)]";',
|
||||
'const el = <div className="text-sm text-muted-foreground" />;',
|
||||
],
|
||||
invalid: [
|
||||
arbitrary('const c = "flex text-[13px]";', "text-[13px]"),
|
||||
arbitrary('const c = "text-[#e5484d]";', "text-[#e5484d]"),
|
||||
arbitrary('const c = "tracking-[-0.26px]";', "tracking-[-0.26px]"),
|
||||
arbitrary('const c = "leading-[1.2]";', "leading-[1.2]"),
|
||||
arbitrary('const c = "rounded-[4px]";', "rounded-[4px]"),
|
||||
arbitrary('const c = "rounded-t-[4px]";', "rounded-t-[4px]"),
|
||||
arbitrary('const c = "border-[0.67px]";', "border-[0.67px]"),
|
||||
arbitrary('const c = "[border-left-width:1px]";', "[border-left-width:1px]"),
|
||||
arbitrary('const c = "dark:text-[#8b9bff]";', "dark:text-[#8b9bff]"),
|
||||
arbitrary('const c = "data-[side=top]:text-[11px]";', "data-[side=top]:text-[11px]"),
|
||||
arbitrary("const c = `px-2 ${x} text-[10px]`;", "text-[10px]"),
|
||||
arbitrary('const el = <div className="text-[12px]" />;', "text-[12px]"),
|
||||
],
|
||||
});
|
||||
|
|
@ -227,3 +227,33 @@ if (typeof window !== "undefined") {
|
|||
disconnect() {}
|
||||
};
|
||||
}
|
||||
|
||||
// jsdom lacks the layout and input APIs ProseMirror editors use. Browsers report the selection as an edit's
|
||||
// target range; layout calls only need a value of the right shape, since tests do not assert on positions.
|
||||
if (typeof InputEvent !== "undefined" && !("getTargetRanges" in InputEvent.prototype)) {
|
||||
Object.defineProperty(InputEvent.prototype, "getTargetRanges", {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
const selection = document.getSelection();
|
||||
return selection && selection.rangeCount > 0 ? [selection.getRangeAt(0)] : [];
|
||||
},
|
||||
});
|
||||
}
|
||||
if (typeof Range !== "undefined" && !("getClientRects" in Range.prototype)) {
|
||||
Object.defineProperties(Range.prototype, {
|
||||
getClientRects: { configurable: true, value: () => [] },
|
||||
getBoundingClientRect: { configurable: true, value: () => new DOMRect() },
|
||||
});
|
||||
}
|
||||
// The virtualized runs list renders no rows in a zero-height viewport, and jsdom reports 0 for every offsetHeight.
|
||||
if (typeof HTMLElement !== "undefined") {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.dataset.testid === "runs-table" ? 720 : 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
if (typeof document !== "undefined" && !("elementFromPoint" in document)) {
|
||||
Object.defineProperty(document, "elementFromPoint", { configurable: true, value: () => null });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue