mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
b61376a99e
commit
2936307661
14 changed files with 1444 additions and 102 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 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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
148
ui/litellm-dashboard/src/components/lens/setup/GatewayFlow.tsx
Normal file
148
ui/litellm-dashboard/src/components/lens/setup/GatewayFlow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue