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)} - - ))} - -
- ); -}