mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
feat(ui): center Lens mode switch in a notch joined to the content card
Larger Traces/Investigations switch, a subtle dot when an investigation is running or queued, and a bigger Lens title with a docs link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d26c52a6ba
commit
282f36e225
4 changed files with 166 additions and 53 deletions
|
|
@ -205,4 +205,25 @@ describe("Lens interactive demo", () => {
|
|||
expect(await screen.findByRole("table", { name: "Agent runs" })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the Investigations tab while a scan runs and clears it once the scan finishes", async () => {
|
||||
const saved = createLensDemoData().lenses[0];
|
||||
const withJob = (status: (typeof saved.jobs)[number]["status"]) => ({
|
||||
...saved,
|
||||
jobs: [{ ...saved.jobs[0], status }, ...saved.jobs.slice(1)],
|
||||
});
|
||||
const lenses = vi.fn(() => [withJob("running")]);
|
||||
network.mockImplementation(async (input) => {
|
||||
const path = new URL(String(input), "http://localhost").pathname;
|
||||
if (path === "/lens") return Response.json({ lenses: lenses(), workers: [], tracing_enabled: false });
|
||||
if (path === "/v1/traces") return Response.json({ detail: "Tracing is not enabled" }, { status: 501 });
|
||||
return Response.json({ data: [], traces: false, requests: false });
|
||||
});
|
||||
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />);
|
||||
const tab = within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Investigations" });
|
||||
await waitFor(() => expect(tab).toHaveAccessibleDescription("An investigation is running"));
|
||||
lenses.mockReturnValue([withJob("completed")]);
|
||||
await testQueryClient.refetchQueries({ queryKey: ["lens", "list"] });
|
||||
await waitFor(() => expect(tab).toHaveAccessibleDescription(""));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
|
||||
import { Activity, Aperture, Info, ScanSearch } from "lucide-react";
|
||||
import { Activity, Aperture, ArrowUpRight, Info, 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";
|
||||
|
|
@ -11,6 +12,10 @@ import { LensPreviewContext } from "./LensPreviewButton";
|
|||
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
|
||||
import { InvestigationsView } from "./investigations/InvestigationsView";
|
||||
import { createLensDemo } from "./demo/createLensDemo";
|
||||
import { lensQueries } from "./api/queries";
|
||||
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";
|
||||
|
||||
type WorkspaceProps = { accessToken: string; userRole: string; readOnly: boolean };
|
||||
|
|
@ -47,79 +52,148 @@ function DemoToggle({ demo, onChange }: { demo: boolean; onChange: (demo: boolea
|
|||
|
||||
const MODE_ICONS = { traces: Activity, investigations: ScanSearch } as const;
|
||||
|
||||
function LensModeSwitch() {
|
||||
const ACTIVITY_DOT: Record<Exclude<InvestigationActivity, "idle">, { className: string; label: string }> = {
|
||||
running: { className: "bg-[#3b5bfd] motion-safe:animate-pulse", label: "An investigation is running" },
|
||||
queued: { className: "bg-muted-foreground/60", label: "An investigation is queued" },
|
||||
};
|
||||
|
||||
function ActivityDot({ activity }: { activity: InvestigationActivity }) {
|
||||
if (activity === "idle") return null;
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
aria-label="Lens"
|
||||
className="relative inline-flex h-8 items-center rounded-full border border-border bg-muted/60 p-0.5"
|
||||
>
|
||||
<TabsPrimitive.Indicator className="absolute top-0.5 bottom-0.5 left-(--active-tab-left) w-(--active-tab-width) rounded-full bg-background shadow-sm ring-1 ring-border transition-[left,width] duration-200 ease-out motion-reduce:transition-none" />
|
||||
{Object.entries(LENS_TABS).map(([view, label]) => {
|
||||
const Icon = MODE_ICONS[view as LensTab];
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
key={view}
|
||||
value={view}
|
||||
className="relative z-raised inline-flex h-full items-center gap-1.5 rounded-full px-3 text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-active:text-foreground"
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-3.5" />
|
||||
{label}
|
||||
</TabsPrimitive.Tab>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-1.5 rounded-full animate-in fade-in-0 zoom-in-50 duration-300",
|
||||
ACTIVITY_DOT[activity].className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NotchCorner({ side }: { side: "left" | "right" }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="13"
|
||||
height="12"
|
||||
viewBox="0 0 13 12"
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 overflow-visible",
|
||||
side === "left" ? "-left-3" : "-right-3 -scale-x-100",
|
||||
)}
|
||||
>
|
||||
<path d="M0 11 A12.5 11 0 0 0 12.5 0 H13 V12 H0 Z" className="fill-card" />
|
||||
<path d="M0 11.5 A12.5 11.5 0 0 0 12.5 0" className="fill-none stroke-border" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LensModeSwitch({ activity }: { activity: InvestigationActivity }) {
|
||||
return (
|
||||
<div className="relative z-raised -mb-px rounded-t-2xl border-x border-t border-border bg-card px-1.5 pt-1.5 pb-[7px]">
|
||||
<NotchCorner side="left" />
|
||||
<NotchCorner side="right" />
|
||||
<TabsPrimitive.List
|
||||
aria-label="Lens"
|
||||
className="relative inline-flex h-9 items-center rounded-full bg-muted/70 p-1"
|
||||
>
|
||||
<TabsPrimitive.Indicator className="absolute top-1 bottom-1 left-(--active-tab-left) w-(--active-tab-width) rounded-full bg-background shadow-sm ring-1 ring-border transition-[left,width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none" />
|
||||
{Object.entries(LENS_TABS).map(([view, label]) => {
|
||||
const Icon = MODE_ICONS[view as LensTab];
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
key={view}
|
||||
value={view}
|
||||
aria-description={
|
||||
view === "investigations" && activity !== "idle" ? ACTIVITY_DOT[activity].label : undefined
|
||||
}
|
||||
className="relative z-raised inline-flex h-full items-center gap-2 rounded-full px-4 text-sm font-medium text-muted-foreground outline-none transition-colors duration-200 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-active:text-foreground"
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-4" />
|
||||
{label}
|
||||
{view === "investigations" && <ActivityDot activity={activity} />}
|
||||
</TabsPrimitive.Tab>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useInvestigationActivity(accessToken: string, enabled: boolean): InvestigationActivity {
|
||||
const api = useLensApi(accessToken);
|
||||
const { data } = useQuery({ ...lensQueries.list(api, false), enabled });
|
||||
return investigationActivity(data?.lenses ?? []);
|
||||
}
|
||||
|
||||
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 [previewTarget, setPreviewTarget] = useState<HTMLDivElement | null>(null);
|
||||
const activeTab = tab ?? (lensId ? "investigations" : "traces");
|
||||
const canInvestigate = isProxyAdminTierRole(userRole);
|
||||
const activity = useInvestigationActivity(accessToken, canInvestigate);
|
||||
const preview = (view: LensTab) => ({
|
||||
target: previewTarget,
|
||||
open: !demo && activeTab === view ? () => setDemo(true) : undefined,
|
||||
});
|
||||
return (
|
||||
<main className="flex h-full w-full min-w-0 flex-1 flex-col gap-2 px-3 pt-2 pb-3">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setTab(value as LensTab)} className="min-h-0 flex-1 gap-2">
|
||||
<div className="flex min-h-8 flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="flex items-center gap-1.5 text-sm font-semibold tracking-tight">
|
||||
<Aperture aria-hidden="true" className="size-4" strokeWidth={2} />
|
||||
<main className="flex h-full w-full min-w-0 flex-1 flex-col px-3 pt-2 pb-3">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setTab(value as LensTab)} className="min-h-0 flex-1 gap-0">
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-end gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 self-center">
|
||||
<h1 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
|
||||
<Aperture aria-hidden="true" className="size-5" strokeWidth={2} />
|
||||
Lens
|
||||
</h1>
|
||||
<LensModeSwitch />
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
Trace your agents and investigate what goes wrong.{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/lens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 font-medium text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Docs
|
||||
<ArrowUpRight aria-hidden="true" className="size-3" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<LensModeSwitch activity={activity} />
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 self-center">
|
||||
<div ref={setPreviewTarget} />
|
||||
<DemoToggle demo={demo} onChange={setDemo} />
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="traces" keepMounted className="flex min-h-0 flex-col overflow-y-auto">
|
||||
<LensPreviewContext.Provider value={preview("traces")}>
|
||||
<AgentTracesPage
|
||||
accessToken={accessToken}
|
||||
isActive={activeTab === "traces"}
|
||||
readOnly={readOnly}
|
||||
canMintTracingKey={isProxyAdminRole(userRole)}
|
||||
/>
|
||||
</LensPreviewContext.Provider>
|
||||
</TabsContent>
|
||||
<TabsContent value="investigations" className="flex min-h-0 flex-col overflow-y-auto">
|
||||
<LensPreviewContext.Provider value={preview("investigations")}>
|
||||
{isProxyAdminTierRole(userRole) ? (
|
||||
<InvestigationsView
|
||||
active={activeTab === "investigations"}
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-border bg-card p-3">
|
||||
<TabsContent value="traces" keepMounted className={PANEL}>
|
||||
<LensPreviewContext.Provider value={preview("traces")}>
|
||||
<AgentTracesPage
|
||||
accessToken={accessToken}
|
||||
readOnly={readOnly || !isProxyAdminRole(userRole)}
|
||||
isActive={activeTab === "traces"}
|
||||
readOnly={readOnly}
|
||||
canMintTracingKey={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>
|
||||
</LensPreviewContext.Provider>
|
||||
</TabsContent>
|
||||
<TabsContent value="investigations" className={PANEL}>
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { nextCheckStatus, workerConnected } from "./status";
|
||||
import { investigationActivity, nextCheckStatus, workerConnected } from "./status";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Job, Lens, LensList } from "./types";
|
||||
|
|
@ -102,3 +102,12 @@ it("shows the actual next schedule and avoids a stale countdown during active sc
|
|||
);
|
||||
expect(nextCheckStatus(lens, now)).toBeNull();
|
||||
});
|
||||
|
||||
it("summarizes activity across investigations, preferring a running scan over a queued one", () => {
|
||||
const withStatus = (status: Job["status"]): Lens => ({ ...lens, jobs: [{ ...job, status }] });
|
||||
expect(investigationActivity([])).toBe("idle");
|
||||
expect(investigationActivity([lens, withStatus("failed")])).toBe("idle");
|
||||
expect(investigationActivity([lens, withStatus("queued")])).toBe("queued");
|
||||
expect(investigationActivity([withStatus("queued"), withStatus("running")])).toBe("running");
|
||||
expect(investigationActivity([{ ...lens, jobs: [lens.jobs[0], { ...job, status: "running" }] }])).toBe("running");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,3 +43,12 @@ export function readiness(
|
|||
const ready = activityReady && connected && !listError;
|
||||
return { tracesReady, requestsReady, activityReady, ready };
|
||||
}
|
||||
|
||||
export type InvestigationActivity = "running" | "queued" | "idle";
|
||||
|
||||
export function investigationActivity(lenses: readonly Lens[]): InvestigationActivity {
|
||||
const statuses = new Set(lenses.flatMap((lens) => lens.jobs.map((job) => job.status)));
|
||||
if (statuses.has("running")) return "running";
|
||||
if (statuses.has("queued")) return "queued";
|
||||
return "idle";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue