diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensView.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensView.integration.test.tsx index 179de1f6643..0d9344fdd14 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensView.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensView.integration.test.tsx @@ -1,8 +1,10 @@ -import { screen, within } from "@testing-library/react"; +import { act, screen, within } 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 { ApiError } from "@/lib/http/client"; import { apiClient } from "@/components/networking"; +import { LIVE_TAIL_INTERVAL_MS } from "@/components/view_logs/log_filter_logic"; import { LensView } from "./LensView"; import { nextCheckStatus, type Lens, type Finding } from "./lensData"; @@ -217,12 +219,16 @@ it("runs saved settings immediately without opening setup", async () => { it("guides a first-time administrator into worker connection and lens setup", async () => { testQueryClient.clear(); vi.mocked(apiClient.get).mockImplementation(async (path) => - path === "/lens" ? { lenses: [], workers: [], tracing_enabled: true } : { data: [] }, + path === "/lens" ? { lenses: [], workers: [], tracing_enabled: true } : { data: [{ trace_id: "first-trace" }] }, ); const user = userEvent.setup(); renderWithProviders(); const guide = within(await screen.findByRole("region", { name: "Understand what your agents are doing" })); - expect(guide.getByRole("link", { name: "View logs" })).toHaveAttribute("href", "/ui/logs/"); + expect(apiClient.get).toHaveBeenCalledWith("/v1/traces", { accessToken: "test", query: { start_ms: 0 } }); + expect(guide.getByRole("link", { name: "View traces" })).toHaveAttribute( + "href", + expect.stringMatching(/^\/ui\/lens\/?\?tab=traces$/), + ); await user.click(guide.getByRole("button", { name: "Connect analyzer" })); const connection = within(await screen.findByRole("dialog", { name: "Set up Lens analysis" })); expect(connection.getByRole("button", { name: "Generate setup command" })).toBeVisible(); @@ -298,3 +304,78 @@ it("reads request content from the beginning after its abbreviated preview", asy await user.click(screen.getByRole("button", { name: "Previous section" })); expect(await screen.findByText("Abbreviated preview")).toBeVisible(); }); + +it.each([false, true])( + "directs a new user to traces when tracing_enabled=%s and there are no traces", + async (enabled) => { + testQueryClient.clear(); + vi.mocked(apiClient.get).mockImplementation(async (path) => + path === "/lens" ? { lenses: [], workers: [], tracing_enabled: enabled } : { data: [] }, + ); + renderWithProviders(); + expect(await screen.findByRole("heading", { name: "Set up traces to start running investigations" })).toBeVisible(); + expect(screen.getByRole("link", { name: "Set up traces" })).toHaveAttribute( + "href", + expect.stringMatching(/^\/ui\/lens\/?\?tab=traces$/), + ); + expect(screen.queryByRole("button", { name: "Set up your first lens" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Set up analysis" })).not.toBeInTheDocument(); + }, +); + +it("enables first-lens setup when a trace arrives without leaving Investigations", async () => { + testQueryClient.clear(); + const traceCheck = vi.fn().mockResolvedValue({ data: [] }); + vi.mocked(apiClient.get).mockImplementation(async (path) => + path === "/lens" ? { lenses: [], workers: [], tracing_enabled: true } : traceCheck(), + ); + vi.useFakeTimers(); + try { + const view = renderWithProviders(); + await act(async () => vi.advanceTimersByTimeAsync(50)); + expect(screen.getByRole("link", { name: "Set up traces" })).toBeVisible(); + + traceCheck.mockResolvedValue({ data: [{ trace_id: "first-trace" }] }); + await act(async () => vi.advanceTimersByTimeAsync(LIVE_TAIL_INTERVAL_MS)); + expect(screen.getByRole("button", { name: "Set up your first lens" })).toBeVisible(); + expect(screen.queryByRole("link", { name: "Set up traces" })).not.toBeInTheDocument(); + + const completedChecks = traceCheck.mock.calls.length; + await act(async () => vi.advanceTimersByTimeAsync(LIVE_TAIL_INTERVAL_MS * 2)); + expect(traceCheck).toHaveBeenCalledTimes(completedChecks); + view.unmount(); + } finally { + vi.useRealTimers(); + } +}); + +it("allows retrying a failed trace readiness check without treating it as an empty account", async () => { + testQueryClient.clear(); + const traceCheck = vi + .fn() + .mockRejectedValueOnce(new ApiError("Trace storage unavailable", 503, {})) + .mockResolvedValue({ data: [] }); + vi.mocked(apiClient.get).mockImplementation(async (path) => { + if (path === "/lens") return { lenses: [], workers: [], tracing_enabled: true }; + if (path === "/v1/traces") return traceCheck(); + return { data: [] }; + }); + const user = userEvent.setup(); + renderWithProviders(); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not check traces. Trace storage unavailable"); + expect(screen.queryByRole("link", { name: "Set up traces" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByRole("link", { name: "Set up traces" })).toBeVisible(); +}); + +it("keeps saved investigations accessible when tracing is disabled", async () => { + testQueryClient.clear(); + vi.mocked(apiClient.get).mockImplementation(async (path) => { + if (path === "/lens") return { lenses: [lens], workers: [], tracing_enabled: false }; + if (path === "/lens/lens/runs") return lens.jobs; + return { data: [] }; + }); + renderWithProviders(); + expect(await screen.findByText(issue.title)).toBeVisible(); + expect(screen.queryByRole("link", { name: "Set up traces" })).not.toBeInTheDocument(); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensView.tsx index dacd93310fd..7a31e0773d6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensView.tsx @@ -3,18 +3,7 @@ import type { components } from "@/lib/http/schema"; import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Aperture, - ArrowUpRight, - CheckCircle2, - Circle, - Info, - Layers3, - Pause, - Play, - Plus, - Settings2, -} from "lucide-react"; +import { ArrowUpRight, CheckCircle2, Circle, Info, Layers3, Pause, Play, Plus, Settings2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet"; @@ -186,18 +175,9 @@ export function LensView({ accessToken, readOnly = false }: { accessToken: strin }; return ( -
-
-
-
-
-

- Understand your agent activity. Find patterns worth acting on. -

-
- {!readOnly && ( +
+
+ {!readOnly && !showEmpty && (
-
+ ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensWelcome.tsx b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensWelcome.tsx index 0b92aa9018e..317e9c92747 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensWelcome.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensWelcome.tsx @@ -1,18 +1,58 @@ +import Link from "next/link"; +import { isTracingNotEnabled, useTraceAvailability } from "@/components/view_logs/TraceView/useAgentTraces"; import { Aperture, ArrowUpRight, CheckCircle2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { uiHref } from "@/utils/uiHref"; export function LensWelcome({ + accessToken, + tracingEnabled, connected, readOnly, onConnect, onCreate, }: { + accessToken: string; + tracingEnabled: boolean; connected: boolean; readOnly: boolean; onConnect: () => void; onCreate: () => void; }) { + const traces = useTraceAvailability(accessToken, tracingEnabled); + if (tracingEnabled && traces.isPending) { + return ( +

+ Checking for traces… +

+ ); + } + if (traces.error && !isTracingNotEnabled(traces.error)) { + return ( +
+

Could not check traces. {traces.error.message}

+ +
+ ); + } + if (!tracingEnabled || !traces.data || isTracingNotEnabled(traces.error)) { + return ( +
+

Set up traces to start running investigations

+ {tracingEnabled && !traces.error && ( +

No agent traces received yet.

+ )} + + Set up traces
+ ); + } return (
@@ -34,12 +74,12 @@ export function LensWelcome({ Use the agent traces or LLM requests already in LiteLLM. Lens needs their inputs and outputs to understand what happened.

- - View logs + View traces