mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
fix: Gate Project Usage behind the enable_projects_ui setting (BerriAI/litellm/issues/40386)
Usage page and the logs filters showed the Project Usage view and Project filter unconditionally, unlike the sidebar, key edit, key info, and create-key surfaces which already respect enable_projects_ui Also format the project-not-found 404 message as a sorted, comma-joined list instead of a raw Python list repr, and drop the frontend workaround that parsed it back out Extract SummaryTileCard as a component shared between EntityUsage and ProjectUsage, and move ProjectUsage's derived data behind useMemo ahead of its early return so hook order stays stable across renders
This commit is contained in:
parent
05ef5627ae
commit
a51066f79e
14 changed files with 157 additions and 108 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) : [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -73,12 +73,9 @@ describe("ProjectUsage", () => {
|
|||
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"],
|
||||
);
|
||||
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);
|
||||
|
|
@ -101,6 +98,20 @@ describe("ProjectUsage", () => {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -13,15 +13,11 @@ import { projectDailyActivityCall } from "@/components/networking";
|
|||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
|
||||
|
||||
import { buildSummaryTiles, type SummaryTile } from "../EntityUsage/entityUsageSummary";
|
||||
import { buildSummaryTiles } from "../EntityUsage/entityUsageSummary";
|
||||
import { SummaryTileCard } from "../EntityUsage/SummaryTileCard";
|
||||
import type { EntityList } from "../EntityUsage/EntityUsage";
|
||||
import ProjectSpendBreakdown from "./ProjectSpendBreakdown";
|
||||
import {
|
||||
buildDailySpendSeries,
|
||||
buildProjectSpendBreakdown,
|
||||
humanizeBackendListMessage,
|
||||
summarizeProjectUsage,
|
||||
} from "./projectUsageAggregations";
|
||||
import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations";
|
||||
|
||||
interface ProjectUsageProps {
|
||||
accessToken: string | null;
|
||||
|
|
@ -30,15 +26,6 @@ interface ProjectUsageProps {
|
|||
premiumUser: boolean;
|
||||
}
|
||||
|
||||
const renderSummaryTile = ({ title, value, className }: SummaryTile) => (
|
||||
<ShadcnCard key={title}>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">{title}</h3>
|
||||
<p className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
);
|
||||
|
||||
const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, dateValue, premiumUser }) => {
|
||||
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
|
||||
|
||||
|
|
@ -57,12 +44,18 @@ const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, d
|
|||
|
||||
const queryOptions = {
|
||||
queryKey: ["project-daily-activity", selectedProjectIds, startTime?.toISOString(), endTime?.toISOString()],
|
||||
queryFn: () => projectDailyActivityCall(accessToken as string, startTime as Date, endTime as Date, selectedProjectIds),
|
||||
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">
|
||||
|
|
@ -78,10 +71,6 @@ const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, d
|
|||
);
|
||||
}
|
||||
|
||||
const rows = data?.results ?? [];
|
||||
const summary = summarizeProjectUsage(rows);
|
||||
const dailySpend = buildDailySpendSeries(rows);
|
||||
const projectBreakdown = buildProjectSpendBreakdown(rows);
|
||||
const isLoadingRows = isPending || (isFetching && isPlaceholderData);
|
||||
|
||||
const renderResultsPanel = () => {
|
||||
|
|
@ -104,7 +93,7 @@ const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, d
|
|||
<div className="col-span-2">
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not load project usage</AlertTitle>
|
||||
<AlertDescription>{humanizeBackendListMessage(extractProxyErrorMessage(error))}</AlertDescription>
|
||||
<AlertDescription>{extractProxyErrorMessage(error)}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -120,7 +109,9 @@ const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, d
|
|||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<div className="grid grid-cols-5 gap-4 mt-4">
|
||||
{buildSummaryTiles(summary, false).map(renderSummaryTile)}
|
||||
{buildSummaryTiles(summary, false).map((tile) => (
|
||||
<SummaryTileCard key={tile.title} tile={tile} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||
|
||||
import type { ProjectDailySpendRow } from "@/components/networking";
|
||||
|
||||
import {
|
||||
buildDailySpendSeries,
|
||||
buildProjectSpendBreakdown,
|
||||
humanizeBackendListMessage,
|
||||
summarizeProjectUsage,
|
||||
} from "./projectUsageAggregations";
|
||||
import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations";
|
||||
|
||||
const row = (overrides: Partial<ProjectDailySpendRow> = {}): ProjectDailySpendRow => ({
|
||||
date: "2026-09-01",
|
||||
|
|
@ -126,23 +121,3 @@ describe("buildProjectSpendBreakdown", () => {
|
|||
expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("Project Alpha");
|
||||
});
|
||||
});
|
||||
|
||||
describe("humanizeBackendListMessage", () => {
|
||||
it("strips a single-quoted Python list down to plain text", () => {
|
||||
expect(humanizeBackendListMessage("Project(s) not found: ['proj-123']")).toBe(
|
||||
"Project(s) not found: proj-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("comma-joins a multi-item Python list", () => {
|
||||
expect(humanizeBackendListMessage("Project(s) not found: ['proj-1', 'proj-2']")).toBe(
|
||||
"Project(s) not found: proj-1, proj-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a message with no trailing list untouched", () => {
|
||||
expect(humanizeBackendListMessage("Not authorized to view this project")).toBe(
|
||||
"Not authorized to view this project",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,20 +44,27 @@ export const summarizeProjectUsage = (rows: ProjectDailySpendRow[]): ProjectUsag
|
|||
);
|
||||
|
||||
export const buildDailySpendSeries = (rows: ProjectDailySpendRow[]): DailyProjectSpendPoint[] => {
|
||||
const spendByDate = rows.reduce<Record<string, number>>(
|
||||
(totals, row) => ({ ...totals, [row.date]: (totals[row.date] ?? 0) + row.spend }),
|
||||
{},
|
||||
);
|
||||
return Object.entries(spendByDate)
|
||||
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[]): Record<string, ProjectDailySpendRow[]> =>
|
||||
rows.reduce<Record<string, ProjectDailySpendRow[]>>(
|
||||
(groups, row) => ({ ...groups, [row.project_id]: [...(groups[row.project_id] ?? []), row] }),
|
||||
{},
|
||||
);
|
||||
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;
|
||||
|
|
@ -75,22 +82,18 @@ const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow =>
|
|||
};
|
||||
|
||||
const disambiguateAliases = (rows: ProjectSpendRow[]): ProjectSpendRow[] => {
|
||||
const aliasCounts = rows.reduce<Record<string, number>>(
|
||||
(counts, row) => ({ ...counts, [row.project_alias]: (counts[row.project_alias] ?? 0) + 1 }),
|
||||
{},
|
||||
);
|
||||
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[row.project_alias] > 1 ? { ...row, project_alias: `${row.project_alias} (${row.project_id})` } : 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 = Object.values(groupByProjectId(rows)).map(summarizeProjectGroup);
|
||||
const summarized = groupByProjectId(rows).map(summarizeProjectGroup);
|
||||
return disambiguateAliases(summarized).sort((a, b) => b.spend - a.spend);
|
||||
};
|
||||
|
||||
export const humanizeBackendListMessage = (message: string): string =>
|
||||
message.replace(/\[([^\]]*)]\s*$/, (_match, listContents: string) => {
|
||||
const items = [...listContents.matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) => m[1] ?? m[2]);
|
||||
return items.length > 0 ? items.join(", ") : listContents;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ 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";
|
||||
|
|
@ -111,7 +112,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const isOrgAdmin = useIsOrgAdmin();
|
||||
const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin);
|
||||
const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage");
|
||||
const canViewProjectUsage = hasCapability(userRole, "viewProjectUsage");
|
||||
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
|
||||
|
|
@ -484,6 +487,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
userRole={userRole}
|
||||
canViewTagUsage={canViewTagUsage}
|
||||
isOrgAdmin={isOrgAdmin}
|
||||
enableProjectsUI={enableProjectsUI}
|
||||
/>
|
||||
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
|
||||
</div>
|
||||
|
|
@ -937,7 +941,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Project Usage Panel */}
|
||||
{usageView === "project" && canViewProjectUsage && (
|
||||
<ProjectUsage
|
||||
accessToken={accessToken}
|
||||
|
|
|
|||
|
|
@ -100,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(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface UsageViewSelectProps {
|
|||
userRole: string | null;
|
||||
canViewTagUsage?: boolean;
|
||||
isOrgAdmin?: boolean;
|
||||
enableProjectsUI?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
"data-id"?: string;
|
||||
|
|
@ -118,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,
|
||||
|
|
@ -125,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue