mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
feat(ui): deep link guardrail detail with ?guardrail= on guardrails pages
The Guardrails and Guardrails Monitor pages kept the selected guardrail in local React state, so the detail view could not be shared, reloaded, or reached with the browser back button. Both pages now read and write the selection through the nuqs `guardrail` query param, matching how the keys, teams, orgs, projects, users, models and logs pages deep link their detail views. Opening a guardrail pushes a history entry and closing it replaces the entry so back returns to the page the user came from
This commit is contained in:
parent
7672399c26
commit
86038318ce
4 changed files with 179 additions and 48 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue