From 5ddcc45b3a93a64476a93d2a5987b50b7dad1354 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:20:14 +0000 Subject: [PATCH] feat(ui): share trace drawer as a closable SidePanel and polish Lens (#44473) * feat(ui): extract trace drawer into a shared SidePanel that closes on outside press Co-Authored-By: Claude Opus 5.5 * feat(ui): center Lens mode switch in a notch joined to the content card Larger Traces/Investigations switch, a subtle dot when an investigation is running or queued, and a bigger Lens title with a docs link. Co-Authored-By: Claude Opus 5.5 * fix(ui): stronger Lens frame border and header spacing Co-Authored-By: Claude Opus 5.5 * fix(ui): align Lens notch fillet with the notch border Co-Authored-By: Claude Opus 5.5 * fix(ui): centered Lens loading/error states and proxy JSON calls in dev The dev server answered GET /lens with the Lens page HTML because the UI route shadowed the proxy fallback rewrite. JSON API requests now go to the proxy before page routes, and a non-JSON success body raises a readable ApiError. Co-Authored-By: Claude Opus 5.5 * refactor(ui): theme-scale type and shape in trace views, aligned pane bars Add local/no-arbitrary-design-value, scoped to TraceView and Lens, banning arbitrary font size, tracking, leading, radius, border and CSS property values. Map the Figma-export values onto the theme scale and replace hex colors with info/destructive tokens. Add PaneBar, a fixed-height bordered row, and build the step tree and span detail headers from it so their borders line up across the split. Co-Authored-By: Claude Opus 5.5 * fix(ui): restore AgentTracesSection emptied in e7c3092571 Co-Authored-By: Claude Opus 5.5 * feat(ui): move Set up tracing into the empty runs state Co-Authored-By: Claude Opus 5.5 * refactor(ui): let the runs table gate the setup CTA on an empty range Co-Authored-By: Claude Opus 5.5 * feat(ui): mark Lens demo mode with a blue toggle and frame instead of a banner Co-Authored-By: Claude Opus 5.5 * fix(ui): draw Lens notch corners with CSS borders and thicken the demo frame The SVG corner strokes did not snap to the same device pixels as the tab and card borders, leaving a visible offset at the join. Co-Authored-By: Claude Opus 5.5 * refactor(ui): drop the redundant Lens timeline header The status, run counts, truncated agent legend and range span all repeated the Live toggle, runs table and range picker Co-Authored-By: Claude Opus 5.5 * fix(ui): keep drawer shortcuts out of open menus and use usehooks-ts for timers and observers SidePanel J/K/Esc now yields to menus and listboxes, not just dialogs. The step tree shortcut footer wraps instead of clipping in narrow columns. Replace hand-rolled timeout, keydown, media query and ResizeObserver effects with usehooks-ts, and drop routine doc comments. Co-Authored-By: Claude Opus 5.5 * fix(ui): offer tracing setup when filters hide every run Co-Authored-By: Claude Opus 5.5 * feat(ui): replace Lens runs filters with a single ProseMirror query box Agent and status dropdowns are gone. One query box (react-prosemirror) takes free text plus key:value clauses (-key:value, key:*glob*) over name, agent, status, model, input and trace_id, with field and value autocomplete. The editor emits after a 150ms pause so typing no longer re-renders the runs view per keystroke, and the URL keeps only q. Co-Authored-By: Claude Opus 5.5 * fix(ui): keep the open trace on screen while the next one loads Switching to an unvisited trace remounted the panel and flashed a loading skeleton. The drawer now keeps the previous trace visible, dimmed and inert, until the new one arrives. Co-Authored-By: Claude Opus 5.5 * feat(ui): virtualize the Lens runs table and drop its status footer The footer's run count only tracked how many pages had loaded and "Updated just now" never changed, so it carried no signal. The zoom clear button moves onto the timeline. Rows now render through @tanstack/react-virtual so scrolling deep into a range stays cheap Co-Authored-By: Claude Opus 5.5 * refactor(ui): load traces with Suspense and show the previous run via useDeferredValue Replaces keepPreviousData with the React pattern for showing stale content while fresh content loads. Load failures go through an error boundary that retries the query on reset. Co-Authored-By: Claude Opus 5.5 * fix(ui): open investigation details from list rows instead of the edit dialog Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Yujong Lee Co-authored-by: Claude Opus 5.5 --- ui/litellm-dashboard/eslint.config.mjs | 5 + ui/litellm-dashboard/next.config.mjs | 9 +- ui/litellm-dashboard/package-lock.json | 172 ++++++++++++ ui/litellm-dashboard/package.json | 8 + .../scripts/eslint-rules/index.mjs | 2 + .../eslint-rules/no-ad-hoc-z-index.mjs | 23 +- .../no-arbitrary-design-value.mjs | 42 +++ .../scripts/eslint-rules/tailwind-utility.mjs | 20 ++ .../lens/LensWorkspace.integration.test.tsx | 49 ++-- .../src/components/lens/LensWorkspace.tsx | 208 ++++++++++---- .../lens/investigations/InvestigationList.tsx | 60 ++-- .../investigations/InvestigationStatus.tsx | 128 +++++++-- .../InvestigationsView.integration.test.tsx | 81 ++++++ .../investigations/InvestigationsView.tsx | 14 +- .../investigations/InvestigationsWelcome.tsx | 2 +- .../lens/investigations/StepFeed.tsx | 6 +- .../src/components/lens/model/status.test.ts | 11 +- .../src/components/lens/model/status.ts | 9 + .../lens/runSearch/RunSearch.test.tsx | 140 ++++++++++ .../components/lens/runSearch/RunSearch.tsx | 257 ++++++++++++++++++ .../components/lens/runSearch/RunsToolbar.tsx | 22 ++ .../lens/runSearch/__fixtures__/runs.ts | 42 +++ .../lens/runSearch/runQuery.test.ts | 96 +++++++ .../src/components/lens/runSearch/runQuery.ts | 111 ++++++++ .../lens/runSearch/suggestions.test.ts | 75 +++++ .../components/lens/runSearch/suggestions.ts | 91 +++++++ .../src/components/shared/SidePanel.tsx | 239 ++++++++++++++++ .../AgentTracesSection.integration.test.tsx | 101 +++---- .../TraceView/AgentTracesSection.test.tsx | 17 -- .../TraceView/AgentTracesSection.tsx | 106 +------- .../TraceView/AgentTracesTable.test.tsx | 69 +++++ .../view_logs/TraceView/AgentTracesTable.tsx | 182 ++++++++----- .../view_logs/TraceView/AttributesDetail.tsx | 4 +- .../view_logs/TraceView/DetailContent.tsx | 15 +- .../view_logs/TraceView/DetailPane.tsx | 43 ++- .../view_logs/TraceView/KeyValueRows.tsx | 4 +- .../view_logs/TraceView/MessageCard.tsx | 22 +- .../view_logs/TraceView/PaneBar.tsx | 5 + .../view_logs/TraceView/RequestDetail.tsx | 8 +- .../view_logs/TraceView/RunDrawer.test.tsx | 81 +++++- .../view_logs/TraceView/RunDrawer.tsx | 222 ++------------- .../view_logs/TraceView/RunsToolbar.tsx | 89 ------ .../view_logs/TraceView/SpanHoverCard.tsx | 8 +- .../view_logs/TraceView/SpanIcon.tsx | 4 +- .../view_logs/TraceView/SpanTree.tsx | 122 ++++----- .../view_logs/TraceView/StatusMark.tsx | 2 +- .../view_logs/TraceView/TimeRangeControls.tsx | 4 +- .../view_logs/TraceView/TraceDrawer.test.tsx | 39 +++ .../view_logs/TraceView/TraceDrawer.tsx | 172 +++++++----- .../view_logs/TraceView/TracesTimeline.tsx | 135 +++------ .../view_logs/TraceView/TracingSetupCard.tsx | 33 +-- .../view_logs/TraceView/lensField.test.ts | 11 - .../view_logs/TraceView/lensField.ts | 6 - .../view_logs/TraceView/traceRouting.ts | 26 +- .../src/lib/http/client.test.ts | 10 + ui/litellm-dashboard/src/lib/http/client.ts | 8 +- .../no-arbitrary-design-value.test.ts | 36 +++ ui/litellm-dashboard/tests/setupTests.ts | 30 ++ 58 files changed, 2509 insertions(+), 1027 deletions(-) create mode 100644 ui/litellm-dashboard/scripts/eslint-rules/no-arbitrary-design-value.mjs create mode 100644 ui/litellm-dashboard/scripts/eslint-rules/tailwind-utility.mjs create mode 100644 ui/litellm-dashboard/src/components/lens/runSearch/RunSearch.test.tsx create mode 100644 ui/litellm-dashboard/src/components/lens/runSearch/RunSearch.tsx create mode 100644 ui/litellm-dashboard/src/components/lens/runSearch/RunsToolbar.tsx create mode 100644 ui/litellm-dashboard/src/components/lens/runSearch/__fixtures__/runs.ts create mode 100644 ui/litellm-dashboard/src/components/lens/runSearch/runQuery.test.ts create mode 100644 ui/litellm-dashboard/src/components/lens/runSearch/runQuery.ts create mode 100644 ui/litellm-dashboard/src/components/lens/runSearch/suggestions.test.ts create mode 100644 ui/litellm-dashboard/src/components/lens/runSearch/suggestions.ts create mode 100644 ui/litellm-dashboard/src/components/shared/SidePanel.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesSection.test.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/TraceView/AgentTracesTable.test.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/TraceView/PaneBar.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/TraceView/RunsToolbar.tsx create mode 100644 ui/litellm-dashboard/tests/eslint-rules/no-arbitrary-design-value.test.ts diff --git a/ui/litellm-dashboard/eslint.config.mjs b/ui/litellm-dashboard/eslint.config.mjs index 5bb7cc29792..ad2923bf2a0 100644 --- a/ui/litellm-dashboard/eslint.config.mjs +++ b/ui/litellm-dashboard/eslint.config.mjs @@ -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" }, diff --git a/ui/litellm-dashboard/next.config.mjs b/ui/litellm-dashboard/next.config.mjs index e7a63206017..d4fdc7af36f 100644 --- a/ui/litellm-dashboard/next.config.mjs +++ b/ui/litellm-dashboard/next.config.mjs @@ -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*` }], }; }, diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index af723f22957..b4c886752df 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -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", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 894f6f230ca..bba51e9aa2b 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -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" }, diff --git a/ui/litellm-dashboard/scripts/eslint-rules/index.mjs b/ui/litellm-dashboard/scripts/eslint-rules/index.mjs index 750b8df4e27..3988a810fca 100644 --- a/ui/litellm-dashboard/scripts/eslint-rules/index.mjs +++ b/ui/litellm-dashboard/scripts/eslint-rules/index.mjs @@ -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, }, }; diff --git a/ui/litellm-dashboard/scripts/eslint-rules/no-ad-hoc-z-index.mjs b/ui/litellm-dashboard/scripts/eslint-rules/no-ad-hoc-z-index.mjs index 6af86d2c501..d349539c5a3 100644 --- a/ui/litellm-dashboard/scripts/eslint-rules/no-ad-hoc-z-index.mjs +++ b/ui/litellm-dashboard/scripts/eslint-rules/no-ad-hoc-z-index.mjs @@ -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"; diff --git a/ui/litellm-dashboard/scripts/eslint-rules/no-arbitrary-design-value.mjs b/ui/litellm-dashboard/scripts/eslint-rules/no-arbitrary-design-value.mjs new file mode 100644 index 00000000000..421a5e15cfc --- /dev/null +++ b/ui/litellm-dashboard/scripts/eslint-rules/no-arbitrary-design-value.mjs @@ -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; diff --git a/ui/litellm-dashboard/scripts/eslint-rules/tailwind-utility.mjs b/ui/litellm-dashboard/scripts/eslint-rules/tailwind-utility.mjs new file mode 100644 index 00000000000..69c80a51d5e --- /dev/null +++ b/ui/litellm-dashboard/scripts/eslint-rules/tailwind-utility.mjs @@ -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(/!$/, ""); +}; diff --git a/ui/litellm-dashboard/src/components/lens/LensWorkspace.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/LensWorkspace.integration.test.tsx index 49aa02fd6f0..999d2f11980 100644 --- a/ui/litellm-dashboard/src/components/lens/LensWorkspace.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/LensWorkspace.integration.test.tsx @@ -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()); @@ -179,9 +177,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 () => { @@ -205,4 +201,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(); + 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("")); + }); }); diff --git a/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx b/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx index 11fdde333b3..bac37e55641 100644 --- a/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx +++ b/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx @@ -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 ( -
- {demo && ( -

-

- )} +
- +
); } const MODE_ICONS = { traces: Activity, investigations: ScanSearch } as const; -function LensModeSwitch() { +const ACTIVITY_DOT: Record, { 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 ( - - - {Object.entries(LENS_TABS).map(([view, label]) => { - const Icon = MODE_ICONS[view as LensTab]; - return ( - - - ); - })} - +