mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
feat(lens): guide setup through the first investigation
This commit is contained in:
parent
8520626e7a
commit
06a50d055a
11 changed files with 933 additions and 94 deletions
|
|
@ -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 findings", () => {
|
||||
it("opens existing lens links on findings", async () => {
|
||||
renderWithProviders(<LensPage />, { searchParams: "?lens=saved-lens" });
|
||||
expect(screen.getByRole("tab", { name: "Findings" })).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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
import { 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("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({ enabled: true, 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();
|
||||
await user.click(screen.getByRole("button", { name: "View investigations" }));
|
||||
expect(await screen.findByRole("heading", { name: "Run your first investigation" })).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 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("opens the saved investigation directly when guided setup finishes", async () => {
|
||||
serve({ enabled: true, traces: true, connected: true });
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = vi.fn();
|
||||
renderWithProviders(<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: "Findings" }),
|
||||
).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" }));
|
||||
});
|
||||
});
|
||||
|
|
@ -25,7 +25,7 @@ describe("Lens interactive demo", () => {
|
|||
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Internal User" readOnly={false} />, {
|
||||
onUrlUpdate,
|
||||
});
|
||||
expect(await screen.findByText("Enable tracing")).toBeVisible();
|
||||
expect(await screen.findByRole("heading", { name: "The gateway that helps your agents improve" })).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Preview sample" }));
|
||||
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
|
||||
expect(screen.getByText("You’re viewing demo data")).toBeVisible();
|
||||
|
|
@ -46,7 +46,7 @@ describe("Lens interactive demo", () => {
|
|||
expect(network).not.toHaveBeenCalled();
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Exit demo" }));
|
||||
expect(await screen.findByText("Enable tracing")).toBeVisible();
|
||||
expect(await screen.findByRole("heading", { name: "The gateway that helps your agents improve" })).toBeVisible();
|
||||
expect(screen.queryByText("You’re viewing demo data")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Can I return my headphones?")).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -84,7 +84,7 @@ describe("Lens interactive demo", () => {
|
|||
expect(network).not.toHaveBeenCalled();
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Exit demo" }));
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Aperture } from "lucide-react";
|
||||
import { Aperture, Loader2 } from "lucide-react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { parseAsString, parseAsStringLiteral, useQueryState } from "nuqs";
|
||||
import AgentTracesPage from "@/components/view_logs/TraceView/AgentTracesPage";
|
||||
|
|
@ -13,6 +13,9 @@ import { LensPreviewTarget } from "./LensPreviewButton";
|
|||
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
|
||||
import { InvestigationsView } from "./investigations/InvestigationsView";
|
||||
import { createLensDemo } from "./demo/createLensDemo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LensGettingStarted } from "./setup/LensGettingStarted";
|
||||
import { useLensSetup, type LensSetupState } from "./setup/useLensSetup";
|
||||
|
||||
type Tab = "traces" | "findings" | "investigations";
|
||||
type WorkspaceProps = { accessToken: string; userRole: string; readOnly: boolean };
|
||||
|
|
@ -61,12 +64,51 @@ function LensContent({
|
|||
"tab",
|
||||
parseAsStringLiteral(["traces", "findings", "investigations"]).withOptions({ history: "push" }),
|
||||
);
|
||||
const [lensId] = useQueryState("lens", parseAsString);
|
||||
const [lensId, setLensId] = useQueryState("lens", parseAsString);
|
||||
const [setup, setSetup] = useQueryState("setup", parseAsStringLiteral(["lens"]).withOptions({ history: "push" }));
|
||||
const [demoTab, setDemoTab] = useState(initialTab);
|
||||
const [previewTarget, setPreviewTarget] = useState<HTMLDivElement | null>(null);
|
||||
const defaultTab = lensId ? "findings" : "traces";
|
||||
const activeTab = demo ? demoTab : tab ?? defaultTab;
|
||||
const openDemo = onDemo ? () => onDemo(activeTab) : undefined;
|
||||
const canInvestigate = isProxyAdminTierRole(userRole);
|
||||
const setupState = useLensSetup(accessToken, !demo, canInvestigate, setup === "lens");
|
||||
const setupLocation = { tab: activeTab, canInvestigate, selected: !!lensId, requested: setup === "lens" };
|
||||
const showSetup = !demo && needsSetup(setupState, setupLocation);
|
||||
const showSetupButton = !demo && !showSetup && isProxyAdminRole(userRole);
|
||||
const startSetup = () => {
|
||||
if (setup !== "lens") void setSetup("lens");
|
||||
};
|
||||
const showTraces = () => {
|
||||
void setSetup(null);
|
||||
void setTab(setupState.tracesReady ? "traces" : "investigations");
|
||||
};
|
||||
const checkingSetup = !demo && setupState.loading;
|
||||
const showCreated = (id: string) => {
|
||||
void setSetup(null);
|
||||
void setLensId(id);
|
||||
void setTab("findings");
|
||||
};
|
||||
const sharedSetup = checkingSetup ? (
|
||||
<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="min-w-0">
|
||||
<LensGettingStarted
|
||||
accessToken={accessToken}
|
||||
state={setupState}
|
||||
readOnly={readOnly}
|
||||
canInvestigate={isProxyAdminRole(userRole)}
|
||||
canMintTracingKey={isProxyAdminRole(userRole)}
|
||||
onStart={startSetup}
|
||||
onExit={showTraces}
|
||||
onCreated={showCreated}
|
||||
onDemo={openDemo}
|
||||
/>
|
||||
</TabsContent>
|
||||
);
|
||||
return (
|
||||
<LensPreviewTarget.Provider value={previewTarget}>
|
||||
<main className="flex min-h-full w-full min-w-0 flex-1 flex-col gap-2 px-3 pt-2 pb-3">
|
||||
|
|
@ -94,36 +136,65 @@ function LensContent({
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div ref={setPreviewTarget} />
|
||||
</div>
|
||||
<TabsContent value="traces" keepMounted className="flex min-h-0 flex-col">
|
||||
<AgentTracesPage
|
||||
accessToken={accessToken}
|
||||
isActive={activeTab === "traces"}
|
||||
readOnly={readOnly}
|
||||
canMintTracingKey={!demo && isProxyAdminRole(userRole)}
|
||||
onDemo={activeTab === "traces" ? openDemo : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
{(["findings", "investigations"] as const).map((view) => (
|
||||
<TabsContent key={view} value={view} keepMounted={!!demo} className="flex min-h-0 flex-col">
|
||||
{demo || isProxyAdminTierRole(userRole) ? (
|
||||
<InvestigationsView
|
||||
view={view}
|
||||
active={activeTab === view}
|
||||
accessToken={accessToken}
|
||||
readOnly={readOnly || !isProxyAdminRole(userRole)}
|
||||
onDemo={activeTab === view ? openDemo : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-6 text-sm text-muted-foreground">
|
||||
Investigations require proxy administrator access. You can still view your traces.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{showSetupButton && (
|
||||
<Button variant="outline" size="sm" onClick={startSetup}>
|
||||
Set up Lens
|
||||
</Button>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
<div ref={setPreviewTarget} />
|
||||
</div>
|
||||
</div>
|
||||
{checkingSetup || showSetup ? (
|
||||
sharedSetup
|
||||
) : (
|
||||
<>
|
||||
<TabsContent value="traces" keepMounted className="flex min-h-0 flex-col">
|
||||
<AgentTracesPage
|
||||
accessToken={accessToken}
|
||||
isActive={activeTab === "traces"}
|
||||
readOnly={readOnly}
|
||||
canMintTracingKey={!demo && isProxyAdminRole(userRole)}
|
||||
onDemo={activeTab === "traces" ? openDemo : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
{(["findings", "investigations"] as const).map((view) => (
|
||||
<TabsContent key={view} value={view} keepMounted={!!demo} className="flex min-h-0 flex-col">
|
||||
{demo || isProxyAdminTierRole(userRole) ? (
|
||||
<InvestigationsView
|
||||
view={view}
|
||||
active={activeTab === view}
|
||||
accessToken={accessToken}
|
||||
readOnly={readOnly || !isProxyAdminRole(userRole)}
|
||||
onDemo={activeTab === view ? openDemo : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-6 text-sm text-muted-foreground">
|
||||
Investigations require proxy administrator access. You can still view your traces.
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</main>
|
||||
</LensPreviewTarget.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function needsSetup(
|
||||
state: LensSetupState,
|
||||
{
|
||||
tab,
|
||||
canInvestigate,
|
||||
selected,
|
||||
requested,
|
||||
}: { tab: Tab; 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.requestsReady || selected;
|
||||
return canInvestigate && !hasActivity;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -303,12 +303,10 @@ it("guides a first-time administrator into worker connection and lens setup", as
|
|||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<InvestigationsView accessToken="test" onDemo={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 guide.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" }));
|
||||
|
|
|
|||
|
|
@ -34,36 +34,45 @@ 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 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="max-w-3xl pb-6">
|
||||
{onDemo && <LensPreviewButton onClick={onDemo} />}
|
||||
<h2 id="lens-welcome" className="text-xl font-semibold tracking-tight">
|
||||
Find what needs attention
|
||||
{activityReady ? "Run your first investigation" : "Find what needs attention"}
|
||||
</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.
|
||||
{activityReady
|
||||
? "Your recorded activity is ready. Connect a worker and choose what Lens should look for."
|
||||
: "Check how your agents behave. Get findings you can trace back to what happened."}
|
||||
</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 +92,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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
"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 { LensPreviewButton } from "../LensPreviewButton";
|
||||
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-6xl space-y-8 py-4">
|
||||
{onDemo && <LensPreviewButton onClick={onDemo} />}
|
||||
<LensIntroduction onStart={start} onDemo={onDemo} />
|
||||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_260px]">
|
||||
<section
|
||||
ref={setupRef}
|
||||
tabIndex={-1}
|
||||
aria-labelledby="lens-setup-title"
|
||||
className="min-w-0 scroll-mt-4 outline-none"
|
||||
onFocusCapture={onStart}
|
||||
>
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="lens-setup-title" className="text-xl font-semibold tracking-tight">
|
||||
Get Lens running
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
We’ll check each connection. Stay here until your first investigation is ready.
|
||||
</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-lg border p-5" aria-labelledby="lens-prerequisites">
|
||||
<h2 id="lens-prerequisites" className="text-base font-medium">
|
||||
Before you start
|
||||
</h2>
|
||||
<ul className="mt-4 space-y-4 text-sm text-muted-foreground">
|
||||
{[
|
||||
"Access to your LiteLLM gateway configuration",
|
||||
"ClickHouse for trace storage",
|
||||
"A server with Docker for the worker",
|
||||
"An analysis model on your gateway",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<Check aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
||||
{item}
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { Aperture, ArrowRight, ArrowUpRight, GitBranch } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { scenarios } from "../demo/scenarios";
|
||||
|
||||
export function LensIntroduction({ onStart, onDemo }: { onStart: () => void; onDemo?: () => void }) {
|
||||
const example = scenarios[1];
|
||||
return (
|
||||
<section aria-labelledby="lens-introduction" className="rounded-lg border bg-card p-5 sm:p-8">
|
||||
<h2 id="lens-introduction" className="max-w-2xl text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
The gateway that helps your agents improve
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Lens reviews your agents’ recorded activity and finds behavior worth fixing. Each finding links to the original
|
||||
steps, so you can see what happened and what to change next.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
{onDemo && (
|
||||
<Button onClick={onDemo}>
|
||||
Explore with sample data <ArrowRight aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant={onDemo ? "outline" : "default"} 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-2 text-sm text-muted-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
Docs <ArrowUpRight aria-hidden="true" className="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-8 grid overflow-hidden rounded-lg border lg:grid-cols-2">
|
||||
<div className="border-b bg-muted/20 p-5 lg:border-r lg:border-b-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<h3 className="flex items-center gap-2 font-medium">
|
||||
<GitBranch aria-hidden="true" className="size-4" />
|
||||
Agent trace
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">Sample · {example.agent}</span>
|
||||
</div>
|
||||
<dl className="mt-5 space-y-4 text-sm">
|
||||
<div>
|
||||
<dt className="font-mono text-xs text-muted-foreground">{example.tool}</dt>
|
||||
<dd className="mt-1.5 leading-6">{example.result}</dd>
|
||||
</div>
|
||||
<div className="border-t pt-4">
|
||||
<dt className="text-xs text-muted-foreground">Final answer</dt>
|
||||
<dd className="mt-1.5 leading-6">“{example.answer}”</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
<Aperture aria-hidden="true" className="size-4" />
|
||||
Lens finding
|
||||
</h3>
|
||||
<h4 className="mt-5 text-base font-medium">Performance claim has no supporting benchmark</h4>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
The answer claims a 40% improvement, but the retrieved documentation contains no comparative benchmark.
|
||||
</p>
|
||||
<p className="mt-4 border-t pt-4 text-sm leading-6">
|
||||
<span className="font-medium">Suggested change: </span>Require a benchmark source for numerical performance
|
||||
claims, or remove the comparison.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
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";
|
||||
|
||||
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.tracingEnabled) return 0;
|
||||
if (!state.tracesReady) return 1;
|
||||
return state.connected ? 3 : 2;
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function continuationLabel(state: LensSetupState) {
|
||||
if (state.connected) return "Continue to investigation";
|
||||
return state.tracesReady ? "Continue to worker" : "Continue with request logs";
|
||||
}
|
||||
|
||||
function AgentStep({ state, onConnect, onCreate, ...props }: StepProps) {
|
||||
if (!state.tracingEnabled)
|
||||
return <p className="text-sm text-muted-foreground">Connect trace storage in step 1 before sending a trace.</p>;
|
||||
const activityReady = state.tracesReady || state.requestsReady;
|
||||
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>
|
||||
)}
|
||||
{activityReady && (
|
||||
<div className="mt-4">
|
||||
<Button onClick={state.connected ? onCreate : onConnect} disabled={props.readOnly || !props.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 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-lg border">
|
||||
{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="flex w-full items-start gap-3 p-5 text-left hover:bg-muted/30"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
item.complete
|
||||
? "flex size-7 shrink-0 items-center justify-center rounded-full bg-success/10 text-success"
|
||||
: "flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-medium"
|
||||
}
|
||||
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="block text-sm font-medium">{item.title}</span>
|
||||
<span className="mt-1 block text-sm 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-5 sm:pl-15">
|
||||
{item.content}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
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, false, settingUp), enabled: enabled && canInvestigate });
|
||||
const activity = useQuery(lensQueries.activity(api, enabled && canInvestigate && list.isSuccess, false));
|
||||
const data = list.data ?? { lenses: [], workers: [], tracing_enabled: false };
|
||||
const traceState = traceSetupState(traces, data.tracing_enabled);
|
||||
const requestsReady = activity.data?.requests === true && !activity.error;
|
||||
const connected = data.workers.some((worker) => workerConnected(worker, now));
|
||||
const hasInvestigations = data.lenses.length > 0;
|
||||
const error = traceState.error || list.error || activity.error;
|
||||
const activityReady = traceState.tracesReady || requestsReady;
|
||||
const loadingActivity = 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,
|
||||
connected,
|
||||
hasInvestigations,
|
||||
missingTraces: traceState.missingTraces,
|
||||
loading: 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>;
|
||||
|
|
@ -645,17 +645,7 @@ function ConnectAgent({
|
|||
);
|
||||
}
|
||||
|
||||
export function TracingSetupCard({
|
||||
detail,
|
||||
accessToken,
|
||||
onOpenTrace,
|
||||
connected = false,
|
||||
onCheck,
|
||||
checking = false,
|
||||
readOnly = false,
|
||||
canMintTracingKey = false,
|
||||
onDemo,
|
||||
}: {
|
||||
type TracingSetupProps = {
|
||||
detail: string | null;
|
||||
accessToken: string;
|
||||
onOpenTrace: (trace: TraceSummary) => void;
|
||||
|
|
@ -664,20 +654,47 @@ export function TracingSetupCard({
|
|||
checking?: boolean;
|
||||
readOnly?: boolean;
|
||||
canMintTracingKey?: boolean;
|
||||
onDemo?: () => void;
|
||||
}) {
|
||||
};
|
||||
|
||||
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({ onDemo, ...props }: TracingSetupProps & { onDemo?: () => void }) {
|
||||
const enabled = props.detail === null;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl pb-8" data-testid="tracing-setup-card">
|
||||
{onDemo && <LensPreviewButton onClick={onDemo} />}
|
||||
<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 />
|
||||
|
|
@ -700,20 +717,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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue