From 79b2a5e56e4a2eb05a26bc5e7a66638b15825006 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 31 Jul 2026 12:55:16 -0700 Subject: [PATCH] feat(ui): put the budgets CTA in the tab bar and scroll the rows, not the page The create button now sits in the tab bar beside the tabs, the way Teams lays it out, with one divider between them and the rule running the full width underneath. Adds a fillHeight mode to DataTable that treats the parent's height as a ceiling rather than a target, so the table still sizes to its rows and a short one keeps its footer under the last row, while a long one scrolls its rows under a sticky header instead of scrolling the page. This replaces the hardcoded viewport-height caps those tables would otherwise need. Two details the mode has to fix: the Table primitive's own overflow container would capture the sticky header, and rows would show through the semi-transparent header tint. --- .../budgets/_components/BudgetTable.tsx | 1 + .../budgets/_components/budget_panel.tsx | 46 ++++++++++--------- .../shared/DataTable/DataTable.test.tsx | 38 +++++++++++++++ .../components/shared/DataTable/DataTable.tsx | 34 ++++++++++---- .../src/components/shared/DataTable/types.ts | 6 +++ 5 files changed, 96 insertions(+), 29 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.tsx index 88e40dfba2f..80872c58d28 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.tsx @@ -226,6 +226,7 @@ const BudgetTable: React.FC = ({ list, canModify, onEditClick, columns={columns} getRowId={(budget, index) => budget.budget_id || String(index)} defaultColumnVisibility={BUDGET_TABLE_HIDDEN_COLUMNS} + fillHeight sortingMode="server" sorting={list.sorting} onSortingChange={list.onSortingChange} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx index fbcbd501313..e49f0ffc722 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx @@ -7,6 +7,7 @@ import { Plus, Wallet } from "lucide-react"; import React, { useCallback, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { PageHeader } 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,31 +79,34 @@ const BudgetPanel: React.FC = ({ accessToken }) => { }; return ( -
+
} title="Budgets" subtitle="Spend, TPM and RPM limits you can assign to customers." /> - {canModify && ( -
- + +
+ {canModify && ( + <> + + + + )} + + + Budgets + + + Examples + +
- )} - - - - Budgets - - - Examples - - - -
+ +
{selectedBudget && ( = ({ accessToken }) => { />
- -
+ +

How to use budget id

diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx index 60547415908..8555a10c326 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx @@ -625,6 +625,44 @@ describe("DataTable layout", () => { const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement; expect(scroller.style.maxHeight).toBe("240px"); }); + + it("caps fillHeight at the parent's height instead of stretching to it, so a short table stays short", () => { + const { container } = render(); + const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement; + const frame = scroller.parentElement as HTMLElement; + const outer = frame.parentElement as HTMLElement; + + // A ceiling, not a stretch: flex-1 here would hold the footer at the bottom on a two-row table. + expect(outer.className).toContain("max-h-full"); + expect(outer.className).not.toContain("flex-1"); + expect(frame.className).not.toContain("flex-1"); + expect(scroller.className).not.toContain("flex-1"); + + expect(outer.className).toContain("flex-col"); + expect(frame.className).toContain("flex-col"); + expect(scroller.className).toContain("min-h-0"); + expect(scroller.className).toContain("overflow-auto"); + expect(scroller.style.maxHeight).toBe(""); + // Without this the Table primitive's own overflow container captures the sticky header. + expect(scroller.className).toContain("[&_[data-slot=table-container]]:overflow-visible"); + + const thead = container.querySelector("thead") as HTMLElement; + expect(thead.className).toContain("sticky"); + // Rows pass under the header, so the semi-transparent row tint alone would let them show through. + expect(thead.className).toContain("bg-background"); + }); + + it("leaves the default layout untouched when neither height mode is set", () => { + const { container } = render(); + const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement; + + expect(scroller.className).toContain("overflow-x-auto"); + expect(scroller.className).not.toContain("min-h-0"); + expect(scroller.style.maxHeight).toBe(""); + expect((scroller.parentElement as HTMLElement).className).not.toContain("flex-col"); + expect(container.querySelector("thead")?.className).not.toContain("sticky"); + expect(container.querySelector("thead")?.className).not.toContain("bg-background"); + }); }); describe("DataTable misconfiguration guards", () => { diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx index c4799594465..8cd0e25dfc4 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -48,6 +48,22 @@ 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. `table-container` is the Table primitive's own overflow-x wrapper; left as a scroll box it + * captures the sticky header and the header scrolls away with the rows. And rows pass under that + * header, which the semi-transparent header row tint alone would not hide. + */ +const FILL_CLASSES = { + outer: "flex max-h-full min-h-0 flex-col", + frame: "flex min-h-0 flex-col", + body: "min-h-0 [&_[data-slot=table-container]]:overflow-visible", + header: "bg-background", +} as const; + +const NO_FILL_CLASSES = { outer: "", frame: "", body: "", header: "" } as const; + export class DataTableConfigError extends Error { constructor(messages: readonly string[]) { super(`DataTable misconfiguration:\n- ${messages.join("\n- ")}`); @@ -538,6 +554,7 @@ export function DataTable(props: DataTableProps(props: DataTableProps { @@ -604,15 +622,15 @@ export function DataTable(props: DataTableProps -
- {toolbar !== undefined &&
{toolbar(table)}
} +
+
+ {toolbar !== undefined &&
{toolbar(table)}
}
- + {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( @@ -631,7 +649,7 @@ export function DataTable(props: DataTableProps{footer(table)}}
- {paginationNode !== null &&
{paginationNode}
} + {paginationNode !== null &&
{paginationNode}
}
); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts index 40f3a4df204..dd578f4df45 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts +++ b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts @@ -69,6 +69,12 @@ export interface DataTableProps { rowClassName?: (row: Row) => string; maxBodyHeight?: number | string; + /** + * Scroll the rows inside whatever height the parent gives the table, rather than growing the page. + * The table becomes a flex column, so the parent must be a height-constrained flex container; without + * one it degrades to the normal auto-height layout. Use instead of `maxBodyHeight` to avoid a magic number. + */ + fillHeight?: boolean; size?: DataTableSize; toolbar?: (table: Table) => React.ReactNode;