mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(ui): add Teams list CSV export with budgets, model grants, and rate limits (#38436)
* feat(ui): add Teams list CSV export with budgets, model grants, and rate limits Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): neutralize formula-leading values in teams CSV export Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin <yassin@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
02035120e4
commit
1fcdb3d92a
4 changed files with 302 additions and 11 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { useTeamsTable } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFilterDrawer,
|
||||
|
|
@ -9,14 +10,17 @@ import {
|
|||
DataTableToolbar,
|
||||
} from "@/components/shared/DataTable";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { Download } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import { getTeamTableColumns, TEAM_TABLE_HIDDEN_COLUMNS } from "./teamTableColumns";
|
||||
import { exportTeamsToCsv } from "./teamsCsvExport";
|
||||
|
||||
interface TeamsTableProps {
|
||||
userRole: string | null;
|
||||
|
|
@ -49,7 +53,9 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
|
|||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS });
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
const getFilterValue = useCallback(
|
||||
(columnId: string): string | undefined => {
|
||||
|
|
@ -61,16 +67,19 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
|
|||
|
||||
const isAdminView = userRole === "Admin" || userRole === "Admin Viewer";
|
||||
|
||||
const teamListOptions = {
|
||||
organizationID: getFilterValue("org_id"),
|
||||
team_alias: getFilterValue("alias"),
|
||||
teamID: getFilterValue("team_id"),
|
||||
search: searchQuery.trim() || undefined,
|
||||
searchTeamIdMatch: "prefix" as const,
|
||||
userID: isAdminView ? undefined : userID ?? undefined,
|
||||
sortBy: sorting[0]?.id,
|
||||
sortOrder: toSortOrder(sorting),
|
||||
};
|
||||
const teamListOptions = useMemo(
|
||||
() => ({
|
||||
organizationID: getFilterValue("org_id"),
|
||||
team_alias: getFilterValue("alias"),
|
||||
teamID: getFilterValue("team_id"),
|
||||
search: searchQuery.trim() || undefined,
|
||||
searchTeamIdMatch: "prefix" as const,
|
||||
userID: isAdminView ? undefined : userID ?? undefined,
|
||||
sortBy: sorting[0]?.id,
|
||||
sortOrder: toSortOrder(sorting),
|
||||
}),
|
||||
[getFilterValue, searchQuery, isAdminView, userID, sorting],
|
||||
);
|
||||
|
||||
const {
|
||||
data: teamsResponse,
|
||||
|
|
@ -97,6 +106,16 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
|
|||
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}, []);
|
||||
|
||||
const handleExportCsv = useCallback(async () => {
|
||||
if (!accessToken || isExporting) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
await exportTeamsToCsv(accessToken, teamListOptions);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [accessToken, isExporting, teamListOptions]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const columnDeps = { organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam };
|
||||
return getTeamTableColumns(columnDeps);
|
||||
|
|
@ -159,7 +178,18 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
|
|||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
filterLabels={FILTER_LABELS}
|
||||
formatFilterValue={formatFilterValue}
|
||||
/>
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExportCsv}
|
||||
disabled={isExporting}
|
||||
data-testid="teams-export-csv"
|
||||
>
|
||||
<Download />
|
||||
{isExporting ? "Exporting..." : "Export CSV"}
|
||||
</Button>
|
||||
</DataTableToolbar>
|
||||
<DataTableFilterDrawer
|
||||
table={table}
|
||||
open={filtersOpen}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { TeamsResponse } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
||||
import type { Team } from "../key_team_helpers/key_list";
|
||||
import {
|
||||
buildTeamsCsv,
|
||||
buildTeamsCsvRows,
|
||||
collectTeamMemberBudgetIds,
|
||||
fetchAllTeams,
|
||||
TEAMS_EXPORT_PAGE_SIZE,
|
||||
} from "./teamsCsvExport";
|
||||
|
||||
const makeTeam = (overrides: Partial<Team>): Team =>
|
||||
({
|
||||
team_id: "team-1",
|
||||
team_alias: "alias-1",
|
||||
models: [],
|
||||
max_budget: null,
|
||||
budget_duration: null,
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
organization_id: "org-1",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
...overrides,
|
||||
}) as Team;
|
||||
|
||||
const makePage = (teams: Team[], page: number, totalPages: number): TeamsResponse => ({
|
||||
teams,
|
||||
total: teams.length,
|
||||
page,
|
||||
page_size: TEAMS_EXPORT_PAGE_SIZE,
|
||||
total_pages: totalPages,
|
||||
});
|
||||
|
||||
describe("fetchAllTeams", () => {
|
||||
it("returns the single page without extra requests", async () => {
|
||||
const fetchPage = vi.fn().mockResolvedValue(makePage([makeTeam({ team_id: "a" })], 1, 1));
|
||||
const teams = await fetchAllTeams(fetchPage);
|
||||
expect(teams.map((t) => t.team_id)).toEqual(["a"]);
|
||||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||
expect(fetchPage).toHaveBeenCalledWith(1, TEAMS_EXPORT_PAGE_SIZE);
|
||||
});
|
||||
|
||||
it("fetches and concatenates every page in order", async () => {
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockImplementation(async (page: number) => makePage([makeTeam({ team_id: `team-${page}` })], page, 3));
|
||||
const teams = await fetchAllTeams(fetchPage);
|
||||
expect(teams.map((t) => t.team_id)).toEqual(["team-1", "team-2", "team-3"]);
|
||||
expect(fetchPage).toHaveBeenCalledTimes(3);
|
||||
expect(fetchPage).toHaveBeenCalledWith(2, TEAMS_EXPORT_PAGE_SIZE);
|
||||
expect(fetchPage).toHaveBeenCalledWith(3, TEAMS_EXPORT_PAGE_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectTeamMemberBudgetIds", () => {
|
||||
it("dedupes ids and skips teams without a member budget", () => {
|
||||
const teams = [
|
||||
makeTeam({ team_id: "a", metadata: { team_member_budget_id: "bud-1" } }),
|
||||
makeTeam({ team_id: "b", metadata: { team_member_budget_id: "bud-1" } }),
|
||||
makeTeam({ team_id: "c", metadata: {} }),
|
||||
makeTeam({ team_id: "d", metadata: { team_member_budget_id: "" } }),
|
||||
makeTeam({ team_id: "e" }),
|
||||
makeTeam({ team_id: "f", metadata: { team_member_budget_id: "bud-2" } }),
|
||||
];
|
||||
expect(collectTeamMemberBudgetIds(teams)).toEqual(["bud-1", "bud-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTeamsCsvRows", () => {
|
||||
it("maps configured limits, spend, models, and rate limits", () => {
|
||||
const teamFields: Partial<Team> = {
|
||||
team_id: "team-42",
|
||||
team_alias: "finance",
|
||||
organization_id: "org-9",
|
||||
models: ["gpt-4o", "claude-sonnet-4-5"],
|
||||
max_budget: 250,
|
||||
budget_duration: "30d",
|
||||
budget_reset_at: "2026-02-01T00:00:00Z",
|
||||
spend: 12.5,
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 50,
|
||||
members_count: 7,
|
||||
keys_count: 3,
|
||||
blocked: false,
|
||||
};
|
||||
const [row] = buildTeamsCsvRows([makeTeam(teamFields)], []);
|
||||
const expectedRow = {
|
||||
"Team Alias": "finance",
|
||||
"Team ID": "team-42",
|
||||
"Organization ID": "org-9",
|
||||
Models: "gpt-4o, claude-sonnet-4-5",
|
||||
"Max Budget (USD)": 250,
|
||||
"Budget Duration": "30d",
|
||||
"Budget Reset At": "2026-02-01T00:00:00Z",
|
||||
"Spend (USD)": 12.5,
|
||||
"TPM Limit": 1000,
|
||||
"RPM Limit": 50,
|
||||
"Team Member Budget (USD)": "",
|
||||
"Team Member Budget Duration": "",
|
||||
"Team Member TPM Limit": "",
|
||||
"Team Member RPM Limit": "",
|
||||
Members: 7,
|
||||
Keys: 3,
|
||||
Blocked: false,
|
||||
"Created At": "2026-01-01T00:00:00Z",
|
||||
};
|
||||
expect(row).toEqual(expectedRow);
|
||||
});
|
||||
|
||||
it("joins team member budget rows by budget id from metadata", () => {
|
||||
const teams = [
|
||||
makeTeam({ team_id: "a", metadata: { team_member_budget_id: "bud-1" } }),
|
||||
makeTeam({ team_id: "b" }),
|
||||
];
|
||||
const rows = buildTeamsCsvRows(teams, [
|
||||
{ budget_id: "bud-1", max_budget: 25, budget_duration: "7d", tpm_limit: 200, rpm_limit: 10 },
|
||||
]);
|
||||
expect(rows[0]["Team Member Budget (USD)"]).toBe(25);
|
||||
expect(rows[0]["Team Member Budget Duration"]).toBe("7d");
|
||||
expect(rows[0]["Team Member TPM Limit"]).toBe(200);
|
||||
expect(rows[0]["Team Member RPM Limit"]).toBe(10);
|
||||
expect(rows[1]["Team Member Budget (USD)"]).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to members_with_roles and keys lengths when counts are absent", () => {
|
||||
const team = makeTeam({
|
||||
members_with_roles: [
|
||||
{ user_id: "u1", role: "admin" },
|
||||
{ user_id: "u2", role: "user" },
|
||||
],
|
||||
keys: [{ token: "t" } as Team["keys"][number]],
|
||||
});
|
||||
const [row] = buildTeamsCsvRows([team], []);
|
||||
expect(row.Members).toBe(2);
|
||||
expect(row.Keys).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTeamsCsv", () => {
|
||||
it("produces a header row and quotes values containing commas", () => {
|
||||
const csv = buildTeamsCsv([makeTeam({ team_alias: "sales, emea", models: ["m1", "m2"] })], []);
|
||||
const [header, row] = csv.split("\r\n");
|
||||
expect(header).toBe(
|
||||
"Team Alias,Team ID,Organization ID,Models,Max Budget (USD),Budget Duration,Budget Reset At,Spend (USD)," +
|
||||
"TPM Limit,RPM Limit,Team Member Budget (USD),Team Member Budget Duration,Team Member TPM Limit," +
|
||||
"Team Member RPM Limit,Members,Keys,Blocked,Created At",
|
||||
);
|
||||
expect(row).toContain('"sales, emea"');
|
||||
expect(row).toContain('"m1, m2"');
|
||||
});
|
||||
|
||||
it("neutralizes formula-leading values so spreadsheets render them as text", () => {
|
||||
const csv = buildTeamsCsv([makeTeam({ team_alias: "=SUM(A1:A9)" })], []);
|
||||
const [, row] = csv.split("\r\n");
|
||||
expect(row).toContain('"\'=SUM(A1:A9)"');
|
||||
expect(row).not.toContain("=SUM(A1:A9),");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import Papa from "papaparse";
|
||||
|
||||
import { TeamListCallOptions, TeamsResponse, teamListCall } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import { apiClient } from "../networking";
|
||||
|
||||
export interface TeamMemberBudget {
|
||||
budget_id: string;
|
||||
max_budget?: number | null;
|
||||
budget_duration?: string | null;
|
||||
tpm_limit?: number | null;
|
||||
rpm_limit?: number | null;
|
||||
}
|
||||
|
||||
export const TEAMS_EXPORT_PAGE_SIZE = 100;
|
||||
|
||||
type FetchTeamsPage = (page: number, pageSize: number) => Promise<TeamsResponse>;
|
||||
|
||||
export const fetchAllTeams = async (fetchPage: FetchTeamsPage): Promise<Team[]> => {
|
||||
const firstPage = await fetchPage(1, TEAMS_EXPORT_PAGE_SIZE);
|
||||
const totalPages = firstPage.total_pages ?? 1;
|
||||
if (totalPages <= 1) return firstPage.teams;
|
||||
|
||||
const remainingPages = await Promise.all(
|
||||
Array.from({ length: totalPages - 1 }, (_, i) => fetchPage(i + 2, TEAMS_EXPORT_PAGE_SIZE)),
|
||||
);
|
||||
return [firstPage, ...remainingPages].flatMap((page) => page.teams);
|
||||
};
|
||||
|
||||
const teamMemberBudgetId = (team: Team): string | null => {
|
||||
const id = team.metadata?.team_member_budget_id;
|
||||
return typeof id === "string" && id.length > 0 ? id : null;
|
||||
};
|
||||
|
||||
export const collectTeamMemberBudgetIds = (teams: Team[]): string[] =>
|
||||
Array.from(new Set(teams.map(teamMemberBudgetId).filter((id): id is string => id !== null)));
|
||||
|
||||
const cell = (value: string | number | boolean | null | undefined): string | number | boolean => value ?? "";
|
||||
|
||||
export const buildTeamsCsvRows = (
|
||||
teams: Team[],
|
||||
budgets: TeamMemberBudget[],
|
||||
): Record<string, string | number | boolean>[] => {
|
||||
const budgetsById = new Map(budgets.map((budget) => [budget.budget_id, budget]));
|
||||
return teams.map((team) => {
|
||||
const budgetId = teamMemberBudgetId(team);
|
||||
const memberBudget = budgetId ? budgetsById.get(budgetId) : undefined;
|
||||
return {
|
||||
"Team Alias": cell(team.team_alias),
|
||||
"Team ID": cell(team.team_id),
|
||||
"Organization ID": cell(team.organization_id),
|
||||
Models: (team.models ?? []).join(", "),
|
||||
"Max Budget (USD)": cell(team.max_budget),
|
||||
"Budget Duration": cell(team.budget_duration),
|
||||
"Budget Reset At": cell(team.budget_reset_at),
|
||||
"Spend (USD)": cell(team.spend),
|
||||
"TPM Limit": cell(team.tpm_limit),
|
||||
"RPM Limit": cell(team.rpm_limit),
|
||||
"Team Member Budget (USD)": cell(memberBudget?.max_budget),
|
||||
"Team Member Budget Duration": cell(memberBudget?.budget_duration),
|
||||
"Team Member TPM Limit": cell(memberBudget?.tpm_limit),
|
||||
"Team Member RPM Limit": cell(memberBudget?.rpm_limit),
|
||||
Members: cell(team.members_count ?? team.members_with_roles?.length),
|
||||
Keys: cell(team.keys_count ?? team.keys?.length),
|
||||
Blocked: cell(team.blocked),
|
||||
"Created At": cell(team.created_at),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const buildTeamsCsv = (teams: Team[], budgets: TeamMemberBudget[]): string =>
|
||||
Papa.unparse(buildTeamsCsvRows(teams, budgets), { escapeFormulae: true });
|
||||
|
||||
const downloadCsv = (csv: string, fileName: string): void => {
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const exportTeamsToCsv = async (accessToken: string, options: TeamListCallOptions): Promise<number> => {
|
||||
const teams = await fetchAllTeams((page, pageSize) => teamListCall(accessToken, page, pageSize, options));
|
||||
const budgetIds = collectTeamMemberBudgetIds(teams);
|
||||
const budgets = budgetIds.length
|
||||
? await apiClient.post<TeamMemberBudget[]>("/budget/info", { accessToken, body: { budgets: budgetIds } })
|
||||
: [];
|
||||
downloadCsv(buildTeamsCsv(teams, budgets), `teams_export_${new Date().toISOString().split("T")[0]}.csv`);
|
||||
return teams.length;
|
||||
};
|
||||
|
|
@ -13,6 +13,9 @@ export interface Team {
|
|||
tpm_limit: number | null;
|
||||
rpm_limit: number | null;
|
||||
organization_id: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
budget_reset_at?: string | null;
|
||||
blocked?: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string | null;
|
||||
keys: KeyResponse[];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue