Merge pull request #39930 from BerriAI/litellm_guardrail_detail_deep_links

feat(ui): deep link guardrail detail with ?guardrail= on guardrails pages
This commit is contained in:
ryan-crabbe-berri 2026-09-05 12:22:01 -07:00 committed by GitHub
commit 86e54a0454
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 213 additions and 59 deletions

View file

@ -1,36 +1,61 @@
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { type UrlUpdateEvent } from "nuqs/adapters/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import GuardrailsMonitorView from "./GuardrailsMonitorView";
import * as networking from "@/components/networking";
import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils";
vi.mock("@/components/networking", () => ({
getGuardrailsUsageOverview: vi.fn(),
getGuardrailsUsageDetail: vi.fn(),
getGuardrailsUsageLogs: vi.fn(),
formatDate: vi.fn((d: Date) => d.toISOString().slice(0, 10)),
}));
const mockGetGuardrailsUsageOverview = vi.mocked(networking.getGuardrailsUsageOverview);
vi.mock("@/components/GuardrailsMonitor/LogViewer", () => ({
LogViewer: ({ guardrailName }: { guardrailName: string }) => <div data-testid="log-viewer">{guardrailName}</div>,
}));
function wrapper({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
const mockGetGuardrailsUsageOverview = vi.mocked(networking.getGuardrailsUsageOverview);
const mockGetGuardrailsUsageDetail = vi.mocked(networking.getGuardrailsUsageDetail);
const mockGetGuardrailsUsageLogs = vi.mocked(networking.getGuardrailsUsageLogs);
const emptyOverview = { rows: [], chart: [], totalRequests: 0, totalBlocked: 0, passRate: 100 };
const piiRow = {
id: "gr-pii",
name: "PII Guard",
type: "pii",
provider: "LiteLLM",
requestsEvaluated: 10,
failRate: 10,
status: "healthy" as const,
trend: "stable" as const,
};
const piiDetail = {
guardrail_name: "PII Guard",
description: "",
status: "healthy",
provider: "LiteLLM",
type: "pii",
requestsEvaluated: 10,
failRate: 10,
avgScore: 0.5,
avgLatency: 20,
};
describe("GuardrailsMonitorView", () => {
it("should render overview and fetch guardrails usage when accessToken is provided", async () => {
mockGetGuardrailsUsageOverview.mockResolvedValue({
rows: [],
chart: [],
totalRequests: 0,
totalBlocked: 0,
passRate: 100,
});
beforeEach(() => {
testQueryClient.clear();
vi.clearAllMocks();
mockGetGuardrailsUsageOverview.mockResolvedValue(emptyOverview);
mockGetGuardrailsUsageDetail.mockResolvedValue(piiDetail);
mockGetGuardrailsUsageLogs.mockResolvedValue({ logs: [], total: 0 });
});
render(<GuardrailsMonitorView accessToken="test-token" />, { wrapper });
it("should render overview and fetch guardrails usage when accessToken is provided", async () => {
renderWithProviders(<GuardrailsMonitorView accessToken="test-token" />);
expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeInTheDocument();
await waitFor(() => {
@ -39,7 +64,54 @@ describe("GuardrailsMonitorView", () => {
});
it("should render without crashing when accessToken is null", async () => {
render(<GuardrailsMonitorView accessToken={null} />, { wrapper });
renderWithProviders(<GuardrailsMonitorView accessToken={null} />);
expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeInTheDocument();
});
describe("guardrail detail deep link (?guardrail=)", () => {
it("should open the detail view directly from a ?guardrail= deep link", async () => {
renderWithProviders(<GuardrailsMonitorView accessToken="test-token" />, { searchParams: "?guardrail=gr-pii" });
expect(await screen.findByRole("heading", { name: "PII Guard" })).toBeInTheDocument();
expect(mockGetGuardrailsUsageDetail).toHaveBeenCalledWith(
"test-token",
"gr-pii",
expect.any(String),
expect.any(String),
);
expect(screen.queryByRole("heading", { name: /Guardrails Monitor/i })).not.toBeInTheDocument();
});
it("should push ?guardrail= as a new history entry when a guardrail is selected", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
mockGetGuardrailsUsageOverview.mockResolvedValue({ ...emptyOverview, rows: [piiRow] });
renderWithProviders(<GuardrailsMonitorView accessToken="test-token" />, { onUrlUpdate });
await user.click(await screen.findByRole("button", { name: "PII Guard" }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0];
expect(lastUpdate.searchParams.get("guardrail")).toBe("gr-pii");
expect(lastUpdate.options.history).toBe("push");
expect(await screen.findByRole("heading", { name: "PII Guard" })).toBeInTheDocument();
});
it("should clear ?guardrail= by replacing history when going back to the overview", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
renderWithProviders(<GuardrailsMonitorView accessToken="test-token" />, {
searchParams: "?guardrail=gr-pii",
onUrlUpdate,
});
await user.click(await screen.findByRole("button", { name: /back to overview/i }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0];
expect(lastUpdate.searchParams.has("guardrail")).toBe(false);
expect(lastUpdate.options.history).toBe("replace");
expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeInTheDocument();
});
});
});

View file

@ -1,12 +1,11 @@
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import { parseAsString, useQueryState } from "nuqs";
import React, { useCallback, useMemo, useState } from "react";
import { formatDate } from "@/components/networking";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { GuardrailDetail } from "./GuardrailDetail";
import { GuardrailsOverview } from "./GuardrailsOverview";
type View = { type: "overview" } | { type: "detail"; guardrailId: string };
interface GuardrailsMonitorViewProps {
accessToken?: string | null;
}
@ -16,7 +15,10 @@ const defaultStart = new Date();
defaultStart.setDate(defaultStart.getDate() - 7);
export default function GuardrailsMonitorView({ accessToken = null }: GuardrailsMonitorViewProps) {
const [view, setView] = useState<View>({ type: "overview" });
const [selectedGuardrailId, setSelectedGuardrailId] = useQueryState(
"guardrail",
parseAsString.withOptions({ history: "push" }),
);
const initialFrom = useMemo(() => new Date(defaultStart), []);
const initialTo = useMemo(() => new Date(defaultEnd), []);
@ -34,11 +36,11 @@ export default function GuardrailsMonitorView({ accessToken = null }: Guardrails
}, []);
const handleSelectGuardrail = (id: string) => {
setView({ type: "detail", guardrailId: id });
void setSelectedGuardrailId(id);
};
const handleBack = () => {
setView({ type: "overview" });
void setSelectedGuardrailId(null, { history: "replace" });
};
const dateRangeControl = (
@ -47,7 +49,7 @@ export default function GuardrailsMonitorView({ accessToken = null }: Guardrails
return (
<main className="w-full min-w-0 flex-1 p-8">
{view.type === "overview" ? (
{!selectedGuardrailId ? (
<GuardrailsOverview
accessToken={accessToken}
startDate={startDate}
@ -59,7 +61,7 @@ export default function GuardrailsMonitorView({ accessToken = null }: Guardrails
<>
<div className="mb-4 flex items-center justify-end">{dateRangeControl}</div>
<GuardrailDetail
guardrailId={view.guardrailId}
guardrailId={selectedGuardrailId}
onBack={handleBack}
accessToken={accessToken}
startDate={startDate}

View file

@ -1,7 +1,8 @@
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { type UrlUpdateEvent } from "nuqs/adapters/testing";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import GuardrailsPanel from "./GuardrailsPanel";
import { getGuardrailsList, deleteGuardrailCall } from "@/components/networking";
import { fireEvent, renderWithProviders, screen, waitFor, within } from "@/../tests/test-utils";
vi.mock("@/components/networking", () => ({
getGuardrailsList: vi.fn(),
@ -15,16 +16,21 @@ vi.mock("./add_guardrail_form", () => ({
vi.mock("./guardrail_table", () => ({
__esModule: true,
default: ({ guardrailsList, onDeleteClick }: any) => (
default: ({ guardrailsList, onDeleteClick, onGuardrailClick }: any) => (
<div>
<div>Mock Guardrail Table</div>
{guardrailsList.length > 0 && (
<button
data-testid="delete-button"
onClick={() => onDeleteClick(guardrailsList[0].guardrail_id, guardrailsList[0].guardrail_name)}
>
Delete
</button>
<>
<button
data-testid="delete-button"
onClick={() => onDeleteClick(guardrailsList[0].guardrail_id, guardrailsList[0].guardrail_name)}
>
Delete
</button>
<button data-testid="open-button" onClick={() => onGuardrailClick(guardrailsList[0].guardrail_id)}>
Open
</button>
</>
)}
</div>
),
@ -32,7 +38,12 @@ vi.mock("./guardrail_table", () => ({
vi.mock("./guardrail_info", () => ({
__esModule: true,
default: () => <div>Mock Guardrail Info View</div>,
default: ({ guardrailId, onClose }: { guardrailId: string; onClose: () => void }) => (
<div>
<div data-testid="guardrail-info-view">Mock Guardrail Info View {guardrailId}</div>
<button onClick={onClose}>Close Guardrail Info</button>
</div>
),
}));
vi.mock("./GuardrailTestPlayground", async () => {
@ -112,7 +123,7 @@ describe("GuardrailsPanel", () => {
});
it("should render the component", async () => {
render(<GuardrailsPanel {...defaultProps} />);
renderWithProviders(<GuardrailsPanel {...defaultProps} />);
expect(screen.getByText("Guardrails")).toBeInTheDocument();
// Activate the Guardrails tab so its content (including the Add button) is rendered
fireEvent.click(screen.getByText("Guardrails"));
@ -120,7 +131,7 @@ describe("GuardrailsPanel", () => {
});
it("should delete the clicked guardrail after confirming in the modal", async () => {
render(<GuardrailsPanel {...defaultProps} />);
renderWithProviders(<GuardrailsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("Guardrails"));
fireEvent.click(await screen.findByTestId("delete-button"));
@ -139,14 +150,14 @@ describe("GuardrailsPanel", () => {
});
it("should mount every tab panel up front so panel state survives tab switches", async () => {
render(<GuardrailsPanel {...defaultProps} />);
renderWithProviders(<GuardrailsPanel {...defaultProps} />);
expect(await screen.findByLabelText("playground draft")).toBeInTheDocument();
expect(screen.getByText("Mock Team Guardrails Tab")).toBeInTheDocument();
});
it("should keep test playground state when switching tabs away and back", async () => {
render(<GuardrailsPanel {...defaultProps} />);
renderWithProviders(<GuardrailsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("Test Playground"));
@ -161,7 +172,7 @@ describe("GuardrailsPanel", () => {
});
it("should not delete anything when the modal is cancelled", async () => {
render(<GuardrailsPanel {...defaultProps} />);
renderWithProviders(<GuardrailsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("Guardrails"));
fireEvent.click(await screen.findByTestId("delete-button"));
@ -171,4 +182,42 @@ describe("GuardrailsPanel", () => {
expect(mockDeleteGuardrailCall).not.toHaveBeenCalled();
});
describe("guardrail detail deep link (?guardrail=)", () => {
it("should open the guardrail info view directly from a ?guardrail= deep link", async () => {
renderWithProviders(<GuardrailsPanel {...defaultProps} />, { searchParams: "?guardrail=test-guardrail-1" });
expect(await screen.findByTestId("guardrail-info-view")).toHaveTextContent("test-guardrail-1");
expect(screen.queryByText("Mock Guardrail Table")).not.toBeInTheDocument();
});
it("should push ?guardrail= as a new history entry when a guardrail row is clicked", async () => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
renderWithProviders(<GuardrailsPanel {...defaultProps} />, { onUrlUpdate });
fireEvent.click(await screen.findByTestId("open-button"));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0];
expect(lastUpdate.searchParams.get("guardrail")).toBe("test-guardrail-1");
expect(lastUpdate.options.history).toBe("push");
expect(await screen.findByTestId("guardrail-info-view")).toHaveTextContent("test-guardrail-1");
});
it("should clear ?guardrail= by replacing history when the info view is closed", async () => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
renderWithProviders(<GuardrailsPanel {...defaultProps} />, {
searchParams: "?guardrail=test-guardrail-1",
onUrlUpdate,
});
fireEvent.click(await screen.findByRole("button", { name: "Close Guardrail Info" }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0];
expect(lastUpdate.searchParams.has("guardrail")).toBe(false);
expect(lastUpdate.options.history).toBe("replace");
expect(await screen.findByText("Mock Guardrail Table")).toBeInTheDocument();
});
});
});

View file

@ -1,3 +1,4 @@
import { parseAsString, useQueryState } from "nuqs";
import React, { useState, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ChevronDown, Code, Plus } from "lucide-react";
@ -40,7 +41,10 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
const [isDeleting, setIsDeleting] = useState(false);
const [guardrailToDelete, setGuardrailToDelete] = useState<Guardrail | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedGuardrailId, setSelectedGuardrailId] = useState<string | null>(null);
const [selectedGuardrailId, setSelectedGuardrailId] = useQueryState(
"guardrail",
parseAsString.withOptions({ history: "push" }),
);
const isAdmin = userRole ? isAdminRole(userRole) : false;
const fetchGuardrails = async () => {
@ -63,16 +67,20 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
fetchGuardrails();
}, [accessToken]);
const closeGuardrailDetail = () => {
void setSelectedGuardrailId(null, { history: "replace" });
};
const handleAddGuardrail = () => {
if (selectedGuardrailId) {
setSelectedGuardrailId(null);
closeGuardrailDetail();
}
setIsAddModalVisible(true);
};
const handleAddCustomCodeGuardrail = () => {
if (selectedGuardrailId) {
setSelectedGuardrailId(null);
closeGuardrailDetail();
}
setIsCustomCodeModalVisible(true);
};
@ -175,7 +183,7 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
{selectedGuardrailId ? (
<GuardrailInfoView
guardrailId={selectedGuardrailId}
onClose={() => setSelectedGuardrailId(null)}
onClose={closeGuardrailDetail}
accessToken={accessToken}
isAdmin={isAdmin}
/>
@ -184,7 +192,7 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
guardrailsList={guardrailsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
onGuardrailClick={(id) => setSelectedGuardrailId(id)}
onGuardrailClick={(id) => void setSelectedGuardrailId(id)}
/>
)}

View file

@ -338,3 +338,27 @@ describe("Guardrail Info", () => {
expect(screen.getByText("Guardrail Settings")).toBeInTheDocument();
});
});
describe("Guardrail Info when the guardrail cannot be loaded", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should keep Back to Guardrails reachable so a stale ?guardrail= link is not a dead end", async () => {
vi.mocked(networking.getGuardrailInfo).mockRejectedValue(new Error("Guardrail stale-id not found"));
vi.mocked(networking.getGuardrailUISettings).mockResolvedValue({
supported_entities: [],
supported_actions: [],
pii_entity_categories: [],
supported_modes: [],
});
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
const onClose = vi.fn();
render(<GuardrailInfoView guardrailId="stale-id" onClose={onClose} accessToken="123" isAdmin={true} />);
expect(await screen.findByText("Guardrail not found")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /back to guardrails/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -481,16 +481,18 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
return <div className="p-4">Loading...</div>;
}
const backButton = (
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeft className="w-4 h-4" />
Back to Guardrails
</Button>
);
if (!guardrailData) {
return <div className="p-4">Guardrail not found</div>;
return <div className="p-4">{backButton}Guardrail not found</div>;
}
// Format date helper function
const formatDate = (dateString?: string) => {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleString();
};
const formatDate = (dateString?: string) => (dateString ? new Date(dateString).toLocaleString() : "-");
// Format the provider display name and logo
const { logo, displayName } = getGuardrailLogoAndName(guardrailData.litellm_params?.guardrail || "");
@ -510,10 +512,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
return (
<div className="p-4">
<div>
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeft className="w-4 h-4" />
Back to Guardrails
</Button>
{backButton}
<h1 className="text-2xl font-semibold">{guardrailData.guardrail_name || "Unnamed Guardrail"}</h1>
<div className="flex items-center cursor-pointer">
<p className="text-muted-foreground font-mono">{guardrailData.guardrail_id}</p>