From 29363076619795084143b42205db04c6b3a77932 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Sat, 3 Oct 2026 20:16:41 -0700 Subject: [PATCH] feat(lens): guide setup through the first investigation (#44475) * feat(lens): guide setup through the first investigation * fix(lens): restore the onboarding reference visuals * fix(lens): compact onboarding and animate gateway flow * feat(lens): refine onboarding motion and linked examples * feat(lens): turn the LED swarm into organized dot groups * fix(lens): make the LED dot flow visibly animate * feat(lens): refine the swarm scale palette and motion * fix(lens): preserve investigations during activity refresh errors --- .../lens/LensNavigation.integration.test.tsx | 36 ++- .../lens/LensSetup.integration.test.tsx | 274 ++++++++++++++++++ .../lens/LensWorkspace.integration.test.tsx | 21 +- .../src/components/lens/LensWorkspace.tsx | 120 ++++++-- .../InvestigationsView.integration.test.tsx | 8 +- .../investigations/InvestigationsWelcome.tsx | 50 ++-- .../src/components/lens/route.tsx | 8 +- .../src/components/lens/setup/GatewayFlow.tsx | 148 ++++++++++ .../lens/setup/LensGettingStarted.tsx | 227 +++++++++++++++ .../lens/setup/LensIntroduction.module.css | 114 ++++++++ .../lens/setup/LensIntroduction.tsx | 190 ++++++++++++ .../components/lens/setup/LensSetupSteps.tsx | 229 +++++++++++++++ .../src/components/lens/setup/useLensSetup.ts | 59 ++++ .../view_logs/TraceView/TracingSetupCard.tsx | 62 ++-- 14 files changed, 1444 insertions(+), 102 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/lens/LensSetup.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/lens/setup/GatewayFlow.tsx create mode 100644 ui/litellm-dashboard/src/components/lens/setup/LensGettingStarted.tsx create mode 100644 ui/litellm-dashboard/src/components/lens/setup/LensIntroduction.module.css create mode 100644 ui/litellm-dashboard/src/components/lens/setup/LensIntroduction.tsx create mode 100644 ui/litellm-dashboard/src/components/lens/setup/LensSetupSteps.tsx create mode 100644 ui/litellm-dashboard/src/components/lens/setup/useLensSetup.ts diff --git a/ui/litellm-dashboard/src/components/lens/LensNavigation.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/LensNavigation.integration.test.tsx index 1ac4b5e6aef..769a77500e8 100644 --- a/ui/litellm-dashboard/src/components/lens/LensNavigation.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/LensNavigation.integration.test.tsx @@ -1,7 +1,7 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "@/../tests/test-utils"; +import { renderWithProviders, testQueryClient } from "@/../tests/test-utils"; import LensPage from "@/app/(dashboard)/lens/page"; const { auth } = vi.hoisted(() => ({ auth: vi.fn() })); @@ -16,32 +16,44 @@ vi.mock("./investigations/InvestigationsView", () => ({ })); describe("Lens navigation", () => { - beforeEach(() => auth.mockReturnValue({ accessToken: "test-token", userRole: "Admin", isViewOnly: false })); + beforeEach(() => { + testQueryClient.clear(); + auth.mockReturnValue({ accessToken: "test-token", userRole: "Admin", isViewOnly: false }); + vi.stubGlobal( + "fetch", + vi.fn(async (input) => { + const path = new URL(String(input), "http://localhost").pathname; + if (path === "/v1/traces") return Response.json({ data: [{}] }); + if (path === "/lens") return Response.json({ lenses: [], workers: [], tracing_enabled: true }); + return Response.json({ traces: true, requests: false, data: [] }); + }), + ); + }); it("opens traces by default and pauses polling while viewing investigations", async () => { const user = userEvent.setup(); const onUrlUpdate = vi.fn(); renderWithProviders(, { onUrlUpdate }); expect(screen.getByRole("tab", { name: "Traces" })).toHaveAttribute("aria-selected", "true"); - expect(screen.getByText("Trace polling active")).toBeVisible(); + expect(await screen.findByText("Trace polling active")).toBeVisible(); await user.click(screen.getByRole("tab", { name: "Investigations" })); - expect(screen.getByText("Manage investigations")).toBeVisible(); + expect(await screen.findByText("Manage investigations")).toBeVisible(); expect(screen.getByText("Trace polling paused")).not.toBeVisible(); expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("tab")).toBe("investigations"); await user.click(screen.getByRole("tab", { name: "Traces" })); - expect(screen.getByText("Trace polling active")).toBeVisible(); + expect(await screen.findByText("Trace polling active")).toBeVisible(); }); - it("opens existing lens links on investigations", () => { + it("opens existing lens links on investigations", async () => { renderWithProviders(, { searchParams: "?lens=saved-lens" }); expect(screen.getByRole("tab", { name: "Investigations" })).toHaveAttribute("aria-selected", "true"); - expect(screen.getByText("Manage investigations")).toBeVisible(); + expect(await screen.findByText("Manage investigations")).toBeVisible(); }); - it("honors an explicit traces tab even when a saved investigation is in the URL", () => { + it("honors an explicit traces tab even when a saved investigation is in the URL", async () => { renderWithProviders(, { searchParams: "?tab=traces&lens=saved-lens" }); expect(screen.getByRole("tab", { name: "Traces" })).toHaveAttribute("aria-selected", "true"); - expect(screen.getByText("Trace polling active")).toBeVisible(); + expect(await screen.findByText("Trace polling active")).toBeVisible(); }); it.each(["Internal User", "Internal Viewer", "Org Admin"])( @@ -50,7 +62,7 @@ describe("Lens navigation", () => { auth.mockReturnValue({ accessToken: "test-token", userRole, isViewOnly: false }); const user = userEvent.setup(); renderWithProviders(); - expect(screen.getByText("Trace polling active")).toBeVisible(); + expect(await screen.findByText("Trace polling active")).toBeVisible(); await user.click(screen.getByRole("tab", { name: "Investigations" })); expect(screen.getByText(/Investigations require proxy administrator access/)).toBeVisible(); expect(screen.queryByText("Manage investigations")).not.toBeInTheDocument(); @@ -60,10 +72,10 @@ describe("Lens navigation", () => { it.each([ { userRole: "Admin Viewer", isViewOnly: false }, { userRole: "Admin", isViewOnly: true }, - ])("preserves read-only investigation access for $userRole with isViewOnly=$isViewOnly", (session) => { + ])("preserves read-only investigation access for $userRole with isViewOnly=$isViewOnly", async (session) => { auth.mockReturnValue({ accessToken: "test-token", ...session }); renderWithProviders(, { searchParams: "?tab=investigations" }); - expect(screen.getByText("Read-only investigations")).toBeVisible(); + expect(await screen.findByText("Read-only investigations")).toBeVisible(); expect(screen.queryByText("Manage investigations")).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/lens/LensSetup.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/LensSetup.integration.test.tsx new file mode 100644 index 00000000000..036b02f04c4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/LensSetup.integration.test.tsx @@ -0,0 +1,274 @@ +import { act, fireEvent, screen, within, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, testQueryClient } from "@/../tests/test-utils"; +import { LensWorkspace } from "./LensWorkspace"; +import { createLensDemoData } from "./demo/createLensDemo"; +import type { LensList } from "./model/types"; + +const network = vi.fn(); +const list = vi.fn<() => Promise>(); +const data = createLensDemoData(); +const worker = () => ({ + id: "setup-worker", + analysis_key_id: "a".repeat(64), + revoked: false, + last_seen: new Date().toISOString(), + scope: data.lenses[0].scope, +}); + +function serve({ enabled = false, traces = false, requests = false, connected = false } = {}) { + list.mockResolvedValue({ lenses: [], workers: connected ? [worker()] : [], tracing_enabled: enabled }); + network.mockImplementation(async (input, init) => { + const path = new URL(String(input), "http://localhost").pathname; + if (path === "/v1/traces") + return enabled + ? Response.json({ data: traces ? [data.runs[0].trace.summary] : [] }) + : Response.json({ detail: "Tracing is not enabled" }, { status: 501 }); + if (path === "/lens/activity/available") return Response.json({ traces, requests }); + if (path === "/lens" && init?.method === "POST") { + const saved = { ...data.lenses[0], settings: { ...data.lenses[0].settings, ...JSON.parse(String(init.body)) } }; + list.mockResolvedValue({ lenses: [saved], workers: [worker()], tracing_enabled: true }); + return Response.json(saved); + } + if (path === "/lens") return Response.json(await list()); + if (path === "/key/generate") return Response.json({ token_id: worker().analysis_key_id }); + if (path === "/lens/workers/register") { + list.mockResolvedValue({ lenses: [], workers: [worker()], tracing_enabled: true }); + return Response.json({ worker: worker(), token: "test-worker-token", image: "test-worker-image" }); + } + if (path === "/models") return Response.json({ data: [{ id: "analysis" }] }); + if (path === "/model_group/info") + return Response.json({ data: [{ model_group: "analysis", providers: ["OpenAI"], mode: "chat" }] }); + if (path === "/key/info") return Response.json({ info: { models: ["analysis"], max_budget: 100 } }); + if (path === "/lens/agents") return Response.json(["support_agent"]); + if (path === "/lens/preview/sample") return Response.json({ eligible: 1, selected: 1, executions: [] }); + if (path.endsWith("/runs")) return Response.json(data.lenses[0].jobs); + return Response.json({ data: [] }); + }); +} + +beforeEach(() => { + testQueryClient.clear(); + network.mockReset(); + list.mockReset(); + vi.stubGlobal("fetch", network); + Element.prototype.scrollIntoView = vi.fn(); + serve(); +}); + +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(); + renderWithProviders(, { onUrlUpdate }); + expect(await screen.findByRole("heading", { name: "Before you start" })).toBeVisible(); + await user.click(screen.getByRole("tab", { name: "Investigations" })); + expect(screen.getByRole("heading", { name: "Before you start" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Set up Lens" })); + await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("setup")).toBe("lens")); + serve({ enabled: true }); + await user.click(screen.getByRole("button", { name: "Check setup" })); + expect(await screen.findByText("Trace storage is connected")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Continue to your agent" })); + expect(screen.getByRole("button", { name: "Check for traces" })).toBeVisible(); + serve({ enabled: true, traces: true }); + await user.click(screen.getByRole("button", { name: "Check for traces" })); + expect(await screen.findByText(/Your first trace is ready/)).toBeVisible(); + expect(screen.queryByRole("table", { name: "Agent runs" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Continue to worker" })); + 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(); + expect(screen.getByRole("heading", { name: "Get Lens running", hidden: true })).toBeInTheDocument(); + }); + + it("resumes setup after a reload and leaves only when the user chooses traces", async () => { + serve({ enabled: true, traces: true }); + const user = userEvent.setup(); + renderWithProviders(, { + searchParams: "?tab=investigations&setup=lens", + }); + expect(await screen.findByRole("button", { name: "Connect worker" })).toBeVisible(); + await user.click(screen.getByRole("tab", { name: "Traces" })); + expect(screen.getByRole("heading", { name: "Get Lens running" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "View traces" })); + expect(await screen.findByRole("table", { name: "Agent runs" })).toBeVisible(); + await user.click(screen.getByRole("tab", { name: "Investigations" })); + expect(await screen.findByRole("heading", { name: "Run your first investigation" })).toBeVisible(); + expect(screen.queryByRole("link", { name: "Set up traces" })).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Get Lens running" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Set up Lens" })); + expect(await screen.findByRole("heading", { name: "Get Lens running" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Connect worker" })).toBeVisible(); + }); + + it("allows request-only investigations without forcing agent instrumentation", async () => { + serve({ requests: true }); + const user = userEvent.setup(); + renderWithProviders(, { + searchParams: "?tab=investigations", + }); + expect(await screen.findByText("Request logs received")).toBeVisible(); + expect(screen.getByRole("button", { name: "Connect worker" })).toBeEnabled(); + 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(); + 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 () => { + serve({ enabled: true }); + const user = userEvent.setup(); + renderWithProviders(, { + searchParams: "?setup=lens", + }); + await screen.findByRole("button", { name: "Check for traces" }); + const normal = network.getMockImplementation()!; + network.mockImplementation(async (input, init) => { + const path = new URL(String(input), "http://localhost").pathname; + if (path === "/v1/traces") return Response.json({ detail: "Trace storage unavailable" }, { status: 503 }); + return normal(input, init); + }); + await user.click(screen.getByRole("button", { name: "Check for traces" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not check setup"); + expect(screen.queryByRole("button", { name: "Continue to worker" })).not.toBeInTheDocument(); + serve({ enabled: true, traces: true }); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByRole("button", { name: "Continue to worker" })).toBeEnabled(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("keeps request-only users in investigations when an activity refresh fails", async () => { + serve({ requests: true, connected: true }); + const user = userEvent.setup(); + renderWithProviders(, { + searchParams: "?tab=investigations", + }); + expect(await screen.findByText("Request logs received")).toBeVisible(); + const normal = network.getMockImplementation()!; + network.mockImplementation((input, init) => + new URL(String(input), "http://localhost").pathname === "/lens/activity/available" + ? Promise.resolve(Response.json({ detail: "Activity unavailable" }, { status: 503 })) + : normal(input, init), + ); + await act(() => testQueryClient.refetchQueries()); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not check recorded activity"); + expect(screen.queryByRole("heading", { name: "Get Lens running" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "New investigation" })).toBeDisabled(); + network.mockImplementation(normal); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText("Request logs received")).toBeVisible(); + expect(screen.getByRole("button", { name: "New investigation" })).toBeEnabled(); + }); + + it("keeps administrator-only setup unavailable to trace viewers", async () => { + serve({ enabled: true, traces: true }); + const user = userEvent.setup(); + renderWithProviders(, { + searchParams: "?setup=lens", + }); + expect(await screen.findByText(/A gateway administrator can connect a worker/)).toBeVisible(); + expect(screen.getByRole("button", { name: "Connect worker" })).toBeDisabled(); + await user.click(screen.getByRole("tab", { name: "Investigations" })); + expect(screen.getByRole("button", { name: "Connect worker" })).toBeDisabled(); + expect(network.mock.calls.some(([input]) => new URL(String(input), "http://localhost").pathname === "/lens")).toBe( + false, + ); + }); + + 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 999d2f11980..41ce6ed77c6 100644 --- a/ui/litellm-dashboard/src/components/lens/LensWorkspace.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/LensWorkspace.integration.test.tsx @@ -30,8 +30,9 @@ describe("Lens interactive demo", () => { renderWithProviders(, { onUrlUpdate, }); - expect(await screen.findByText("Enable tracing")).toBeVisible(); - await user.click(screen.getByRole("button", { name: "Preview sample" })); + expect(await screen.findByRole("heading", { name: "The gateway that helps your agents improve" })).toBeVisible(); + 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.getByRole("switch", { name: "Demo data" })).toBeChecked(); await expectUrl(onUrlUpdate, (url) => expect(url.get("demo")).toBe("true")); @@ -84,7 +85,7 @@ describe("Lens interactive demo", () => { await expectUrl(onUrlUpdate, (url) => expect(url.get("view")).toBe("conversation")); expect(network).not.toHaveBeenCalled(); await user.click(screen.getByRole("switch", { name: "Demo data" })); - expect(await screen.findByText("Enable tracing")).toBeVisible(); + expect(await screen.findByRole("heading", { name: "The gateway that helps your agents improve" })).toBeVisible(); await expectUrl(onUrlUpdate, (url) => expect([...url.keys()]).toEqual([])); }); @@ -93,7 +94,7 @@ describe("Lens interactive demo", () => { const onUrlUpdate = vi.fn(); network.mockResolvedValue(Response.json({ detail: "Tracing is not enabled" }, { status: 501 })); renderWithProviders(, { - searchParams: "?tab=traces&trace=live-trace&span=live-span&lens=live-lens", + searchParams: "?tab=traces&setup=lens&trace=live-trace&span=live-span&lens=live-lens", onUrlUpdate, }); await user.click(await screen.findByRole("switch", { name: "Demo data" })); @@ -129,8 +130,7 @@ describe("Lens interactive demo", () => { searchParams: "?tab=investigations", onUrlUpdate, }); - await screen.findByRole("button", { name: "Preview sample" }); - await user.click(screen.getByRole("button", { name: "Preview sample" })); + await user.click(await screen.findByRole("button", { name: "Explore with sample data" })); network.mockClear(); await user.click(await screen.findByRole("row", { name: /Repeated lookups leave customers without an answer/ })); const finding = screen.getByRole("dialog"); @@ -155,7 +155,7 @@ describe("Lens interactive demo", () => { await expectUrl(onUrlUpdate, (url) => expect(url.get("demo")).toBe("true")); await expectUrl(onUrlUpdate, (url) => expect(url.has("span")).toBe(false)); await user.click(screen.getByRole("switch", { name: "Demo data" })); - expect(await screen.findByRole("heading", { name: "Find what needs attention" })).toBeVisible(); + expect(await screen.findByRole("heading", { name: "The gateway that helps your agents improve" })).toBeVisible(); }); it("has no demo entry for existing investigations, populated traces, or connecting another agent", async () => { @@ -180,7 +180,7 @@ describe("Lens interactive demo", () => { 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 () => { + it("offers sample data in the main panel only for the active tab that needs setup", async () => { const user = userEvent.setup(); const saved = createLensDemoData().lenses[0]; network.mockImplementation(async (input) => { @@ -191,13 +191,14 @@ describe("Lens interactive demo", () => { return Response.json({ data: [], traces: false, requests: false }); }); renderWithProviders(); - expect(await screen.findByRole("button", { name: "Preview sample" })).toBeVisible(); + expect(await screen.findByRole("button", { name: "Explore with sample data" })).toBeVisible(); const tabs = within(screen.getByRole("tablist", { name: "Lens" })); await user.click(tabs.getByRole("tab", { name: "Investigations" })); expect(await screen.findByRole("row", { name: new RegExp(saved.settings.name) })).toBeVisible(); expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Explore with sample data" })).not.toBeInTheDocument(); await user.click(tabs.getByRole("tab", { name: "Traces" })); - await user.click(await screen.findByRole("button", { name: "Preview sample" })); + await user.click(await screen.findByRole("button", { name: "Explore with sample data" })); expect(await screen.findByRole("table", { name: "Agent runs" })).toBeVisible(); expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx b/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx index bac37e55641..63bef720211 100644 --- a/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx +++ b/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx @@ -3,7 +3,7 @@ import { useId, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; -import { Activity, Aperture, ArrowUpRight, 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"; @@ -17,6 +17,9 @@ 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"; +import { useLensSetup, type LensSetupState } from "./setup/useLensSetup"; type WorkspaceProps = { accessToken: string; userRole: string; readOnly: boolean }; @@ -144,15 +147,51 @@ 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 { tab, lensId, demo, settingUp, setTab, setLensId, setDemo, setSetup } = useLensRoute(); const [previewTarget, setPreviewTarget] = useState(null); const activeTab = tab ?? (lensId ? "investigations" : "traces"); const canInvestigate = isProxyAdminTierRole(userRole); + const setupState = useLensSetup(accessToken, !demo, canInvestigate, settingUp); + const setupLocation = { tab: activeTab, canInvestigate, selected: !!lensId, requested: settingUp }; + const showSetup = !demo && needsSetup(setupState, setupLocation); + const showSetupButton = !demo && !showSetup && isProxyAdminRole(userRole); + const startSetup = () => { + if (!settingUp) setSetup(true); + }; + const showTraces = () => { + setSetup(false); + setTab(setupState.tracesReady ? "traces" : "investigations"); + }; + const showCreated = (id: string) => { + setSetup(false); + setLensId(id); + setTab("investigations"); + }; const activity = useInvestigationActivity(accessToken, canInvestigate); const preview = (view: LensTab) => ({ target: previewTarget, open: !demo && activeTab === view ? () => setDemo(true) : undefined, }); + const setupContent = setupState.loading ? ( +

+

+ ) : ( + + setDemo(true)} + /> + + ); return (
setTab(value as LensTab)} className="min-h-0 flex-1 gap-0"> @@ -177,38 +216,65 @@ function LensContent({ accessToken, userRole, readOnly }: WorkspaceProps) {
+ {showSetupButton && ( + + )}
- - - - - - - - {canInvestigate ? ( - - ) : ( -

- Investigations require proxy administrator access. You can still view your traces. -

- )} -
-
+ {showSetup ? ( + setupContent + ) : ( + <> + + + + + + + + {canInvestigate ? ( + + ) : ( +

+ Investigations require proxy administrator access. You can still view your traces. +

+ )} +
+
+ + )}
); } + +function needsSetup( + state: LensSetupState, + { + tab, + canInvestigate, + selected, + requested, + }: { tab: LensTab; canInvestigate: boolean; selected: boolean; requested: boolean }, +) { + if (requested) return true; + if (!state.missingTraces) return false; + if (tab === "traces") return true; + const hasActivity = state.hasInvestigations || state.hasRequests || selected; + return canInvestigate && !hasActivity; +} diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx index 05bebc9b923..26445880070 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx @@ -309,12 +309,10 @@ it("guides a first-time administrator into worker connection and lens setup", as }); const user = userEvent.setup(); renderWithProviders(withPreview(, vi.fn())); - const guide = within(await screen.findByRole("region", { name: "Find what needs attention" })); + const guide = within(await screen.findByRole("region", { name: "Run your first investigation" })); expect(apiClient.get).toHaveBeenCalledWith("/lens/activity/available", { accessToken: "test" }); - expect(await guide.findByRole("link", { name: "View traces" })).toHaveAttribute( - "href", - expect.stringMatching(/^\/ui\/lens\/?\?tab=traces$/), - ); + expect(guide.getByText("Traces received")).toBeVisible(); + expect(guide.queryByRole("link", { name: "View traces" })).not.toBeInTheDocument(); expect(await screen.findByRole("button", { name: "Preview sample" })).toBeVisible(); await user.click(guide.getByRole("button", { name: "Connect worker" })); const connection = within(await screen.findByRole("dialog", { name: "Connect a worker" })); diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsWelcome.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsWelcome.tsx index 6e39e2e2f3e..6b0b20ead4b 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsWelcome.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsWelcome.tsx @@ -34,36 +34,50 @@ export function InvestigationsWelcome({ const canConnect = activityReady && !readOnly; const traceStatus = activityStatus(tracesReady, requestsReady, checking); const waitingForWorker = activityReady && !connected; - const firstStepTitle = requestsReady && !tracesReady ? "Recorded activity" : "Set up traces"; + const stepOffset = Number(!activityReady); + const introduction = activityReady + ? { + title: "Run your first investigation", + description: "Your recorded activity is ready. Connect a worker and choose what Lens should look for.", + } + : { + title: "Find what needs attention", + description: "Check how your agents behave. Get findings you can trace back to what happened.", + }; const traceButtonClass = buttonVariants({ - variant: activityReady ? "ghost" : "default", + variant: "default", className: "col-start-2 w-fit sm:col-start-auto", }); return (
{showPreview && }

- Find what needs attention + {introduction.title}

-

- Check how your agents behave. Get findings you can trace back to what happened. -

+

{introduction.description}

+ {activityReady && ( +

+ {traceStatus} +

+ )}
    -
  1. - -
    -

    {firstStepTitle}

    -

    {traceStatus}

    -
    - - {tracesReady ? "View traces" : "Set up traces"} - -
  2. + {!activityReady && ( +
  3. + +
    +

    Set up traces

    +

    {traceStatus}

    +
    + + Set up traces + +
  4. + )}
  5. - +

    Connect a worker

    @@ -83,7 +97,7 @@ export function InvestigationsWelcome({ data-state={workerReady ? "active" : "inactive"} className="grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 text-muted-foreground data-[state=active]:text-foreground sm:grid-cols-[28px_minmax(0,1fr)_auto]" > - +

    Run an investigation

    diff --git a/ui/litellm-dashboard/src/components/lens/route.tsx b/ui/litellm-dashboard/src/components/lens/route.tsx index 9d07973fd09..abc5cf2c0ad 100644 --- a/ui/litellm-dashboard/src/components/lens/route.tsx +++ b/ui/litellm-dashboard/src/components/lens/route.tsx @@ -18,6 +18,7 @@ const LENS_PARSERS = { tab: parseAsStringLiteral(lensTabs), lens: parseAsString, demo: parseAsBoolean.withDefault(false), + setup: parseAsStringLiteral(["lens"]), }; const ISSUE_PARSERS = { issue: parseAsString }; @@ -56,14 +57,16 @@ export interface LensRoute { readonly tab: LensTab | null; readonly lensId: string | null; readonly demo: boolean; + readonly settingUp: boolean; setTab(tab: LensTab): void; setLensId(lensId: string | null): void; setDemo(demo: boolean): void; + setSetup(settingUp: boolean): void; } /** Lens navigation lives in the URL, sample session included, so any view is a shareable link. */ export function useLensRoute(): LensRoute { - const [{ tab, lens, demo }, setParams] = useQueryStates(SESSION_PARSERS, { history: "push" }); + const [{ tab, lens, demo, setup }, setParams] = useQueryStates(SESSION_PARSERS, { history: "push" }); const setTab = useCallback((next: LensTab) => void setParams({ tab: next }), [setParams]); const setLensId = useCallback( (next: string | null) => void setParams({ ...CLEARED_RESULTS, lens: next }), @@ -73,7 +76,8 @@ export function useLensRoute(): LensRoute { (next: boolean) => void setParams(next ? { ...CLEARED_SESSION, demo: true } : CLEARED_SESSION), [setParams], ); - return { tab, lensId: lens, demo, setTab, setLensId, setDemo }; + const setSetup = useCallback((next: boolean) => void setParams({ setup: next ? "lens" : null }), [setParams]); + return { tab, lensId: lens, demo, settingUp: setup === "lens", setTab, setLensId, setDemo, setSetup }; } export function useIssueRoute() { diff --git a/ui/litellm-dashboard/src/components/lens/setup/GatewayFlow.tsx b/ui/litellm-dashboard/src/components/lens/setup/GatewayFlow.tsx new file mode 100644 index 00000000000..bc61078a18d --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/setup/GatewayFlow.tsx @@ -0,0 +1,148 @@ +import { useId } from "react"; +import styles from "./LensIntroduction.module.css"; + +const dotColors = [ + "fill-sky-400 dark:fill-sky-300", + "fill-indigo-400 dark:fill-indigo-300", + "fill-violet-400 dark:fill-violet-300", +]; + +const swarmRows = Array.from({ length: 11 }, (_, row) => ({ + y: 4 + row * 10, + duration: `${[4.8, 5.2, 5.6, 5][row % 4]}s`, + delay: `${-row * 0.17}s`, + dots: Array.from({ length: 80 }, (_, column) => { + const seed = (column % 24) + row * 31; + const variation = (seed * 73 + seed * seed * 19) % 101; + return { + x: column * 10 - 235, + color: dotColors[variation % dotColors.length], + opacity: variation < 15 ? 0 : 0.5 + variation * 0.005, + }; + }), +})); + +const organizedColumns = Array.from({ length: 58 }, (_, column) => column - 8); + +export function GatewayFlow() { + const id = useId(); + return ( +
    +
    +
    +

    Agent swarms

    +

    Every run, every recorded step

    +
    +
    +

    LiteLLM gateway

    +

    One place, your infrastructure

    +
    +
    +

    Lens

    +

    Findings to improve your agents

    +
    +
    + +
    + ); +} diff --git a/ui/litellm-dashboard/src/components/lens/setup/LensGettingStarted.tsx b/ui/litellm-dashboard/src/components/lens/setup/LensGettingStarted.tsx new file mode 100644 index 00000000000..01b62c864da --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/setup/LensGettingStarted.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Check, ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes"; +import { lensQueries } from "../api/queries"; +import { useSaveLens } from "../api/mutations"; +import { useLensApi } from "../services"; +import { TraceSheet } from "../investigations/TraceSheet"; +import { useAnalysisKeyInfo } from "./worker/AnalysisKeyDetails"; +import { WorkerDialog } from "./worker/WorkerDialog"; +import { InvestigationSetupDialog } from "./InvestigationSetupDialog"; +import { LensIntroduction } from "./LensIntroduction"; +import type { LensSetupState } from "./useLensSetup"; +import type { Settings } from "../model/types"; +import { initialSetupStep, LensSetupSteps } from "./LensSetupSteps"; + +export function LensGettingStarted({ + accessToken, + state, + readOnly, + canInvestigate, + canMintTracingKey, + onStart, + onExit, + onCreated, + onDemo, +}: { + accessToken: string; + state: LensSetupState; + readOnly: boolean; + canInvestigate: boolean; + canMintTracingKey: boolean; + onStart: () => void; + onExit: () => void; + onCreated: (id: string) => void; + onDemo?: () => void; +}) { + const setupRef = useRef(null); + const [step, setStep] = useState(() => initialSetupStep(state)); + const [workerOpen, setWorkerOpen] = useState(false); + const [investigationOpen, setInvestigationOpen] = useState(false); + const [trace, setTrace] = useState(null); + const selectStep = (index: number) => { + onStart(); + setStep(index); + setupRef.current?.querySelector(`[aria-controls="lens-setup-step-${index}"]`)?.focus(); + }; + const canLeave = state.tracesReady || state.requestsReady || state.hasInvestigations; + const start = () => { + onStart(); + setupRef.current?.scrollIntoView({ block: "start" }); + setupRef.current?.focus({ preventScroll: true }); + }; + const connectWorker = () => { + selectStep(2); + setWorkerOpen(true); + }; + const createInvestigation = () => { + selectStep(3); + setInvestigationOpen(true); + }; + + return ( +
    + +
    +
    +
    +
    +

    + Get Lens running +

    +

    + Each step checks your connection, so you’ll see when it’s working. +

    +
    + {canLeave && ( + + )} +
    + + {state.error && ( +
    +

    Could not check setup. {state.error}

    + +
    + )} + {(readOnly || !canInvestigate) && ( +

    + A gateway administrator can connect a worker and run investigations. +

    + )} +
    + +
    + {workerOpen && ( + setWorkerOpen(false)} + onChanged={state.refresh} + onReady={ + state.ready + ? () => { + setWorkerOpen(false); + createInvestigation(); + } + : undefined + } + /> + )} + {investigationOpen && ( + setInvestigationOpen(false)} + onCreated={onCreated} + /> + )} + {trace && ( + setTrace(null)} + /> + )} +
    + ); +} + +function FirstInvestigation({ + accessToken, + state, + onClose, + onCreated, +}: { + accessToken: string; + state: LensSetupState; + onClose: () => void; + onCreated: (id: string) => void; +}) { + const api = useLensApi(accessToken); + const saveLens = useSaveLens(accessToken); + const models = useQuery({ ...lensQueries.models(api) }); + const modelDetails = useQuery({ ...lensQueries.modelDetails(api) }); + const workers = state.workers.filter((worker) => !worker.revoked); + const analysisAccess = useAnalysisKeyInfo( + accessToken, + workers.length === 1 ? workers[0].analysis_key_id ?? undefined : undefined, + ); + const defaultModel = analysisAccess.data?.models.length === 1 ? analysisAccess.data.models[0] : undefined; + const save = async (settings: Settings) => { + if (!state.ready) + throw new Error("Wait for recorded activity and a connected worker before starting an investigation"); + const saved = await saveLens.mutateAsync({ settings }); + state.refresh(); + onCreated(saved.id); + }; + + return ( + model.id) ?? []} + modelDetails={modelDetails.data?.data ?? []} + modelsLoading={models.isLoading} + modelsError={models.error?.message} + onClose={onClose} + onSave={save} + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/lens/setup/LensIntroduction.module.css b/ui/litellm-dashboard/src/components/lens/setup/LensIntroduction.module.css new file mode 100644 index 00000000000..b9f5fc11d88 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/setup/LensIntroduction.module.css @@ -0,0 +1,114 @@ +.swarmRow { + animation: swarm-flow 5.2s linear infinite; +} + +.organizedDots { + animation: organized-flow 1.6s linear infinite; +} + +.gatewayGlow { + opacity: 0.7; + transition: opacity 300ms ease; +} + +.flowGraphic:hover .gatewayGlow { + opacity: 1; +} + +.sampleCard { + transition: + border-color 200ms ease, + box-shadow 200ms ease; +} + +.sampleCard:hover, +.sampleCard:focus-within { + border-color: color-mix(in oklab, var(--color-indigo-500) 22%, var(--border)); + box-shadow: 0 4px 20px -16px color-mix(in oklab, var(--color-indigo-500) 40%, transparent); +} + +.examples { + --evidence-tint: color-mix(in oklab, var(--color-rose-400) 6%, transparent); + --evidence-outline: color-mix(in oklab, var(--color-rose-400) 32%, transparent); +} + +.evidenceLink { + cursor: pointer; + border-radius: 4px; + outline-offset: 4px; + transition: background-color 200ms ease; +} + +.evidenceLink:focus-visible { + outline: 2px solid var(--ring); +} + +.evidenceLink:hover { + background: var(--evidence-tint); +} + +.evidenceOutput, +.evidenceFinding, +.evidenceBar { + transition: + background-color 200ms ease, + box-shadow 200ms ease; +} + +.evidenceFinding { + border-radius: 6px; +} + +.examples:is( + [data-evidence-active="true"], + :has([data-evidence-link]:hover), + :has([data-evidence-link]:focus-visible) + ) { + .evidenceOutput { + box-shadow: inset 0 0 0 1px var(--evidence-outline); + } + + .evidenceFinding { + background: var(--evidence-tint); + box-shadow: 0 0 0 7px var(--evidence-tint); + } + + .evidenceBar { + box-shadow: 0 0 10px 2px var(--evidence-outline); + } +} + +@keyframes swarm-flow { + from { + transform: translateX(0); + } + to { + transform: translateX(240px); + } +} + +@keyframes organized-flow { + from { + transform: translateX(0); + } + to { + transform: translateX(80px); + } +} + +@media (prefers-reduced-motion: reduce) { + .swarmRow, + .organizedDots { + animation: none; + opacity: 0.75; + } + + .gatewayGlow, + .sampleCard, + .evidenceLink, + .evidenceOutput, + .evidenceFinding, + .evidenceBar { + transition: none; + } +} diff --git a/ui/litellm-dashboard/src/components/lens/setup/LensIntroduction.tsx b/ui/litellm-dashboard/src/components/lens/setup/LensIntroduction.tsx new file mode 100644 index 00000000000..c4820a95517 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/setup/LensIntroduction.tsx @@ -0,0 +1,190 @@ +import { useState } from "react"; +import { ArrowRight, ArrowUpRight, ChevronDown, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import styles from "./LensIntroduction.module.css"; +import { GatewayFlow } from "./GatewayFlow"; + +const traceSteps = [ + { name: "research_agent", start: 0, duration: 14.2, failed: false }, + { name: "plan", start: 0, duration: 1.7, failed: false }, + { name: "search_docs", start: 1.7, duration: 3.1, failed: false }, + { name: "run_benchmark", start: 4.8, duration: 1.3, failed: true }, + { name: "search_docs", start: 6.1, duration: 3.6, failed: false }, + { name: "answer", start: 9.7, duration: 4.5, failed: false }, +]; + +const sampleAnswer = "The column store is 40% faster than the row store for your workload."; + +type EvidenceProps = { highlighted: boolean; onHighlight: () => void }; + +function TraceExample({ highlighted, onHighlight }: EvidenceProps) { + return ( +
    +
    +

    + Tracing +

    +

    Sample trace · 14.2s

    +
    +
      + {traceSteps.map((item, index) => { + const linked = item.failed || item.name === "answer"; + const rowClass = + "grid w-full grid-cols-[minmax(0,1fr)_minmax(48px,1fr)_2.5rem] items-center gap-2 text-left sm:gap-3"; + const content = ( + <> + 0 ? "pl-2" : ""}`}> + {index === 0 ? ( + +
    +
    +
    +

    run_benchmark · output

    +

    Error: benchmark runner unavailable (503)

    +
    +
    +

    answer · output

    +

    “{sampleAnswer}”

    +
    +
    +
    + ); +} + +function FindingExamples({ highlighted, onHighlight }: EvidenceProps) { + return ( +
    +
    +

    + Lens findings +

    +

    500 sample runs reviewed

    +
    +
    +
    +

    + +

    +
    +

    Reports a speedup even though the benchmark failed.

    +
    + “…40% faster than the row store for your workload.” +
    +

    + Next step: Report the failed benchmark instead of + estimating. +

    +

    38 linked runs · High priority

    +
    +
    +
    +

    +

    +
    +

    After an order lookup times out, the agent promises to check and ends the run.

    +

    12 linked runs · Medium priority

    +
    +
    +
    +
    + ); +} + +export function LensIntroduction({ onStart, onDemo }: { onStart: () => void; onDemo?: () => void }) { + const [highlighted, setHighlighted] = useState(false); + const toggleEvidence = () => setHighlighted((current) => !current); + return ( +
    +

    + The gateway that helps your agents improve +

    +

    + Turn recorded agent runs into findings linked to the exact steps, so you know what happened and what to change. +

    +
    + {onDemo && ( + + )} + + + Docs +
    + +
    + + +
    +
    + ); +} diff --git a/ui/litellm-dashboard/src/components/lens/setup/LensSetupSteps.tsx b/ui/litellm-dashboard/src/components/lens/setup/LensSetupSteps.tsx new file mode 100644 index 00000000000..cdf95ef25ef --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/setup/LensSetupSteps.tsx @@ -0,0 +1,229 @@ +import { ArrowRight, Check, ChevronDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { TracingSetupFields } from "@/components/view_logs/TraceView/TracingSetupCard"; +import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes"; +import type { LensSetupState } from "./useLensSetup"; +import { cn } from "@/lib/cva.config"; + +type StepProps = { + accessToken: string; + state: LensSetupState; + readOnly: boolean; + canInvestigate: boolean; + canMintTracingKey: boolean; + onStep: (step: number) => void; + onTrace: (trace: TraceSummary) => void; + onConnect: () => void; + onCreate: () => void; +}; + +export function initialSetupStep(state: LensSetupState) { + if (state.tracesReady || state.requestsReady) return state.connected ? 3 : 2; + return state.tracingEnabled ? 1 : 0; +} + +function StorageStep({ state, onStep, ...props }: StepProps) { + if (!state.tracingEnabled) + return ( + <> + + + + ); + return ( +
    +

    + Trace storage is connected +

    + + +
    + ); +} + +function continuationLabel(state: LensSetupState) { + if (state.connected) return "Continue to investigation"; + return state.tracesReady ? "Continue to worker" : "Continue with request logs"; +} + +function ActivityContinuation({ + state, + onConnect, + onCreate, + readOnly, + canInvestigate, +}: Pick) { + if (!state.tracesReady && !state.requestsReady) return null; + return ( +
    + + {state.requestsReady && !state.tracesReady && ( +

    + Request logs are already available. You can investigate them now and add agent traces later. +

    + )} +
    + ); +} + +function AgentStep({ state, ...props }: StepProps) { + if (!state.tracingEnabled) + return ( + <> +

    Connect trace storage in step 1 before sending a trace.

    + + + ); + return ( + <> + + {state.tracesReady && ( +

    + Your first trace is ready. Continue setup so Lens can investigate your agent’s behavior. +

    + )} + + + ); +} + +function WorkerStep({ state, onConnect, onCreate, readOnly, canInvestigate }: StepProps) { + const activityReady = state.tracesReady || state.requestsReady; + return ( +
    +

    + {state.connected + ? "Worker connected. You’re ready to create an investigation." + : "The worker reviews recorded activity using a model on your gateway. You choose its analysis model and spending limit."} +

    + {!activityReady && ( +

    Record activity in step 2 before connecting a worker.

    + )} + +
    + ); +} + +function InvestigationStep({ state, onCreate, readOnly, canInvestigate }: StepProps) { + return ( +
    +

    + Choose the activity to review and describe how your agent should behave. Lens will show findings with evidence + and suggested changes. +

    + {!state.ready && ( +

    + Recorded activity and a connected worker are required before you can run an investigation. +

    + )} + +
    + ); +} + +export function LensSetupSteps({ step, ...props }: StepProps & { step: number }) { + const items = [ + { + title: "Enable tracing on the gateway", + description: "Connect ClickHouse and restart the gateway.", + complete: props.state.tracingEnabled, + content: , + }, + { + title: "Send your first trace", + description: "Capture your agent’s inputs, outputs, and tool calls.", + complete: props.state.tracesReady, + content: , + }, + { + title: "Connect a worker", + description: "Choose a model and run the worker on your infrastructure.", + complete: props.state.connected, + content: , + }, + { + title: "Run your first investigation", + description: "Describe the expected behavior and review a sample of activity.", + complete: props.state.hasInvestigations, + content: , + }, + ]; + return ( +
      + {items.map((item, index) => ( +
    1. +

      + +

      + +
    2. + ))} +
    + ); +} diff --git a/ui/litellm-dashboard/src/components/lens/setup/useLensSetup.ts b/ui/litellm-dashboard/src/components/lens/setup/useLensSetup.ts new file mode 100644 index 00000000000..12903e8a15c --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/setup/useLensSetup.ts @@ -0,0 +1,59 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNow } from "@/hooks/useNow"; +import { isTracingNotEnabled, useTraceAvailability } from "@/components/view_logs/TraceView/useAgentTraces"; +import { lensQueries } from "../api/queries"; +import { workerConnected } from "../model/status"; +import { useLensApi } from "../services"; + +function traceSetupState(traces: ReturnType, configured: boolean) { + const disabled = isTracingNotEnabled(traces.error); + return { + tracesReady: traces.data === true && !traces.error, + tracingEnabled: !disabled && (traces.isSuccess || configured), + missingTraces: disabled || traces.data === false, + error: disabled ? null : traces.error, + }; +} + +export function useLensSetup(accessToken: string, enabled: boolean, canInvestigate: boolean, settingUp: boolean) { + const api = useLensApi(accessToken); + const now = useNow(2000); + const traces = useTraceAvailability(accessToken, enabled); + const list = useQuery({ ...lensQueries.list(api, settingUp), enabled: enabled && canInvestigate }); + const activity = useQuery(lensQueries.activity(api, enabled && canInvestigate && list.isSuccess)); + const data = list.data ?? { lenses: [], workers: [], tracing_enabled: false }; + const traceState = traceSetupState(traces, data.tracing_enabled); + const hasRequests = activity.data?.requests === true; + const requestsReady = hasRequests && !activity.error; + const connected = data.workers.some((worker) => workerConnected(worker, now)); + const hasInvestigations = data.lenses.length > 0; + const activityReady = traceState.tracesReady || requestsReady; + const activityError = activityReady ? null : traceState.error || activity.error; + const error = list.error || activityError; + const loadingActivity = !activityReady && list.isSuccess && activity.isPending; + const loadingInvestigations = canInvestigate && (list.isPending || loadingActivity); + const refresh = () => { + void traces.refetch(); + if (canInvestigate) { + void list.refetch(); + void activity.refetch(); + } + }; + return { + tracingEnabled: traceState.tracingEnabled, + tracesReady: traceState.tracesReady, + requestsReady, + hasRequests, + connected, + hasInvestigations, + missingTraces: traceState.missingTraces, + loading: (!activityReady && traces.isPending) || loadingInvestigations, + checking: traces.isFetching || list.isFetching || activity.isFetching, + ready: activityReady && connected && !error, + workers: data.workers, + error: error?.message, + refresh, + }; +} + +export type LensSetupState = ReturnType; diff --git a/ui/litellm-dashboard/src/components/view_logs/TraceView/TracingSetupCard.tsx b/ui/litellm-dashboard/src/components/view_logs/TraceView/TracingSetupCard.tsx index e00870e71eb..26904ef49ff 100644 --- a/ui/litellm-dashboard/src/components/view_logs/TraceView/TracingSetupCard.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/TraceView/TracingSetupCard.tsx @@ -636,16 +636,7 @@ function ConnectAgent({ ); } -export function TracingSetupCard({ - detail, - accessToken, - onOpenTrace, - connected = false, - onCheck, - checking = false, - readOnly = false, - canMintTracingKey = false, -}: { +type TracingSetupProps = { detail: string | null; accessToken: string; onOpenTrace: (trace: TraceSummary) => void; @@ -654,19 +645,47 @@ export function TracingSetupCard({ checking?: boolean; readOnly?: boolean; canMintTracingKey?: boolean; -}) { +}; + +export function TracingSetupFields({ + detail, + accessToken, + onOpenTrace, + connected = false, + onCheck, + checking = false, + readOnly = false, + canMintTracingKey = false, +}: TracingSetupProps) { const [checked, setChecked] = useState(false); - const enabled = detail === null; const check = () => { setChecked(true); onCheck?.(); }; + return detail === null ? ( + + ) : ( + + ); +} + +export function TracingSetupCard(props: TracingSetupProps) { + const enabled = props.detail === null; return (
    - {!connected && } + {!props.connected && }
    -

    {setupTitle(enabled, connected)}

    +

    {setupTitle(enabled, props.connected ?? false)}

    {enabled ? ( @@ -689,20 +708,7 @@ export function TracingSetupCard({ ? "Send your agent’s runs to LiteLLM to see its inputs, outputs, and tool calls." : "Tracing needs ClickHouse and a small update to your LiteLLM proxy configuration."}

    - {enabled ? ( - - ) : ( - - )} +
    ); }