From 4b9f9903f33310a416a5ae6ff79a4cdbd66a06e7 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:35:34 +0000 Subject: [PATCH] refactor(ui): compose dashboard pages with shared layouts (#44306) * refactor(ui): compose logs tabs directly in the route * feat(ui): share composable dashboard page layouts * refactor(ui): compose page header and logs toolbar from parts PageHeader drops its icon/title/subtitle/primaryAction/tabs/utilities props and the leadingControls render prop in favor of PageHeaderTitle, PageHeaderDescription and PageHeaderControls that each wrap one element and forward native props. LogsTableToolbar's 15 props collapse into one LogsTimeRange value plus composable LogsToolbar, LogsTimeRangePicker and LogsToolbarSwitch parts assembled in the panel. Co-Authored-By: Claude Opus 5.5 * refactor(ui): express DataTable layout classes as cva variants Replaces the hand-rolled class-pair constants with boolean cva variants, which also brings DataTable back under the complexity budget. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Yujong Lee Co-authored-by: Claude Opus 5.5 --- tests/e2e/ui/tests/logs/logs.spec.ts | 69 +++++++++ .../_components/AccessGroupsPage.tsx | 81 ++++++----- .../(dashboard)/api-keys/ApiKeysDashboard.tsx | 5 +- .../budgets/_components/budget_panel.tsx | 61 ++++---- .../_components/CostOptimizationView.tsx | 56 ++++---- .../_components/GuardrailsMonitorView.tsx | 5 +- .../_components/GuardrailsOverview.tsx | 30 ++-- .../src/app/(dashboard)/layout.tsx | 2 +- .../logs/page.integration.test.tsx} | 18 +-- .../(dashboard)/logs/page.test.tsx} | 28 ++-- .../src/app/(dashboard)/logs/page.tsx | 75 ++++++++-- .../_components/ModelInsightsView.tsx | 21 +-- .../projects/_components/ProjectsPage.tsx | 77 +++++----- .../_components/ROICalculatorView.tsx | 53 +++---- .../DeletedKeysPage/DeletedKeysPage.tsx | 7 +- .../DeletedKeysTable/DeletedKeysTable.tsx | 1 + .../DeletedTeamsPage/DeletedTeamsPage.tsx | 7 +- .../DeletedTeamsTable/DeletedTeamsTable.tsx | 1 + .../src/components/Teams.test.tsx | 10 -- ui/litellm-dashboard/src/components/Teams.tsx | 63 ++++---- .../VirtualKeysPage/VirtualKeysTable.tsx | 21 +-- .../shared/DataTable/DataTable.test.tsx | 22 --- .../components/shared/DataTable/DataTable.tsx | 94 +++++++----- .../src/components/shared/Page.tsx | 40 ++++++ .../src/components/shared/PageHeader.test.tsx | 99 +++++-------- .../src/components/shared/PageHeader.tsx | 78 ++++------ .../components/view_logs/AuditLogsPanel.tsx | 6 +- .../components/view_logs/AuditLogsTable.tsx | 1 + .../components/view_logs/LogsTableToolbar.tsx | 135 +++++++----------- .../view_logs/RequestLogsPanel.test.tsx | 8 +- .../components/view_logs/RequestLogsPanel.tsx | 68 ++++----- .../src/components/view_logs/index.tsx | 105 -------------- 32 files changed, 661 insertions(+), 686 deletions(-) rename ui/litellm-dashboard/src/{components/view_logs/index.integration.test.tsx => app/(dashboard)/logs/page.integration.test.tsx} (90%) rename ui/litellm-dashboard/src/{components/view_logs/index.test.tsx => app/(dashboard)/logs/page.test.tsx} (88%) create mode 100644 ui/litellm-dashboard/src/components/shared/Page.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/index.tsx diff --git a/tests/e2e/ui/tests/logs/logs.spec.ts b/tests/e2e/ui/tests/logs/logs.spec.ts index 60b547ccda0..688bfe8e3b4 100644 --- a/tests/e2e/ui/tests/logs/logs.spec.ts +++ b/tests/e2e/ui/tests/logs/logs.spec.ts @@ -54,6 +54,75 @@ test.describe("Logs page", () => { permissions: ["clipboard-read", "clipboard-write"], }); + test("log tables fill the available height and empty requests stay centered after resizing", async ({ + page, + }) => { + await navigateToPage(page, Page.Logs); + await dismissFeedbackPopup(page); + await visibleTestId(page, "datatable-search").fill( + `missing-request-${uniqueSuffix()}`, + ); + const emptyTitle = page.getByText("No matching requests", { exact: true }); + await expect(emptyTitle).toBeVisible(); + + for (const viewport of [ + { width: 1440, height: 900 }, + { width: 1024, height: 720 }, + ]) { + await page.setViewportSize(viewport); + await expect + .poll(async () => { + const frame = await visibleTestId( + page, + "data-table-frame", + ).boundingBox(); + return frame + ? Math.abs(viewport.height - frame.y - frame.height - 24) + : Infinity; + }) + .toBeLessThanOrEqual(2); + await expect + .poll(async () => { + const body = await page + .locator("table") + .filter({ visible: true }) + .first() + .locator("tbody") + .boundingBox(); + const scroller = await visibleTestId( + page, + "data-table-scroller", + ).boundingBox(); + const message = await emptyTitle.locator("..").boundingBox(); + if (!body || !message || !scroller) return Infinity; + return Math.max( + Math.abs( + message.x + message.width / 2 - scroller.x - scroller.width / 2, + ), + Math.abs(message.y + message.height / 2 - body.y - body.height / 2), + ); + }) + .toBeLessThanOrEqual(4); + for (const tab of ["Deleted Keys", "Deleted Teams"]) { + await page.getByRole("tab", { name: tab, exact: true }).click(); + await expect + .poll(async () => { + const frame = await visibleTestId( + page, + "data-table-frame", + ).boundingBox(); + return frame + ? Math.abs(viewport.height - frame.y - frame.height - 24) + : Infinity; + }) + .toBeLessThanOrEqual(2); + } + await page + .getByRole("tab", { name: "Request Logs", exact: true }) + .click(); + } + }); + test("a chat sent from the Playground lands in Logs with its content", async ({ page, request }) => { const prompt = `logs-playground-prompt-${uniqueSuffix()}`; await openPlayground(page); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx index 2e82fe3c418..ac40a28a258 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx @@ -1,9 +1,10 @@ +import { Page, PageContent } from "@/components/shared/Page"; import { AccessGroupResponse, useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup"; import { Boxes, Plus, SearchIcon, X } from "lucide-react"; import { useMemo, useState } from "react"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; -import { PageHeader } from "@/components/shared/PageHeader"; +import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } 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,49 +60,53 @@ export function AccessGroupsPage() { } return ( -
- } - title="Access Groups" - subtitle="Manage resource permissions for your organization" - primaryAction={ - canModify ? ( + + + + + Access Groups + + Manage resource permissions for your organization + {canModify && ( + - ) : undefined - } - /> + + )} + -
- - - - - setSearchText(e.target.value)} - /> - {searchText && ( - - setSearchText("")}> - - + +
+ + + - )} - -
+ setSearchText(e.target.value)} + /> + {searchText && ( + + setSearchText("")}> + + + + )} +
+
- 0} - canModify={canModify} - onGroupClick={setSelectedGroupId} - onDeleteClick={setGroupToDelete} - /> + 0} + canModify={canModify} + onGroupClick={setSelectedGroupId} + onDeleteClick={setGroupToDelete} + /> + @@ -126,6 +131,6 @@ export function AccessGroupsPage() { }} confirmLoading={deleteMutation.isPending} /> -
+ ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx index 376fee72b88..915df2f5ded 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx @@ -1,5 +1,6 @@ "use client"; +import { Page } from "@/components/shared/Page"; import { teamListCall as v2TeamListCall } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { KeyResponse, Team } from "@/components/key_team_helpers/key_list"; @@ -71,7 +72,7 @@ export default function ApiKeysDashboard() { }, [accessToken, userID, userRole]); return ( -
+ -
+ ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx index 7455c252e26..630d90e91b9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx @@ -3,13 +3,15 @@ * */ +import { Page, PageTabs, PageTabsList, PageTabsTrigger } from "@/components/shared/Page"; import { Plus, Wallet } from "lucide-react"; import React, { useCallback, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { prism } from "react-syntax-highlighter/dist/esm/styles/prism"; import { useSyntaxTheme } from "@/hooks/useSyntaxTheme"; -import { PageHeader } from "@/components/shared/PageHeader"; +import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader"; +import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; @@ -78,35 +80,30 @@ const BudgetPanel: React.FC = ({ accessToken }) => { }; return ( -
- - } - title="Budgets" - subtitle="Spend, TPM and RPM limits you can assign to customers." - primaryAction={ - canModify ? ( - - ) : undefined - } - tabs={({ leadingControls }) => ( - - {leadingControls} - - Budgets - - - Examples - - - )} - /> + + + + + + Budgets + + Spend, TPM and RPM limits you can assign to customers. + + + {canModify && ( + <> + + + + )} + Budgets + Examples + + +
@@ -174,8 +171,8 @@ const BudgetPanel: React.FC = ({ accessToken }) => {
-
-
+ + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx index 0aa4f88495a..6a4c49963df 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx @@ -1,12 +1,13 @@ "use client"; +import { Page, PageTabs, PageTabsList, PageTabsTrigger } from "@/components/shared/Page"; import React from "react"; import { Info, PiggyBank } from "lucide-react"; import useCan from "@/app/(dashboard)/hooks/useCan"; import { Alert, AlertDescription } from "@/components/shared/Alert"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { PageHeader } from "@/components/shared/PageHeader"; +import { TabsContent } from "@/components/ui/tabs"; +import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader"; import UsageTab from "./UsageTab"; import PromptCompressionTab from "./PromptCompressionTab"; import PromptCachingTab from "./PromptCachingTab"; @@ -33,37 +34,30 @@ const CostOptimizationView: React.FC = ({ accessToken }; return ( -
- - } - 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 }) => ( - - {leadingControls} - - Overall - + + + + + + Cost Optimization + + + 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 + + + + Overall {canViewProxyWideCostData && ( <> - - Prompt Compression - - - Prompt Caching - - - Auto-Router - + Prompt Compression + Prompt Caching + Auto-Router )} - - )} - /> + + +
= ({ accessToken )} - -
+ + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx index f90a46e19e4..1dd6686d7fe 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx @@ -1,3 +1,4 @@ +import { Page } from "@/components/shared/Page"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import { parseAsString, useQueryState } from "nuqs"; import React, { useCallback, useMemo, useState } from "react"; @@ -48,7 +49,7 @@ export default function GuardrailsMonitorView({ accessToken = null }: Guardrails ); return ( -
+ {!selectedGuardrailId ? ( )} -
+ ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx index 468e6967d81..5627e7fc3cb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx @@ -18,7 +18,7 @@ import { type UsageUnits, } from "@/components/GuardrailsMonitor/usageUnits"; import { Button } from "@/components/ui/button"; -import { PageHeader } from "@/components/shared/PageHeader"; +import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { EvaluationSettingsModal } from "./EvaluationSettingsModal"; import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard"; @@ -282,20 +282,20 @@ export function GuardrailsOverview({ return (
- } - title="Guardrails Monitor" - subtitle="Monitor guardrail performance across all requests" - utilities={ - <> - {dateRangeControl} - - - } - /> + + + + Guardrails Monitor + + Monitor guardrail performance across all requests + + {dateRangeControl} + + +
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 72f26919060..9d8718cb84a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -149,7 +149,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) { -
{children}
+
{children}
{!isPlayground && }
diff --git a/ui/litellm-dashboard/src/components/view_logs/index.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/logs/page.integration.test.tsx similarity index 90% rename from ui/litellm-dashboard/src/components/view_logs/index.integration.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/logs/page.integration.test.tsx index f2d70b74c96..dc8a57e50ba 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/page.integration.test.tsx @@ -1,8 +1,8 @@ import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import SpendLogsTable from "./index"; -import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; +import LogsPage from "./page"; +import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils"; const { useAuthorizedMock, useOrganizationsMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn(), @@ -17,7 +17,7 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganizations: useOrganizationsMock, })); -vi.mock("./RequestLogsPanel", () => ({ +vi.mock("@/components/view_logs/RequestLogsPanel", () => ({ default: function RequestLogsPanelMock() { return
; }, @@ -47,17 +47,13 @@ const defaultProps = { const ORG_ADMIN_MEMBERSHIPS = [{ organization_id: "org-1", members: [{ user_id: "user-1", user_role: "org_admin" }] }]; const renderAs = (sessionRole: string, organizations: unknown[] = []) => { - useAuthorizedMock.mockReturnValue({ - accessToken: "sk-test", - userId: "user-1", - userRole: sessionRole, - premiumUser: true, - }); + const session = { ...defaultProps, userId: defaultProps.userID, userRole: sessionRole }; + useAuthorizedMock.mockReturnValue(session); useOrganizationsMock.mockReturnValue({ data: organizations }); - return renderWithProviders(); + return renderWithProviders(); }; -describe("SpendLogsTable network access by role", () => { +describe("LogsPage network access by role", () => { beforeEach(() => { testQueryClient.clear(); vi.clearAllMocks(); diff --git a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/logs/page.test.tsx similarity index 88% rename from ui/litellm-dashboard/src/components/view_logs/index.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/logs/page.test.tsx index 7c8c14771bd..c7e0a82a363 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/page.test.tsx @@ -1,8 +1,8 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import SpendLogsTable from "./index"; -import { renderWithProviders } from "../../../tests/test-utils"; +import LogsPage from "./page"; +import { renderWithProviders } from "../../../../tests/test-utils"; const { useAuthorizedMock, useOrganizationsMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn(), @@ -17,25 +17,25 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganizations: useOrganizationsMock, })); -vi.mock("./RequestLogsPanel", () => ({ +vi.mock("@/components/view_logs/RequestLogsPanel", () => ({ default: function RequestLogsPanelMock({ isActive }: { isActive: boolean }) { return
{isActive ? "active" : "inactive"}
; }, })); -vi.mock("./AuditLogsPanel", () => ({ +vi.mock("@/components/view_logs/AuditLogsPanel", () => ({ default: function AuditLogsPanelMock({ isActive }: { isActive: boolean }) { return
{isActive ? "active" : "inactive"}
; }, })); -vi.mock("../DeletedKeysPage/DeletedKeysPage", () => ({ +vi.mock("@/components/DeletedKeysPage/DeletedKeysPage", () => ({ default: function DeletedKeysPageMock() { return
; }, })); -vi.mock("../DeletedTeamsPage/DeletedTeamsPage", () => ({ +vi.mock("@/components/DeletedTeamsPage/DeletedTeamsPage", () => ({ default: function DeletedTeamsPageMock() { return
; }, @@ -52,16 +52,16 @@ const defaultProps = { const ORG_ADMIN_MEMBERSHIPS = [{ organization_id: "org-1", members: [{ user_id: "user-1", user_role: "org_admin" }] }]; const renderAs = (sessionRole: string, organizations: unknown[] = []) => { - useAuthorizedMock.mockReturnValue({ userId: "user-1", userRole: sessionRole }); + useAuthorizedMock.mockReturnValue({ ...defaultProps, userId: defaultProps.userID, userRole: sessionRole }); useOrganizationsMock.mockReturnValue({ data: organizations }); - return renderWithProviders(); + return renderWithProviders(); }; const tabNames = () => screen.getAllByRole("tab").map((tab) => tab.textContent); -describe("SpendLogsTable", () => { +describe("LogsPage", () => { beforeEach(() => { - useAuthorizedMock.mockReturnValue({ userId: "user-1", userRole: "Admin" }); + useAuthorizedMock.mockReturnValue({ ...defaultProps, userId: defaultProps.userID }); useOrganizationsMock.mockReturnValue({ data: [] }); }); @@ -182,17 +182,17 @@ describe("SpendLogsTable", () => { describe("auth-not-ready guard", () => { it("shows a loading spinner when credentials are not yet resolved", () => { - useAuthorizedMock.mockReturnValue({ userRole: "Admin" }); - renderWithProviders(); + useAuthorizedMock.mockReturnValue({ ...defaultProps, userId: defaultProps.userID, accessToken: null }); + renderWithProviders(); - expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Loading" })).toHaveAttribute("aria-busy", "true"); expect(screen.queryByRole("tab", { name: "Request Logs" })).not.toBeInTheDocument(); }); it("renders the tabs (no spinner) once all credentials are present", () => { renderAs("Admin"); - expect(document.querySelector('[aria-busy="true"]')).not.toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Loading" })).not.toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Request Logs" })).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/logs/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/logs/page.tsx index 88909e3b87f..51603dec29e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/logs/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/logs/page.tsx @@ -1,17 +1,74 @@ "use client"; -import SpendLogsTable from "@/components/view_logs"; +import { useState } from "react"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import useCan from "@/app/(dashboard)/hooks/useCan"; +import DeletedKeysPage from "@/components/DeletedKeysPage/DeletedKeysPage"; +import DeletedTeamsPage from "@/components/DeletedTeamsPage/DeletedTeamsPage"; +import AuditLogsPanel from "@/components/view_logs/AuditLogsPanel"; +import RequestLogsPanel from "@/components/view_logs/RequestLogsPanel"; +import { Page, PageTabs, PageTabsList, PageTabsTrigger, PageTabsContent } from "@/components/shared/Page"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; -export default function Logs() { +type LogsTab = "request logs" | "audit logs" | "deleted keys" | "deleted teams"; + +export default function LogsPage() { const { accessToken, userRole, userId, token, premiumUser } = useAuthorized(); + const [activeTab, setActiveTab] = useState("request logs"); + const canViewAuditLogs = useCan("viewAuditLogs"); + const canViewDeletedTeams = useCan("viewDeletedTeams"); + + const credentialsPending = !accessToken || !token; + const identityPending = !userRole || !userId; + + if (credentialsPending || identityPending) { + return ( +
+ +
+ ); + } + return ( - + + setActiveTab(value)}> + + Request Logs + {canViewAuditLogs && Audit Logs} + Deleted Keys + {canViewDeletedTeams && Deleted Teams} + + + + + + {canViewAuditLogs && ( + + + + )} + + + + {canViewDeletedTeams && ( + + + + )} + + ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/model-insights/_components/ModelInsightsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/model-insights/_components/ModelInsightsView.tsx index c946e8aabe0..ab406597589 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/model-insights/_components/ModelInsightsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/model-insights/_components/ModelInsightsView.tsx @@ -1,5 +1,6 @@ "use client"; +import { Page } from "@/components/shared/Page"; import React from "react"; import { Bar, BarChart, CartesianGrid, Treemap, XAxis, YAxis } from "recharts"; import { ArrowDownRight, ArrowUpRight, BarChart3, Layers, Minus } from "lucide-react"; @@ -7,7 +8,7 @@ import { ArrowDownRight, ArrowUpRight, BarChart3, Layers, Minus } from "lucide-r import { apiClient } from "@/components/networking"; import { extractErrorMessage } from "@/utils/errorUtils"; import { ProviderLogo } from "@/components/molecules/models/ProviderLogo"; -import { PageHeader } from "@/components/shared/PageHeader"; +import { PageHeader, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; @@ -210,12 +211,16 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string ) satisfies ChartConfig; return ( -
- } - title="Model Leaderboard" - subtitle={`See which models your gateway used from ${data.start_date} through ${data.end_date}`} - /> + + + + + Model Leaderboard + + + See which models your gateway used from {data.start_date} through {data.end_date} + + @@ -375,6 +380,6 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string

-
+ ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx index 2d3c1acf75e..cb595916041 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx @@ -1,9 +1,10 @@ +import { Page, PageContent } from "@/components/shared/Page"; import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { Folder, Plus, SearchIcon, X } from "lucide-react"; import { parseAsString, useQueryState } from "nuqs"; import { useMemo, useState } from "react"; -import { PageHeader } from "@/components/shared/PageHeader"; +import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } 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"; @@ -56,49 +57,53 @@ export function ProjectsPage() { } return ( -
- } - title="Projects" - subtitle="Manage projects within your teams" - primaryAction={ + + + + + Projects + + Manage projects within your teams + - } - /> + + -
- - - - - setSearchText(e.target.value)} - /> - {searchText && ( - - setSearchText("")}> - - + +
+ + + - )} - -
+ setSearchText(e.target.value)} + /> + {searchText && ( + + setSearchText("")}> + + + + )} +
+
- 0} - onProjectClick={(id) => void setSelectedProjectId(id)} - teamAliasMap={teamAliasMap} - isTeamsLoading={isTeamsLoading} - /> + 0} + onProjectClick={(id) => void setSelectedProjectId(id)} + teamAliasMap={teamAliasMap} + isTeamsLoading={isTeamsLoading} + /> + setIsCreateModalVisible(false)} /> -
+ ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx index 40c1104354d..2b17d2019ee 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx @@ -1,11 +1,12 @@ "use client"; +import { Page } from "@/components/shared/Page"; import React from "react"; import { Calculator, RefreshCw } from "lucide-react"; import { apiClient } from "@/components/networking"; -import { PageHeader } from "@/components/shared/PageHeader"; import { DemoNotice } from "@/components/shared/DemoNotice"; +import { PageHeader, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -219,33 +220,33 @@ export default function ROICalculatorView({ const syncedAt = syncIsUpToDate ? summary?.synced_at : null; return ( -
- } - title="ROI Calculator" - titleAction={ - !liveSummary && showLiveStatus ? ( + + +
+ + + ROI Calculator + + {!liveSummary && showLiveStatus && ( - ) : undefined - } - subtitle={ - <> - {summary - ? `${summary.start} through ${summary.end} · UTC` - : "Compare gateway spend with estimated engineering effort for merged pull requests"} - {syncedAt && ( - - Last synced {formatSyncedAt(syncedAt)} - {!status.running && status.phase === "complete" && status.reused > 0 - ? ` · ${status.reused} of ${status.total} estimates reused` - : ""} - - )} - - } - /> + )} +
+ + {summary + ? `${summary.start} through ${summary.end} · UTC` + : "Compare gateway spend with estimated engineering effort for merged pull requests"} + {syncedAt && ( + + Last synced {formatSyncedAt(syncedAt)} + {!status.running && status.phase === "complete" && status.reused > 0 + ? ` · ${status.reused} of ${status.total} estimates reused` + : ""} + + )} + +
{sampleSummary && setSampleSummary(null)} />} {liveSummary && showLiveStatus && (

@@ -365,6 +366,6 @@ export default function ROICalculatorView({ onSave={updateIdentity} /> )} -

+ ); } diff --git a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysPage.tsx b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysPage.tsx index 0af668b5e9c..2686806c95b 100644 --- a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysPage.tsx +++ b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysPage.tsx @@ -1,4 +1,5 @@ "use client"; +import { PageContent } from "@/components/shared/Page"; import { useState } from "react"; import { PaginationState } from "@tanstack/react-table"; import { Info } from "lucide-react"; @@ -14,9 +15,9 @@ export default function DeletedKeysPage() { const { data: keysData, isLoading } = useDeletedKeys(pagination.pageIndex + 1, pagination.pageSize); return ( -
+ {!premiumUser && ( - + Coming soon to Enterprise @@ -31,6 +32,6 @@ export default function DeletedKeysPage() { pagination={pagination} onPaginationChange={setPagination} /> -
+ ); } diff --git a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx index bc6941a7860..afe507366eb 100644 --- a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx @@ -44,6 +44,7 @@ export function DeletedKeysTable({ return ( key.token || String(index)} diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.tsx index 8c3aac2cac7..a491fe138f6 100644 --- a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.tsx +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.tsx @@ -1,6 +1,7 @@ "use client"; import { PaginationState } from "@tanstack/react-table"; import { Info } from "lucide-react"; +import { PageContent } from "@/components/shared/Page"; import { useState } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable"; @@ -17,9 +18,9 @@ export default function DeletedTeamsPage() { const { data: teamsData, isLoading } = useDeletedTeams(pagination.pageIndex + 1, pagination.pageSize); return ( -
+ {!premiumUser && ( - + Coming soon to Enterprise @@ -34,6 +35,6 @@ export default function DeletedTeamsPage() { onPaginationChange={setPagination} rowCount={teamsData?.total ?? 0} /> -
+ ); } diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx index c7e759754b8..236b8b7cf18 100644 --- a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx @@ -44,6 +44,7 @@ export function DeletedTeamsTable({ return ( team.team_id || String(index)} diff --git a/ui/litellm-dashboard/src/components/Teams.test.tsx b/ui/litellm-dashboard/src/components/Teams.test.tsx index 5ff95d2af0c..3b89ebdd318 100644 --- a/ui/litellm-dashboard/src/components/Teams.test.tsx +++ b/ui/litellm-dashboard/src/components/Teams.test.tsx @@ -529,15 +529,6 @@ describe("Teams - team detail deep link (?team=)", () => { expect(onUrlUpdate.mock.calls.at(-1)![0].searchParams.has("team")).toBe(false); await waitFor(() => expect(screen.queryByTestId("team-info-view")).not.toBeInTheDocument()); }); - - it("should preserve the legacy inset for the team detail view", async () => { - renderWithQueryClient(, { - searchParams: "?team=team-from-url", - }); - - await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); - expect(screen.getByRole("main")).toHaveClass("px-12", "py-6"); - }); }); describe("Teams - Create Team CTA is grouped with the tabs on the left", () => { @@ -553,7 +544,6 @@ describe("Teams - Create Team CTA is grouped with the tabs on the left", () => { const createButton = within(tabNav).getByTestId("create-team-button"); const firstTab = within(tabNav).getByRole("tab", { name: "Your Teams" }); - expect(screen.getByRole("main")).toHaveClass("p-8"); expect(within(tabNav).getByRole("separator")).toBeInTheDocument(); expect(createButton.compareDocumentPosition(firstTab) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index c2a23cef83a..dfc0a59f0a0 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -1,3 +1,4 @@ +import { Page, PageTabs, PageTabsList, PageTabsTrigger } from "@/components/shared/Page"; import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import useCan from "@/app/(dashboard)/hooks/useCan"; import AvailableTeamsPanel from "@/components/team/AvailableTeamsPanel"; @@ -15,12 +16,13 @@ import { SearchSelect } from "@/components/shared/SearchSelect"; import { labelWithDocsHint, labelWithHint } from "@/components/shared/form/LabelWithHint"; import { useZodForm } from "@/lib/forms/useZodForm"; import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { TabsContent } from "@/components/ui/tabs"; import { ChevronDown, Plus, Users } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { z } from "zod/v4"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { PageHeader } from "@/components/shared/PageHeader"; +import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader"; +import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator"; import { Button as UIButton } from "@/components/ui/button"; import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams"; import { parseAsString, useQueryState } from "nuqs"; @@ -652,7 +654,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser ]; return ( -
+ {selectedTeamId ? ( = ({ accessToken, userID, userRole, premiumUser premiumUser={premiumUser} /> ) : ( - - } - title="Teams" - subtitle="Manage teams, members, and their access to models and budgets" - primaryAction={ - canCreateOrManageTeams(userRole, userID, organizations) ? ( - - - Create Team - - ) : undefined - } - tabs={({ leadingControls }) => ( - - {leadingControls} + + + + + Teams + + Manage teams, members, and their access to models and budgets + + + {canCreateOrManageTeams(userRole, userID, organizations) && ( + <> + + + Create Team + + + + )} {tabItems.map((item) => ( - + {item.label} - + ))} - - )} - /> + + + {tabItems.map((item) => ( {item.children} ))} - + )} {canCreateOrManageTeams(userRole, userID, organizations) && ( @@ -1312,7 +1309,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser )} -
+ ); }; diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index ec405ee9cad..cc25d22e546 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -16,7 +16,8 @@ import { type UrlTableStateOptions, } from "@/components/shared/DataTable"; import { SearchSelect } from "@/components/shared/SearchSelect"; -import { PageHeader } from "@/components/shared/PageHeader"; +import { PageContent } from "@/components/shared/Page"; +import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; @@ -229,13 +230,15 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { } return ( -
- } - title="Virtual Keys" - subtitle="Every key that authenticates requests to the gateway." - primaryAction={headerActions} - /> + + + + + Virtual Keys + + Every key that authenticates requests to the gateway. + {headerActions != null && {headerActions}} + )} /> -
+ ); } diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx index df88287b369..05ba7e04c39 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx @@ -899,28 +899,6 @@ describe("DataTable layout", () => { expect(screen.getByTestId("data-table-head")).toHaveClass("sticky", "bg-background"); }); - it("caps fillHeight at the parent's height instead of stretching to it, so a short table stays short", () => { - render(); - const outer = screen.getByTestId("data-table-root"); - const frame = screen.getByTestId("data-table-frame"); - const scroller = screen.getByTestId("data-table-scroller"); - - // A ceiling, not a stretch: flex-1 here would hold the footer at the bottom on a two-row table. - expect(outer).toHaveClass("max-h-full", "flex-col"); - expect(outer).not.toHaveClass("flex-1"); - expect(frame).toHaveClass("flex-col"); - expect(frame).not.toHaveClass("flex-1"); - expect(scroller).not.toHaveClass("flex-1"); - - expect(scroller).toHaveClass("min-h-0", "overflow-auto"); - expect(scroller).toHaveStyle({ maxHeight: "" }); - // Without this the Table primitive's own overflow container captures the sticky header. - expect(scroller).toHaveClass("[&_[data-slot=table-container]]:overflow-visible"); - - // Rows pass under the header, so the semi-transparent row tint alone would let them show through. - expect(screen.getByTestId("data-table-head")).toHaveClass("sticky", "bg-background"); - }); - it("leaves the default layout untouched when neither height mode is set", () => { render(); const scroller = screen.getByTestId("data-table-scroller"); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx index feb1615b4e1..a94c4e34611 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -40,7 +40,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { cn } from "@/lib/cva.config"; +import { cn, cva } from "@/lib/cva.config"; import "./columnMeta"; import { DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "./DataTablePagination"; @@ -58,25 +58,40 @@ const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, [role=checkbox const noop = () => {}; -/** - * Height-filling mode. The table still sizes to its rows; the parent's height is only a ceiling, so - * a short table keeps its footer under the last row and a long one scrolls its rows instead of the - * page. - */ -const FILL_CLASSES = { - outer: "flex max-h-full min-h-0 flex-col", - frame: "flex min-h-0 flex-col", - body: "min-h-0", -} as const; +const dataTableRoot = cva("w-full", { + variants: { fill: { true: "flex h-full min-h-0 flex-1 flex-col", false: null } }, +}); -const NO_FILL_CLASSES = { outer: "", frame: "", body: "" } as const; +const dataTableFrame = cva("overflow-hidden rounded-lg border border-border", { + variants: { fill: { true: "flex min-h-0 flex-1 flex-col", false: null } }, +}); -const STICKY_CLASSES = { - body: "[&_[data-slot=table-container]]:overflow-visible", - header: "bg-background", -} as const; +const dataTableScroller = cva("", { + variants: { + sticky: { true: "overflow-auto [&_[data-slot=table-container]]:overflow-visible", false: "overflow-x-auto" }, + fill: { true: "min-h-0 flex-1", false: null }, + stretchEmpty: { true: "[container-type:inline-size] [&_[data-slot=table-container]]:h-full", false: null }, + }, +}); -const NO_STICKY_CLASSES = { body: "", header: "" } as const; +const dataTableTable = cva("", { + variants: { + resizable: { true: "table-fixed", false: null }, + stretchEmpty: { true: "h-full", false: null }, + }, +}); + +const dataTableHeader = cva("", { + variants: { sticky: { true: "sticky top-0 z-sticky bg-background", false: null } }, +}); + +const dataTableBody = cva("", { + variants: { stretchEmpty: { true: "h-full", false: null } }, +}); + +const messageCell = cva("h-24 text-center align-middle text-sm whitespace-normal text-muted-foreground", { + variants: { stretch: { true: "p-0", false: null } }, +}); function columnDefId(column: ColumnDef): string | undefined { if ("id" in column && typeof column.id === "string") { @@ -314,14 +329,23 @@ function DataTableBodyRow({ ); } -function MessageRow({ colSpan, children }: { colSpan: number; children: React.ReactNode }) { +function MessageRow({ + colSpan, + children, + stretch = false, +}: { + colSpan: number; + children: React.ReactNode; + stretch?: boolean; +}) { return ( - - {children} + + {stretch ? ( +
{children}
+ ) : ( + children + )}
); @@ -598,8 +622,7 @@ export function DataTable(props: DataTableProps { @@ -636,7 +659,11 @@ export function DataTable(props: DataTableProps{noDataMessage ?? }; + return ( + + {noDataMessage ?? } + + ); } return rows.map((row) => ( (props: DataTableProps -
+
+
{toolbar !== undefined &&
{toolbar(table)}
}
- - + + {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( @@ -682,7 +706,7 @@ export function DataTable(props: DataTableProps ))} - {renderBody()} + {renderBody()} {footer !== undefined && {footer(table)}}
diff --git a/ui/litellm-dashboard/src/components/shared/Page.tsx b/ui/litellm-dashboard/src/components/shared/Page.tsx new file mode 100644 index 00000000000..2799ab43911 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/Page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/cva.config"; + +export function Page({ className, ...props }: ComponentProps<"div">) { + return
; +} + +export function PageContent({ className, ...props }: ComponentProps<"div">) { + return
; +} + +export function PageTabs({ className, ...props }: ComponentProps) { + return ; +} + +export function PageTabsList({ className, ...props }: ComponentProps) { + return ( + + ); +} + +export function PageTabsTrigger({ className, ...props }: ComponentProps) { + return ( + + ); +} + +export function PageTabsContent({ className, ...props }: ComponentProps) { + return ; +} diff --git a/ui/litellm-dashboard/src/components/shared/PageHeader.test.tsx b/ui/litellm-dashboard/src/components/shared/PageHeader.test.tsx index 3741542abad..b3283611997 100644 --- a/ui/litellm-dashboard/src/components/shared/PageHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/PageHeader.test.tsx @@ -1,77 +1,54 @@ import { renderWithProviders, screen, within } from "@/../tests/test-utils"; +import { Users } from "lucide-react"; import { describe, expect, it } from "vitest"; -import { PageHeader } from "./PageHeader"; - -const identity = { - icon: Teams icon, - title: "Teams", - subtitle: "Manage teams, members, and their access to models and budgets", -}; +import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } from "./PageHeader"; describe("PageHeader", () => { - it("should render the page identity", () => { - renderWithProviders(); - - expect(screen.getByRole("heading", { name: "Teams" })).toBeInTheDocument(); - expect(screen.getByText("Teams icon").parentElement).toHaveAttribute("aria-hidden", "true"); - expect(screen.getByText(identity.subtitle)).toBeInTheDocument(); - }); - - it("should apply the standard title and subtext typography", () => { - renderWithProviders(); - - const icon = screen.getByText("Teams icon").parentElement; - expect(screen.getByRole("heading", { name: "Teams" })).toHaveClass("text-2xl", "font-semibold", "tracking-tight"); - expect(screen.getByText(identity.subtitle)).toHaveClass("mt-1.5", "text-sm", "text-muted-foreground"); - expect(icon).toHaveClass("size-5", "[&_svg]:size-5", "[&_svg]:stroke-[1.75]"); - expect(icon?.parentElement).toHaveClass("gap-2.5"); - }); - - it("should render the primary action, divider, tabs, and utilities in the standard control row", () => { + it("should name the page heading by its text alone when it carries an icon", () => { renderWithProviders( - Create Team} - tabs={ -
- -
- } - utilities={} - />, + + + + Teams + + Manage teams + , + ); + + expect(screen.getByRole("heading", { level: 1, name: "Teams" })).toBeInTheDocument(); + expect(screen.getByText("Manage teams")).toBeInTheDocument(); + }); + + it("should group the composed controls under one accessible label", () => { + renderWithProviders( + + Teams + + + + + , ); const controls = screen.getByRole("group", { name: "Page controls" }); - expect(controls).toHaveClass("mt-5", "h-9"); - expect(within(controls).getByRole("separator")).toHaveClass("mx-4", "h-6"); - expect(controls).toHaveTextContent("Create TeamYour TeamsRefresh"); + expect( + within(controls) + .getAllByRole("button") + .map((button) => button.textContent), + ).toEqual(["Create Team", "Refresh"]); }); - it("should omit the divider when tabs are absent", () => { - renderWithProviders(Create Team} />); - - expect(screen.queryByRole("separator")).not.toBeInTheDocument(); - }); - - it("should provide standard controls to an embedded tab shell", () => { + it("should forward native attributes to each part", () => { renderWithProviders( - Create Team} - tabs={({ leadingControls, utilities }) => ( -
- {leadingControls} - - {utilities} -
- )} - utilities={} - />, + + Teams + + , ); - const tabs = screen.getByRole("tablist"); - expect(within(tabs).getByRole("separator")).toBeInTheDocument(); - expect(tabs).toHaveTextContent("Create TeamYour TeamsRefresh"); + expect(screen.getByTestId("header")).toContainElement(screen.getByRole("heading", { name: "Teams" })); + expect(screen.getByRole("heading", { name: "Teams" })).toHaveAttribute("id", "page-title"); + expect(screen.getByRole("group", { name: "Team controls" })).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/shared/PageHeader.tsx b/ui/litellm-dashboard/src/components/shared/PageHeader.tsx index 6fc5b0f3238..28a22f0a69f 100644 --- a/ui/litellm-dashboard/src/components/shared/PageHeader.tsx +++ b/ui/litellm-dashboard/src/components/shared/PageHeader.tsx @@ -1,62 +1,36 @@ "use client"; -import * as React from "react"; +import type { ComponentProps } from "react"; -import { ToolbarSeparator } from "./ToolbarSeparator"; +import { cn } from "@/lib/cva.config"; -interface EmbeddedTabsSlots { - leadingControls: React.ReactNode; - utilities: React.ReactNode; +export function PageHeader({ className, ...props }: ComponentProps<"div">) { + return
; } -interface PageHeaderProps { - title: React.ReactNode; - subtitle: React.ReactNode; - icon: React.ReactNode; - primaryAction?: React.ReactNode; - titleAction?: React.ReactNode; - tabs?: React.ReactNode | ((slots: EmbeddedTabsSlots) => React.ReactNode); - utilities?: React.ReactNode; -} - -export function PageHeader({ title, subtitle, icon, primaryAction, titleAction, tabs, utilities }: PageHeaderProps) { - const leadingControls = - primaryAction == null ? null : ( -
- {primaryAction} - {tabs != null && } -
- ); - const utilityControls = utilities == null ? null :
{utilities}
; - const hasControlRow = primaryAction != null || tabs != null || utilities != null; - +export function PageHeaderTitle({ className, ...props }: ComponentProps<"h1">) { return ( -
-
-
- -

{title}

-
- {titleAction} -
-

{subtitle}

- - {typeof tabs === "function" ? ( -
{tabs({ leadingControls, utilities: utilityControls })}
- ) : ( - hasControlRow && ( -
- {leadingControls} - {tabs} - {utilityControls != null &&
{utilityControls}
} -
- ) +

+ {...props} + /> + ); +} + +export function PageHeaderDescription({ className, ...props }: ComponentProps<"p">) { + return

; +} + +export function PageHeaderControls({ className, ...props }: ComponentProps<"div">) { + return ( +

); } diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx index abc00b75884..6480cb59682 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx @@ -100,7 +100,7 @@ export default function AuditLogsPanel({ if (!premiumUser) { return ( -
+

✨ Enterprise Feature.

This is a LiteLLM Enterprise feature, and requires a valid key to use. @@ -128,10 +128,6 @@ export default function AuditLogsPanel({ return ( <> -

-

Audit Logs

-
- row.id} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx index 60657872ccc..3496b709d9b 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx @@ -2,72 +2,59 @@ import moment from "moment"; import { CalendarDays } from "lucide-react"; -import { useState } from "react"; +import { type ComponentProps, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/cva.config"; import { QUICK_SELECT_OPTIONS } from "./constants"; import { getTimeRangeDisplay } from "./logs_utils"; -interface LogsTableToolbarProps { +const DATETIME_LOCAL_FORMAT = "YYYY-MM-DDTHH:mm"; + +export interface LogsTimeRange { startTime: string; - onStartTimeChange: (value: string) => void; endTime: string; - onEndTimeChange: (value: string) => void; isCustomDate: boolean; - onIsCustomDateChange: (value: boolean) => void; - selectedTimeInterval: { value: number; unit: string }; - onSelectedTimeIntervalChange: (value: { value: number; unit: string }) => void; - isLiveTail: boolean; - onIsLiveTailChange: (value: boolean) => void; - excludeInternalHealthChecks: boolean; - onExcludeInternalHealthChecksChange: (value: boolean) => void; - onResetToFirstPage: () => void; - onResetFilters: () => void; + interval: { value: number; unit: string }; } -export function LogsTableToolbar({ - startTime, - onStartTimeChange, - endTime, - onEndTimeChange, - isCustomDate, - onIsCustomDateChange, - selectedTimeInterval, - onSelectedTimeIntervalChange, - isLiveTail, - onIsLiveTailChange, - excludeInternalHealthChecks, - onExcludeInternalHealthChecksChange, - onResetToFirstPage, - onResetFilters, -}: LogsTableToolbarProps) { - const [quickSelectOpen, setQuickSelectOpen] = useState(false); +const relativeTimeRange = (interval: LogsTimeRange["interval"]): LogsTimeRange => ({ + startTime: moment() + .subtract(interval.value, interval.unit as moment.unitOfTime.DurationConstructor) + .format(DATETIME_LOCAL_FORMAT), + endTime: moment().format(DATETIME_LOCAL_FORMAT), + isCustomDate: false, + interval, +}); - const applyQuickSelect = (option: { label: string; value: number; unit: string }) => { - onResetToFirstPage(); - onEndTimeChange(moment().format("YYYY-MM-DDTHH:mm")); - onStartTimeChange( - moment() - .subtract(option.value, option.unit as moment.unitOfTime.DurationConstructor) - .format("YYYY-MM-DDTHH:mm"), - ); - onSelectedTimeIntervalChange({ value: option.value, unit: option.unit }); - onIsCustomDateChange(false); - setQuickSelectOpen(false); - }; +export const defaultLogsTimeRange = (): LogsTimeRange => relativeTimeRange({ value: 24, unit: "hours" }); + +export function LogsToolbar({ className, ...props }: ComponentProps<"div">) { + return
; +} + +interface LogsTimeRangePickerProps { + value: LogsTimeRange; + onValueChange: (value: LogsTimeRange) => void; +} + +export function LogsTimeRangePicker({ value, onValueChange }: LogsTimeRangePickerProps) { + const [open, setOpen] = useState(false); const selectedOption = QUICK_SELECT_OPTIONS.find( - (option) => option.value === selectedTimeInterval.value && option.unit === selectedTimeInterval.unit, + (option) => option.value === value.interval.value && option.unit === value.interval.unit, ); - const displayLabel = isCustomDate ? getTimeRangeDisplay(isCustomDate, startTime, endTime) : selectedOption?.label; + const displayLabel = value.isCustomDate + ? getTimeRangeDisplay(true, value.startTime, value.endTime) + : selectedOption?.label; return ( -
- + <> + @@ -83,7 +70,10 @@ export function LogsTableToolbar({ key={option.label} variant="ghost" className="w-full justify-start font-normal" - onClick={() => applyQuickSelect(option)} + onClick={() => { + onValueChange(relativeTimeRange({ value: option.value, unit: option.unit })); + setOpen(false); + }} > {option.label} @@ -92,10 +82,7 @@ export function LogsTableToolbar({ @@ -103,58 +90,32 @@ export function LogsTableToolbar({ - {isCustomDate && ( + {value.isCustomDate && (
{ - onStartTimeChange(event.target.value); - onResetToFirstPage(); - }} + value={value.startTime} + onChange={(event) => onValueChange({ ...value, startTime: event.target.value })} /> to { - onEndTimeChange(event.target.value); - onResetToFirstPage(); - }} + value={value.endTime} + onChange={(event) => onValueChange({ ...value, endTime: event.target.value })} />
)} - -
- Live Tail - -
- -
- Hide Health Checks - -
- - -
+ ); } -export function LiveTailBanner({ onStop }: { onStop: () => void }) { +export function LogsToolbarSwitch({ label, ...props }: ComponentProps & { label: string }) { return ( -
- Auto-refreshing every 15 seconds - +
+ {label} +
); } diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx index 8a0e529e2e8..aefbf68d662 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx @@ -792,15 +792,15 @@ describe("RequestLogsPanel", () => { }); describe("live tail", () => { - it("shows the auto-refresh banner on the first page and hides it once stopped", async () => { + it("shows the refresh status on the first page and hides it when live tail is disabled", async () => { const user = userEvent.setup(); renderPanel(); - expect(await screen.findByText("Auto-refreshing every 15 seconds")).toBeInTheDocument(); + expect(await screen.findByText("Refreshing every 15s")).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Stop" })); + await user.click(screen.getByRole("switch", { name: "Live Tail" })); - expect(screen.queryByText("Auto-refreshing every 15 seconds")).not.toBeInTheDocument(); + expect(screen.queryByText("Refreshing every 15s")).not.toBeInTheDocument(); }); }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx index 0b5e5ff9616..ff26e30baca 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -7,6 +7,7 @@ import moment from "moment"; import { useCallback, useEffect, useMemo, useState } from "react"; import { DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable"; +import { Button } from "@/components/ui/button"; import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import type { KeyResponse } from "../key_team_helpers/key_list"; @@ -23,11 +24,16 @@ import { } from "./log_filter_logic"; import { useLogDetailRouting } from "./logDetailRouting"; import { LogDetailsDrawer } from "./LogDetailsDrawer"; -import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar"; +import { + defaultLogsTimeRange, + type LogsTimeRange, + LogsTimeRangePicker, + LogsToolbar, + LogsToolbarSwitch, +} from "./LogsTableToolbar"; import { RequestLogsTable } from "./RequestLogsTable"; const PAGE_SIZE = DEFAULT_PAGE_SIZE_OPTIONS[0]; -const DEFAULT_INTERVAL = { value: 24, unit: "hours" }; const matchesLogId = (log: LogEntry, logId: string) => log.request_id === logId || log.litellm_call_id === logId; const findLogById = (logs: readonly LogEntry[], logId: string): LogEntry | null => logs.find((log) => log.request_id === logId) ?? logs.find((log) => log.litellm_call_id === logId) ?? null; @@ -46,10 +52,8 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, const [columnFilters, setColumnFilters] = useState([]); const [sessionCursors, setSessionCursors] = useState>({}); - const [startTime, setStartTime] = useState(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm")); - const [endTime, setEndTime] = useState(moment().format("YYYY-MM-DDTHH:mm")); - const [isCustomDate, setIsCustomDate] = useState(false); - const [selectedTimeInterval, setSelectedTimeInterval] = useState<{ value: number; unit: string }>(DEFAULT_INTERVAL); + const [timeRange, setTimeRange] = useState(defaultLogsTimeRange); + const { startTime, endTime, isCustomDate } = timeRange; const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState(null); const [selectedLog, setSelectedLog] = useState(null); @@ -232,12 +236,17 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, [resetToFirstPage], ); + const handleTimeRangeChange = useCallback( + (value: LogsTimeRange) => { + setTimeRange(value); + resetToFirstPage(); + }, + [resetToFirstPage], + ); + const handleResetFilters = useCallback(() => { setColumnFilters([]); - setStartTime(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm")); - setEndTime(moment().format("YYYY-MM-DDTHH:mm")); - setIsCustomDate(false); - setSelectedTimeInterval(DEFAULT_INTERVAL); + setTimeRange(defaultLogsTimeRange()); resetToFirstPage(); }, [resetToFirstPage]); @@ -288,12 +297,6 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, return ( -
-

Request Logs

-
- - {isLiveTail && pagination.pageIndex === 0 && setIsLiveTail(false)} />} - + + + + {isLiveTail && pagination.pageIndex === 0 && ( + + Refreshing every 15s + + )} + + + } /> diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx deleted file mode 100644 index fe3b99a1882..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useState } from "react"; -import useCan from "@/app/(dashboard)/hooks/useCan"; -import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage"; -import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage"; -import AuditLogsPanel from "./AuditLogsPanel"; -import RequestLogsPanel from "./RequestLogsPanel"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; - -interface SpendLogsTableProps { - accessToken: string | null; - token: string | null; - userRole: string | null; - userID: string | null; - premiumUser: boolean; -} - -type LogsTabId = "request logs" | "audit logs" | "deleted keys" | "deleted teams"; - -interface LogsTab { - id: LogsTabId; - label: string; -} - -const REQUEST_LOGS_TAB: LogsTab = { id: "request logs", label: "Request Logs" }; -const AUDIT_LOGS_TAB: LogsTab = { id: "audit logs", label: "Audit Logs" }; -const DELETED_KEYS_TAB: LogsTab = { id: "deleted keys", label: "Deleted Keys" }; -const DELETED_TEAMS_TAB: LogsTab = { id: "deleted teams", label: "Deleted Teams" }; - -const tabContentClassName = (tabId: LogsTabId): string => - tabId === REQUEST_LOGS_TAB.id ? "flex min-h-0 flex-1 flex-col" : "min-h-0 flex-1 overflow-y-auto"; - -export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) { - const [activeTab, setActiveTab] = useState(REQUEST_LOGS_TAB.id); - const canViewAuditLogs = useCan("viewAuditLogs"); - const canViewDeletedTeams = useCan("viewDeletedTeams"); - - if (!accessToken || !token || !userRole || !userID) { - return ( -
- -
- ); - } - - const tabs: LogsTab[] = [ - REQUEST_LOGS_TAB, - ...(canViewAuditLogs ? [AUDIT_LOGS_TAB] : []), - DELETED_KEYS_TAB, - ...(canViewDeletedTeams ? [DELETED_TEAMS_TAB] : []), - ]; - - const renderPanel = (tabId: LogsTabId) => { - switch (tabId) { - case "request logs": - return ( - - ); - case "audit logs": - return ( - - ); - case "deleted keys": - return ; - case "deleted teams": - return ; - } - }; - - return ( -
- setActiveTab(value as LogsTabId)} - className="min-h-0 flex-1 gap-0" - > - - {tabs.map((tab) => ( - - {tab.label} - - ))} - - {tabs.map((tab) => ( - - {renderPanel(tab.id)} - - ))} - -
- ); -}