mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
feat(ui): rebuild the Virtual Keys table on the shared DataTable (#32991)
* feat(ui): rebuild the Virtual Keys table on the shared DataTable Replaces the hand-rolled Tremor table and bespoke toolbar/pagination on the admin Virtual Keys page with the shared DataTable: server-side sort, paginate, and filter, a sticky scrolling body, a search plus column-visibility plus filters toolbar, a right-side filter drawer, and a rows-per-page footer. A page header with the existing key icon carries the Create New Key action. Adds reusable, shadcn-default building blocks for the tables migrating onto the DataTable next: shared IdentityCell, ModelsCell, and SpendBudgetCell in shared/table_cells, plus a shared PageHeader. The models cell reveals overflow in a hover tooltip and the spend/budget cell uses the Meter primitive. All data and domain logic is preserved, including the useKeys query, team and org alias resolution, the user popover, and the KeyInfoView detail swap. The rich async Team/Org/Alias filters move into the drawer, and the toolbar search maps to the key-alias substring search. Status now also reflects key expiry alongside blocked and SCIM-blocked. The VirtualKeysTable tests are updated to the new markup and extended with focused coverage for each new shared cell * fix(ui): address Virtual Keys redesign review feedback Fold the status badge into the clickable Key cell and drop the separate Status column so a key's alias, secret, and status read as one unit. The Key cell is now the single click target that opens the key detail; the whole-row click is removed Migrate the filter drawer off AntD to shadcn. A new Combobox composed from Popover and Input backs the Team, Organization, and Key Alias filters, keeping search and the alias infinite-scroll Show $0.00 for zero spend instead of a hyphen, and extend the shared DataTable with badge, chips, and meter skeleton shapes so the loading state matches the loaded cells (status pill, model chips, spend meter) rather than uniform bars Fix key sorting: the Key column sent its column id "key" as sort_by, which /key/list rejects with 400. It now sorts by the backend field key_alias * fix(ui): use the shadcn base combobox and refine the keys filters and skeletons Replace the hand-rolled filter combobox with the supported shadcn Base UI combobox (ui/combobox, added via the CLI and reused through a small SearchSelect wrapper). Its vended input-group and textarea deps are written for React 19 (plain functions with ref-as-prop); this app is on React 18, where those subcomponents drop the refs Base UI passes for focus and anchoring, so InputGroupInput, InputGroupButton, and ComboboxTrigger are adapted to forwardRef. Those ui/ files now diverge from the registry, and a future shadcn add would overwrite the adaptation until the app moves to React 19. Adds class-variance-authority, which input-group needs Give loading skeletons a per-column renderSkeleton escape hatch on the shared DataTable and mirror the Key cell exactly (alias line, secret, status pill), so skeleton rows match the real rows instead of being shorter and simpler Resolve the automated review: the toolbar search and the drawer Key Alias filter both mapped to the key-alias query, so the search silently overrode the drawer value while its chip stayed visible. Consolidate to a single alias search in the toolbar (placeholder now "Search by key alias…") and drop the redundant drawer field. Re-add coverage for the Created By column's alias-over-email display Refine the Team and Organization filters: they match on name and id, so the labels read "Team" and "Organization" rather than "... ID", each option shows the name with the id on a muted second line instead of "name (id)", and the active-filter chip shows the friendly name * chore(ui): drop duplicate class-variance-authority, use the repo cva package in input-group
This commit is contained in:
parent
aa9dcb43cf
commit
fa09cde3c0
23 changed files with 1647 additions and 890 deletions
|
|
@ -1690,14 +1690,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/VirtualKeysPage/VirtualKeysTable.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/activity_metrics.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ const mockKey: KeyResponse = {
|
|||
key_alias: "Test Key Alias",
|
||||
spend: 5.5,
|
||||
max_budget: 100,
|
||||
expires: "2024-12-31T23:59:59Z",
|
||||
expires: "2999-12-31T23:59:59Z",
|
||||
models: ["gpt-3.5-turbo", "gpt-4"],
|
||||
aliases: {},
|
||||
config: {},
|
||||
|
|
@ -154,6 +154,8 @@ const keysResult = (keys: KeyResponse[], data: Partial<KeysResponse> = {}, extra
|
|||
...extra,
|
||||
}) as any;
|
||||
|
||||
const openFilters = () => fireEvent.click(screen.getByRole("button", { name: "Filters" }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
|
|
@ -170,6 +172,12 @@ it("should render VirtualKeysTable component", () => {
|
|||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the page header with the create-key action slot", () => {
|
||||
renderWithProviders(<VirtualKeysTable headerActions={<button>Create New Key</button>} />);
|
||||
expect(screen.getByRole("heading", { name: "Virtual Keys" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Create New Key" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display key information correctly", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
|
|
@ -177,6 +185,7 @@ it("should display key information correctly", async () => {
|
|||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("$5.5000")).toBeInTheDocument();
|
||||
expect(screen.getByText("of $100")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -188,14 +197,49 @@ it("should display user email correctly", async () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should show loading message only on initial load (isPending)", () => {
|
||||
it("shows the user alias over the email in the visible cell when both exist", async () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([{ ...mockKey, user: { user_id: "user-1", user_email: "user@example.com", user_alias: "The User" } }]),
|
||||
);
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
const row = (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement;
|
||||
expect(within(row).getByText("The User")).toBeInTheDocument();
|
||||
expect(within(row).queryByText("user@example.com")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows created_by_user alias over email in the Created By column when it is enabled", async () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([
|
||||
{
|
||||
...mockKey,
|
||||
created_by: "some-uuid",
|
||||
created_by_user: { user_id: "some-uuid", user_email: "creator@example.com", user_alias: "The Creator" },
|
||||
},
|
||||
]),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
// Created By is hidden by default; turn it on via the Columns menu.
|
||||
await user.click(screen.getByRole("button", { name: "Columns" }));
|
||||
await user.click(await screen.findByText("Created By"));
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
const row = (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement;
|
||||
expect(within(row).getByText("The Creator")).toBeInTheDocument();
|
||||
expect(within(row).queryByText("creator@example.com")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a loading state on the initial load and hide the data", () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([], {}, { data: null, isPending: true, isFetching: true }));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
expect(screen.getByText("🚅 Loading keys...")).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading keys...")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("Test Key Alias")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Team")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'No keys found' message when the key list is empty", () => {
|
||||
|
|
@ -206,61 +250,52 @@ it("should show 'No keys found' message when the key list is empty", () => {
|
|||
expect(screen.getByText("No keys found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle models with more than 3 entries to trigger expansion UI", () => {
|
||||
it("collapses models beyond the visible limit into a '+N more' badge", () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([{ ...mockKey, models: ["gpt-3.5-turbo", "gpt-4", "gpt-4-turbo", "claude-3", "claude-3-5-sonnet"] }]),
|
||||
);
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
expect(screen.getByText("+2 more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render table headers correctly", () => {
|
||||
it("should render the redesigned table headers", () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
expect(screen.getByText("Key ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("Key Alias")).toBeInTheDocument();
|
||||
expect(screen.getByText("Key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("Models")).toBeInTheDocument();
|
||||
expect(screen.getByText("Spend (USD)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Spend / Budget")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle column resizing hover events", () => {
|
||||
it("sorts by the backend key_alias field (not the column label) when the Key header is clicked", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
const headerCell = document.querySelector("[data-header-id]") as HTMLElement;
|
||||
expect(headerCell).toBeInTheDocument();
|
||||
const keyHeader = screen.getByText("Key").closest("button") as HTMLElement;
|
||||
fireEvent.click(keyHeader);
|
||||
|
||||
const resizer = headerCell?.querySelector(".resizer") as HTMLElement;
|
||||
expect(resizer).toBeInTheDocument();
|
||||
expect(resizer.style.opacity).toBe("0");
|
||||
|
||||
fireEvent.mouseEnter(headerCell);
|
||||
expect(resizer.style.opacity).toBe("0.5");
|
||||
|
||||
fireEvent.mouseLeave(headerCell);
|
||||
expect(resizer.style.opacity).toBe("0");
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: "key_alias" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("should open KeyInfoView when clicking on a key ID button", async () => {
|
||||
it("should open KeyInfoView when clicking the key cell", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Showing.*results/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pagination-range")).toBeInTheDocument();
|
||||
|
||||
const keyIdButton = screen.getByText("sk-1234567890abcdef");
|
||||
fireEvent.click(keyIdButton);
|
||||
fireEvent.click(screen.getByText("Test Key Alias"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Back to Keys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Created At")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/Showing.*results/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pagination-range")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display 'Default Proxy Admin' for user_id when value is 'default_user_id'", async () => {
|
||||
|
|
@ -282,44 +317,6 @@ it("should display 'Default Proxy Admin' for user_id when value is 'default_user
|
|||
});
|
||||
});
|
||||
|
||||
it("should display created_by_user email in 'Created By' column when available", async () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([
|
||||
{
|
||||
...mockKey,
|
||||
created_by: "some-uuid-1234",
|
||||
created_by_user: { user_id: "some-uuid-1234", user_email: "creator@example.com", user_alias: null },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("creator@example.com")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display created_by_user alias over email when both are available", async () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([
|
||||
{
|
||||
...mockKey,
|
||||
created_by: "some-uuid-1234",
|
||||
created_by_user: { user_id: "some-uuid-1234", user_email: "creator@example.com", user_alias: "The Creator" },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
// Scope to the key's row so we assert the visible cell value: the hover popover that
|
||||
// also holds the email is portaled out of the row, not the displayed "Created By" text.
|
||||
const row = (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement;
|
||||
expect(within(row).getByText("The Creator")).toBeInTheDocument();
|
||||
expect(within(row).queryByText("creator@example.com")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render table without crashing when models is null", async () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, models: null as unknown as string[] }]));
|
||||
|
||||
|
|
@ -327,6 +324,7 @@ it("should render table without crashing when models is null", async () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -341,13 +339,14 @@ it("should display 'Unknown' for last_active when value is null", async () => {
|
|||
});
|
||||
|
||||
describe("server-side filtering – the LIT-4080 regression guard", () => {
|
||||
it("threads an active User ID filter into the useKeys query so any refetch keeps it", async () => {
|
||||
it("threads an applied User ID filter into the useKeys query so any refetch keeps it", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Filters" }));
|
||||
openFilters();
|
||||
|
||||
const userIdInput = await screen.findByPlaceholderText("Enter User ID...");
|
||||
const userIdInput = await screen.findByPlaceholderText(/Enter User ID/);
|
||||
fireEvent.change(userIdInput, { target: { value: "user-42" } });
|
||||
fireEvent.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" }));
|
||||
|
|
@ -361,18 +360,19 @@ describe("server-side filtering – the LIT-4080 regression guard", () => {
|
|||
expect(lastCall[2] ?? {}).toMatchObject({ userID: undefined, teamID: undefined, keyHash: undefined });
|
||||
});
|
||||
|
||||
it("drops the filter from the useKeys query when Reset Filters is clicked", async () => {
|
||||
it("drops the filter from the useKeys query when it is cleared", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Filters" }));
|
||||
const userIdInput = await screen.findByPlaceholderText("Enter User ID...");
|
||||
openFilters();
|
||||
const userIdInput = await screen.findByPlaceholderText(/Enter User ID/);
|
||||
fireEvent.change(userIdInput, { target: { value: "user-42" } });
|
||||
fireEvent.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" }));
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset Filters" }));
|
||||
fireEvent.click(screen.getByTestId("datatable-clear-filters"));
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = mockUseKeys.mock.calls[mockUseKeys.mock.calls.length - 1];
|
||||
|
|
@ -388,8 +388,8 @@ describe("pagination display – total count comes from useKeys", () => {
|
|||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Showing 1 - 50 of 509 results")).toBeInTheDocument();
|
||||
expect(screen.getByText("Page 1 of 11")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 509");
|
||||
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 11");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -399,57 +399,44 @@ describe("pagination display – total count comes from useKeys", () => {
|
|||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Showing 1 - 1 of 1 results")).toBeInTheDocument();
|
||||
expect(screen.getByText("Page 1 of 1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-1 of 1");
|
||||
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refetch button", () => {
|
||||
it("should show Fetch button in normal state", () => {
|
||||
describe("refresh button", () => {
|
||||
it("renders an enabled refresh control in the normal state", () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
const fetchButton = screen.getByTitle("Fetch data");
|
||||
expect(fetchButton).toBeInTheDocument();
|
||||
expect(fetchButton).not.toBeDisabled();
|
||||
expect(screen.getByText("Fetch")).toBeInTheDocument();
|
||||
const refresh = screen.getByTestId("datatable-refresh");
|
||||
expect(refresh).toBeInTheDocument();
|
||||
expect(refresh).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should show Fetching state and keep table data visible during refetch", () => {
|
||||
it("disables the refresh control while a fetch is in flight but keeps data visible", () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([mockKey], {}, { isFetching: true }));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
expect(screen.getByText("Fetching")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Fetch data")).toBeDisabled();
|
||||
expect(screen.getByTestId("datatable-refresh")).toBeDisabled();
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
expect(screen.queryByText("🚅 Loading keys...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call refetch when Fetch button is clicked", () => {
|
||||
it("calls refetch when the refresh control is clicked", () => {
|
||||
const mockRefetch = vi.fn();
|
||||
mockUseKeys.mockReturnValue(keysResult([mockKey], {}, { refetch: mockRefetch }));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
fireEvent.click(screen.getByTitle("Fetch data"));
|
||||
fireEvent.click(screen.getByTestId("datatable-refresh"));
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should show Fetch button enabled on error so user can retry", () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([], {}, { data: null, isError: true }));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
const fetchButton = screen.getByTitle("Fetch data");
|
||||
expect(fetchButton).not.toBeDisabled();
|
||||
expect(screen.getByText("Fetch")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Status column reflects key.blocked / scim_blocked metadata", () => {
|
||||
it("should render Active for a non-blocked key", async () => {
|
||||
describe("Status column reflects blocked / expiry / scim metadata", () => {
|
||||
it("renders Active for a non-blocked, unexpired key", async () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, blocked: false, metadata: {} }]));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
|
@ -459,7 +446,19 @@ describe("Status column reflects key.blocked / scim_blocked metadata", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should render Blocked when key.blocked is true", async () => {
|
||||
it("renders Expired when the expiry date has passed", async () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([{ ...mockKey, blocked: false, metadata: {}, expires: "2020-01-01T00:00:00Z" }]),
|
||||
);
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`key-status-${mockKey.token_id}`)).toHaveTextContent("Expired");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders Blocked when key.blocked is true", async () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, blocked: true, metadata: {} }]));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
|
@ -470,7 +469,7 @@ describe("Status column reflects key.blocked / scim_blocked metadata", () => {
|
|||
expect(screen.queryByText(/Blocked by SCIM/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should mark a SCIM-blocked key with the SCIM tooltip reason", async () => {
|
||||
it("marks a SCIM-blocked key with the SCIM tooltip reason", async () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, blocked: true, metadata: { scim_blocked: true } }]));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,353 @@
|
|||
"use client";
|
||||
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Popover, Typography } from "antd";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DateCell,
|
||||
IdCell,
|
||||
IdentityCell,
|
||||
ModelsCell,
|
||||
SpendBudgetCell,
|
||||
StatusBadge,
|
||||
type StatusTone,
|
||||
} from "@/components/shared/table_cells";
|
||||
|
||||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import { Organization } from "../networking";
|
||||
|
||||
interface KeyStatus {
|
||||
tone: StatusTone;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const getKeyStatus = (key: KeyResponse): KeyStatus => {
|
||||
if (key.blocked === true) {
|
||||
const isScimBlocked = (key.metadata as Record<string, unknown> | null | undefined)?.scim_blocked === true;
|
||||
return {
|
||||
tone: "error",
|
||||
label: "Blocked",
|
||||
tooltip: isScimBlocked
|
||||
? "Blocked by SCIM (external identity provider deactivated or deleted the owning user)."
|
||||
: "Blocked. Requests using this key will be rejected with 401.",
|
||||
};
|
||||
}
|
||||
const expiresAt = key.expires ? Date.parse(key.expires) : Number.NaN;
|
||||
if (!Number.isNaN(expiresAt) && expiresAt < Date.now()) {
|
||||
return { tone: "warning", label: "Expired", tooltip: "This key has passed its expiry date." };
|
||||
}
|
||||
return { tone: "success", label: "Active" };
|
||||
};
|
||||
|
||||
const UserPopoverCell = ({
|
||||
userAlias,
|
||||
userEmail,
|
||||
userId,
|
||||
width,
|
||||
}: {
|
||||
userAlias: string | null;
|
||||
userEmail: string | null;
|
||||
userId: string | null;
|
||||
width: number;
|
||||
}) => {
|
||||
const displayValue = userAlias || userEmail || userId;
|
||||
const isDefaultAdmin = userId === "default_user_id";
|
||||
|
||||
const popoverContent = (
|
||||
<div className="flex flex-col gap-2 text-xs min-w-[200px] max-w-[300px]">
|
||||
{[
|
||||
{ label: "User Alias", value: userAlias },
|
||||
{ label: "User Email", value: userEmail },
|
||||
{ label: "User ID", value: userId },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex flex-col min-w-0">
|
||||
<span className="text-gray-400">{label}</span>
|
||||
{value ? (
|
||||
<Typography.Text className="font-mono text-xs" ellipsis={{ tooltip: value }} copyable>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<span className="font-mono">-</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isDefaultAdmin && !userAlias && !userEmail) {
|
||||
return (
|
||||
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
|
||||
<span className="cursor-default">
|
||||
<DefaultProxyAdminTag userId={userId} />
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
|
||||
<span className="font-mono text-xs truncate block cursor-default" style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{displayValue || "-"}
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const InfoHeader = ({ label, tooltip }: { label: string; tooltip: string }) => (
|
||||
<span className="flex items-center gap-1">
|
||||
{label}
|
||||
<Popover content={tooltip} trigger="hover">
|
||||
<InfoCircleOutlined className="text-gray-400 text-xs cursor-help" />
|
||||
</Popover>
|
||||
</span>
|
||||
);
|
||||
|
||||
interface KeyTableColumnsDeps {
|
||||
allTeams: Team[];
|
||||
organizations: Organization[];
|
||||
onSelectKey: (key: KeyResponse) => void;
|
||||
}
|
||||
|
||||
export const getKeyTableColumns = ({
|
||||
allTeams,
|
||||
organizations,
|
||||
onSelectKey,
|
||||
}: KeyTableColumnsDeps): ColumnDef<KeyResponse>[] => [
|
||||
{
|
||||
id: "key_alias",
|
||||
accessorKey: "key_alias",
|
||||
meta: {
|
||||
title: "Key",
|
||||
renderSkeleton: () => (
|
||||
<div className="flex flex-col gap-1 py-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Key" variant="header-cycle" />,
|
||||
size: 260,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const status = getKeyStatus(row.original);
|
||||
return (
|
||||
<IdentityCell
|
||||
title={row.original.key_alias || "-"}
|
||||
subtitle={row.original.key_name}
|
||||
badge={
|
||||
<StatusBadge
|
||||
tone={status.tone}
|
||||
label={status.label}
|
||||
tooltip={status.tooltip}
|
||||
dataTestId={`key-status-${row.original.token_id}`}
|
||||
/>
|
||||
}
|
||||
onClick={() => onSelectKey(row.original)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "token",
|
||||
accessorKey: "token",
|
||||
meta: { title: "Key ID" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Key ID" variant="header-cycle" />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: (info) => <IdCell value={info.getValue() as string | null} onClick={() => onSelectKey(info.row.original)} />,
|
||||
},
|
||||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_id",
|
||||
meta: { title: "Team" },
|
||||
header: "Team",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const teamId = info.getValue() as string | null;
|
||||
if (!teamId) return "-";
|
||||
const team = allTeams.find((t) => t.team_id === teamId);
|
||||
const displayValue = team?.team_alias || teamId;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{displayValue}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "organization_alias",
|
||||
accessorKey: "org_id",
|
||||
meta: { title: "Organization" },
|
||||
header: "Organization",
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const orgId = info.getValue() as string | null;
|
||||
if (!orgId) return "-";
|
||||
const org = organizations.find((o) => o.organization_id === orgId);
|
||||
const displayValue = org?.organization_alias || orgId;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{displayValue}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "user",
|
||||
accessorKey: "user",
|
||||
meta: { title: "User" },
|
||||
header: () => (
|
||||
<InfoHeader label="User" tooltip="Displays the first available value: User Alias, User Email, or User ID." />
|
||||
),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const key = row.original;
|
||||
return (
|
||||
<UserPopoverCell
|
||||
userAlias={key.user?.user_alias ?? null}
|
||||
userEmail={key.user?.user_email ?? key.user_email ?? null}
|
||||
userId={key.user_id ?? null}
|
||||
width={160}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" variant="header-cycle" />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "created_by",
|
||||
accessorKey: "created_by",
|
||||
meta: { title: "Created By" },
|
||||
header: "Created By",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const userId = info.getValue() as string | null;
|
||||
if (!userId) return "-";
|
||||
const createdByUser = info.row.original.created_by_user;
|
||||
return (
|
||||
<UserPopoverCell
|
||||
userAlias={createdByUser?.user_alias ?? null}
|
||||
userEmail={createdByUser?.user_email ?? null}
|
||||
userId={userId}
|
||||
width={160}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
meta: { title: "Updated At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" variant="header-cycle" />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
|
||||
},
|
||||
{
|
||||
id: "last_active",
|
||||
accessorKey: "last_active",
|
||||
meta: { title: "Last Active" },
|
||||
header: () => (
|
||||
<InfoHeader
|
||||
label="Last Active"
|
||||
tooltip="This is a new field and is not backfilled. Only new key usage will update this value."
|
||||
/>
|
||||
),
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Unknown" />,
|
||||
},
|
||||
{
|
||||
id: "expires",
|
||||
accessorKey: "expires",
|
||||
meta: { title: "Expires" },
|
||||
header: "Expires",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend / Budget", skeleton: "meter" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Spend / Budget" variant="header-cycle" />,
|
||||
size: 180,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const teamId = row.original.team_id;
|
||||
const team = allTeams.find((t) => t.team_id === teamId);
|
||||
return (
|
||||
<SpendBudgetCell
|
||||
spend={row.original.spend}
|
||||
maxBudget={row.original.max_budget}
|
||||
teamMaxBudget={team?.max_budget ?? null}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "budget_reset_at",
|
||||
accessorKey: "budget_reset_at",
|
||||
meta: { title: "Budget Reset" },
|
||||
header: "Budget Reset",
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} fallback="Never" />,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
accessorKey: "models",
|
||||
meta: { title: "Models", skeleton: "chips" },
|
||||
header: "Models",
|
||||
size: 220,
|
||||
enableSorting: false,
|
||||
cell: (info) => <ModelsCell models={info.getValue() as string[] | null | undefined} />,
|
||||
},
|
||||
{
|
||||
id: "rate_limits",
|
||||
meta: { title: "Rate Limits" },
|
||||
header: "Rate Limits",
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const key = row.original;
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<div>TPM: {key.tpm_limit !== null ? key.tpm_limit : "Unlimited"}</div>
|
||||
<div>RPM: {key.rpm_limit !== null ? key.rpm_limit : "Unlimited"}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const KEY_TABLE_HIDDEN_COLUMNS: Record<string, boolean> = {
|
||||
token: false,
|
||||
organization_alias: false,
|
||||
created_by: false,
|
||||
updated_at: false,
|
||||
expires: false,
|
||||
budget_reset_at: false,
|
||||
rate_limits: false,
|
||||
};
|
||||
|
|
@ -303,6 +303,38 @@ describe("DataTable loading", () => {
|
|||
// per-column widths differ instead of every cell sharing one fixed width
|
||||
expect(new Set(bars.map((bar) => bar.className)).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders shape-specific skeletons for badge, chips, and meter columns", () => {
|
||||
const columns: ColumnDef<Person, unknown>[] = [
|
||||
{ id: "badge", header: "Badge", meta: { skeleton: "badge" }, cell: () => null },
|
||||
{ id: "chips", header: "Chips", meta: { skeleton: "chips" }, cell: () => null },
|
||||
{ id: "meter", header: "Meter", meta: { skeleton: "meter" }, cell: () => null },
|
||||
];
|
||||
render(<DataTable data={CHARLIE_ALICE_BOB} columns={columns} isLoading />);
|
||||
|
||||
const firstRow = screen.getAllByTestId("skeleton-row").at(0);
|
||||
const cells = Array.from(firstRow?.querySelectorAll("td") ?? []);
|
||||
const barsIn = (cell: Element | undefined) => cell?.querySelectorAll('[data-slot="skeleton"]').length ?? 0;
|
||||
|
||||
// badge = a single pill, chips = three pills, meter = value bar + track bar
|
||||
expect(barsIn(cells[0])).toBe(1);
|
||||
expect(cells[0]?.querySelector('[data-slot="skeleton"]')?.className).toContain("rounded-full");
|
||||
expect(barsIn(cells[1])).toBe(3);
|
||||
expect(barsIn(cells[2])).toBe(2);
|
||||
});
|
||||
|
||||
it("uses a column's renderSkeleton override when provided", () => {
|
||||
const columns: ColumnDef<Person, unknown>[] = [
|
||||
{
|
||||
id: "custom",
|
||||
header: "Custom",
|
||||
meta: { renderSkeleton: () => <div data-testid="custom-skeleton">loading</div> },
|
||||
cell: () => null,
|
||||
},
|
||||
];
|
||||
render(<DataTable data={CHARLIE_ALICE_BOB} columns={columns} isLoading />);
|
||||
expect(screen.getAllByTestId("custom-skeleton").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataTable column visibility", () => {
|
||||
|
|
|
|||
|
|
@ -338,7 +338,11 @@ const SKELETON_WIDTHS = ["w-[58%]", "w-[44%]", "w-[70%]", "w-[50%]", "w-[64%]",
|
|||
function SkeletonCell<TData>({ column, index }: { column: Column<TData, unknown> | undefined; index: number }) {
|
||||
const meta = column?.columnDef.meta;
|
||||
const width = SKELETON_WIDTHS[index % SKELETON_WIDTHS.length];
|
||||
if (meta?.skeleton === "twoLine") {
|
||||
const shape = meta?.skeleton;
|
||||
if (meta?.renderSkeleton !== undefined) {
|
||||
return <>{meta.renderSkeleton()}</>;
|
||||
}
|
||||
if (shape === "twoLine") {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className={cn("h-3.5", width)} />
|
||||
|
|
@ -346,6 +350,26 @@ function SkeletonCell<TData>({ column, index }: { column: Column<TData, unknown>
|
|||
</div>
|
||||
);
|
||||
}
|
||||
if (shape === "badge") {
|
||||
return <Skeleton className={cn("h-5 w-16 rounded-full", meta?.numeric ? "ml-auto" : "")} />;
|
||||
}
|
||||
if (shape === "chips") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="h-5 w-14 rounded-full" />
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-5 w-9 rounded-full opacity-65" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (shape === "meter") {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
<Skeleton className="h-1.5 w-full rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Skeleton className={cn("h-3.5", width, meta?.numeric ? "ml-auto" : "")} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { RowData } from "@tanstack/react-table";
|
||||
import type * as React from "react";
|
||||
|
||||
import type { ColumnPinnedSide, DataTableSkeletonShape } from "./types";
|
||||
|
||||
|
|
@ -10,5 +11,7 @@ declare module "@tanstack/react-table" {
|
|||
title?: string;
|
||||
pinned?: ColumnPinnedSide;
|
||||
skeleton?: DataTableSkeletonShape;
|
||||
/** Full control over this column's loading skeleton, for cells the built-in shapes can't mirror. */
|
||||
renderSkeleton?: () => React.ReactNode;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export type FilterMode = "none" | "client" | "server";
|
|||
export type ColumnResizeMode = "onEnd" | "onChange";
|
||||
export type DataTableSize = "compact" | "default";
|
||||
export type ColumnPinnedSide = "left" | "right";
|
||||
export type DataTableSkeletonShape = "text" | "twoLine";
|
||||
export type DataTableSkeletonShape = "text" | "twoLine" | "badge" | "chips" | "meter";
|
||||
|
||||
export interface DataTableProps<TData extends RowData, TValue> {
|
||||
data: TData[];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PageHeader } from "./PageHeader";
|
||||
|
||||
describe("PageHeader", () => {
|
||||
it("renders the title as a heading", () => {
|
||||
render(<PageHeader title="Virtual Keys" />);
|
||||
expect(screen.getByRole("heading", { name: "Virtual Keys" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the subtitle, icon, and actions when provided", () => {
|
||||
render(
|
||||
<PageHeader
|
||||
title="Virtual Keys"
|
||||
subtitle="Every key that authenticates requests"
|
||||
icon={<svg data-testid="icon" />}
|
||||
actions={<button>Create New Key</button>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Every key that authenticates requests")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Create New Key" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the optional slots when not provided", () => {
|
||||
render(<PageHeader title="Virtual Keys" />);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(document.querySelector("p")).toBeNull();
|
||||
});
|
||||
});
|
||||
29
ui/litellm-dashboard/src/components/shared/PageHeader.tsx
Normal file
29
ui/litellm-dashboard/src/components/shared/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, icon, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{icon != null && (
|
||||
<span className="flex size-9 flex-none items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">{title}</h1>
|
||||
{subtitle != null && <p className="mt-0.5 text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions != null && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SearchSelect } from "./SearchSelect";
|
||||
|
||||
const OPTIONS = [
|
||||
{ label: "Acme Prod", value: "team-1" },
|
||||
{ label: "Growth", value: "team-2" },
|
||||
{ label: "Data Team", value: "team-3" },
|
||||
];
|
||||
|
||||
describe("SearchSelect", () => {
|
||||
it("renders the placeholder when nothing is selected", () => {
|
||||
render(<SearchSelect options={OPTIONS} onValueChange={vi.fn()} placeholder="Select Team…" />);
|
||||
expect(screen.getByPlaceholderText("Select Team…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the selected option's label in the field", () => {
|
||||
render(<SearchSelect options={OPTIONS} value="team-2" onValueChange={vi.fn()} />);
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Growth");
|
||||
});
|
||||
|
||||
it("shows a clear control only when a value is selected", () => {
|
||||
const { rerender } = render(<SearchSelect options={OPTIONS} onValueChange={vi.fn()} />);
|
||||
expect(document.querySelector('[data-slot="combobox-clear"]')).toBeNull();
|
||||
rerender(<SearchSelect options={OPTIONS} value="team-1" onValueChange={vi.fn()} />);
|
||||
expect(document.querySelector('[data-slot="combobox-clear"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("filters the options client-side as you type", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchSelect options={OPTIONS} onValueChange={vi.fn()} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
await user.type(input, "grow");
|
||||
expect(await screen.findByText("Growth")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Acme Prod")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a muted sublabel and matches it when searching", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SearchSelect
|
||||
options={[{ label: "Acme Prod", value: "team-1", sublabel: "team-abc-123" }]}
|
||||
onValueChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
expect(await screen.findByText("team-abc-123")).toBeInTheDocument();
|
||||
await user.type(input, "abc-123");
|
||||
expect(await screen.findByText("Acme Prod")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects an option and reports its value", async () => {
|
||||
const onValueChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<SearchSelect options={OPTIONS} onValueChange={onValueChange} />);
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(await screen.findByText("Growth"));
|
||||
expect(onValueChange).toHaveBeenCalledWith("team-2");
|
||||
});
|
||||
});
|
||||
76
ui/litellm-dashboard/src/components/shared/SearchSelect.tsx
Normal file
76
ui/litellm-dashboard/src/components/shared/SearchSelect.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
|
||||
export interface SearchSelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
/** Optional muted second line (e.g. an id); also matched when searching. */
|
||||
sublabel?: string;
|
||||
}
|
||||
|
||||
interface SearchSelectProps {
|
||||
options: SearchSelectOption[];
|
||||
value?: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchSelect({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = "Select…",
|
||||
emptyText = "No results",
|
||||
disabled = false,
|
||||
className,
|
||||
}: SearchSelectProps) {
|
||||
const selected = options.find((option) => option.value === value) ?? null;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
items={options}
|
||||
value={selected}
|
||||
onValueChange={(item: SearchSelectOption | null) => onValueChange(item?.value ?? "")}
|
||||
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
|
||||
itemToStringLabel={(item: SearchSelectOption) => item.label}
|
||||
filter={(item: SearchSelectOption, query: string) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return item.label.toLowerCase().includes(q) || (item.sublabel?.toLowerCase().includes(q) ?? false);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder={placeholder}
|
||||
showClear={value != null && value !== ""}
|
||||
className={`w-full ${className ?? ""}`}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item: SearchSelectOption) => (
|
||||
<ComboboxItem key={item.value} value={item}>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.sublabel != null && item.sublabel !== "" && (
|
||||
<span className="truncate text-xs text-muted-foreground">{item.sublabel}</span>
|
||||
)}
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { IdentityCell } from "./identity_cell";
|
||||
|
||||
describe("IdentityCell", () => {
|
||||
it("renders the title", () => {
|
||||
render(<IdentityCell title="prod-gateway" />);
|
||||
expect(screen.getByText("prod-gateway")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the subtitle and an inline badge together", () => {
|
||||
render(<IdentityCell title="prod-gateway" subtitle="sk-...v0Pw" badge={<span>Active</span>} />);
|
||||
expect(screen.getByText("sk-...v0Pw")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the subtitle row when there is no subtitle or badge", () => {
|
||||
render(<IdentityCell title="a" />);
|
||||
expect(document.querySelector("span.font-mono")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a static div (no button) when not clickable", () => {
|
||||
render(<IdentityCell title="a" subtitle="b" />);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a clickable button and fires onClick", async () => {
|
||||
const onClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<IdentityCell title="prod-gateway" subtitle="sk-...v0Pw" onClick={onClick} />);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.querySelector(".lucide-chevron-right")).not.toBeNull();
|
||||
await user.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
interface IdentityCellProps {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
badge?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
}
|
||||
|
||||
export function IdentityCell({ title, subtitle, badge, onClick, className, titleClassName }: IdentityCellProps) {
|
||||
const hasSubtitleRow = (subtitle != null && subtitle !== "") || badge != null;
|
||||
|
||||
const body = (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className={cn("truncate text-sm font-medium text-foreground", titleClassName)}>{title}</span>
|
||||
{hasSubtitleRow && (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{subtitle != null && subtitle !== "" && (
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">{subtitle}</span>
|
||||
)}
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (onClick != null) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn("group flex w-full items-center gap-2 rounded-md py-1 text-left", className)}
|
||||
>
|
||||
{body}
|
||||
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn("min-w-0", className)}>{body}</div>;
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
export { CellTooltip } from "./cell_tooltip";
|
||||
export { DateCell, formatCellDate, formatFullTimestamp, type DatePrecision } from "./date_cell";
|
||||
export { IdCell, type IdCellVariant } from "./id_cell";
|
||||
export { IdentityCell } from "./identity_cell";
|
||||
export { ModelsCell } from "./models_cell";
|
||||
export { MoneyCell } from "./money_cell";
|
||||
export { SpendBudgetCell } from "./spend_budget_cell";
|
||||
export { StatusBadge, type StatusTone } from "./status_badge";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ModelsCell } from "./models_cell";
|
||||
|
||||
describe("ModelsCell", () => {
|
||||
it("shows 'All Proxy Models' when the list is empty, null, or undefined", () => {
|
||||
const { rerender } = render(<ModelsCell models={[]} />);
|
||||
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
|
||||
rerender(<ModelsCell models={null} />);
|
||||
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
|
||||
rerender(<ModelsCell models={undefined} />);
|
||||
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders every model with no overflow badge when at or below the limit", () => {
|
||||
render(<ModelsCell models={["gpt-4o", "claude-sonnet-4-5", "o3-mini"]} maxVisible={3} />);
|
||||
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
|
||||
expect(screen.getByText("claude-sonnet-4-5")).toBeInTheDocument();
|
||||
expect(screen.getByText("o3-mini")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/more$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses models beyond the limit into a '+N more' badge", () => {
|
||||
render(<ModelsCell models={["a", "b", "c", "d", "e"]} maxVisible={2} />);
|
||||
expect(screen.getByText("a")).toBeInTheDocument();
|
||||
expect(screen.getByText("b")).toBeInTheDocument();
|
||||
expect(screen.queryByText("c")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("+3 more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals the hidden models in a tooltip on hover", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelsCell models={["a", "b", "c", "d"]} maxVisible={2} />);
|
||||
await user.hover(screen.getByText("+2 more"));
|
||||
expect(await screen.findByText("c")).toBeInTheDocument();
|
||||
expect(await screen.findByText("d")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels the all-proxy-models wildcard", () => {
|
||||
render(<ModelsCell models={["all-proxy-models"]} />);
|
||||
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import { CellTooltip } from "./cell_tooltip";
|
||||
|
||||
interface ModelsCellProps {
|
||||
models: string[] | null | undefined;
|
||||
maxVisible?: number;
|
||||
}
|
||||
|
||||
const WILDCARD_MODEL = "all-proxy-models";
|
||||
|
||||
const formatModel = (model: string): string => {
|
||||
if (model === WILDCARD_MODEL) {
|
||||
return "All Proxy Models";
|
||||
}
|
||||
const name = getModelDisplayName(model);
|
||||
return name.length > 30 ? `${name.slice(0, 30)}...` : name;
|
||||
};
|
||||
|
||||
export function ModelsCell({ models, maxVisible = 3 }: ModelsCellProps) {
|
||||
if (!Array.isArray(models) || models.length === 0) {
|
||||
return <Badge variant="secondary">All Proxy Models</Badge>;
|
||||
}
|
||||
|
||||
const visible = models.slice(0, maxVisible);
|
||||
const overflow = models.slice(maxVisible);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{visible.map((model, index) => (
|
||||
<Badge key={index} variant={model === WILDCARD_MODEL ? "secondary" : "outline"}>
|
||||
{formatModel(model)}
|
||||
</Badge>
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<CellTooltip
|
||||
content={
|
||||
<div className="flex max-w-[280px] flex-col gap-0.5">
|
||||
{overflow.map((model, index) => (
|
||||
<span key={index}>{formatModel(model)}</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
trigger={
|
||||
<Badge variant="outline" className="cursor-default">
|
||||
+{overflow.length} more
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SpendBudgetCell } from "./spend_budget_cell";
|
||||
|
||||
const indicator = (container: HTMLElement) => container.querySelector('[data-slot="meter-indicator"]');
|
||||
|
||||
describe("SpendBudgetCell", () => {
|
||||
it("shows Unlimited and renders no meter when there is no budget", () => {
|
||||
const { container } = render(<SpendBudgetCell spend={0.5} maxBudget={null} />);
|
||||
expect(screen.getByText("· Unlimited")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("meter")).not.toBeInTheDocument();
|
||||
expect(indicator(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows $0.00 for zero or undefined spend, never a hyphen", () => {
|
||||
const { rerender } = render(<SpendBudgetCell spend={0} maxBudget={100} />);
|
||||
expect(screen.getByText("$0.00")).toBeInTheDocument();
|
||||
expect(screen.queryByText("-")).not.toBeInTheDocument();
|
||||
rerender(<SpendBudgetCell spend={null} maxBudget={null} />);
|
||||
expect(screen.getByText("$0.00")).toBeInTheDocument();
|
||||
expect(screen.queryByText("-")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a meter carrying the spend and budget when a budget exists", () => {
|
||||
render(<SpendBudgetCell spend={25} maxBudget={100} />);
|
||||
const meter = screen.getByRole("meter");
|
||||
expect(meter).toHaveAttribute("aria-valuenow", "25");
|
||||
expect(meter).toHaveAttribute("aria-valuemax", "100");
|
||||
expect(screen.getByText("of $100")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the default tone below 80% usage", () => {
|
||||
const { container } = render(<SpendBudgetCell spend={50} maxBudget={100} />);
|
||||
expect(indicator(container)?.className).toContain("bg-primary");
|
||||
});
|
||||
|
||||
it("switches to the warning tone at 80% usage", () => {
|
||||
const { container } = render(<SpendBudgetCell spend={80} maxBudget={100} />);
|
||||
expect(indicator(container)?.className).toContain("bg-amber-500");
|
||||
});
|
||||
|
||||
it("switches to the over tone above 100% usage", () => {
|
||||
const { container } = render(<SpendBudgetCell spend={150} maxBudget={100} />);
|
||||
expect(indicator(container)?.className).toContain("bg-destructive");
|
||||
});
|
||||
|
||||
it("falls back to the team budget and labels it", () => {
|
||||
render(<SpendBudgetCell spend={10} maxBudget={null} teamMaxBudget={200} />);
|
||||
expect(screen.getByText("of $200 (Team)")).toBeInTheDocument();
|
||||
expect(screen.getByRole("meter")).toHaveAttribute("aria-valuemax", "200");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter";
|
||||
import { formatNumberWithCommas, getSpendString } from "@/utils/dataUtils";
|
||||
|
||||
interface SpendBudgetCellProps {
|
||||
spend: number | null | undefined;
|
||||
maxBudget: number | null | undefined;
|
||||
teamMaxBudget?: number | null;
|
||||
}
|
||||
|
||||
const meterTone = (pct: number): "default" | "warning" | "over" => {
|
||||
if (pct > 100) return "over";
|
||||
if (pct >= 80) return "warning";
|
||||
return "default";
|
||||
};
|
||||
|
||||
export function SpendBudgetCell({ spend, maxBudget, teamMaxBudget }: SpendBudgetCellProps) {
|
||||
const spendValue = typeof spend === "number" && !Number.isNaN(spend) ? spend : 0;
|
||||
const budget = maxBudget ?? teamMaxBudget ?? null;
|
||||
const isTeamBudget = maxBudget == null && teamMaxBudget != null;
|
||||
const hasBudget = typeof budget === "number" && budget > 0;
|
||||
const pct = hasBudget ? (spendValue / budget) * 100 : 0;
|
||||
|
||||
const spendText = spendValue > 0 ? getSpendString(spendValue, 4) : "$0.00";
|
||||
const budgetLabel =
|
||||
budget === null ? "· Unlimited" : `of $${formatNumberWithCommas(budget)}${isTeamBudget ? " (Team)" : ""}`;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[130px] flex-col gap-1">
|
||||
<div className="whitespace-nowrap text-xs">
|
||||
<span className="font-medium tabular-nums text-foreground">{spendText}</span>{" "}
|
||||
<span className="text-muted-foreground">{budgetLabel}</span>
|
||||
</div>
|
||||
{hasBudget && (
|
||||
<Meter value={spendValue} max={budget} aria-valuetext={`${spendText} of $${formatNumberWithCommas(budget)}`}>
|
||||
<MeterTrack>
|
||||
<MeterIndicator tone={meterTone(pct)} />
|
||||
</MeterTrack>
|
||||
</Meter>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
266
ui/litellm-dashboard/src/components/ui/combobox.tsx
Normal file
266
ui/litellm-dashboard/src/components/ui/combobox.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react";
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root;
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
|
||||
}
|
||||
|
||||
const ComboboxTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof ComboboxPrimitive.Trigger>,
|
||||
ComboboxPrimitive.Trigger.Props
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
ComboboxTrigger.displayName = "ComboboxTrigger";
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean;
|
||||
showClear?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
render={<ComboboxTrigger />}
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<ComboboxPrimitive.Positioner.Props, "side" | "align" | "sideOffset" | "alignOffset" | "anchor">) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return <ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null);
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
};
|
||||
140
ui/litellm-dashboard/src/components/ui/input-group.tsx
Normal file
140
ui/litellm-dashboard/src/components/ui/input-group.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { type VariantProps } from "cva";
|
||||
|
||||
import { cn, cva } from "@/lib/cva.config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex h-9 w-full min-w-0 items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva({
|
||||
base: "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start": "order-first pl-2 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem]",
|
||||
"inline-end": "order-last pr-2 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||
"block-end": "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
});
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva({
|
||||
base: "flex items-center gap-2 text-sm shadow-none",
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
});
|
||||
|
||||
const InputGroupButton = React.forwardRef<
|
||||
React.ComponentRef<typeof Button>,
|
||||
Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: "button" | "submit" | "reset";
|
||||
}
|
||||
>(({ className, type = "button", variant = "ghost", size = "xs", ...props }, ref) => {
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
InputGroupButton.displayName = "InputGroupButton";
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const InputGroupInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<"input">>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
InputGroupInput.displayName = "InputGroupInput";
|
||||
|
||||
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea };
|
||||
18
ui/litellm-dashboard/src/components/ui/textarea.tsx
Normal file
18
ui/litellm-dashboard/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
|
|
@ -288,18 +288,21 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
|
|||
<div className="w-full mx-4 h-[75vh]">
|
||||
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
|
||||
<Col numColSpan={1} className="flex flex-col gap-2">
|
||||
{canCreateKey && (
|
||||
<CreateKey
|
||||
key={selectedTeam ? selectedTeam.team_id : null}
|
||||
team={selectedTeam as Team | null}
|
||||
teams={teams as Team[]}
|
||||
data={keys}
|
||||
addKey={addKey}
|
||||
autoOpenCreate={autoOpenCreate}
|
||||
prefillData={prefillData}
|
||||
/>
|
||||
)}
|
||||
<VirtualKeysTable />
|
||||
<VirtualKeysTable
|
||||
headerActions={
|
||||
canCreateKey ? (
|
||||
<CreateKey
|
||||
key={selectedTeam ? selectedTeam.team_id : null}
|
||||
team={selectedTeam as Team | null}
|
||||
teams={teams as Team[]}
|
||||
data={keys}
|
||||
addKey={addKey}
|
||||
autoOpenCreate={autoOpenCreate}
|
||||
prefillData={prefillData}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue