mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
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:
parent
b28ce93d2b
commit
4b9f9903f3
32 changed files with 661 additions and 686 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export function DeletedKeysTable({
|
|||
|
||||
return (
|
||||
<DataTable
|
||||
fillHeight
|
||||
data={keys}
|
||||
columns={columns}
|
||||
getRowId={(key, index) => key.token || String(index)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export function DeletedTeamsTable({
|
|||
|
||||
return (
|
||||
<DataTable
|
||||
fillHeight
|
||||
data={teams}
|
||||
columns={columns}
|
||||
getRowId={(team, index) => team.team_id || String(index)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
40
ui/litellm-dashboard/src/components/shared/Page.tsx
Normal file
40
ui/litellm-dashboard/src/components/shared/Page.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ export function AuditLogsTable({
|
|||
|
||||
return (
|
||||
<DataTable
|
||||
fillHeight
|
||||
data={data}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue