Merge pull request #1 from sarvika/litellm_project_filter_usage_page

feat: Add project usage view to the Usage page (BerriAI/litellm/issue…
This commit is contained in:
Jay 2026-09-11 12:47:48 +05:30 • committed by GitHub
commit 0cc180cb12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1070 additions and 98 deletions

View file

@ -1178,7 +1178,9 @@ async def _resolve_project_daily_activity_scope(
found_by_id: Final = {p.project_id: p for p in projects}
missing: Final = [pid for pid in requested if pid not in found_by_id]
if missing:
raise _project_daily_activity_error(status_code=404, message=f"Project(s) not found: {missing}")
raise _project_daily_activity_error(
status_code=404, message=f"Project(s) not found: {', '.join(sorted(missing))}"
)
if not user_api_key_has_admin_view(user_api_key_dict):
for project_id in requested:

View file

@ -1517,11 +1517,12 @@ async def test_get_project_daily_activity_unknown_project_404(monkeypatch):
with pytest.raises(HTTPException) as exc_info:
await get_project_daily_activity(
user_api_key_dict=admin,
project_ids="does-not-exist",
project_ids="does-not-exist,also-missing",
start_date="2026-09-01",
end_date="2026-09-02",
)
assert exc_info.value.status_code == 404
assert exc_info.value.detail == {"error": "Project(s) not found: also-missing, does-not-exist"}
mock_prisma.db.query_raw.assert_not_called()

View file

@ -17,7 +17,7 @@ export const useUISettings = (options?: { staleTime?: number; refetchInterval?:
return useQuery<Record<string, any>>({
queryKey: uiSettingsKeys.list({}),
queryFn: async () => await getUiSettings(),
staleTime: options?.staleTime ?? 60 * 60 * 1000, // 1 hour - data rarely changes
staleTime: options?.staleTime ?? 60 * 60 * 1000,
gcTime: 60 * 60 * 1000, // 1 hour - keep in cache for 1 hour
refetchInterval: options?.refetchInterval,
});

View file

@ -10,16 +10,15 @@ import {
type ProviderSpendRow,
} from "./entityUsageAggregations";
import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost, type SummaryTile } from "./entityUsageSummary";
import { SummaryTileCard } from "./SummaryTileCard";
import { MoneyCell } from "@/components/shared/table_cells";
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { hasCapability, type Capability } from "@/utils/capabilities";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import { ChevronDown, ChevronRight, Info } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import React, { type ReactNode, useMemo, useState } from "react";
import TeamMultiSelect from "@/components/common_components/team_multi_select";
import UserDropdown from "@/components/common_components/UserDropdown";
@ -358,29 +357,13 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
[],
);
const chev = "size-3 text-muted-foreground";
const expandIcon = showCostBreakdown ? <ChevronDown className={chev} /> : <ChevronRight className={chev} />;
const renderSummaryTile = ({ title, value, className, tooltip, expandable }: SummaryTile) => (
<ShadcnCard
key={title}
className={expandable ? "cursor-pointer hover:bg-accent transition-colors" : undefined}
onClick={expandable ? () => setShowCostBreakdown(!showCostBreakdown) : undefined}
>
<CardContent>
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-foreground">{title}</h3>
{tooltip ? (
<Tooltip>
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
) : null}
{expandable ? expandIcon : null}
</div>
<p className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</p>
</CardContent>
</ShadcnCard>
const renderSummaryTile = (tile: SummaryTile) => (
<SummaryTileCard
key={tile.title}
tile={tile}
expanded={showCostBreakdown}
onToggleExpand={() => setShowCostBreakdown(!showCostBreakdown)}
/>
);
const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata) : [];

View file

@ -1,15 +1,12 @@
import { DonutChart } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { Info } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import React, { useState } from "react";
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import { ChartLoader } from "@/components/shared/chart_loader";
import { SpendByCategoryPanel } from "../SpendByCategoryPanel";
type ProviderSpendData = {
provider: string;
@ -70,13 +67,10 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
const filteredProviderSpend = providerSpend.filter((provider) => {
const isUnknown = provider.provider?.toLowerCase() === "unknown";
// If includeUnknown is true, always include unknown provider
if (isUnknown) {
return includeUnknown;
}
// If includeZeroSpend is true, include all providers (including those with 0 spend)
// Otherwise, only include providers with spend > 0
if (includeZeroSpend) {
return true;
}
@ -84,54 +78,37 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
return provider.spend > 0;
});
const headerAction = (
<>
<div className="flex items-center gap-2">
<label className="text-sm text-foreground">Show Zero Spend</label>
<Switch checked={includeZeroSpend} onCheckedChange={setIncludeZeroSpend} />
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<label className="text-sm text-foreground">Show Unknown</label>
<Tooltip>
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
<TooltipContent>Requests that failed to route to a provider</TooltipContent>
</Tooltip>
</div>
<Switch checked={includeUnknown} onCheckedChange={setIncludeUnknown} />
</div>
</>
);
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Spend by Provider</CardTitle>
<CardAction className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-foreground">Show Zero Spend</label>
<Switch checked={includeZeroSpend} onCheckedChange={setIncludeZeroSpend} />
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<label className="text-sm text-foreground">Show Unknown</label>
<Tooltip>
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
<TooltipContent>Requests that failed to route to a provider</TooltipContent>
</Tooltip>
</div>
<Switch checked={includeUnknown} onCheckedChange={setIncludeUnknown} />
</div>
</CardAction>
</CardHeader>
<CardContent>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<div className="grid grid-cols-2">
<DonutChart
className="mt-4 h-40"
data={filteredProviderSpend}
index="provider"
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
colors={["cyan"]}
showLabel
startAngle={90}
endAngle={-270}
/>
<DataTable
columns={columns}
data={filteredProviderSpend}
getRowId={(row) => row.provider}
noDataMessage="No provider usage data"
size="compact"
/>
</div>
)}
</CardContent>
</Card>
<SpendByCategoryPanel
title="Spend by Provider"
headerAction={headerAction}
loading={loading}
isDateChanging={isDateChanging}
data={filteredProviderSpend}
indexKey="provider"
columns={columns}
getRowId={(row) => row.provider}
noDataMessage="No provider usage data"
/>
);
};

View file

@ -0,0 +1,41 @@
import { ChevronDown, ChevronRight, Info } from "lucide-react";
import { Card as ShadcnCard, CardContent } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { SummaryTile } from "./entityUsageSummary";
interface SummaryTileCardProps {
tile: SummaryTile;
expanded?: boolean;
onToggleExpand?: () => void;
}
export function SummaryTileCard({ tile, expanded = false, onToggleExpand }: SummaryTileCardProps) {
const { title, value, className, tooltip, expandable } = tile;
const chev = "size-3 text-muted-foreground";
const expandIcon = expanded ? <ChevronDown className={chev} /> : <ChevronRight className={chev} />;
return (
<ShadcnCard
className={expandable ? "cursor-pointer hover:bg-accent transition-colors" : undefined}
onClick={expandable ? onToggleExpand : undefined}
>
<CardContent>
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-foreground">{title}</h3>
{tooltip ? (
<Tooltip>
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
) : null}
{expandable ? expandIcon : null}
</div>
<p className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</p>
</CardContent>
</ShadcnCard>
);
}
export default SummaryTileCard;

View file

@ -0,0 +1,66 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import ProjectSpendBreakdown from "./ProjectSpendBreakdown";
import type { ProjectSpendRow } from "./projectUsageAggregations";
vi.mock("@/components/shared/chart_loader", () => ({
ChartLoader: ({ isDateChanging }: { isDateChanging: boolean }) => (
<div data-testid="chart-loader">{isDateChanging ? "Processing date selection..." : "Loading chart data..."}</div>
),
}));
const mockProjectSpend: ProjectSpendRow[] = [
{
project_id: "project-alpha",
project_alias: "Project Alpha",
spend: 150.5,
requests: 100,
successful_requests: 95,
failed_requests: 5,
tokens: 50000,
},
{
project_id: "project-beta",
project_alias: "Project Beta",
spend: 200.75,
requests: 120,
successful_requests: 115,
failed_requests: 5,
tokens: 75000,
},
];
describe("ProjectSpendBreakdown", () => {
it("displays the title", () => {
render(<ProjectSpendBreakdown loading={false} isDateChanging={false} projectSpend={[]} />);
expect(screen.getByText("Spend by Project")).toBeInTheDocument();
});
it("shows the loader instead of chart content while loading", () => {
render(<ProjectSpendBreakdown loading isDateChanging={false} projectSpend={[]} />);
expect(screen.getByTestId("chart-loader")).toBeInTheDocument();
expect(screen.queryByText("No project usage data")).not.toBeInTheDocument();
});
it("displays table headers", () => {
render(<ProjectSpendBreakdown loading={false} isDateChanging={false} projectSpend={mockProjectSpend} />);
expect(screen.getByText("Project")).toBeInTheDocument();
expect(screen.getByText("Spend")).toBeInTheDocument();
expect(screen.getByText("Successful")).toBeInTheDocument();
expect(screen.getByText("Failed")).toBeInTheDocument();
expect(screen.getByText("Tokens")).toBeInTheDocument();
});
it("displays each project's alias and formatted spend in the table", () => {
render(<ProjectSpendBreakdown loading={false} isDateChanging={false} projectSpend={mockProjectSpend} />);
expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0);
expect(screen.getAllByText("Project Beta").length).toBeGreaterThan(0);
expect(screen.getByText("$150.50")).toBeInTheDocument();
expect(screen.getByText("$200.75")).toBeInTheDocument();
});
it("shows the empty message when no project has usage", () => {
render(<ProjectSpendBreakdown loading={false} isDateChanging={false} projectSpend={[]} />);
expect(screen.getByText("No project usage data")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,59 @@
import React from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { MoneyCell } from "@/components/shared/table_cells";
import { SpendByCategoryPanel } from "../SpendByCategoryPanel";
import type { ProjectSpendRow } from "./projectUsageAggregations";
interface ProjectSpendBreakdownProps {
loading: boolean;
isDateChanging: boolean;
projectSpend: ProjectSpendRow[];
}
const columns: ColumnDef<ProjectSpendRow>[] = [
{
header: "Project",
accessorKey: "project_alias",
},
{
header: "Spend",
accessorKey: "spend",
meta: { numeric: true },
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
},
{
header: "Successful",
accessorKey: "successful_requests",
meta: { numeric: true, className: "text-success" },
cell: ({ row }) => row.original.successful_requests.toLocaleString(),
},
{
header: "Failed",
accessorKey: "failed_requests",
meta: { numeric: true, className: "text-destructive" },
cell: ({ row }) => row.original.failed_requests.toLocaleString(),
},
{
header: "Tokens",
accessorKey: "tokens",
meta: { numeric: true },
cell: ({ row }) => row.original.tokens.toLocaleString(),
},
];
const ProjectSpendBreakdown: React.FC<ProjectSpendBreakdownProps> = ({ loading, isDateChanging, projectSpend }) => (
<SpendByCategoryPanel
title="Spend by Project"
loading={loading}
isDateChanging={isDateChanging}
data={projectSpend}
indexKey="project_alias"
columns={columns}
getRowId={(row) => row.project_id}
noDataMessage="No project usage data"
/>
);
export default ProjectSpendBreakdown;

View file

@ -0,0 +1,165 @@
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils";
import * as networking from "@/components/networking";
import ProjectUsage from "./ProjectUsage";
import type { EntityList } from "../EntityUsage/EntityUsage";
vi.mock("@/components/networking", () => ({
projectDailyActivityCall: vi.fn(),
}));
const PROJECT_LIST: EntityList[] = [
{ label: "Project Alpha", value: "project-alpha" },
{ label: "Project Beta", value: "project-beta" },
];
const DATE_VALUE = { from: new Date("2026-09-01"), to: new Date("2026-09-08") };
describe("ProjectUsage", () => {
const mockProjectDailyActivityCall = vi.mocked(networking.projectDailyActivityCall);
beforeEach(() => {
vi.clearAllMocks();
testQueryClient.clear();
});
it("shows an enterprise upsell and never calls the API when the caller is not a premium user", () => {
renderWithProviders(
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={false} />,
);
expect(screen.getByText("Project Usage is an Enterprise feature")).toBeInTheDocument();
expect(mockProjectDailyActivityCall).not.toHaveBeenCalled();
});
it("prompts for a project before fetching anything", () => {
renderWithProviders(
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
);
expect(screen.getByText("Select at least one project above to view its usage.")).toBeInTheDocument();
expect(mockProjectDailyActivityCall).not.toHaveBeenCalled();
});
it("fetches and renders the picked project's daily spend", async () => {
mockProjectDailyActivityCall.mockResolvedValue({
start_date: "2026-09-01",
end_date: "2026-09-08",
results: [
{
date: "2026-09-01",
project_id: "project-alpha",
project_alias: "Project Alpha",
spend: 12.5,
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
api_requests: 4,
successful_requests: 3,
failed_requests: 1,
},
],
});
const user = userEvent.setup();
renderWithProviders(
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: "Project Alpha" }));
await waitFor(() => expect(mockProjectDailyActivityCall).toHaveBeenCalledTimes(1));
expect(mockProjectDailyActivityCall).toHaveBeenCalledWith("test-token", DATE_VALUE.from, DATE_VALUE.to, [
"project-alpha",
]);
await waitFor(() => expect(screen.getAllByText("$12.50").length).toBeGreaterThan(0));
expect(screen.getAllByText("4").length).toBeGreaterThan(0);
expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0);
});
it("shows an error instead of silently rendering zeros when the request fails", async () => {
mockProjectDailyActivityCall.mockRejectedValue(new Error("Project management is an enterprise feature"));
const user = userEvent.setup();
renderWithProviders(
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: "Project Alpha" }));
expect(await screen.findByText("Could not load project usage")).toBeInTheDocument();
expect(screen.getByText("Project management is an enterprise feature")).toBeInTheDocument();
expect(screen.queryByText("No project usage data")).not.toBeInTheDocument();
});
it("renders a backend not-found message verbatim, without mangling the comma-joined list", async () => {
mockProjectDailyActivityCall.mockRejectedValue(new Error("Project(s) not found: also-missing, does-not-exist"));
const user = userEvent.setup();
renderWithProviders(
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: "Project Alpha" }));
expect(await screen.findByText("Project(s) not found: also-missing, does-not-exist")).toBeInTheDocument();
});
it("shows a loading indicator while fetching data for a newly-added project", async () => {
let resolveSecondCall: (value: unknown) => void = () => {};
mockProjectDailyActivityCall
.mockResolvedValueOnce({
start_date: "2026-09-01",
end_date: "2026-09-08",
results: [
{
date: "2026-09-01",
project_id: "project-alpha",
project_alias: "Project Alpha",
spend: 12.5,
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
api_requests: 4,
successful_requests: 3,
failed_requests: 1,
},
],
})
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecondCall = resolve;
}),
);
const user = userEvent.setup();
renderWithProviders(
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: "Project Alpha" }));
await waitFor(() => expect(screen.getAllByText("$12.50").length).toBeGreaterThan(0));
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: "Project Beta" }));
await waitFor(() => expect(screen.getAllByText("Loading chart data...").length).toBeGreaterThan(0));
resolveSecondCall({
start_date: "2026-09-01",
end_date: "2026-09-08",
results: [],
});
await waitFor(() => expect(screen.queryAllByText("Loading chart data...").length).toBe(0));
});
});

View file

@ -0,0 +1,178 @@
import React, { useMemo, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { Info } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { BarChart } from "@/components/shared/charts";
import { ChartLoader } from "@/components/shared/chart_loader";
import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { projectDailyActivityCall } from "@/components/networking";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
import { buildSummaryTiles } from "../EntityUsage/entityUsageSummary";
import { SummaryTileCard } from "../EntityUsage/SummaryTileCard";
import type { EntityList } from "../EntityUsage/EntityUsage";
import ProjectSpendBreakdown from "./ProjectSpendBreakdown";
import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations";
interface ProjectUsageProps {
accessToken: string | null;
projectList: EntityList[] | null;
dateValue: DateRangePickerValue;
premiumUser: boolean;
}
const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, dateValue, premiumUser }) => {
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
const projectOptions = useMemo<MultiSelectOption[]>(
() => (projectList ?? []).map((project) => ({ label: project.label || project.value, value: project.value })),
[projectList],
);
const hasSelection = selectedProjectIds.length > 0;
const hasDateRange = !!startTime && !!endTime;
const hasRequestWindow = premiumUser && !!accessToken && hasDateRange;
const enabled = hasRequestWindow && hasSelection;
const queryOptions = {
queryKey: ["project-daily-activity", selectedProjectIds, startTime?.toISOString(), endTime?.toISOString()],
queryFn: () =>
projectDailyActivityCall(accessToken as string, startTime as Date, endTime as Date, selectedProjectIds),
enabled,
placeholderData: keepPreviousData,
};
const { data, isPending, isFetching, isPlaceholderData, isError, error } = useQuery(queryOptions);
const rows = useMemo(() => data?.results ?? [], [data]);
const summary = useMemo(() => summarizeProjectUsage(rows), [rows]);
const dailySpend = useMemo(() => buildDailySpendSeries(rows), [rows]);
const projectBreakdown = useMemo(() => buildProjectSpendBreakdown(rows), [rows]);
if (!premiumUser) {
return (
<Alert variant="info">
<AlertTitle>Project Usage is an Enterprise feature</AlertTitle>
<AlertDescription>
Filtering usage by project requires a LiteLLM Enterprise license. Get a 7 day trial at{" "}
<a href="https://www.litellm.ai/enterprise#trial" target="_blank" rel="noreferrer" className="underline">
litellm.ai/enterprise
</a>
.
</AlertDescription>
</Alert>
);
}
const isLoadingRows = isPending || (isFetching && isPlaceholderData);
const renderResultsPanel = () => {
if (!hasSelection) {
return (
<div className="col-span-2">
<ShadcnCard>
<CardContent>
<p className="text-sm text-muted-foreground py-8 text-center">
Select at least one project above to view its usage.
</p>
</CardContent>
</ShadcnCard>
</div>
);
}
if (isError) {
return (
<div className="col-span-2">
<Alert variant="error">
<AlertTitle>Could not load project usage</AlertTitle>
<AlertDescription>{extractProxyErrorMessage(error)}</AlertDescription>
</Alert>
</div>
);
}
return (
<>
<div className="col-span-2">
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Project Spend Overview</h3>
{isLoadingRows ? (
<ChartLoader isDateChanging={false} />
) : (
<div className="grid grid-cols-5 gap-4 mt-4">
{buildSummaryTiles(summary, false).map((tile) => (
<SummaryTileCard key={tile.title} tile={tile} />
))}
</div>
)}
</CardContent>
</ShadcnCard>
</div>
<div className="col-span-2">
<ShadcnCard>
<CardHeader>
<CardTitle className="text-base font-semibold">Daily Spend</CardTitle>
</CardHeader>
<CardContent>
{isLoadingRows ? (
<ChartLoader isDateChanging={false} />
) : (
<BarChart
data={dailySpend}
index="date"
categories={["spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
yAxisWidth={100}
/>
)}
</CardContent>
</ShadcnCard>
</div>
<div className="col-span-2">
<ProjectSpendBreakdown loading={isLoadingRows} isDateChanging={false} projectSpend={projectBreakdown} />
</div>
</>
);
};
return (
<div className="grid grid-cols-2 gap-2 w-full">
<div className="col-span-2">
<ShadcnCard>
<CardContent>
<div className="flex items-center gap-2 mb-2">
<h3 className="text-sm font-medium text-foreground">Projects</h3>
<Tooltip>
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
<TooltipContent>Select one or more projects to compare their usage</TooltipContent>
</Tooltip>
</div>
<MultiSelect
options={projectOptions}
value={selectedProjectIds}
onValueChange={setSelectedProjectIds}
placeholder="Search or select projects..."
emptyText="No projects found"
/>
</CardContent>
</ShadcnCard>
</div>
{renderResultsPanel()}
</div>
);
};
export default ProjectUsage;

View file

@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import type { ProjectDailySpendRow } from "@/components/networking";
import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations";
const row = (overrides: Partial<ProjectDailySpendRow> = {}): ProjectDailySpendRow => ({
date: "2026-09-01",
project_id: "project-alpha",
project_alias: "Project Alpha",
spend: 1,
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
api_requests: 3,
successful_requests: 2,
failed_requests: 1,
...overrides,
});
describe("summarizeProjectUsage", () => {
it("returns all-zero totals for no rows", () => {
expect(summarizeProjectUsage([])).toEqual({
total_spend: 0,
total_api_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,
total_tokens: 0,
});
});
it("sums spend, requests, and tokens across every row regardless of project or date", () => {
const projectBetaRow: Partial<ProjectDailySpendRow> = {
project_id: "project-beta",
date: "2026-09-02",
spend: 2.25,
api_requests: 4,
successful_requests: 4,
failed_requests: 0,
total_tokens: 40,
};
const rows = [row({ spend: 1.5 }), row(projectBetaRow)];
expect(summarizeProjectUsage(rows)).toEqual({
total_spend: 3.75,
total_api_requests: 7,
total_successful_requests: 6,
total_failed_requests: 1,
total_tokens: 55,
});
});
});
describe("buildDailySpendSeries", () => {
it("sums same-day spend across projects into a single point", () => {
const rows = [row(), row({ project_id: "project-beta", spend: 2 })];
expect(buildDailySpendSeries(rows)).toEqual([{ date: "2026-09-01", spend: 3 }]);
});
it("sorts distinct dates oldest first regardless of input order", () => {
const rows = [row({ date: "2026-09-03", spend: 3 }), row(), row({ date: "2026-09-02", spend: 2 })];
expect(buildDailySpendSeries(rows).map((point) => point.date)).toEqual(["2026-09-01", "2026-09-02", "2026-09-03"]);
});
});
describe("buildProjectSpendBreakdown", () => {
it("sums a project's spend and tokens across every day into a single row", () => {
const secondDayRow: Partial<ProjectDailySpendRow> = {
date: "2026-09-02",
spend: 2,
total_tokens: 20,
successful_requests: 1,
failed_requests: 2,
};
const rows = [row({ total_tokens: 10, api_requests: 2, failed_requests: 0 }), row(secondDayRow)];
expect(buildProjectSpendBreakdown(rows)).toEqual([
{
project_id: "project-alpha",
project_alias: "Project Alpha",
spend: 3,
requests: 5,
successful_requests: 3,
failed_requests: 2,
tokens: 30,
},
]);
});
it("sorts projects by spend descending", () => {
const rows = [
row({ project_id: "project-cheap", project_alias: "Cheap" }),
row({ project_id: "project-costly", project_alias: "Costly", spend: 9 }),
];
expect(buildProjectSpendBreakdown(rows).map((r) => r.project_id)).toEqual(["project-costly", "project-cheap"]);
});
it("falls back to the project id when the project has no alias", () => {
const rows = [row({ project_id: "project-untitled", project_alias: null })];
expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("project-untitled");
});
it("disambiguates two projects that share the same human-set alias", () => {
const rows = [
row({ project_id: "project-one", project_alias: "Production" }),
row({ project_id: "project-two", project_alias: "Production" }),
];
const aliases = buildProjectSpendBreakdown(rows).map((r) => r.project_alias);
expect(new Set(aliases).size).toBe(2);
expect(aliases.every((alias) => alias.includes("Production"))).toBe(true);
});
it("leaves a unique alias untouched", () => {
const rows = [row({ project_id: "project-alpha", project_alias: "Project Alpha" })];
expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("Project Alpha");
});
});

View file

@ -0,0 +1,99 @@
import type { ProjectDailySpendRow } from "@/components/networking";
export interface ProjectSpendRow extends Record<string, unknown> {
project_id: string;
project_alias: string;
spend: number;
requests: number;
successful_requests: number;
failed_requests: number;
tokens: number;
}
export interface DailyProjectSpendPoint extends Record<string, unknown> {
date: string;
spend: number;
}
export interface ProjectUsageSummary {
total_spend: number;
total_api_requests: number;
total_successful_requests: number;
total_failed_requests: number;
total_tokens: number;
}
const EMPTY_SUMMARY: ProjectUsageSummary = {
total_spend: 0,
total_api_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,
total_tokens: 0,
};
export const summarizeProjectUsage = (rows: ProjectDailySpendRow[]): ProjectUsageSummary =>
rows.reduce(
(totals, row) => ({
total_spend: totals.total_spend + row.spend,
total_api_requests: totals.total_api_requests + row.api_requests,
total_successful_requests: totals.total_successful_requests + row.successful_requests,
total_failed_requests: totals.total_failed_requests + row.failed_requests,
total_tokens: totals.total_tokens + row.total_tokens,
}),
EMPTY_SUMMARY,
);
export const buildDailySpendSeries = (rows: ProjectDailySpendRow[]): DailyProjectSpendPoint[] => {
const spendByDate = new Map<string, number>();
for (const row of rows) {
spendByDate.set(row.date, (spendByDate.get(row.date) ?? 0) + row.spend);
}
return [...spendByDate.entries()]
.map(([date, spend]) => ({ date, spend }))
.sort((a, b) => a.date.localeCompare(b.date));
};
const groupByProjectId = (rows: ProjectDailySpendRow[]): ProjectDailySpendRow[][] => {
const groups = new Map<string, ProjectDailySpendRow[]>();
for (const row of rows) {
const existing = groups.get(row.project_id);
if (existing) {
existing.push(row);
} else {
groups.set(row.project_id, [row]);
}
}
return [...groups.values()];
};
const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow => {
const [{ project_id, project_alias }] = rows;
const totals = rows.reduce(
(acc, row) => ({
spend: acc.spend + row.spend,
requests: acc.requests + row.api_requests,
successful_requests: acc.successful_requests + row.successful_requests,
failed_requests: acc.failed_requests + row.failed_requests,
tokens: acc.tokens + row.total_tokens,
}),
{ spend: 0, requests: 0, successful_requests: 0, failed_requests: 0, tokens: 0 },
);
return { project_id, project_alias: project_alias || project_id, ...totals };
};
const disambiguateAliases = (rows: ProjectSpendRow[]): ProjectSpendRow[] => {
const aliasCounts = new Map<string, number>();
for (const row of rows) {
aliasCounts.set(row.project_alias, (aliasCounts.get(row.project_alias) ?? 0) + 1);
}
return rows.map((row) =>
(aliasCounts.get(row.project_alias) ?? 0) > 1
? { ...row, project_alias: `${row.project_alias} (${row.project_id})` }
: row,
);
};
export const buildProjectSpendBreakdown = (rows: ProjectDailySpendRow[]): ProjectSpendRow[] => {
const summarized = groupByProjectId(rows).map(summarizeProjectGroup);
return disambiguateAliases(summarized).sort((a, b) => b.spend - a.spend);
};

View file

@ -0,0 +1,118 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ColumnDef } from "@tanstack/react-table";
import { SpendByCategoryPanel } from "./SpendByCategoryPanel";
vi.mock("@/components/shared/chart_loader", () => ({
ChartLoader: ({ isDateChanging }: { isDateChanging: boolean }) => (
<div data-testid="chart-loader">{isDateChanging ? "Processing date selection..." : "Loading chart data..."}</div>
),
}));
interface TestRow extends Record<string, unknown> {
key: string;
spend: number;
}
const columns: ColumnDef<TestRow>[] = [{ header: "Key", accessorKey: "key" }];
const rows: TestRow[] = [{ key: "row-1", spend: 5 }];
describe("SpendByCategoryPanel", () => {
it("displays the given title", () => {
render(
<SpendByCategoryPanel
title="Spend by Widget"
loading={false}
isDateChanging={false}
data={[]}
indexKey="key"
columns={columns}
getRowId={(row) => row.key}
noDataMessage="No data"
/>,
);
expect(screen.getByText("Spend by Widget")).toBeInTheDocument();
});
it("shows the loader instead of chart content while loading", () => {
render(
<SpendByCategoryPanel
title="Spend by Widget"
loading
isDateChanging={false}
data={rows}
indexKey="key"
columns={columns}
getRowId={(row) => row.key}
noDataMessage="No data"
/>,
);
expect(screen.getByTestId("chart-loader")).toBeInTheDocument();
expect(screen.queryByText("row-1")).not.toBeInTheDocument();
});
it("renders the table rows once loaded", () => {
render(
<SpendByCategoryPanel
title="Spend by Widget"
loading={false}
isDateChanging={false}
data={rows}
indexKey="key"
columns={columns}
getRowId={(row) => row.key}
noDataMessage="No data"
/>,
);
expect(screen.getAllByText("row-1").length).toBeGreaterThan(0);
});
it("shows the empty message when there is no data", () => {
render(
<SpendByCategoryPanel
title="Spend by Widget"
loading={false}
isDateChanging={false}
data={[]}
indexKey="key"
columns={columns}
getRowId={(row) => row.key}
noDataMessage="No data for this range"
/>,
);
expect(screen.getByText("No data for this range")).toBeInTheDocument();
});
it("renders a header action only when one is given", () => {
const { rerender } = render(
<SpendByCategoryPanel
title="Spend by Widget"
loading={false}
isDateChanging={false}
data={[]}
indexKey="key"
columns={columns}
getRowId={(row) => row.key}
noDataMessage="No data"
/>,
);
expect(screen.queryByText("Toggle")).not.toBeInTheDocument();
rerender(
<SpendByCategoryPanel
title="Spend by Widget"
headerAction={<button type="button">Toggle</button>}
loading={false}
isDateChanging={false}
data={[]}
indexKey="key"
columns={columns}
getRowId={(row) => row.key}
noDataMessage="No data"
/>,
);
expect(screen.getByText("Toggle")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,63 @@
import React from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { DonutChart } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { ChartLoader } from "@/components/shared/chart_loader";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatNumberWithCommas } from "@/utils/dataUtils";
interface SpendByCategoryPanelProps<TRow extends Record<string, unknown>> {
title: string;
headerAction?: React.ReactNode;
loading: boolean;
isDateChanging: boolean;
data: TRow[];
indexKey: keyof TRow & string;
columns: ColumnDef<TRow>[];
getRowId: (row: TRow) => string;
noDataMessage: string;
}
export function SpendByCategoryPanel<TRow extends Record<string, unknown>>({
title,
headerAction,
loading,
isDateChanging,
data,
indexKey,
columns,
getRowId,
noDataMessage,
}: SpendByCategoryPanelProps<TRow>) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>{title}</CardTitle>
{headerAction && <CardAction className="flex items-center gap-4">{headerAction}</CardAction>}
</CardHeader>
<CardContent>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<div className="grid grid-cols-2">
<DonutChart
className="mt-4 h-40"
data={data}
index={indexKey}
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
colors={["cyan"]}
showLabel
startAngle={90}
endAngle={-270}
/>
<DataTable columns={columns} data={data} getRowId={getRowId} noDataMessage={noDataMessage} size="compact" />
</div>
)}
</CardContent>
</Card>
);
}
export default SpendByCategoryPanel;

View file

@ -20,9 +20,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { hasCapability } from "@/utils/capabilities";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
@ -59,6 +61,7 @@ import {
import EndpointUsage from "./EndpointUsage/EndpointUsage";
import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
import ModelViewToggle, { ModelViewType } from "./ModelViewToggle";
import ProjectUsage from "./ProjectUsage/ProjectUsage";
import SpendByProvider from "./EntityUsage/SpendByProvider";
import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView";
import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView";
@ -102,12 +105,16 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
// filter reads as loading rather than as a range with no customers.
const { data: customers } = useCustomers();
const { data: agentsResponse } = useAgents();
const { data: projectsResponse } = useProjects();
const { data: currentUser } = useCurrentUser();
const isAdmin = all_admin_roles.includes(userRole || "");
const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || "");
const isOrgAdmin = useIsOrgAdmin();
const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin);
const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage");
const { data: uiSettingsData } = useUISettings();
const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui);
const canViewProjectUsage = hasCapability(userRole, "viewProjectUsage") && enableProjectsUI;
// For admins: null means global view (all users), a string means filter by that user
// For non-admins: always set to their own user ID
@ -117,12 +124,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
const [selectedUsageView, setUsageView] = useState<UsageOption>("global");
// Org-admin membership is read from the server, so unlike the other usage
// views this one can be revoked while the page is open. Derive the view in
// render rather than storing it, so the fallback lands on the same paint and
// the selector never holds a value it no longer offers.
const usageView: UsageOption =
selectedUsageView === "organization" && !canViewOrganizationUsage ? "global" : selectedUsageView;
const stillHasAccessToSelectedView =
(selectedUsageView !== "organization" || canViewOrganizationUsage) &&
(selectedUsageView !== "project" || canViewProjectUsage);
const usageView: UsageOption = stillHasAccessToSelectedView ? selectedUsageView : "global";
const [showCredentialBanner, setShowCredentialBanner] = useState(true);
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
@ -482,6 +487,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
userRole={userRole}
canViewTagUsage={canViewTagUsage}
isOrgAdmin={isOrgAdmin}
enableProjectsUI={enableProjectsUI}
/>
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</div>
@ -935,6 +941,20 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
/>
)}
{usageView === "project" && canViewProjectUsage && (
<ProjectUsage
accessToken={accessToken}
projectList={
projectsResponse?.map((project) => ({
label: project.project_alias || project.project_id,
value: project.project_id,
})) || null
}
dateValue={dateValue}
premiumUser={premiumUser}
/>
)}
{/* Customer Usage Panel */}
{usageView === "customer" && (
<EntityUsage

View file

@ -60,15 +60,18 @@ describe("UsageViewSelect", () => {
expect(offers(container, "Tag Usage")).toBe(false);
});
it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", async (optionName) => {
const user = userEvent.setup();
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])(
"should show %s to an admin",
async (optionName) => {
const user = userEvent.setup();
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
await openMenu(user);
expect(offers(container, optionName)).toBe(true);
});
await openMenu(user);
expect(offers(container, optionName)).toBe(true);
},
);
it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", async (optionName) => {
it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])("should hide %s from an internal user", async (optionName) => {
const user = userEvent.setup();
const { container } = render(
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
@ -86,6 +89,7 @@ describe("UsageViewSelect", () => {
it.each([
["Organization Usage", true],
["Agent Usage (A2A)", false],
["Project Usage", false],
] as const)("should offer %s to an org admin: %s", async (optionName, expected) => {
const user = userEvent.setup();
const { container } = render(
@ -96,6 +100,16 @@ describe("UsageViewSelect", () => {
expect(offers(container, optionName)).toBe(expected);
});
it("should hide Project Usage from an admin when enableProjectsUI is false", async () => {
const user = userEvent.setup();
const { container } = render(
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" enableProjectsUI={false} />,
);
await openMenu(user);
expect(offers(container, "Project Usage")).toBe(false);
});
it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", async (optionName) => {
const user = userEvent.setup();
const { container } = render(

View file

@ -1,4 +1,4 @@
import { BarChart3, Bot, Building2, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react";
import { BarChart3, Bot, Building2, Folder, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react";
import React from "react";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -9,6 +9,7 @@ export type UsageOption =
| "my-usage"
| "organization"
| "team"
| "project"
| "customer"
| "tag"
| "agent"
@ -20,6 +21,7 @@ export interface UsageViewSelectProps {
userRole: string | null;
canViewTagUsage?: boolean;
isOrgAdmin?: boolean;
enableProjectsUI?: boolean;
title?: string;
description?: string;
"data-id"?: string;
@ -68,6 +70,13 @@ const OPTIONS: OptionConfig[] = [
description: "View usage by team",
icon: <Users className="size-4" />,
},
{
value: "project",
label: "Project Usage",
description: "View usage by project",
icon: <Folder className="size-4" />,
capability: "viewProjectUsage",
},
{
value: "customer",
label: "Customer Usage",
@ -110,6 +119,7 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
userRole,
canViewTagUsage = false,
isOrgAdmin = false,
enableProjectsUI = true,
title = "Usage View",
description = "Select the usage data you want to view",
"data-id": dataId,
@ -117,6 +127,9 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
const isAdmin = all_admin_roles.includes(userRole ?? "");
const getFilteredOptions = () => {
return OPTIONS.filter((option) => {
if (option.value === "project" && !enableProjectsUI) {
return false;
}
if (option.capability) {
return hasCapability(userRole, option.capability, isOrgAdmin);
}

View file

@ -1623,6 +1623,30 @@ export const agentDailyActivityCall = async (
});
};
export type ProjectDailySpendResponse = components["schemas"]["ProjectDailySpendResponse"];
export type ProjectDailySpendRow = components["schemas"]["ProjectDailySpendRow"];
export const projectDailyActivityCall = async (
accessToken: string,
startTime: Date,
endTime: Date,
projectIds: string[],
): Promise<ProjectDailySpendResponse> => {
try {
return await apiClient.get<ProjectDailySpendResponse>(`/project/daily/activity`, {
accessToken,
query: {
project_ids: projectIds.join(","),
start_date: formatDate(startTime),
end_date: formatDate(endTime),
},
});
} catch (error) {
console.error("Failed to fetch project daily activity:", error);
throw error;
}
};
export const getOnboardingCredentials = async (inviteUUID: string) => {
/**
* Get all models on proxy

View file

@ -28,11 +28,16 @@ vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
useProjects: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: vi.fn(),
}));
import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers";
import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers";
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
const emptyInfiniteQuery = {
data: { pages: [], pageParams: [] },
@ -85,6 +90,9 @@ describe("RequestLogsFilters", () => {
vi.mocked(useProjects).mockReturnValue({ data: [], isLoading: false } as unknown as ReturnType<
typeof useProjects
>);
vi.mocked(useUISettings).mockReturnValue({
data: { values: { enable_projects_ui: true } },
} as unknown as ReturnType<typeof useUISettings>);
});
it("renders every backend-supported filter field", async () => {
@ -152,6 +160,17 @@ describe("RequestLogsFilters", () => {
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.PROJECT_ID, "project-1");
});
it("hides the Project filter when the projects UI setting is disabled", async () => {
vi.mocked(useUISettings).mockReturnValue({
data: { values: { enable_projects_ui: false } },
} as unknown as ReturnType<typeof useUISettings>);
renderFilters();
expect(await screen.findByText("Team ID")).toBeInTheDocument();
expect(screen.queryByText("Project")).not.toBeInTheDocument();
});
it("pushes the User ID picker query to the paginated user lookup", async () => {
const user = userEvent.setup();
renderFilters();

View file

@ -7,6 +7,7 @@ import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useS
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { DataTableFilterField } from "@/components/shared/DataTable";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
@ -346,6 +347,8 @@ interface RequestLogsFiltersProps {
export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsFiltersProps) {
const valueOf = (id: string): string => asString(get(id));
const setter = (id: string) => (next: string | undefined) => set(id, next);
const { data: uiSettingsData } = useUISettings();
const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui);
return (
<>
@ -355,7 +358,9 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
teams={teams}
/>
<ProjectFilterField value={valueOf(LOG_FILTER_IDS.PROJECT_ID)} onChange={setter(LOG_FILTER_IDS.PROJECT_ID)} />
{enableProjectsUI && (
<ProjectFilterField value={valueOf(LOG_FILTER_IDS.PROJECT_ID)} onChange={setter(LOG_FILTER_IDS.PROJECT_ID)} />
)}
<DataTableFilterField label="Status">
<Select

View file

@ -24,6 +24,7 @@ const ADMIN_ONLY_CAPABILITIES: Capability[] = [
"viewPrompts",
"viewOrganizationUsage",
"viewAgentUsage",
"viewProjectUsage",
];
const PROXY_ADMIN_ONLY_PAGE_CAPABILITIES: Capability[] = [

View file

@ -10,6 +10,7 @@ const CAPABILITY_ROLES = {
viewPrompts: all_admin_roles,
viewOrganizationUsage: all_admin_roles,
viewAgentUsage: all_admin_roles,
viewProjectUsage: all_admin_roles,
viewGlobalSpend: proxyAdminOnlyRoles,
viewWorkflowRuns: proxyAdminOnlyRoles,
viewMemory: proxyAdminOnlyRoles,