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
This commit is contained in:
moe-berri 2026-10-03 20:16:41 -07:00 • committed by GitHub
parent b61376a99e
commit 2936307661
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1444 additions and 102 deletions

View file

@ -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(<LensPage />, { 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(<LensPage />, { 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(<LensPage />, { 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(<LensPage />);
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(<LensPage />, { 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();
});
});

View file

@ -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<typeof fetch>();
const list = vi.fn<() => Promise<LensList>>();
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<Response>(() => {})
: normal(input, init),
);
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />);
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<Response>(() => {})
: normal(input, init),
);
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
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(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, { 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(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
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(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
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(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
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(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
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(<LensWorkspace accessToken="setup-token" userRole="Internal User" readOnly={false} />, {
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<Response>(() => {});
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(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
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 }),
);
},
);
});

View file

@ -30,8 +30,9 @@ describe("Lens interactive demo", () => {
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Internal User" readOnly={false} />, {
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(<LensWorkspace accessToken="live-token" userRole="Internal User" readOnly={false} />, {
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(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />);
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();
});

View file

@ -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<HTMLDivElement | null>(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 ? (
<p role="status" className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
Checking Lens setup…
</p>
) : (
<TabsContent value={activeTab} keepMounted className={cn(PANEL, "p-4")}>
<LensGettingStarted
accessToken={accessToken}
state={setupState}
readOnly={readOnly}
canInvestigate={isProxyAdminRole(userRole)}
canMintTracingKey={isProxyAdminRole(userRole)}
onStart={startSetup}
onExit={showTraces}
onCreated={showCreated}
onDemo={() => setDemo(true)}
/>
</TabsContent>
);
return (
<main className="flex h-full w-full min-w-0 flex-1 flex-col px-4 pt-3 pb-4">
<Tabs value={activeTab} onValueChange={(value) => setTab(value as LensTab)} className="min-h-0 flex-1 gap-0">
@ -177,38 +216,65 @@ function LensContent({ accessToken, userRole, readOnly }: WorkspaceProps) {
</div>
<LensModeSwitch activity={activity} demo={demo} />
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 pb-3">
{showSetupButton && (
<Button variant="outline" size="sm" onClick={startSetup}>
Set up Lens
</Button>
)}
<div ref={setPreviewTarget} />
<DemoToggle demo={demo} onChange={setDemo} />
</div>
</div>
<div className={cn("flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-card", frameOf(demo).card)}>
<TabsContent value="traces" keepMounted className={PANEL}>
<LensPreviewContext.Provider value={preview("traces")}>
<AgentTracesPage
accessToken={accessToken}
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={isProxyAdminRole(userRole)}
/>
</LensPreviewContext.Provider>
</TabsContent>
<TabsContent value="investigations" className={cn(PANEL, "p-4")}>
<LensPreviewContext.Provider value={preview("investigations")}>
{canInvestigate ? (
<InvestigationsView
active={activeTab === "investigations"}
accessToken={accessToken}
readOnly={readOnly || !isProxyAdminRole(userRole)}
/>
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</LensPreviewContext.Provider>
</TabsContent>
{showSetup ? (
setupContent
) : (
<>
<TabsContent value="traces" keepMounted className={PANEL}>
<LensPreviewContext.Provider value={preview("traces")}>
<AgentTracesPage
accessToken={accessToken}
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={isProxyAdminRole(userRole)}
/>
</LensPreviewContext.Provider>
</TabsContent>
<TabsContent value="investigations" className={cn(PANEL, "p-4")}>
<LensPreviewContext.Provider value={preview("investigations")}>
{canInvestigate ? (
<InvestigationsView
active={activeTab === "investigations"}
accessToken={accessToken}
readOnly={readOnly || !isProxyAdminRole(userRole)}
/>
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</LensPreviewContext.Provider>
</TabsContent>
</>
)}
</div>
</Tabs>
</main>
);
}
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;
}

View file

@ -309,12 +309,10 @@ it("guides a first-time administrator into worker connection and lens setup", as
});
const user = userEvent.setup();
renderWithProviders(withPreview(<InvestigationsView accessToken="test" />, 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" }));

View file

@ -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 (
<section aria-labelledby="lens-welcome" className="m-auto w-full max-w-2xl py-10">
{showPreview && <LensPreviewButton />}
<h2 id="lens-welcome" className="text-xl font-semibold tracking-tight">
Find what needs attention
{introduction.title}
</h2>
<p className="mt-2 max-w-lg text-sm leading-6 text-muted-foreground">
Check how your agents behave. Get findings you can trace back to what happened.
</p>
<p className="mt-2 max-w-lg text-sm leading-6 text-muted-foreground">{introduction.description}</p>
{activityReady && (
<p role="status" className="mt-3 text-sm text-success">
{traceStatus}
</p>
)}
<ol className="mt-8 divide-y border-y">
<li className="grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 sm:grid-cols-[28px_minmax(0,1fr)_auto]">
<Step number={1} complete={activityReady} checking={checking} active={!activityReady} />
<div>
<h3 className="text-sm font-medium">{firstStepTitle}</h3>
<p className="mt-1 text-sm text-muted-foreground">{traceStatus}</p>
</div>
<Link href={uiHref("lens/?tab=traces")} className={traceButtonClass}>
{tracesReady ? "View traces" : "Set up traces"}
</Link>
</li>
{!activityReady && (
<li className="grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 sm:grid-cols-[28px_minmax(0,1fr)_auto]">
<Step number={1} complete={activityReady} checking={checking} active={!activityReady} />
<div>
<h3 className="text-sm font-medium">Set up traces</h3>
<p className="mt-1 text-sm text-muted-foreground">{traceStatus}</p>
</div>
<Link href={uiHref("lens/?tab=traces")} className={traceButtonClass}>
Set up traces
</Link>
</li>
)}
<li
data-state={activityReady ? "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]"
>
<Step number={2} complete={workerReady} active={waitingForWorker} />
<Step number={stepOffset + 1} complete={workerReady} active={waitingForWorker} />
<div>
<h3 className="text-sm font-medium">Connect a worker</h3>
<p className="mt-1 text-sm text-muted-foreground">
@ -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]"
>
<Step number={3} complete={false} active={workerReady} />
<Step number={stepOffset + 2} complete={false} active={workerReady} />
<div>
<h3 className="text-sm font-medium">Run an investigation</h3>
</div>

View file

@ -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() {

View file

@ -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 (
<div className="mt-5 sm:mt-6">
<div className="grid grid-cols-3 gap-3 text-xs">
<div>
<p className="font-semibold">Agent swarms</p>
<p className="mt-0.5 leading-4 text-muted-foreground">Every run, every recorded step</p>
</div>
<div className="text-center">
<p className="font-semibold">LiteLLM gateway</p>
<p className="mt-0.5 leading-4 text-muted-foreground">One place, your infrastructure</p>
</div>
<div className="text-right">
<p className="font-semibold">Lens</p>
<p className="mt-0.5 leading-4 text-muted-foreground">Findings to improve your agents</p>
</div>
</div>
<svg
aria-hidden="true"
viewBox="0 0 1000 108"
className={`${styles.flowGraphic} mt-2 h-auto w-full`}
focusable="false"
>
<defs>
<pattern id={`${id}-grid`} width="40" height="40" patternUnits="userSpaceOnUse">
<circle cx="5" cy="4" r="1" className="fill-muted-foreground/15" />
</pattern>
<filter id={`${id}-soft-edge`} x="-10%" y="-30%" width="120%" height="160%">
<feGaussianBlur stdDeviation="3" />
</filter>
<clipPath id={`${id}-after-gate`}>
<rect x="500" width="500" height="108" />
</clipPath>
<linearGradient id={`${id}-incoming`} x1="480" x2="530" y1="0" y2="0" gradientUnits="userSpaceOnUse">
<stop stopColor="white" />
<stop offset="1" stopColor="black" />
</linearGradient>
<mask id={`${id}-before-gate`}>
<rect width="1000" height="108" fill={`url(#${id}-incoming)`} />
</mask>
<mask id={`${id}-swarm-shape`}>
<g fill="none" stroke="white" strokeWidth="24" strokeLinecap="round" filter={`url(#${id}-soft-edge)`}>
<path d="M -30 20 C 80 0 142 38 244 22 S 402 36 530 54" />
<path d="M -30 54 C 90 76 174 32 278 52 S 420 60 530 54" />
<path d="M -30 90 C 80 112 172 66 276 86 S 420 68 530 54" />
</g>
</mask>
<mask id={`${id}-organized-shape`}>
<path
d="M 490 54 C 620 54 664 66 772 58 S 920 42 1020 52"
fill="none"
stroke="white"
strokeWidth="32"
filter={`url(#${id}-soft-edge)`}
/>
</mask>
<linearGradient id={`${id}-fade`}>
<stop offset="0" stopColor="white" stopOpacity="0" />
<stop offset="0.06" stopColor="white" />
<stop offset="0.94" stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
<mask id={`${id}-edges`}>
<rect width="1000" height="108" fill={`url(#${id}-fade)`} />
</mask>
<radialGradient id={`${id}-glow`} className="text-indigo-500 dark:text-indigo-400">
<stop stopColor="currentColor" stopOpacity="0.14" />
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
</radialGradient>
<linearGradient id={`${id}-gate`} x1="0" y1="0" x2="0" y2="1">
<stop stopColor="currentColor" className="text-sky-400" />
<stop offset="0.5" stopColor="currentColor" className="text-indigo-500 dark:text-indigo-300" />
<stop offset="1" stopColor="currentColor" className="text-violet-400" />
</linearGradient>
</defs>
<g mask={`url(#${id}-edges)`}>
<rect width="1000" height="108" fill={`url(#${id}-grid)`} />
<ellipse cx="500" cy="54" rx="44" ry="40" fill={`url(#${id}-glow)`} className={styles.gatewayGlow} />
<g clipPath={`url(#${id}-after-gate)`}>
<g mask={`url(#${id}-organized-shape)`}>
<g className={`${styles.organizedDots} fill-indigo-500 dark:fill-indigo-400`}>
{organizedColumns.map((column) => (
<g key={column} opacity={0.6 + (((column + 8) * 3) % 8) * 0.05}>
{[34, 44, 54, 64, 74].map((y) => (
<circle key={y} cx={505 + column * 10} cy={y} r="2.3" />
))}
</g>
))}
</g>
</g>
</g>
<g mask={`url(#${id}-swarm-shape)`}>
<g mask={`url(#${id}-before-gate)`}>
{swarmRows.map((row, index) => (
<g
key={index}
className={styles.swarmRow}
style={{ animationDuration: row.duration, animationDelay: row.delay }}
>
{row.dots.map((dot, column) => (
<circle key={column} cx={dot.x} cy={row.y} r="2.3" className={dot.color} opacity={dot.opacity} />
))}
</g>
))}
</g>
</g>
</g>
<rect x="497" y="30" width="6" height="48" rx="3" className="fill-background/90" />
<path
d="M 490 30 H 497 V 78 H 490 M 510 30 H 503 V 78 H 510"
fill="none"
stroke={`url(#${id}-gate)`}
strokeWidth="1.4"
opacity="0.75"
/>
<circle cx="500" cy="54" r="4" className="fill-background stroke-indigo-400" strokeWidth="1.4" />
<circle cx="500" cy="54" r="1.6" className="fill-indigo-500 dark:fill-indigo-300" />
</svg>
</div>
);
}

View file

@ -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<HTMLElement>(null);
const [step, setStep] = useState(() => initialSetupStep(state));
const [workerOpen, setWorkerOpen] = useState(false);
const [investigationOpen, setInvestigationOpen] = useState(false);
const [trace, setTrace] = useState<TraceSummary | null>(null);
const selectStep = (index: number) => {
onStart();
setStep(index);
setupRef.current?.querySelector<HTMLButtonElement>(`[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 (
<div className="mx-auto w-full max-w-7xl space-y-8 py-3 sm:space-y-10 sm:py-4">
<LensIntroduction onStart={start} onDemo={onDemo} />
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_280px] xl:gap-8">
<section
ref={setupRef}
tabIndex={-1}
aria-labelledby="lens-setup-title"
className="min-w-0 scroll-mt-4 outline-none"
onFocusCapture={onStart}
>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 id="lens-setup-title" className="text-2xl font-semibold tracking-tight">
Get Lens running
</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Each step checks your connection, so you’ll see when it’s working.
</p>
</div>
{canLeave && (
<Button variant="outline" size="sm" onClick={onExit}>
{state.tracesReady ? "View traces" : "View investigations"}
</Button>
)}
</div>
<LensSetupSteps
step={step}
state={state}
accessToken={accessToken}
readOnly={readOnly}
canInvestigate={canInvestigate}
canMintTracingKey={canMintTracingKey}
onStep={selectStep}
onConnect={connectWorker}
onCreate={createInvestigation}
onTrace={setTrace}
/>
{state.error && (
<div role="alert" className="mt-4 flex flex-wrap items-center gap-3 text-sm text-destructive">
<p>Could not check setup. {state.error}</p>
<Button variant="outline" size="sm" onClick={state.refresh} disabled={state.checking}>
Retry
</Button>
</div>
)}
{(readOnly || !canInvestigate) && (
<p className="mt-4 text-sm text-muted-foreground">
A gateway administrator can connect a worker and run investigations.
</p>
)}
</section>
<aside className="rounded-2xl border bg-card p-6" aria-labelledby="lens-prerequisites">
<h2 id="lens-prerequisites" className="text-base font-semibold">
Before you start
</h2>
<ul className="mt-5 space-y-5 text-sm">
{[
{ title: "LiteLLM gateway", detail: "Access to its configuration" },
{ title: "ClickHouse", detail: "Self-hosted or managed trace storage" },
{ title: "A server with Docker", detail: "To run the analysis worker" },
{ title: "An analysis model", detail: "Available through your gateway" },
].map((item) => (
<li key={item.title} className="flex gap-3">
<Check aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium">{item.title}</p>
<p className="mt-1 leading-5 text-muted-foreground">{item.detail}</p>
</div>
</li>
))}
</ul>
<div className="mt-5 flex gap-2 border-t pt-5 text-xs leading-5 text-muted-foreground">
<ShieldCheck aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<p>
Your infrastructure stores the traces. Investigation content is sent to your selected model provider
through the gateway.
</p>
</div>
</aside>
</div>
{workerOpen && (
<WorkerDialog
accessToken={accessToken}
workers={state.workers}
onClose={() => setWorkerOpen(false)}
onChanged={state.refresh}
onReady={
state.ready
? () => {
setWorkerOpen(false);
createInvestigation();
}
: undefined
}
/>
)}
{investigationOpen && (
<FirstInvestigation
accessToken={accessToken}
state={state}
onClose={() => setInvestigationOpen(false)}
onCreated={onCreated}
/>
)}
{trace && (
<TraceSheet
open
traceId={trace.trace_id}
traceRef={trace.trace_ref}
accessToken={accessToken}
onClose={() => setTrace(null)}
/>
)}
</div>
);
}
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 (
<InvestigationSetupDialog
accessToken={accessToken}
ready={state.ready}
defaultSource={!state.tracesReady && state.requestsReady ? "requests" : "traces"}
defaultModel={defaultModel}
models={models.data?.data.map((model) => model.id) ?? []}
modelDetails={modelDetails.data?.data ?? []}
modelsLoading={models.isLoading}
modelsError={models.error?.message}
onClose={onClose}
onSave={save}
/>
);
}

View file

@ -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;
}
}

View file

@ -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 (
<section
aria-labelledby="lens-sample-trace"
className={`${styles.sampleCard} min-w-0 rounded-xl border bg-card p-4`}
>
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<h3 id="lens-sample-trace" className="font-semibold">
Tracing
</h3>
<p className="text-xs text-muted-foreground">Sample trace · 14.2s</p>
</div>
<ol className="mt-3 space-y-1.5 text-xs leading-5 sm:text-sm" aria-label="Sample trace timeline">
{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 = (
<>
<span className={`flex min-w-0 items-center gap-1 ${index > 0 ? "pl-2" : ""}`}>
{index === 0 ? (
<ChevronDown aria-hidden="true" className="size-3 shrink-0" />
) : (
<ChevronRight aria-hidden="true" className="size-3 shrink-0" />
)}
<span className="truncate" title={item.name}>
{item.name}
</span>
{item.failed && <span className="sr-only">Failed</span>}
</span>
<span aria-hidden="true" className="relative h-2.5 overflow-hidden rounded-sm bg-muted/70">
<span
className={`absolute inset-y-0 rounded-sm ${linked ? styles.evidenceBar : ""} ${item.failed ? "bg-rose-400" : "bg-indigo-600 dark:bg-indigo-400"}`}
style={{ left: `${(item.start / 14.2) * 100}%`, width: `${(item.duration / 14.2) * 100}%` }}
/>
</span>
<span className="text-right tabular-nums text-muted-foreground">{item.duration}s</span>
</>
);
return (
<li key={`${item.name}-${item.start}`}>
{linked ? (
<button
type="button"
className={`${rowClass} ${styles.evidenceLink}`}
data-evidence-link=""
aria-label={`${item.name}, ${item.duration} seconds${item.failed ? ", failed" : ""}. Highlight related finding`}
aria-controls="lens-linked-finding"
aria-pressed={highlighted}
onClick={onHighlight}
>
{content}
</button>
) : (
<div className={rowClass}>{content}</div>
)}
</li>
);
})}
</ol>
<div className="mt-3 grid gap-3 border-t pt-3 sm:grid-cols-2">
<div id="lens-benchmark-output" className={`${styles.evidenceOutput} rounded-lg bg-muted/60 p-2.5`}>
<p className="text-xs text-muted-foreground">run_benchmark · output</p>
<p className="mt-1.5 text-sm leading-4.5">Error: benchmark runner unavailable (503)</p>
</div>
<div id="lens-answer-output" className={`${styles.evidenceOutput} rounded-lg bg-muted/60 p-2.5`}>
<p className="text-xs text-muted-foreground">answer · output</p>
<p className="mt-1.5 text-sm leading-4.5">“{sampleAnswer}”</p>
</div>
</div>
</section>
);
}
function FindingExamples({ highlighted, onHighlight }: EvidenceProps) {
return (
<section
aria-labelledby="lens-sample-findings"
className={`${styles.sampleCard} min-w-0 rounded-xl border bg-card p-4`}
>
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<h3 id="lens-sample-findings" className="font-semibold">
Lens findings
</h3>
<p className="text-xs text-muted-foreground">500 sample runs reviewed</p>
</div>
<div className="mt-3 divide-y border-t">
<article id="lens-linked-finding" className={`${styles.evidenceFinding} py-3`}>
<h4 className="text-sm font-semibold">
<button
type="button"
className={`${styles.evidenceLink} flex w-full items-start gap-2 text-left`}
data-evidence-link=""
aria-controls="lens-benchmark-output lens-answer-output"
aria-pressed={highlighted}
onClick={onHighlight}
>
<span aria-hidden="true" className="mt-1.5 size-2 shrink-0 rounded-full bg-rose-500" />
Claims numbers it never measured
</button>
</h4>
<div className="ml-4 mt-1.5 space-y-1.5 text-sm leading-4.5">
<p className="text-muted-foreground">Reports a speedup even though the benchmark failed.</p>
<blockquote className="border-l-2 pl-3 italic">
“…40% faster than the row store for your workload.”
</blockquote>
<p>
<span className="text-muted-foreground">Next step: </span>Report the failed benchmark instead of
estimating.
</p>
<p className="text-xs text-muted-foreground">38 linked runs · High priority</p>
</div>
</article>
<article className="pt-3">
<h4 className="flex items-start gap-2 text-sm font-semibold">
<span aria-hidden="true" className="mt-1.5 size-2 shrink-0 rounded-full bg-amber-500" />
Promises to follow up, then stops
</h4>
<div className="ml-4 mt-1.5 space-y-1.5 text-sm leading-4.5 text-muted-foreground">
<p>After an order lookup times out, the agent promises to check and ends the run.</p>
<p className="text-xs">12 linked runs · Medium priority</p>
</div>
</article>
</div>
</section>
);
}
export function LensIntroduction({ onStart, onDemo }: { onStart: () => void; onDemo?: () => void }) {
const [highlighted, setHighlighted] = useState(false);
const toggleEvidence = () => setHighlighted((current) => !current);
return (
<section
aria-labelledby="lens-introduction"
className="rounded-2xl border bg-card p-5 sm:px-6 sm:py-5 xl:px-7 xl:py-6"
>
<h2 id="lens-introduction" className="text-2xl leading-tight font-semibold tracking-tight sm:text-3xl">
The gateway that helps your agents improve
</h2>
<p className="mt-3 max-w-[780px] text-sm leading-6 text-muted-foreground">
Turn recorded agent runs into findings linked to the exact steps, so you know what happened and what to change.
</p>
<div className="mt-4 flex flex-wrap items-center gap-3">
{onDemo && (
<Button className="rounded-lg px-4" onClick={onDemo}>
Explore with sample data <ArrowRight aria-hidden="true" className="size-4" />
</Button>
)}
<Button variant={onDemo ? "outline" : "default"} className="rounded-lg px-4" onClick={onStart}>
Set up Lens
</Button>
<a
href="https://docs.litellm.ai/docs/proxy/lens"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 px-3 text-sm font-medium text-muted-foreground underline-offset-4 hover:underline"
>
Docs <ArrowUpRight aria-hidden="true" className="size-4" />
</a>
</div>
<GatewayFlow />
<div
className={`${styles.examples} mt-4 grid items-stretch gap-3 md:grid-cols-2`}
data-evidence-active={highlighted}
>
<TraceExample highlighted={highlighted} onHighlight={toggleEvidence} />
<FindingExamples highlighted={highlighted} onHighlight={toggleEvidence} />
</div>
</section>
);
}

View file

@ -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 (
<>
<TracingSetupFields
detail="Tracing is not enabled"
accessToken={props.accessToken}
onOpenTrace={props.onTrace}
onCheck={state.refresh}
checking={state.checking}
readOnly={props.readOnly}
/>
<ActivityContinuation state={state} {...props} />
</>
);
return (
<div className="space-y-4">
<p role="status" className="text-sm text-success">
Trace storage is connected
</p>
<Button onClick={() => onStep(1)}>
Continue to your agent <ArrowRight aria-hidden="true" className="size-4" />
</Button>
<ActivityContinuation state={state} {...props} />
</div>
);
}
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<StepProps, "state" | "onConnect" | "onCreate" | "readOnly" | "canInvestigate">) {
if (!state.tracesReady && !state.requestsReady) return null;
return (
<div className="mt-4">
<Button onClick={state.connected ? onCreate : onConnect} disabled={readOnly || !canInvestigate}>
{continuationLabel(state)}
<ArrowRight aria-hidden="true" className="size-4" />
</Button>
{state.requestsReady && !state.tracesReady && (
<p className="mt-2 text-xs text-muted-foreground">
Request logs are already available. You can investigate them now and add agent traces later.
</p>
)}
</div>
);
}
function AgentStep({ state, ...props }: StepProps) {
if (!state.tracingEnabled)
return (
<>
<p className="text-sm text-muted-foreground">Connect trace storage in step 1 before sending a trace.</p>
<ActivityContinuation state={state} {...props} />
</>
);
return (
<>
<div hidden={state.tracesReady}>
<TracingSetupFields
detail={null}
accessToken={props.accessToken}
onOpenTrace={(value) => {
state.refresh();
props.onTrace(value);
}}
onCheck={state.refresh}
checking={state.checking}
readOnly={props.readOnly}
canMintTracingKey={props.canMintTracingKey}
/>
</div>
{state.tracesReady && (
<p role="status" className="text-sm text-success">
Your first trace is ready. Continue setup so Lens can investigate your agent’s behavior.
</p>
)}
<ActivityContinuation state={state} {...props} />
</>
);
}
function WorkerStep({ state, onConnect, onCreate, readOnly, canInvestigate }: StepProps) {
const activityReady = state.tracesReady || state.requestsReady;
return (
<div className="space-y-4">
<p role="status" className="text-sm text-muted-foreground">
{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."}
</p>
{!activityReady && (
<p className="text-sm text-muted-foreground">Record activity in step 2 before connecting a worker.</p>
)}
<Button onClick={state.connected ? onCreate : onConnect} disabled={!activityReady || readOnly || !canInvestigate}>
{state.connected ? "Continue to investigation" : "Connect worker"}
<ArrowRight aria-hidden="true" className="size-4" />
</Button>
</div>
);
}
function InvestigationStep({ state, onCreate, readOnly, canInvestigate }: StepProps) {
return (
<div className="space-y-4">
<p className="text-sm leading-6 text-muted-foreground">
Choose the activity to review and describe how your agent should behave. Lens will show findings with evidence
and suggested changes.
</p>
{!state.ready && (
<p className="text-sm text-muted-foreground">
Recorded activity and a connected worker are required before you can run an investigation.
</p>
)}
<Button onClick={onCreate} disabled={!state.ready || readOnly || !canInvestigate}>
New investigation <ArrowRight aria-hidden="true" className="size-4" />
</Button>
</div>
);
}
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: <StorageStep {...props} />,
},
{
title: "Send your first trace",
description: "Capture your agent’s inputs, outputs, and tool calls.",
complete: props.state.tracesReady,
content: <AgentStep {...props} />,
},
{
title: "Connect a worker",
description: "Choose a model and run the worker on your infrastructure.",
complete: props.state.connected,
content: <WorkerStep {...props} />,
},
{
title: "Run your first investigation",
description: "Describe the expected behavior and review a sample of activity.",
complete: props.state.hasInvestigations,
content: <InvestigationStep {...props} />,
},
];
return (
<ol className="divide-y overflow-hidden rounded-2xl border bg-card">
{items.map((item, index) => (
<li key={item.title}>
<h3>
<button
type="button"
aria-expanded={step === index}
aria-controls={`lens-setup-step-${index}`}
onClick={() => props.onStep(index)}
className="group flex w-full items-start gap-4 p-5 text-left outline-none hover:bg-muted/30 focus-visible:bg-muted/50 sm:p-6"
>
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full text-sm font-medium",
item.complete ? "bg-success/10 text-success" : "border text-muted-foreground",
!item.complete && step === index && "border-primary bg-primary text-primary-foreground",
)}
aria-label={item.complete ? `Step ${index + 1} complete` : `Step ${index + 1}`}
>
{item.complete ? <Check aria-hidden="true" className="size-4" /> : index + 1}
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block text-sm font-medium sm:text-base",
step !== index && "text-muted-foreground group-hover:text-foreground",
)}
>
{item.title}
</span>
<span className="mt-1 block text-sm leading-6 font-normal text-muted-foreground">
{item.description}
</span>
</span>
<ChevronDown
aria-hidden="true"
className={`mt-1 size-4 shrink-0 text-muted-foreground ${step === index ? "rotate-180" : ""}`}
/>
</button>
</h3>
<div id={`lens-setup-step-${index}`} hidden={step !== index} className="px-5 pb-6 sm:pr-6 sm:pb-7 sm:pl-18">
{item.content}
</div>
</li>
))}
</ol>
);
}

View file

@ -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<typeof useTraceAvailability>, 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<typeof useLensSetup>;

View file

@ -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 ? (
<ConnectAgent
accessToken={accessToken}
onOpenTrace={onOpenTrace}
connected={connected}
checked={checked}
checking={checking}
onCheck={check}
readOnly={readOnly}
canMintTracingKey={canMintTracingKey}
/>
) : (
<EnableTracing checked={checked} checking={checking} onCheck={check} />
);
}
export function TracingSetupCard(props: TracingSetupProps) {
const enabled = props.detail === null;
return (
<div className="w-full max-w-3xl pb-8" data-testid="tracing-setup-card">
{!connected && <LensPreviewButton />}
{!props.connected && <LensPreviewButton />}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<h2 className="text-xl font-semibold tracking-tight">{setupTitle(enabled, connected)}</h2>
<h2 className="text-xl font-semibold tracking-tight">{setupTitle(enabled, props.connected ?? false)}</h2>
<span role="status" className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
{enabled ? (
<ActiveDot />
@ -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."}
</p>
{enabled ? (
<ConnectAgent
accessToken={accessToken}
onOpenTrace={onOpenTrace}
connected={connected}
checked={checked}
checking={checking}
onCheck={check}
readOnly={readOnly}
canMintTracingKey={canMintTracingKey}
/>
) : (
<EnableTracing checked={checked} checking={checking} onCheck={check} />
)}
<TracingSetupFields {...props} />
</div>
);
}