diff --git a/deploy/lens/README.md b/deploy/lens/README.md index 4971bfae671..6f8c7d3940e 100644 --- a/deploy/lens/README.md +++ b/deploy/lens/README.md @@ -2,62 +2,74 @@ Lens reviews recorded activity and saves evidence-linked findings in the LiteLLM dashboard under Observability, Lens (`/ui/lens/`) -## Install the release stack +## Install -Each stable, RC, and dev release containing Lens publishes the worker at the same version on GHCR and Docker Hub. Use the [LiteLLM releases page](https://github.com/BerriAI/litellm/releases) to select a version that includes the coordinated worker release +Build LiteLLM and its worker from the same source commit with the same release identity. The worker runs separately and connects to your gateway using a limited worker token -For a new local installation, install Docker with Compose, download the two release files, and create a private environment file. Replace `X.Y.Z` with the release version, without `v` (RCs use `X.Y.Z-rc.N`) +### New local installation + +Install Docker with Compose and Git. This builds LiteLLM and its worker from the same checkout and starts the existing local tracing stack: ```bash -mkdir litellm-lens -cd litellm-lens -LENS_RELEASE=X.Y.Z -curl -fSLo compose.yaml "https://raw.githubusercontent.com/BerriAI/litellm/v${LENS_RELEASE}/deploy/lens/stack.yaml" -curl -fSLo config.yaml "https://raw.githubusercontent.com/BerriAI/litellm/v${LENS_RELEASE}/deploy/lens/config.yaml" -umask 077 -printf 'LITELLM_VERSION=%s\nLITELLM_MASTER_KEY=sk-%s\nLITELLM_SALT_KEY=sk-%s\n' \ - "$LENS_RELEASE" "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env -printf 'POSTGRES_PASSWORD=%s\nCLICKHOUSE_PASSWORD=%s\n' \ - "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" >> .env -docker compose up -d +git clone https://github.com/BerriAI/litellm.git +cd litellm +export LITELLM_RELEASE_TAG="sha-$(git rev-parse HEAD)" +export LENS_WORKER_IMAGE="litellm-lens-worker:${LITELLM_RELEASE_TAG}" +export OPENAI_API_KEY='sk-...' +docker build --build-arg LITELLM_RELEASE_TAG="$LITELLM_RELEASE_TAG" \ + -f deploy/lens/Dockerfile -t "$LENS_WORKER_IMAGE" . +docker compose -f docker/docker-compose.tracing.yml up -d --build ``` -Open `http://localhost:4000/ui/`, log in as `admin` with `LITELLM_MASTER_KEY` from `.env`, and add a model in the dashboard. In Lens, select **Connect worker**, choose that model and a monthly budget, then **Get install command**. Expand **Using Docker Compose or Helm?**, copy the worker token, and add `LENS_WORKER_TOKEN=` to `.env` +Open `http://localhost:4002/ui/` and sign in as `admin` with password `sk-1234`. Go to **Lens > Investigations > Connect worker**, choose a model and monthly budget, then **Get install command**. Expand **Using Docker Compose or Helm?** and copy the worker token. In the same terminal, run: ```bash -docker compose --profile lens up -d +export LITELLM_URL=http://litellm:4000 +export LENS_WORKER_TOKEN='' +docker compose -f docker/docker-compose.tracing.yml -f deploy/lens/compose.yaml up -d ``` -The stack starts LiteLLM, PostgreSQL, ClickHouse, and the worker from published images. The dashboard shows **Worker connected**. The worker has a limited token, no database credentials, and no provider keys. The stack exposes only the dashboard on localhost; use your normal ingress and managed databases for a public production deployment +The worker joins the gateway's Docker network, and the dashboard shows **Worker connected**. Save the token privately for restarts and upgrades -Keep `.env` private and preserve its salt key. Keep both named database volumes. To upgrade, wait for active investigations to finish, stop the worker, change only `LITELLM_VERSION`, then pull and recreate the stack: +This stack is for local evaluation: it binds to localhost and uses development database credentials. For a hosted deployment, keep your normal database, keys, networking, and deployment process. Build both images from one source revision with the same `LITELLM_RELEASE_TAG`, publish the worker to your registry, and set `LENS_WORKER_IMAGE` on LiteLLM to that image + +### Existing LiteLLM installation + +Keep your deployment and PostgreSQL database. A working gateway/worker pair can stay as it is until you upgrade both. For a gateway built from source, use its exact commit and `LITELLM_RELEASE_TAG`; a release version or the latest commit on `main` is not a substitute for that source identity + +The public development package is `ghcr.io/berriai/litellm-lens-worker-dev:sha-`. It publishes amd64 images on Lens-related changes, so an arbitrary source commit may have no image. Check the exact image exists before using it. If it is unavailable, your gateway uses a different release identity, or you need native arm64, build the worker from the gateway's checkout: ```bash -docker compose --profile lens stop lens-worker -# Update LITELLM_VERSION in .env to the new release -docker compose --profile lens pull -docker compose --profile lens up -d +export LITELLM_RELEASE_TAG='' +export LENS_WORKER_IMAGE='/litellm-lens-worker:' +docker build --build-arg LITELLM_RELEASE_TAG="$LITELLM_RELEASE_TAG" \ + -f deploy/lens/Dockerfile -t "$LENS_WORKER_IMAGE" . ``` -This preserves your investigations, findings, model credentials, and worker token. Never use `down -v` during an upgrade. If moving from an existing installation, keep its databases and add the standalone worker instead of creating an empty replacement stack +For a remote worker host, publish that image to a registry the host can pull from. Set the gateway's `LENS_WORKER_IMAGE` to the resulting image reference, restart the gateway using its normal deployment process, then copy its install command. Prefer the published image digest for hosted installations. Do not change the gateway's release identity just to accept another worker + +For Kubernetes or Render, run the standalone worker using `LITELLM_URL` and `LENS_WORKER_TOKEN` from setup. Keep existing databases and secrets. The worker needs no inbound port. ## Helm -The componentized `helm/litellm` chart includes an optional Lens worker. Configure PostgreSQL and ClickHouse as usual, install the chart, then obtain a limited worker token from Lens setup. Store it in a Kubernetes Secret and enable the worker in your values: +The componentized source chart at `helm/litellm` includes an optional Lens worker. Use the chart from the same checkout as your gateway and keep your component image overrides in your values. Configure PostgreSQL and ClickHouse as usual, install the chart, then obtain a limited worker token from Lens setup. Store it in a Kubernetes Secret and enable the worker in your values: ```yaml lensWorker: enabled: true + image: + repository: + digest: sha256: tokenSecret: name: litellm-lens-worker key: token ``` -Published release charts pin the worker's approved image digest. Source charts without a digest default to the chart's application version. The chart connects the worker to the backend service. Keep these values and the Secret when upgrading the chart so the gateway and worker upgrade together. `lensWorker.replicaCount` controls simultaneous investigations. To use a private registry or external proxy, set `lensWorker.image.repository`, `lensWorker.image.digest` (or `tag` for a source build), and `lensWorker.url`. A digest takes precedence over the tag. The dashboard uses the chart's worker image for standalone install commands too +Set the worker repository and digest explicitly to an image built from the gateway's source commit and release identity. The chart connects the worker to the backend service. Keep these values and the Secret when upgrading the chart and update the gateway and worker image overrides together. `lensWorker.replicaCount` controls simultaneous investigations. To use a private registry or external proxy, set `lensWorker.image.repository`, `lensWorker.image.digest` (or `tag` for a source build), and `lensWorker.url`. A digest takes precedence over the tag. The dashboard uses the chart's worker image for standalone install commands too ## Standalone worker -Upgrade your existing LiteLLM proxy to a release that includes Lens with PostgreSQL and agent tracing. Configure one ClickHouse URL for trace writes, bounded reads, and Lens queries: +Start with a source deployment that includes Lens, PostgreSQL, and agent tracing, and prepare its matching worker as described above. Configure one ClickHouse URL for trace writes, bounded reads, and Lens queries: ```yaml general_settings: @@ -74,13 +86,13 @@ Retention changes require a proxy restart. ClickHouse removes expired rows durin In **Lens > Investigations**, click **Connect worker**, choose an analysis model and monthly limit, then **Get install command**. Use **Advanced options** to select an existing virtual key or change the proxy URL if the server running Docker needs a different network address. Copy the command and run it on your server. The dashboard shows **Worker connected** when the container checks in -The command already contains the compatible worker image and one worker token. The selected virtual key stays on the proxy; its secret is never sent to the worker. No source checkout, environment file, or second LiteLLM deployment is needed. Keep the command private because it includes the token. The LiteLLM release provides the dashboard and APIs; the container only runs background analysis +The command already contains the compatible worker image and one worker token. The selected virtual key stays on the proxy; its secret is never sent to the worker. Once the matching image is available on the worker host, no second LiteLLM deployment is needed. Keep the command private because it includes the token. The LiteLLM release provides the dashboard and APIs; the container only runs background analysis -The dashboard selects the worker image matching the running gateway release. Release images support Linux amd64 and arm64. CI also publishes `:sha-` development images; use those only with a gateway built from the same commit and release tag +The dashboard uses the gateway's `LENS_WORKER_IMAGE` override when set. Public `:sha-` development images must match both the gateway commit and release identity. Build from source for the worker host's native architecture After upgrading the gateway, update the worker image and redeploy it while keeping its proxy URL and token. Existing containers do not update automatically. If an investigation reports a worker compatibility error, update the image before retrying -For deployments managed with Compose, download `compose.yaml` and provide `LITELLM_URL`, `LENS_WORKER_TOKEN`, and `LITELLM_VERSION` (without `v`) in a private environment file. To use another registry, set `LENS_WORKER_IMAGE` to the compatible image instead of setting a version: +For deployments managed with Compose, download `compose.yaml` and provide `LITELLM_URL`, `LENS_WORKER_TOKEN`, and an explicit `LENS_WORKER_IMAGE` in a private environment file: ```bash docker compose --env-file /path/to/lens.env -f compose.yaml up -d @@ -219,9 +231,9 @@ Upgrades using `--use_prisma_db_push` stop before schema changes if any legacy L ## Release compatibility -Released gateway and worker images carry `LITELLM_RELEASE_TAG`. A worker announces its release and protocol before claiming an investigation. A mismatch returns HTTP 409 with the required image, leaving queued investigations untouched. During a rolling upgrade, workers wait for a gateway from their release +Gateway and worker builds carry the same `LITELLM_RELEASE_TAG`. A worker announces its release and protocol before claiming an investigation. A mismatch returns HTTP 409 with the required image, leaving queued investigations untouched. During a rolling upgrade, workers wait for a gateway from their release -The dashboard reads its image from the running gateway. `LENS_WORKER_IMAGE` overrides the registry/image for private deployments. Worker-only Compose accepts `LITELLM_VERSION` (without `v`) or an explicit `LENS_WORKER_IMAGE`. Release workers are available as `ghcr.io/berriai/litellm-lens-worker:vX.Y.Z` and `docker.io/litellm/litellm-lens-worker:vX.Y.Z`, including matching RC/dev suffixes, on amd64 and arm64 +The dashboard reads its image from the running gateway. `LENS_WORKER_IMAGE` overrides the registry/image for private deployments. Set an explicit `LENS_WORKER_IMAGE` for worker-only Compose. Verify that the image exists and matches the gateway before deploying it For source development, use `make lens-dev`, which gives the proxy and source worker the same commit identity. For custom containers, build both from the same checkout with `--build-arg LITELLM_RELEASE_TAG=sha-$(git rev-parse HEAD)` and set the proxy's `LENS_WORKER_IMAGE` to the worker image you built. An unlabelled custom build refuses worker setup and claims instead of guessing from the Python package version. Normal package-index installations use their installed release version diff --git a/deploy/lens/compose.yaml b/deploy/lens/compose.yaml index 799f0a4fb1e..aa915fef663 100644 --- a/deploy/lens/compose.yaml +++ b/deploy/lens/compose.yaml @@ -1,6 +1,6 @@ services: lens-worker: - image: ${LENS_WORKER_IMAGE:-ghcr.io/berriai/litellm-lens-worker:v${LITELLM_VERSION:?Set LITELLM_VERSION to the gateway release, without the v prefix}} + image: ${LENS_WORKER_IMAGE:-${LITELLM_VERSION:+ghcr.io/berriai/litellm-lens-worker:v}${LITELLM_VERSION:-}} environment: LITELLM_URL: ${LITELLM_URL:?Set the URL reachable from this container} LENS_WORKER_TOKEN: ${LENS_WORKER_TOKEN:?Create a worker credential in the Lens UI} diff --git a/docker/docker-compose.tracing.yml b/docker/docker-compose.tracing.yml index 8f960d50872..c8d90fbc0ae 100644 --- a/docker/docker-compose.tracing.yml +++ b/docker/docker-compose.tracing.yml @@ -5,6 +5,8 @@ services: build: context: .. target: runtime + args: + LITELLM_RELEASE_TAG: ${LITELLM_RELEASE_TAG:-} command: ["--config", "/app/tracing-config.yaml", "--port", "4000"] environment: LITELLM_MASTER_KEY: sk-1234 @@ -15,6 +17,7 @@ services: CLICKHOUSE_URL: http://default:local-tracing@clickhouse:8123 CLICKHOUSE_DATABASE: litellm OPENAI_API_KEY: ${OPENAI_API_KEY:-} + LENS_WORKER_IMAGE: ${LENS_WORKER_IMAGE:-} volumes: - ./tracing-config.yaml:/app/tracing-config.yaml:ro ports: diff --git a/tests/e2e/migrations/lens_compose_smoke.sh b/tests/e2e/migrations/lens_compose_smoke.sh index 69a13b58d88..6a57926895b 100644 --- a/tests/e2e/migrations/lens_compose_smoke.sh +++ b/tests/e2e/migrations/lens_compose_smoke.sh @@ -1,6 +1,19 @@ #!/usr/bin/env bash set -euo pipefail +worker_image() { + env -u LENS_WORKER_IMAGE -u LITELLM_VERSION \ + LITELLM_URL=http://litellm:4000 LENS_WORKER_TOKEN=config-test "$@" \ + docker compose --env-file /dev/null -f deploy/lens/compose.yaml config --images +} +[[ "$(worker_image LENS_WORKER_IMAGE=registry.example/lens:source)" == registry.example/lens:source ]] +[[ "$(worker_image LITELLM_VERSION=1.2.3)" == ghcr.io/berriai/litellm-lens-worker:v1.2.3 ]] +[[ "$(worker_image LENS_WORKER_IMAGE=registry.example/lens:source LITELLM_VERSION=1.2.3)" == registry.example/lens:source ]] +if worker_image > /dev/null 2>&1; then + printf 'Worker Compose accepted neither an image nor a release version\n' >&2 + exit 1 +fi + qa_dir=$(mktemp -d) master_key="sk-$(openssl rand -hex 32)" compose=(docker compose -p lens-compose-ci --env-file "$qa_dir/env" -f deploy/lens/stack.yaml) 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/LensSetup.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/LensSetup.integration.test.tsx index 72da4520240..8d3b5742334 100644 --- a/ui/litellm-dashboard/src/components/lens/LensSetup.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/LensSetup.integration.test.tsx @@ -58,6 +58,39 @@ beforeEach(() => { }); describe("Lens setup journey", () => { + it.each(["/lens", "/lens/activity/available"])( + "keeps recorded traces visible while %s is pending", + async (pendingPath) => { + serve({ enabled: true, traces: true }); + const normal = network.getMockImplementation()!; + network.mockImplementation((input, init) => + new URL(String(input), "http://localhost").pathname === pendingPath + ? new Promise(() => {}) + : normal(input, init), + ); + renderWithProviders(); + expect(await screen.findByRole("table", { name: "Agent runs" })).toBeVisible(); + }, + ); + + it.each(["/v1/traces", "/lens/activity/available"])( + "opens a saved investigation while %s is pending", + async (pendingPath) => { + serve(); + list.mockResolvedValue({ lenses: data.lenses, workers: [worker()], tracing_enabled: false }); + const normal = network.getMockImplementation()!; + network.mockImplementation((input, init) => + new URL(String(input), "http://localhost").pathname === pendingPath + ? new Promise(() => {}) + : normal(input, init), + ); + renderWithProviders(, { + searchParams: `?lens=${data.lenses[0].id}`, + }); + expect(await screen.findByRole("heading", { name: data.lenses[0].settings.name })).toBeVisible(); + }, + ); + it("shares the introduction across tabs and stays in setup after the first trace", async () => { const user = userEvent.setup(); const onUrlUpdate = vi.fn(); @@ -108,7 +141,7 @@ describe("Lens setup journey", () => { }); it("allows request-only investigations without forcing agent instrumentation", async () => { - serve({ enabled: true, requests: true }); + serve({ requests: true }); const user = userEvent.setup(); renderWithProviders(, { searchParams: "?tab=investigations", @@ -118,8 +151,18 @@ describe("Lens setup journey", () => { expect(screen.queryByRole("heading", { name: "Before you start" })).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Set up Lens" })); expect(await screen.findByRole("heading", { name: "Before you start" })).toBeVisible(); - await user.click(screen.getByRole("button", { name: "View investigations" })); - expect(await screen.findByRole("heading", { name: "Run your first investigation" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Connect worker" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: /Enable tracing on the gateway/ })); + expect(screen.getByRole("button", { name: "Continue with request logs" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: /Send your first trace/ })); + await user.click(screen.getByRole("button", { name: "Continue with request logs" })); + const connection = within(await screen.findByRole("dialog", { name: "Connect a worker" })); + await user.click(connection.getByRole("combobox", { name: "Analysis model" })); + await user.click(await screen.findByRole("option", { name: "analysis" })); + await user.click(connection.getByRole("button", { name: "Get install command" })); + const connected = within(await screen.findByRole("dialog", { name: "Worker connected" })); + await user.click(connected.getByRole("button", { name: "New investigation" })); + expect(await screen.findByRole("dialog", { name: "Which activity should we investigate?" })).toBeVisible(); }); it("keeps setup recoverable when checking for a first trace fails", async () => { @@ -159,33 +202,50 @@ describe("Lens setup journey", () => { ); }); - it("opens the saved investigation directly when guided setup finishes", async () => { - serve({ enabled: true, traces: true, connected: true }); - const user = userEvent.setup(); - const onUrlUpdate = vi.fn(); - renderWithProviders(, { - searchParams: "?setup=lens", - onUrlUpdate, - }); - await user.click(await screen.findByRole("button", { name: "New investigation" })); - const dialog = within(screen.getByRole("dialog")); - fireEvent.change(dialog.getByRole("textbox", { name: "Investigation name" }), { - target: { value: "My first review" }, - }); - await user.click(dialog.getByRole("button", { name: "Continue" })); - await user.click(dialog.getByRole("button", { name: "Continue" })); - await waitFor(() => expect(dialog.getByRole("button", { name: "Run and monitor" })).toBeEnabled()); - await user.click(dialog.getByRole("button", { name: "Run and monitor" })); - expect(await screen.findByRole("heading", { name: "My first review" })).toBeVisible(); - expect(screen.queryByRole("heading", { name: "Get Lens running" })).not.toBeInTheDocument(); - expect( - within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Investigations" }), - ).toHaveAttribute("aria-selected", "true"); - await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("setup")).toBeNull()); - const create = network.mock.calls.find( - ([input, init]) => new URL(String(input), "http://localhost").pathname === "/lens" && init?.method === "POST", - ); - expect(create).toBeDefined(); - expect(JSON.parse(String(create?.[1]?.body))).toEqual(expect.objectContaining({ name: "My first review" })); - }); + it.each(["traces", "requests with trace errors", "requests with pending traces", "traces with activity errors"])( + "finishes guided setup with %s and opens the saved investigation", + async (scenario) => { + const source = scenario.startsWith("requests") ? "requests" : "traces"; + const activity = { enabled: true, traces: source === "traces", requests: source === "requests", connected: true }; + serve(activity); + const normal = network.getMockImplementation()!; + const failingPath = scenario === "requests with trace errors" ? "/v1/traces" : "/lens/activity/available"; + network.mockImplementation((input, init) => { + const path = new URL(String(input), "http://localhost").pathname; + if (path === "/v1/traces" && scenario === "requests with pending traces") + return new Promise(() => {}); + if (scenario.endsWith("errors") && path === failingPath) + return Promise.resolve(Response.json({ detail: "Activity unavailable" }, { status: 503 })); + return normal(input, init); + }); + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { + searchParams: "?setup=lens", + onUrlUpdate, + }); + await user.click(await screen.findByRole("button", { name: "New investigation" })); + const dialog = within(screen.getByRole("dialog")); + fireEvent.change(dialog.getByRole("textbox", { name: "Investigation name" }), { + target: { value: "My first review" }, + }); + await user.click(dialog.getByRole("button", { name: "Continue" })); + await user.click(dialog.getByRole("button", { name: "Continue" })); + await waitFor(() => expect(dialog.getByRole("button", { name: "Run and monitor" })).toBeEnabled()); + await user.click(dialog.getByRole("button", { name: "Run and monitor" })); + expect(await screen.findByRole("heading", { name: "My first review" })).toBeVisible(); + expect(screen.queryByRole("heading", { name: "Get Lens running" })).not.toBeInTheDocument(); + expect( + within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Investigations" }), + ).toHaveAttribute("aria-selected", "true"); + await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("setup")).toBeNull()); + const create = network.mock.calls.find( + ([input, init]) => new URL(String(input), "http://localhost").pathname === "/lens" && init?.method === "POST", + ); + expect(create).toBeDefined(); + expect(JSON.parse(String(create?.[1]?.body))).toEqual( + expect.objectContaining({ name: "My first review", source }), + ); + }, + ); }); 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 9fd20987497..41ce6ed77c6 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"; @@ -34,29 +34,27 @@ describe("Lens interactive demo", () => { expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Explore with sample data" })); 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"]])); @@ -69,7 +67,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("offers sample data in the main panel only for the active tab that needs setup", async () => { @@ -206,4 +202,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 c82ede71c88..18aa6439498 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, Loader2, ScanSearch } from "lucide-react"; +import { Activity, Aperture, ArrowUpRight, Loader2, 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"; import { Button } from "@/components/ui/button"; import { LensGettingStarted } from "./setup/LensGettingStarted"; @@ -35,45 +40,112 @@ 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 ( - - - ); - })} - +