Merge pull request #38306 from BerriAI/litellm_/page-header-spec-rollout-46f0dd

refactor(ui): move every page header onto the shared PageHeader
This commit is contained in:
yuneng-jiang 2026-08-26 13:42:56 -07:00 committed by GitHub
commit 1a431687f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 196 additions and 226 deletions

View file

@ -99,6 +99,7 @@ describe("AccessGroupsPage", () => {
renderWithProviders(<AccessGroupsPage />);
expect(screen.getByRole("heading", { name: "Access Groups" })).toBeInTheDocument();
expect(screen.getByText("Manage resource permissions for your organization")).toBeInTheDocument();
expect(document.querySelector(".lucide-boxes")).not.toBeNull();
});
it("shows the Create Access Group button for an admin", () => {

View file

@ -1,9 +1,9 @@
import { AccessGroupResponse, useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup";
import { Plus, SearchIcon, X } from "lucide-react";
import { Boxes, Plus, SearchIcon, X } from "lucide-react";
import { useMemo, useState } from "react";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import { LegacyPageHeader } from "@/components/shared/LegacyPageHeader";
import { PageHeader } from "@/components/shared/PageHeader";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
@ -59,23 +59,22 @@ export function AccessGroupsPage() {
}
return (
<div className="p-6 px-12">
<div className="mb-4">
<LegacyPageHeader
title="Access Groups"
subtitle="Manage resource permissions for your organization"
actions={
canModify ? (
<Button onClick={() => setIsCreateModalVisible(true)}>
<Plus className="size-4" />
Create Access Group
</Button>
) : undefined
}
/>
</div>
<div className="p-8">
<PageHeader
icon={<Boxes />}
title="Access Groups"
subtitle="Manage resource permissions for your organization"
primaryAction={
canModify ? (
<Button onClick={() => setIsCreateModalVisible(true)}>
<Plus className="size-4" />
Create Access Group
</Button>
) : undefined
}
/>
<div className="mb-3 flex items-center">
<div className="mt-6 mb-3 flex items-center">
<InputGroup className="max-w-[400px]">
<InputGroupAddon>
<SearchIcon className="size-4 text-muted-foreground" />

View file

@ -85,6 +85,14 @@ describe("Budget Panel", () => {
respondWith(DEFAULT_ROWS, 1);
});
it("renders the standard page header with the sidebar's Budgets icon", async () => {
const { container } = renderPanel();
expect(await screen.findByRole("heading", { level: 1, name: "Budgets" })).toBeInTheDocument();
expect(screen.getByText("Spend, TPM and RPM limits you can assign to customers.")).toBeInTheDocument();
expect(container.querySelector(".lucide-wallet")).not.toBeNull();
});
it("loads the first page of budgets, newest first", async () => {
renderPanel();
await waitFor(() => expect(getMock).toHaveBeenCalled());

View file

@ -9,8 +9,7 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { prism } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import { LegacyPageHeader } from "@/components/shared/LegacyPageHeader";
import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
import { PageHeader } from "@/components/shared/PageHeader";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
@ -79,34 +78,37 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
};
return (
<div className="flex h-full flex-col gap-4 p-6 px-12">
<LegacyPageHeader
icon={<Wallet className="size-5" />}
title="Budgets"
subtitle="Spend, TPM and RPM limits you can assign to customers."
/>
<Tabs defaultValue="budgets" className="min-h-0 flex-1 gap-0">
<div className="flex items-center gap-4 border-b border-border">
{canModify && (
<>
<main className="flex h-full flex-col p-8">
<Tabs defaultValue="budgets" className="min-h-0 flex-1 gap-6">
<PageHeader
icon={<Wallet />}
title="Budgets"
subtitle="Spend, TPM and RPM limits you can assign to customers."
primaryAction={
canModify ? (
<Button onClick={() => setIsCreateModelVisible(true)}>
<Plus className="size-4" />
Create Budget
</Button>
<ToolbarSeparator className="h-6" />
</>
) : undefined
}
tabs={({ leadingControls }) => (
<TabsList
variant="line"
className="gap-0 p-0 [&>[data-slot=tabs-trigger]+[data-slot=tabs-trigger]]:ml-[22px]"
>
{leadingControls}
<TabsTrigger value="budgets" className="flex-none px-0 py-[7px] data-active:font-semibold">
Budgets
</TabsTrigger>
<TabsTrigger value="examples" className="flex-none px-0 py-[7px] data-active:font-semibold">
Examples
</TabsTrigger>
</TabsList>
)}
<TabsList variant="line">
<TabsTrigger value="budgets" className="flex-none px-4">
Budgets
</TabsTrigger>
<TabsTrigger value="examples" className="flex-none px-4">
Examples
</TabsTrigger>
</TabsList>
</div>
/>
<TabsContent value="budgets" className="flex min-h-0 flex-1 flex-col" keepMounted>
<div className="flex min-h-0 flex-1 flex-col pt-6">
<div className="flex min-h-0 flex-1 flex-col">
<BudgetModal isModalVisible={isCreateModelVisible} setIsModalVisible={setIsCreateModelVisible} />
{selectedBudget && (
<EditBudgetModal
@ -172,7 +174,7 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
</div>
</TabsContent>
</Tabs>
</div>
</main>
);
};

View file

@ -44,6 +44,14 @@ describe("CostOptimizationView", () => {
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "Admin" });
});
it("renders the standard page header with the sidebar's Cost Optimization icon", () => {
const { container, getByRole, getByText } = renderView();
expect(getByRole("heading", { level: 1, name: "Cost Optimization" })).toBeInTheDocument();
expect(getByText(/Track and configure the mechanisms that save you money/)).toBeInTheDocument();
expect(container.querySelector(".lucide-piggy-bank")).not.toBeNull();
});
it("renders the four cost-optimization tabs", () => {
const { getByText } = renderView();

View file

@ -6,6 +6,7 @@ import { Info, PiggyBank } from "lucide-react";
import useCan from "@/app/(dashboard)/hooks/useCan";
import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageHeader } from "@/components/shared/PageHeader";
import UsageTab from "./UsageTab";
import PromptCompressionTab from "./PromptCompressionTab";
import PromptCachingTab from "./PromptCachingTab";
@ -32,63 +33,63 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
};
return (
<div className="w-full space-y-6 p-6">
<div>
<div className="flex items-center gap-2">
<PiggyBank className="size-6 text-primary" strokeWidth={1.75} />
<h1 className="text-xl font-semibold text-foreground">Cost Optimization</h1>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Track and configure the mechanisms that save you money: prompt compression and prompt caching. Auto routers
live under Models + Endpoints, on the Auto-Routers tab
</p>
</div>
<div
role="alert"
className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-lg border border-border bg-muted/50 px-4 py-4"
>
<Info className="mt-0.5 size-5 text-primary" aria-hidden="true" />
<p className="font-medium text-foreground">This is an experimental dashboard</p>
<p className="col-start-2 text-sm text-muted-foreground">
Have feedback? Join the discussion{" "}
<a
href="https://github.com/BerriAI/litellm/discussions/32168"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
here
</a>
</p>
</div>
<PaginationStatusAlerts
isFetchingMore={activity.isFetchingMore}
cancelled={activity.cancelled}
progress={activity.progress}
cancel={activity.cancel}
/>
<Tabs defaultValue="usage" onValueChange={handleTabChange}>
<TabsList variant="line" className="h-auto w-full justify-start rounded-none p-0">
<TabsTrigger value="usage" className="flex-none rounded-none px-4 py-2">
Overall
</TabsTrigger>
{canViewProxyWideCostData && (
<>
<TabsTrigger value="compression" className="flex-none rounded-none px-4 py-2">
Prompt Compression
<main className="w-full p-8">
<Tabs defaultValue="usage" onValueChange={handleTabChange} className="gap-6">
<PageHeader
icon={<PiggyBank />}
title="Cost Optimization"
subtitle="Track and configure the mechanisms that save you money: prompt compression and prompt caching. Auto routers live under Models + Endpoints, on the Auto-Routers tab"
tabs={({ leadingControls }) => (
<TabsList
variant="line"
className="gap-0 p-0 [&>[data-slot=tabs-trigger]+[data-slot=tabs-trigger]]:ml-[22px]"
>
{leadingControls}
<TabsTrigger value="usage" className="flex-none px-0 py-[7px] data-active:font-semibold">
Overall
</TabsTrigger>
<TabsTrigger value="caching" className="flex-none rounded-none px-4 py-2">
Prompt Caching
</TabsTrigger>
<TabsTrigger value="autorouter-usage" className="flex-none rounded-none px-4 py-2">
Auto-Router
</TabsTrigger>
</>
{canViewProxyWideCostData && (
<>
<TabsTrigger value="compression" className="flex-none px-0 py-[7px] data-active:font-semibold">
Prompt Compression
</TabsTrigger>
<TabsTrigger value="caching" className="flex-none px-0 py-[7px] data-active:font-semibold">
Prompt Caching
</TabsTrigger>
<TabsTrigger value="autorouter-usage" className="flex-none px-0 py-[7px] data-active:font-semibold">
Auto-Router
</TabsTrigger>
</>
)}
</TabsList>
)}
</TabsList>
/>
<div
role="alert"
className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-lg border border-border bg-muted/50 px-4 py-4"
>
<Info className="mt-0.5 size-5 text-primary" aria-hidden="true" />
<p className="font-medium text-foreground">This is an experimental dashboard</p>
<p className="col-start-2 text-sm text-muted-foreground">
Have feedback? Join the discussion{" "}
<a
href="https://github.com/BerriAI/litellm/discussions/32168"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
here
</a>
</p>
</div>
<PaginationStatusAlerts
isFetchingMore={activity.isFetchingMore}
cancelled={activity.cancelled}
progress={activity.progress}
cancel={activity.cancel}
/>
<TabsContent value="usage" keepMounted={visitedTabs.includes("usage")}>
<UsageTab accessToken={accessToken} activity={activity} />
</TabsContent>
@ -106,7 +107,7 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
</>
)}
</Tabs>
</div>
</main>
);
};

View file

@ -41,27 +41,32 @@ export default function GuardrailsMonitorView({ accessToken = null }: Guardrails
setView({ type: "overview" });
};
const dateRangeControl = (
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} label="" showTimeRange={false} />
);
return (
<div className="p-6 w-full min-w-0 flex-1">
<div className="flex items-center justify-end mb-4">
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} label="" showTimeRange={false} />
</div>
<main className="w-full min-w-0 flex-1 p-8">
{view.type === "overview" ? (
<GuardrailsOverview
accessToken={accessToken}
startDate={startDate}
endDate={endDate}
onSelectGuardrail={handleSelectGuardrail}
dateRangeControl={dateRangeControl}
/>
) : (
<GuardrailDetail
guardrailId={view.guardrailId}
onBack={handleBack}
accessToken={accessToken}
startDate={startDate}
endDate={endDate}
/>
<>
<div className="mb-4 flex items-center justify-end">{dateRangeControl}</div>
<GuardrailDetail
guardrailId={view.guardrailId}
onBack={handleBack}
accessToken={accessToken}
startDate={startDate}
endDate={endDate}
/>
</>
)}
</div>
</main>
);
}

View file

@ -110,6 +110,7 @@ describe("GuardrailsOverview", () => {
expect(await screen.findByRole("heading", { name: "Guardrails Monitor", level: 1 })).toBeInTheDocument();
expect(screen.getByText("Monitor guardrail performance across all requests")).toBeInTheDocument();
expect(document.querySelector(".lucide-heart-pulse")).not.toBeNull();
expect(screen.getByRole("button", { name: /Export Data/i })).toBeInTheDocument();
});

View file

@ -1,11 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import type { ColumnDef, OnChangeFn, SortingState } from "@tanstack/react-table";
import { Download, Settings, Shield, TrendingUp, TriangleAlert } from "lucide-react";
import { Download, HeartPulse, Settings, TrendingUp, TriangleAlert } from "lucide-react";
import React, { useMemo, useState } from "react";
import { DataTable, DataTableSortHeader } from "@/components/shared/DataTable";
import { getGuardrailsUsageOverview } from "@/components/networking";
import { type PerformanceRow } from "@/components/GuardrailsMonitor/mockData";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/shared/PageHeader";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { EvaluationSettingsModal } from "./EvaluationSettingsModal";
import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard";
@ -16,6 +17,7 @@ interface GuardrailsOverviewProps {
startDate: string;
endDate: string;
onSelectGuardrail: (id: string) => void;
dateRangeControl?: React.ReactNode;
}
type SortKey = "failRate" | "requestsEvaluated" | "avgLatency" | "falsePositiveRate" | "falseNegativeRate";
@ -43,6 +45,7 @@ export function GuardrailsOverview({
startDate,
endDate,
onSelectGuardrail,
dateRangeControl,
}: GuardrailsOverviewProps) {
const [sortBy, setSortBy] = useState<SortKey>("failRate");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
@ -197,23 +200,22 @@ export function GuardrailsOverview({
return (
<div>
<div className="flex items-start justify-between mb-5">
<div>
<div className="flex items-center gap-2 mb-1">
<Shield className="size-5 text-indigo-500" />
<h1 className="text-xl font-semibold text-foreground">Guardrails Monitor</h1>
</div>
<p className="text-sm text-muted-foreground">Monitor guardrail performance across all requests</p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" title="Coming soon">
<Download className="size-4" />
Export Data
</Button>
</div>
</div>
<PageHeader
icon={<HeartPulse />}
title="Guardrails Monitor"
subtitle="Monitor guardrail performance across all requests"
utilities={
<>
{dateRangeControl}
<Button variant="outline" title="Coming soon">
<Download className="size-4" />
Export Data
</Button>
</>
}
/>
<div className="mb-6 grid grid-cols-[repeat(auto-fit,minmax(7rem,1fr))] gap-4">
<div className="mt-6 mb-6 grid grid-cols-[repeat(auto-fit,minmax(7rem,1fr))] gap-4">
<MetricCard label="Total Evaluations" value={metrics.totalRequests.toLocaleString()} />
<MetricCard
label="Blocked Requests"

View file

@ -82,6 +82,8 @@ describe("ProjectsPage", () => {
mockUseProjects.mockReturnValue({ data: [], isLoading: false });
renderWithProviders(<ProjectsPage />);
expect(screen.getByRole("heading", { name: /projects/i })).toBeInTheDocument();
expect(screen.getByText("Manage projects within your teams")).toBeInTheDocument();
expect(document.querySelector(".lucide-folder")).not.toBeNull();
});
it("should show a 'Create Project' button", () => {

View file

@ -1,9 +1,9 @@
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Plus, SearchIcon, X } from "lucide-react";
import { Folder, Plus, SearchIcon, X } from "lucide-react";
import { parseAsString, useQueryState } from "nuqs";
import { useMemo, useState } from "react";
import { LegacyPageHeader } from "@/components/shared/LegacyPageHeader";
import { PageHeader } from "@/components/shared/PageHeader";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { CreateProjectModal } from "./ProjectModals/CreateProjectModal";
@ -54,21 +54,20 @@ export function ProjectsPage() {
}
return (
<div className="p-6 px-12">
<div className="mb-4">
<LegacyPageHeader
title="Projects"
subtitle="Manage projects within your teams"
actions={
<Button onClick={() => setIsCreateModalVisible(true)}>
<Plus className="size-4" />
Create Project
</Button>
}
/>
</div>
<div className="p-8">
<PageHeader
icon={<Folder />}
title="Projects"
subtitle="Manage projects within your teams"
primaryAction={
<Button onClick={() => setIsCreateModalVisible(true)}>
<Plus className="size-4" />
Create Project
</Button>
}
/>
<div className="mb-3 flex items-center">
<div className="mt-6 mb-3 flex items-center">
<InputGroup className="max-w-[400px]">
<InputGroupAddon>
<SearchIcon className="size-4 text-muted-foreground" />

View file

@ -207,6 +207,8 @@ it("left-anchors the create-key CTA below the title, between the header and the
renderWithProviders(<VirtualKeysTable headerActions={<button>Create New Key</button>} />);
const heading = screen.getByRole("heading", { name: "Virtual Keys" });
expect(screen.getByText("Every key that authenticates requests to the gateway.")).toBeInTheDocument();
expect(document.querySelector(".lucide-key-round")).not.toBeNull();
const ctas = screen.getAllByRole("button", { name: "Create New Key" });
expect(ctas).toHaveLength(1);
const cta = ctas[0];

View file

@ -12,7 +12,7 @@ import {
DataTableToolbar,
} from "@/components/shared/DataTable";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { LegacyPageHeader } from "@/components/shared/LegacyPageHeader";
import { PageHeader } from "@/components/shared/PageHeader";
import { Input } from "@/components/ui/input";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
@ -182,13 +182,13 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
}
return (
<div className="flex h-full flex-col gap-4 overflow-hidden py-2">
<LegacyPageHeader
icon={<KeyRound className="size-5" />}
<div className="flex h-full flex-col gap-6 overflow-hidden">
<PageHeader
icon={<KeyRound />}
title="Virtual Keys"
subtitle="Every key that authenticates requests to the gateway."
primaryAction={headerActions}
/>
{headerActions}
<DataTable
data={keyList}
columns={columns}

View file

@ -1,33 +0,0 @@
import { renderWithProviders, screen } from "@/../tests/test-utils";
import { describe, expect, it } from "vitest";
import { LegacyPageHeader } from "./LegacyPageHeader";
describe("LegacyPageHeader", () => {
it("should render the title as a heading", () => {
renderWithProviders(<LegacyPageHeader title="Virtual Keys" />);
expect(screen.getByRole("heading", { name: "Virtual Keys" })).toBeInTheDocument();
});
it("should render the optional identity and actions", () => {
renderWithProviders(
<LegacyPageHeader
title="Virtual Keys"
subtitle="Every key that authenticates requests"
icon={<span>Key icon</span>}
actions={<button>Create New Key</button>}
/>,
);
expect(screen.getByText("Every key that authenticates requests")).toBeInTheDocument();
expect(screen.getByText("Key icon")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create New Key" })).toBeInTheDocument();
});
it("should omit optional actions when none are provided", () => {
renderWithProviders(<LegacyPageHeader title="Virtual Keys" />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});

View file

@ -1,25 +0,0 @@
"use client";
import * as React from "react";
interface LegacyPageHeaderProps {
title: React.ReactNode;
subtitle?: React.ReactNode;
icon?: React.ReactNode;
actions?: React.ReactNode;
}
export function LegacyPageHeader({ title, subtitle, icon, actions }: LegacyPageHeaderProps) {
return (
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-2.5">
{icon != null && <span className="flex flex-none items-center text-foreground">{icon}</span>}
<div className="min-w-0">
<h1 className="text-xl font-semibold tracking-tight text-foreground">{title}</h1>
{subtitle != null && <p className="mt-0.5 text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div>
{actions != null && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}

View file

@ -216,27 +216,25 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
const canCreateKey = userRole !== "Admin Viewer" && userRole !== "proxy_admin_viewer";
return (
<div className="mx-4 h-[75vh]">
<div className="grid grid-cols-1 gap-2 p-8 w-full mt-2">
<div className="col-span-1 flex flex-col gap-2">
<VirtualKeysTable
headerActions={
canCreateKey ? (
<CreateKey
key={selectedTeam ? selectedTeam.team_id : null}
team={selectedTeam as Team | null}
teams={teams as Team[]}
data={keys}
addKey={addKey}
autoOpenCreate={autoOpenCreate}
prefillData={prefillData}
/>
) : undefined
}
/>
</div>
<main className="h-[75vh] p-8">
<div className="flex h-full flex-col">
<VirtualKeysTable
headerActions={
canCreateKey ? (
<CreateKey
key={selectedTeam ? selectedTeam.team_id : null}
team={selectedTeam as Team | null}
teams={teams as Team[]}
data={keys}
addKey={addKey}
autoOpenCreate={autoOpenCreate}
prefillData={prefillData}
/>
) : undefined
}
/>
</div>
</div>
</main>
);
};