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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-03 01:35:34 +00:00 • committed by GitHub
parent b28ce93d2b
commit 4b9f9903f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 661 additions and 686 deletions

View file

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

View file

@ -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 (
<div className="p-8">
<PageHeader
icon={<Boxes />}
title="Access Groups"
subtitle="Manage resource permissions for your organization"
primaryAction={
canModify ? (
<Page>
<PageHeader>
<PageHeaderTitle>
<Boxes />
Access Groups
</PageHeaderTitle>
<PageHeaderDescription>Manage resource permissions for your organization</PageHeaderDescription>
{canModify && (
<PageHeaderControls>
<Button onClick={() => setIsCreateModalVisible(true)}>
<Plus className="size-4" />
Create Access Group
</Button>
) : undefined
}
/>
</PageHeaderControls>
)}
</PageHeader>
<div className="mt-6 mb-3 flex items-center">
<InputGroup className="max-w-[400px]">
<InputGroupAddon>
<SearchIcon className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search groups by name, ID, or description..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
{searchText && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchText("")}>
<X />
</InputGroupButton>
<PageContent className="gap-3">
<div className="flex items-center">
<InputGroup className="max-w-[400px]">
<InputGroupAddon>
<SearchIcon className="size-4 text-muted-foreground" />
</InputGroupAddon>
)}
</InputGroup>
</div>
<InputGroupInput
placeholder="Search groups by name, ID, or description..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
{searchText && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchText("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
</div>
<AccessGroupsTable
groups={filteredGroups}
isLoading={isLoading}
isFiltered={searchText.trim().length > 0}
canModify={canModify}
onGroupClick={setSelectedGroupId}
onDeleteClick={setGroupToDelete}
/>
<AccessGroupsTable
groups={filteredGroups}
isLoading={isLoading}
isFiltered={searchText.trim().length > 0}
canModify={canModify}
onGroupClick={setSelectedGroupId}
onDeleteClick={setGroupToDelete}
/>
</PageContent>
<AccessGroupCreateDialog open={isCreateModalVisible} onOpenChange={setIsCreateModalVisible} />
@ -126,6 +131,6 @@ export function AccessGroupsPage() {
}}
confirmLoading={deleteMutation.isPending}
/>
</div>
</Page>
);
}

View file

@ -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 (
<main className="flex h-full flex-col p-8">
<Page className="h-full">
<VirtualKeysTable
headerActions={
isViewOnly ? undefined : (
@ -86,6 +87,6 @@ export default function ApiKeysDashboard() {
)
}
/>
</main>
</Page>
);
}

View file

@ -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<BudgetSettingsPageProps> = ({ accessToken }) => {
};
return (
<main className="flex h-full flex-col p-8">
<Tabs defaultValue="budgets" className="min-h-0 flex-1 gap-6">
<PageHeader
icon={<Wallet />}
title="Budgets"
subtitle="Spend, TPM and RPM limits you can assign to customers."
primaryAction={
canModify ? (
<Button onClick={() => setIsCreateModelVisible(true)}>
<Plus className="size-4" />
Create Budget
</Button>
) : undefined
}
tabs={({ leadingControls }) => (
<TabsList
variant="line"
className="gap-0 p-0 [&>[data-slot=tabs-trigger]+[data-slot=tabs-trigger]]:ml-[22px]"
>
{leadingControls}
<TabsTrigger value="budgets" className="flex-none px-0 py-[7px] data-active:font-semibold">
Budgets
</TabsTrigger>
<TabsTrigger value="examples" className="flex-none px-0 py-[7px] data-active:font-semibold">
Examples
</TabsTrigger>
</TabsList>
)}
/>
<Page className="h-full">
<PageTabs defaultValue="budgets">
<PageHeader>
<PageHeaderTitle>
<Wallet />
Budgets
</PageHeaderTitle>
<PageHeaderDescription>Spend, TPM and RPM limits you can assign to customers.</PageHeaderDescription>
<PageHeaderControls>
<PageTabsList>
{canModify && (
<>
<Button onClick={() => setIsCreateModelVisible(true)}>
<Plus className="size-4" />
Create Budget
</Button>
<ToolbarSeparator className="mx-0 h-6" />
</>
)}
<PageTabsTrigger value="budgets">Budgets</PageTabsTrigger>
<PageTabsTrigger value="examples">Examples</PageTabsTrigger>
</PageTabsList>
</PageHeaderControls>
</PageHeader>
<TabsContent value="budgets" className="flex min-h-0 flex-1 flex-col" keepMounted>
<div className="flex min-h-0 flex-1 flex-col">
<BudgetModal isModalVisible={isCreateModelVisible} setIsModalVisible={setIsCreateModelVisible} />
@ -174,8 +171,8 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
</Tabs>
</div>
</TabsContent>
</Tabs>
</main>
</PageTabs>
</Page>
);
};

View file

@ -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<CostOptimizationViewProps> = ({ accessToken
};
return (
<main className="w-full p-8">
<Tabs defaultValue="usage" onValueChange={handleTabChange} className="gap-6">
<PageHeader
icon={<PiggyBank />}
title="Cost Optimization"
subtitle="Track and configure the mechanisms that save you money: prompt compression and prompt caching. Auto routers live under Models + Endpoints, on the Auto-Routers tab"
tabs={({ leadingControls }) => (
<TabsList
variant="line"
className="gap-0 p-0 [&>[data-slot=tabs-trigger]+[data-slot=tabs-trigger]]:ml-[22px]"
>
{leadingControls}
<TabsTrigger value="usage" className="flex-none px-0 py-[7px] data-active:font-semibold">
Overall
</TabsTrigger>
<Page>
<PageTabs defaultValue="usage" onValueChange={handleTabChange}>
<PageHeader>
<PageHeaderTitle>
<PiggyBank />
Cost Optimization
</PageHeaderTitle>
<PageHeaderDescription>
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
</PageHeaderDescription>
<PageHeaderControls>
<PageTabsList>
<PageTabsTrigger value="usage">Overall</PageTabsTrigger>
{canViewProxyWideCostData && (
<>
<TabsTrigger value="compression" className="flex-none px-0 py-[7px] data-active:font-semibold">
Prompt Compression
</TabsTrigger>
<TabsTrigger value="caching" className="flex-none px-0 py-[7px] data-active:font-semibold">
Prompt Caching
</TabsTrigger>
<TabsTrigger value="autorouter-usage" className="flex-none px-0 py-[7px] data-active:font-semibold">
Auto-Router
</TabsTrigger>
<PageTabsTrigger value="compression">Prompt Compression</PageTabsTrigger>
<PageTabsTrigger value="caching">Prompt Caching</PageTabsTrigger>
<PageTabsTrigger value="autorouter-usage">Auto-Router</PageTabsTrigger>
</>
)}
</TabsList>
)}
/>
</PageTabsList>
</PageHeaderControls>
</PageHeader>
<div
role="alert"
@ -108,8 +102,8 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
</TabsContent>
</>
)}
</Tabs>
</main>
</PageTabs>
</Page>
);
};

View file

@ -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 (
<main className="w-full min-w-0 flex-1 p-8">
<Page>
{!selectedGuardrailId ? (
<GuardrailsOverview
accessToken={accessToken}
@ -69,6 +70,6 @@ export default function GuardrailsMonitorView({ accessToken = null }: Guardrails
/>
</>
)}
</main>
</Page>
);
}

View file

@ -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 (
<div>
<PageHeader
icon={<HeartPulse />}
title="Guardrails Monitor"
subtitle="Monitor guardrail performance across all requests"
utilities={
<>
{dateRangeControl}
<Button variant="outline" title="Coming soon">
<Download className="size-4" />
Export Data
</Button>
</>
}
/>
<PageHeader>
<PageHeaderTitle>
<HeartPulse />
Guardrails Monitor
</PageHeaderTitle>
<PageHeaderDescription>Monitor guardrail performance across all requests</PageHeaderDescription>
<PageHeaderControls className="justify-end">
{dateRangeControl}
<Button variant="outline" title="Coming soon">
<Download className="size-4" />
Export Data
</Button>
</PageHeaderControls>
</PageHeader>
<div className="mt-6 mb-6 grid grid-cols-[repeat(auto-fit,minmax(7rem,1fr))] gap-4">
<MetricCard label="Total Evaluations" value={metrics.totalRequests.toLocaleString()} />

View file

@ -149,7 +149,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
<LicenseExpiryBanner accessToken={accessToken} />
<UserBanner accessToken={accessToken} />
<UpgradeBanner accessToken={accessToken} />
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto">{children}</main>
{!isPlayground && <LiteAdmin />}
</div>
</div>

View file

@ -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 <div data-testid="request-logs-panel" />;
},
@ -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(<SpendLogsTable {...defaultProps} userRole={sessionRole} />);
return renderWithProviders(<LogsPage />);
};
describe("SpendLogsTable network access by role", () => {
describe("LogsPage network access by role", () => {
beforeEach(() => {
testQueryClient.clear();
vi.clearAllMocks();

View file

@ -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 <div data-testid="request-logs-panel">{isActive ? "active" : "inactive"}</div>;
},
}));
vi.mock("./AuditLogsPanel", () => ({
vi.mock("@/components/view_logs/AuditLogsPanel", () => ({
default: function AuditLogsPanelMock({ isActive }: { isActive: boolean }) {
return <div data-testid="audit-logs-panel">{isActive ? "active" : "inactive"}</div>;
},
}));
vi.mock("../DeletedKeysPage/DeletedKeysPage", () => ({
vi.mock("@/components/DeletedKeysPage/DeletedKeysPage", () => ({
default: function DeletedKeysPageMock() {
return <div data-testid="deleted-keys-page" />;
},
}));
vi.mock("../DeletedTeamsPage/DeletedTeamsPage", () => ({
vi.mock("@/components/DeletedTeamsPage/DeletedTeamsPage", () => ({
default: function DeletedTeamsPageMock() {
return <div data-testid="deleted-teams-page" />;
},
@ -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(<SpendLogsTable {...defaultProps} userRole={sessionRole} />);
return renderWithProviders(<LogsPage />);
};
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(<SpendLogsTable {...defaultProps} accessToken={null} />);
useAuthorizedMock.mockReturnValue({ ...defaultProps, userId: defaultProps.userID, accessToken: null });
renderWithProviders(<LogsPage />);
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();
});
});

View file

@ -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<LogsTab>("request logs");
const canViewAuditLogs = useCan("viewAuditLogs");
const canViewDeletedTeams = useCan("viewDeletedTeams");
const credentialsPending = !accessToken || !token;
const identityPending = !userRole || !userId;
if (credentialsPending || identityPending) {
return (
<div role="status" aria-busy="true" aria-label="Loading" className="flex h-64 items-center justify-center">
<UiLoadingSpinner className="size-8 text-primary" />
</div>
);
}
return (
<SpendLogsTable
userID={userId}
userRole={userRole}
token={token}
accessToken={accessToken}
premiumUser={premiumUser}
/>
<Page className="h-full">
<PageTabs value={activeTab} onValueChange={(value: LogsTab) => setActiveTab(value)}>
<PageTabsList>
<PageTabsTrigger value="request logs">Request Logs</PageTabsTrigger>
{canViewAuditLogs && <PageTabsTrigger value="audit logs">Audit Logs</PageTabsTrigger>}
<PageTabsTrigger value="deleted keys">Deleted Keys</PageTabsTrigger>
{canViewDeletedTeams && <PageTabsTrigger value="deleted teams">Deleted Teams</PageTabsTrigger>}
</PageTabsList>
<PageTabsContent value="request logs" keepMounted>
<RequestLogsPanel
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userId}
isActive={activeTab === "request logs"}
/>
</PageTabsContent>
{canViewAuditLogs && (
<PageTabsContent value="audit logs" keepMounted>
<AuditLogsPanel
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userId}
premiumUser={premiumUser ?? false}
isActive={activeTab === "audit logs"}
/>
</PageTabsContent>
)}
<PageTabsContent value="deleted keys" keepMounted>
<DeletedKeysPage />
</PageTabsContent>
{canViewDeletedTeams && (
<PageTabsContent value="deleted teams" keepMounted>
<DeletedTeamsPage />
</PageTabsContent>
)}
</PageTabs>
</Page>
);
}

View file

@ -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 (
<main className="w-full space-y-6 p-8">
<PageHeader
icon={<BarChart3 />}
title="Model Leaderboard"
subtitle={`See which models your gateway used from ${data.start_date} through ${data.end_date}`}
/>
<Page>
<PageHeader>
<PageHeaderTitle>
<BarChart3 />
Model Leaderboard
</PageHeaderTitle>
<PageHeaderDescription>
See which models your gateway used from {data.start_date} through {data.end_date}
</PageHeaderDescription>
</PageHeader>
<Card aria-busy={isStale} className={isStale ? "opacity-60 transition-opacity" : "transition-opacity"}>
<CardHeader className="flex-row items-start justify-between space-y-0">
@ -375,6 +380,6 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string
</p>
</CardContent>
</Card>
</main>
</Page>
);
}

View file

@ -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 (
<div className="p-8">
<PageHeader
icon={<Folder />}
title="Projects"
subtitle="Manage projects within your teams"
primaryAction={
<Page>
<PageHeader>
<PageHeaderTitle>
<Folder />
Projects
</PageHeaderTitle>
<PageHeaderDescription>Manage projects within your teams</PageHeaderDescription>
<PageHeaderControls>
<Button onClick={() => setIsCreateModalVisible(true)}>
<Plus className="size-4" />
Create Project
</Button>
}
/>
</PageHeaderControls>
</PageHeader>
<div className="mt-6 mb-3 flex items-center">
<InputGroup className="max-w-[400px]">
<InputGroupAddon>
<SearchIcon className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search projects by name, ID, description, or team..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
{searchText && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchText("")}>
<X />
</InputGroupButton>
<PageContent className="gap-3">
<div className="flex items-center">
<InputGroup className="max-w-[400px]">
<InputGroupAddon>
<SearchIcon className="size-4 text-muted-foreground" />
</InputGroupAddon>
)}
</InputGroup>
</div>
<InputGroupInput
placeholder="Search projects by name, ID, description, or team..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
{searchText && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchText("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
</div>
<ProjectsTable
projects={filteredProjects}
isLoading={isLoading}
isFiltered={searchText.trim().length > 0}
onProjectClick={(id) => void setSelectedProjectId(id)}
teamAliasMap={teamAliasMap}
isTeamsLoading={isTeamsLoading}
/>
<ProjectsTable
projects={filteredProjects}
isLoading={isLoading}
isFiltered={searchText.trim().length > 0}
onProjectClick={(id) => void setSelectedProjectId(id)}
teamAliasMap={teamAliasMap}
isTeamsLoading={isTeamsLoading}
/>
</PageContent>
<CreateProjectModal isOpen={isCreateModalVisible} onClose={() => setIsCreateModalVisible(false)} />
</div>
</Page>
);
}

View file

@ -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 (
<main className="w-full space-y-6 p-8">
<PageHeader
icon={<Calculator />}
title="ROI Calculator"
titleAction={
!liveSummary && showLiveStatus ? (
<Page>
<PageHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<PageHeaderTitle>
<Calculator />
ROI Calculator
</PageHeaderTitle>
{!liveSummary && showLiveStatus && (
<Button variant="outline" onClick={() => void previewSample()}>
Preview sample report
</Button>
) : undefined
}
subtitle={
<>
{summary
? `${summary.start} through ${summary.end} · UTC`
: "Compare gateway spend with estimated engineering effort for merged pull requests"}
{syncedAt && (
<span className="mt-1 block text-xs text-muted-foreground" role="status">
Last synced {formatSyncedAt(syncedAt)}
{!status.running && status.phase === "complete" && status.reused > 0
? ` · ${status.reused} of ${status.total} estimates reused`
: ""}
</span>
)}
</>
}
/>
)}
</div>
<PageHeaderDescription>
{summary
? `${summary.start} through ${summary.end} · UTC`
: "Compare gateway spend with estimated engineering effort for merged pull requests"}
{syncedAt && (
<span className="mt-1 block text-xs text-muted-foreground" role="status">
Last synced {formatSyncedAt(syncedAt)}
{!status.running && status.phase === "complete" && status.reused > 0
? ` · ${status.reused} of ${status.total} estimates reused`
: ""}
</span>
)}
</PageHeaderDescription>
</PageHeader>
{sampleSummary && <DemoNotice onExit={() => setSampleSummary(null)} />}
{liveSummary && showLiveStatus && (
<p className="text-xs text-muted-foreground">
@ -365,6 +366,6 @@ export default function ROICalculatorView({
onSave={updateIdentity}
/>
)}
</main>
</Page>
);
}

View file

@ -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 (
<div className="flex flex-col gap-4">
<PageContent>
{!premiumUser && (
<Alert>
<Alert className="shrink-0">
<Info />
<AlertTitle>Coming soon to Enterprise</AlertTitle>
<AlertDescription>
@ -31,6 +32,6 @@ export default function DeletedKeysPage() {
pagination={pagination}
onPaginationChange={setPagination}
/>
</div>
</PageContent>
);
}

View file

@ -44,6 +44,7 @@ export function DeletedKeysTable({
return (
<DataTable
fillHeight
data={keys}
columns={columns}
getRowId={(key, index) => key.token || String(index)}

View file

@ -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 (
<div className="flex flex-col gap-4">
<PageContent>
{!premiumUser && (
<Alert>
<Alert className="shrink-0">
<Info />
<AlertTitle>Coming soon to Enterprise</AlertTitle>
<AlertDescription>
@ -34,6 +35,6 @@ export default function DeletedTeamsPage() {
onPaginationChange={setPagination}
rowCount={teamsData?.total ?? 0}
/>
</div>
</PageContent>
);
}

View file

@ -44,6 +44,7 @@ export function DeletedTeamsTable({
return (
<DataTable
fillHeight
data={teams}
columns={columns}
getRowId={(team, index) => team.team_id || String(index)}

View file

@ -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(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />, {
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();
});

View file

@ -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<TeamProps> = ({ accessToken, userID, userRole, premiumUser
];
return (
<main className={selectedTeamId ? "px-12 py-6" : "flex h-full flex-col p-8"}>
<Page className={selectedTeamId ? undefined : "h-full"}>
{selectedTeamId ? (
<TeamInfoView
teamId={selectedTeamId}
@ -672,43 +674,38 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
premiumUser={premiumUser}
/>
) : (
<Tabs defaultValue={tabItems[0].key} className="min-h-0 flex-1 gap-6">
<PageHeader
icon={<Users />}
title="Teams"
subtitle="Manage teams, members, and their access to models and budgets"
primaryAction={
canCreateOrManageTeams(userRole, userID, organizations) ? (
<UIButton onClick={openCreateTeamModal} data-testid="create-team-button">
<Plus className="size-4" />
Create Team
</UIButton>
) : undefined
}
tabs={({ leadingControls }) => (
<TabsList
variant="line"
className="gap-0 p-0 [&>[data-slot=tabs-trigger]+[data-slot=tabs-trigger]]:ml-[22px]"
>
{leadingControls}
<PageTabs defaultValue={tabItems[0].key}>
<PageHeader>
<PageHeaderTitle>
<Users />
Teams
</PageHeaderTitle>
<PageHeaderDescription>Manage teams, members, and their access to models and budgets</PageHeaderDescription>
<PageHeaderControls>
<PageTabsList>
{canCreateOrManageTeams(userRole, userID, organizations) && (
<>
<UIButton onClick={openCreateTeamModal} data-testid="create-team-button">
<Plus className="size-4" />
Create Team
</UIButton>
<ToolbarSeparator className="mx-0 h-6" />
</>
)}
{tabItems.map((item) => (
<TabsTrigger
key={item.key}
value={item.key}
className="flex-none px-0 py-[7px] data-active:font-semibold"
>
<PageTabsTrigger key={item.key} value={item.key}>
{item.label}
</TabsTrigger>
</PageTabsTrigger>
))}
</TabsList>
)}
/>
</PageTabsList>
</PageHeaderControls>
</PageHeader>
{tabItems.map((item) => (
<TabsContent key={item.key} value={item.key} className={item.className}>
{item.children}
</TabsContent>
))}
</Tabs>
</PageTabs>
)}
{canCreateOrManageTeams(userRole, userID, organizations) && (
@ -1312,7 +1309,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
</DialogContent>
</Dialog>
)}
</main>
</Page>
);
};

View file

@ -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 (
<div className="flex min-h-0 flex-1 flex-col gap-6">
<PageHeader
icon={<KeyRound />}
title="Virtual Keys"
subtitle="Every key that authenticates requests to the gateway."
primaryAction={headerActions}
/>
<PageContent>
<PageHeader>
<PageHeaderTitle>
<KeyRound />
Virtual Keys
</PageHeaderTitle>
<PageHeaderDescription>Every key that authenticates requests to the gateway.</PageHeaderDescription>
{headerActions != null && <PageHeaderControls>{headerActions}</PageHeaderControls>}
</PageHeader>
<DataTable
data={keyList}
columns={columns}
@ -338,6 +341,6 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
</>
)}
/>
</div>
</PageContent>
);
}

View file

@ -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(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} fillHeight />);
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(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} />);
const scroller = screen.getByTestId("data-table-scroller");

View file

@ -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<TData, TValue>(column: ColumnDef<TData, TValue>): string | undefined {
if ("id" in column && typeof column.id === "string") {
@ -314,14 +329,23 @@ function DataTableBodyRow<TData>({
);
}
function MessageRow({ colSpan, children }: { colSpan: number; children: React.ReactNode }) {
function MessageRow({
colSpan,
children,
stretch = false,
}: {
colSpan: number;
children: React.ReactNode;
stretch?: boolean;
}) {
return (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={colSpan}
className="h-24 text-center align-middle text-sm whitespace-normal text-muted-foreground"
>
{children}
<TableCell colSpan={colSpan} className={messageCell({ stretch })}>
{stretch ? (
<div className="sticky left-0 flex h-full w-[100cqw] items-center justify-center">{children}</div>
) : (
children
)}
</TableCell>
</TableRow>
);
@ -598,8 +622,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
const rows = table.getRowModel().rows;
const visibleColumnCount = table.getVisibleLeafColumns().length;
const stickyHeader = maxBodyHeight !== undefined || fillHeight;
const fill = fillHeight ? FILL_CLASSES : NO_FILL_CLASSES;
const sticky = stickyHeader ? STICKY_CLASSES : NO_STICKY_CLASSES;
const stretchEmpty = fillHeight && !isLoading && rows.length === 0;
const tableStyle = enableColumnResizing ? { width: table.getTotalSize(), minWidth: "100%" } : undefined;
const renderPagination = (): React.ReactNode => {
@ -636,7 +659,11 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
);
}
if (rows.length === 0) {
return <MessageRow colSpan={visibleColumnCount}>{noDataMessage ?? <DefaultEmptyState />}</MessageRow>;
return (
<MessageRow colSpan={visibleColumnCount} stretch={stretchEmpty}>
{noDataMessage ?? <DefaultEmptyState />}
</MessageRow>
);
}
return rows.map((row) => (
<DataTableBodyRow
@ -655,19 +682,16 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
const paginationNode = renderPagination();
return (
<div data-testid="data-table-root" className={cn("w-full", fill.outer)}>
<div data-testid="data-table-frame" className={cn("overflow-hidden rounded-lg border border-border", fill.frame)}>
<div data-testid="data-table-root" className={dataTableRoot({ fill: fillHeight })}>
<div data-testid="data-table-frame" className={dataTableFrame({ fill: fillHeight })}>
{toolbar !== undefined && <div className="shrink-0 border-b border-border px-4 py-3">{toolbar(table)}</div>}
<div
data-testid="data-table-scroller"
className={cn(stickyHeader ? "overflow-auto" : "overflow-x-auto", sticky.body, fill.body)}
className={dataTableScroller({ sticky: stickyHeader, fill: fillHeight, stretchEmpty })}
style={maxBodyHeight !== undefined ? { maxHeight: maxBodyHeight } : undefined}
>
<TableRoot className={enableColumnResizing ? "table-fixed" : ""} style={tableStyle}>
<TableHeader
data-testid="data-table-head"
className={cn(stickyHeader ? "sticky top-0 z-sticky" : "", sticky.header)}
>
<TableRoot className={dataTableTable({ resizable: enableColumnResizing, stretchEmpty })} style={tableStyle}>
<TableHeader data-testid="data-table-head" className={dataTableHeader({ sticky: stickyHeader })}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50">
{headerGroup.headers.map((header) => (
@ -682,7 +706,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
</TableRow>
))}
</TableHeader>
<TableBody>{renderBody()}</TableBody>
<TableBody className={dataTableBody({ stretchEmpty })}>{renderBody()}</TableBody>
{footer !== undefined && <TableFooter>{footer(table)}</TableFooter>}
</TableRoot>
</div>

View file

@ -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 <div className={cn("flex min-h-0 min-w-0 w-full flex-col gap-6 p-4 sm:p-6", className)} {...props} />;
}
export function PageContent({ className, ...props }: ComponentProps<"div">) {
return <div className={cn("flex min-h-0 min-w-0 flex-1 flex-col gap-6", className)} {...props} />;
}
export function PageTabs({ className, ...props }: ComponentProps<typeof Tabs>) {
return <Tabs className={cn("min-h-0 min-w-0 flex-1 gap-6", className)} {...props} />;
}
export function PageTabsList({ className, ...props }: ComponentProps<typeof TabsList>) {
return (
<TabsList
variant="line"
className={cn("h-10 w-full shrink-0 justify-start gap-6 overflow-x-auto border-b border-border p-0", className)}
{...props}
/>
);
}
export function PageTabsTrigger({ className, ...props }: ComponentProps<typeof TabsTrigger>) {
return (
<TabsTrigger
className={cn("flex-none px-0 font-normal group-data-horizontal/tabs:after:bottom-0", className)}
{...props}
/>
);
}
export function PageTabsContent({ className, ...props }: ComponentProps<typeof TabsContent>) {
return <TabsContent className={cn("flex min-h-0 min-w-0 flex-1 flex-col", className)} {...props} />;
}

View file

@ -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: <span>Teams icon</span>,
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(<PageHeader {...identity} />);
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(<PageHeader {...identity} />);
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(
<PageHeader
{...identity}
primaryAction={<button>Create Team</button>}
tabs={
<div role="tablist">
<button role="tab">Your Teams</button>
</div>
}
utilities={<button>Refresh</button>}
/>,
<PageHeader>
<PageHeaderTitle>
<Users />
Teams
</PageHeaderTitle>
<PageHeaderDescription>Manage teams</PageHeaderDescription>
</PageHeader>,
);
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(
<PageHeader>
<PageHeaderTitle>Teams</PageHeaderTitle>
<PageHeaderControls>
<button>Create Team</button>
<button>Refresh</button>
</PageHeaderControls>
</PageHeader>,
);
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(<PageHeader {...identity} primaryAction={<button>Create Team</button>} />);
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(
<PageHeader
{...identity}
primaryAction={<button>Create Team</button>}
tabs={({ leadingControls, utilities }) => (
<div role="tablist">
{leadingControls}
<button role="tab">Your Teams</button>
{utilities}
</div>
)}
utilities={<button>Refresh</button>}
/>,
<PageHeader data-testid="header">
<PageHeaderTitle id="page-title">Teams</PageHeaderTitle>
<PageHeaderControls aria-label="Team controls" />
</PageHeader>,
);
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();
});
});

View file

@ -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 <div className={cn("shrink-0", className)} {...props} />;
}
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 : (
<div className="flex h-9 items-center">
{primaryAction}
{tabs != null && <ToolbarSeparator className="mx-4 h-6" />}
</div>
);
const utilityControls = utilities == null ? null : <div className="flex items-center gap-2">{utilities}</div>;
const hasControlRow = primaryAction != null || tabs != null || utilities != null;
export function PageHeaderTitle({ className, ...props }: ComponentProps<"h1">) {
return (
<div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="flex size-5 flex-none items-center justify-center text-foreground [&_svg]:size-5 [&_svg]:stroke-[1.75]"
>
{icon}
</span>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
</div>
{titleAction}
</div>
<p className="mt-1.5 text-sm text-muted-foreground">{subtitle}</p>
{typeof tabs === "function" ? (
<div className="mt-5">{tabs({ leadingControls, utilities: utilityControls })}</div>
) : (
hasControlRow && (
<div className="mt-5 flex h-9 items-center" role="group" aria-label="Page controls">
{leadingControls}
{tabs}
{utilityControls != null && <div className="ml-auto">{utilityControls}</div>}
</div>
)
<h1
className={cn(
"flex items-center gap-2.5 text-2xl font-semibold tracking-tight text-foreground [&_svg]:size-5 [&_svg]:flex-none [&_svg]:stroke-[1.75]",
className,
)}
</div>
{...props}
/>
);
}
export function PageHeaderDescription({ className, ...props }: ComponentProps<"p">) {
return <p className={cn("mt-1.5 text-sm text-muted-foreground", className)} {...props} />;
}
export function PageHeaderControls({ className, ...props }: ComponentProps<"div">) {
return (
<div
role="group"
aria-label="Page controls"
className={cn("mt-5 flex min-h-9 items-center gap-2", className)}
{...props}
/>
);
}

View file

@ -100,7 +100,7 @@ export default function AuditLogsPanel({
if (!premiumUser) {
return (
<div style={{ textAlign: "center", marginTop: "20px" }}>
<div className="flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto text-center">
<h1 style={{ display: "block", marginBottom: "10px" }}>✨ Enterprise Feature.</h1>
<p style={{ display: "block", marginBottom: "10px" }}>
This is a LiteLLM Enterprise feature, and requires a valid key to use.
@ -128,10 +128,6 @@ export default function AuditLogsPanel({
return (
<>
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">Audit Logs</h1>
</div>
<AuditLogsTable
data={query.data?.audit_logs ?? []}
rowCount={query.data?.total ?? 0}

View file

@ -117,6 +117,7 @@ export function AuditLogsTable({
return (
<DataTable
fillHeight
data={data}
columns={columns}
getRowId={(row) => row.id}

View file

@ -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 <div className={cn("flex flex-wrap items-center gap-2", className)} {...props} />;
}
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 (
<div className="flex flex-wrap items-center gap-2">
<Popover open={quickSelectOpen} onOpenChange={setQuickSelectOpen}>
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="gap-2">
@ -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}
</Button>
@ -92,10 +82,7 @@ export function LogsTableToolbar({
<Button
variant="ghost"
className="w-full justify-start font-normal"
onClick={() => {
onIsCustomDateChange(!isCustomDate);
onResetToFirstPage();
}}
onClick={() => onValueChange({ ...value, isCustomDate: !value.isCustomDate })}
>
Custom Range
</Button>
@ -103,58 +90,32 @@ export function LogsTableToolbar({
</PopoverContent>
</Popover>
{isCustomDate && (
{value.isCustomDate && (
<div className="flex items-center gap-2">
<Input
type="datetime-local"
className="w-auto"
value={startTime}
onChange={(event) => {
onStartTimeChange(event.target.value);
onResetToFirstPage();
}}
value={value.startTime}
onChange={(event) => onValueChange({ ...value, startTime: event.target.value })}
/>
<span className="text-sm text-muted-foreground">to</span>
<Input
type="datetime-local"
className="w-auto"
value={endTime}
onChange={(event) => {
onEndTimeChange(event.target.value);
onResetToFirstPage();
}}
value={value.endTime}
onChange={(event) => onValueChange({ ...value, endTime: event.target.value })}
/>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Live Tail</span>
<Switch checked={isLiveTail} onCheckedChange={onIsLiveTailChange} aria-label="Live Tail" />
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Hide Health Checks</span>
<Switch
checked={excludeInternalHealthChecks}
onCheckedChange={onExcludeInternalHealthChecksChange}
aria-label="Hide Health Checks"
/>
</div>
<Button variant="outline" size="sm" onClick={onResetFilters}>
Reset Filters
</Button>
</div>
</>
);
}
export function LiveTailBanner({ onStop }: { onStop: () => void }) {
export function LogsToolbarSwitch({ label, ...props }: ComponentProps<typeof Switch> & { label: string }) {
return (
<div className="mb-4 flex items-center justify-between rounded-md border border-success/20 bg-success/10 px-4 py-2">
<span className="text-sm text-success">Auto-refreshing every 15 seconds</span>
<button type="button" onClick={onStop} className="text-sm text-success hover:text-success/80">
Stop
</button>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{label}</span>
<Switch aria-label={label} {...props} />
</div>
);
}

View file

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

View file

@ -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<ColumnFiltersState>([]);
const [sessionCursors, setSessionCursors] = useState<Record<number, string>>({});
const [startTime, setStartTime] = useState<string>(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"));
const [endTime, setEndTime] = useState<string>(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<LogsTimeRange>(defaultLogsTimeRange);
const { startTime, endTime, isCustomDate } = timeRange;
const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState<string | null>(null);
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(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 (
<AutoRouterModelGroupsProvider>
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">Request Logs</h1>
</div>
{isLiveTail && pagination.pageIndex === 0 && <LiveTailBanner onStop={() => setIsLiveTail(false)} />}
<RequestLogsTable
data={rows}
rowCount={rowCount}
@ -314,22 +317,23 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
teams={allTeams ?? []}
logsWindow={logsWindow}
toolbarChildren={
<LogsTableToolbar
startTime={startTime}
onStartTimeChange={setStartTime}
endTime={endTime}
onEndTimeChange={setEndTime}
isCustomDate={isCustomDate}
onIsCustomDateChange={setIsCustomDate}
selectedTimeInterval={selectedTimeInterval}
onSelectedTimeIntervalChange={setSelectedTimeInterval}
isLiveTail={isLiveTail}
onIsLiveTailChange={setIsLiveTail}
excludeInternalHealthChecks={excludeInternalHealthChecks}
onExcludeInternalHealthChecksChange={handleExcludeInternalHealthChecksChange}
onResetToFirstPage={resetToFirstPage}
onResetFilters={handleResetFilters}
/>
<LogsToolbar>
<LogsTimeRangePicker value={timeRange} onValueChange={handleTimeRangeChange} />
<LogsToolbarSwitch label="Live Tail" checked={isLiveTail} onCheckedChange={setIsLiveTail} />
{isLiveTail && pagination.pageIndex === 0 && (
<span role="status" className="whitespace-nowrap text-xs text-muted-foreground">
Refreshing every 15s
</span>
)}
<LogsToolbarSwitch
label="Hide Health Checks"
checked={excludeInternalHealthChecks}
onCheckedChange={handleExcludeInternalHealthChecksChange}
/>
<Button variant="outline" size="sm" onClick={handleResetFilters}>
Reset Filters
</Button>
</LogsToolbar>
}
/>

View file

@ -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<LogsTabId>(REQUEST_LOGS_TAB.id);
const canViewAuditLogs = useCan("viewAuditLogs");
const canViewDeletedTeams = useCan("viewDeletedTeams");
if (!accessToken || !token || !userRole || !userID) {
return (
<div role="status" aria-busy="true" aria-label="Loading" className="flex h-64 items-center justify-center">
<UiLoadingSpinner className="size-8 text-primary" />
</div>
);
}
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 (
<RequestLogsPanel
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userID}
isActive={activeTab === "request logs"}
/>
);
case "audit logs":
return (
<AuditLogsPanel
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
isActive={activeTab === "audit logs"}
premiumUser={premiumUser}
/>
);
case "deleted keys":
return <DeletedKeysPage />;
case "deleted teams":
return <DeletedTeamsPage />;
}
};
return (
<div className="flex h-full w-full flex-col px-4 pb-4">
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as LogsTabId)}
className="min-h-0 flex-1 gap-0"
>
<TabsList variant="line" className="-mx-4 h-10 w-auto justify-start gap-4 border-b border-border px-4">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="flex-none gap-1.5 px-0 text-[13px] font-normal">
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} keepMounted className={tabContentClassName(tab.id)}>
{renderPanel(tab.id)}
</TabsContent>
))}
</Tabs>
</div>
);
}