mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
feat(ui): usage pages consume bounded daily activity routes (#43409)
Resolves LIT-8899 Usage, cost optimization, user and team pages read the aggregate, paginated key, search, model top key, cache leakage and export routes added by the lower layers instead of downloading every key's daily rows into the browser. Key detail, model top key and search failures render explicit errors with retry controls, Overall Usage shows a loader while the aggregate is in flight, Retry on a failed first key page shows the loading state while it refetches, a short query keeps the loader or first page error visible instead of No keys match, key paging advances by the server offset so a page of already loaded keys moves on and only an empty page ends paging, and search results are stored as rows so a new teams array from the parent does not restart an in-flight search, and the global Cost tab shows a loader instead of zero totals while the aggregate reloads. 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
54a51c80df
commit
ee3eff26cb
55 changed files with 3394 additions and 6163 deletions
|
|
@ -1140,15 +1140,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": {
|
||||
"react-hooks/refs": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/users/_components/BulkEditUsers.tsx": {
|
||||
|
|
@ -1279,11 +1271,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/EntityUsageExport/utils.ts": {
|
||||
"max-params": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/GuardrailSettingsView.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
|
|
@ -1786,13 +1773,13 @@
|
|||
"count": 1
|
||||
},
|
||||
"max-params": {
|
||||
"count": 21
|
||||
"count": 15
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 5
|
||||
},
|
||||
"no-restricted-syntax": {
|
||||
"count": 147
|
||||
"count": 146
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 31
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { DailyData, KeyMetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import type { KeySpendActivityRow } from "@/components/UsagePage/dailyActivityApi";
|
||||
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
|
||||
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
|
||||
import type { DailyActivityRange } from "./useDailyActivityRange";
|
||||
|
||||
const mockCacheLeakageKeysCall = vi.fn();
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
cacheLeakageKeysCall: (...args: unknown[]) => mockCacheLeakageKeysCall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shared/advanced_date_picker", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="date-picker" />,
|
||||
|
|
@ -11,8 +20,9 @@ vi.mock("@/components/shared/advanced_date_picker", () => ({
|
|||
|
||||
import CacheLeakageCard from "./CacheLeakageCard";
|
||||
|
||||
const baseMetrics = (overrides: Partial<SpendMetrics>): SpendMetrics => ({
|
||||
const baseMetrics = (overrides: Partial<SpendMetrics>): components["schemas"]["SpendMetrics"] => ({
|
||||
spend: 0,
|
||||
flat_cost: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
|
|
@ -21,27 +31,22 @@ const baseMetrics = (overrides: Partial<SpendMetrics>): SpendMetrics => ({
|
|||
failed_requests: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
compression_saved_tokens: 0,
|
||||
compression_savings_spend: 0,
|
||||
prompt_caching_savings_spend: 0,
|
||||
gateway_injected_caching_savings_spend: 0,
|
||||
autorouter_savings_spend: 0,
|
||||
total_response_time_ms: 0,
|
||||
timed_requests: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const key = (alias: string, metrics: Partial<SpendMetrics>): KeyMetricWithMetadata => ({
|
||||
const keyRow = (hash: string, alias: string, metrics: Partial<SpendMetrics>): KeySpendActivityRow => ({
|
||||
api_key: hash,
|
||||
metrics: baseMetrics(metrics),
|
||||
metadata: { key_alias: alias, team_id: null },
|
||||
});
|
||||
|
||||
const dayWithKeys = (date: string, apiKeys: Record<string, KeyMetricWithMetadata>): DailyData => ({
|
||||
date,
|
||||
metrics: baseMetrics({}),
|
||||
breakdown: {
|
||||
models: {},
|
||||
model_groups: {},
|
||||
mcp_servers: {},
|
||||
providers: {},
|
||||
api_keys: apiKeys,
|
||||
entities: {},
|
||||
},
|
||||
});
|
||||
|
||||
const dayWithModels = (date: string, models: Record<string, Partial<SpendMetrics>>): DailyData => ({
|
||||
date,
|
||||
metrics: baseMetrics({}),
|
||||
|
|
@ -67,27 +72,37 @@ const renderWith = (results: DailyData[], overrides: Partial<DailyActivityRange>
|
|||
dateValue: {},
|
||||
onDateChange: vi.fn(),
|
||||
results,
|
||||
metadata: EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
loading: false,
|
||||
isFetchingMore: false,
|
||||
progress: { currentPage: 1, totalPages: 1 },
|
||||
cancelled: false,
|
||||
failed: false,
|
||||
cancel: vi.fn(),
|
||||
scope: {
|
||||
accessToken: "test-token",
|
||||
startTime: new Date(2025, 0, 1),
|
||||
endTime: new Date(2025, 0, 31),
|
||||
userId: null,
|
||||
apiKey: null,
|
||||
},
|
||||
...overrides,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("CacheLeakageCard", () => {
|
||||
it("ranks leaking keys by uncached prompt tokens and shows cache hit ratio", () => {
|
||||
renderWith([
|
||||
dayWithKeys("2026-07-12", {
|
||||
"hash-caching": key("caching-key", { prompt_tokens: 1000, cache_read_input_tokens: 900 }),
|
||||
"hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
|
||||
}),
|
||||
]);
|
||||
beforeEach(() => {
|
||||
mockCacheLeakageKeysCall.mockReset();
|
||||
mockCacheLeakageKeysCall.mockResolvedValue({ api_keys: [] });
|
||||
});
|
||||
|
||||
expect(screen.getByText("leaky-key")).toBeInTheDocument();
|
||||
it("ranks leaking keys from the server-ranked key list and shows cache hit ratio", async () => {
|
||||
mockCacheLeakageKeysCall.mockResolvedValue({
|
||||
api_keys: [
|
||||
keyRow("hash-caching", "caching-key", { prompt_tokens: 1000, cache_read_input_tokens: 900 }),
|
||||
keyRow("hash-leaky", "leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
|
||||
],
|
||||
});
|
||||
renderWith([]);
|
||||
|
||||
expect(await screen.findByText("leaky-key")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.0%")).toBeInTheDocument();
|
||||
expect(screen.getByText("90.0%")).toBeInTheDocument();
|
||||
[
|
||||
|
|
@ -97,23 +112,42 @@ describe("CacheLeakageCard", () => {
|
|||
].forEach((info) => expect(screen.getByLabelText(info)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("sorts by the clicked column, worst cache hit rate first", () => {
|
||||
renderWith([
|
||||
dayWithKeys("2026-07-12", {
|
||||
"hash-a": key("alpha", {
|
||||
it("asks the server for the key ranking under the activity scope", async () => {
|
||||
renderWith([], {
|
||||
scope: {
|
||||
accessToken: "test-token",
|
||||
startTime: new Date(2025, 0, 1),
|
||||
endTime: new Date(2025, 0, 31),
|
||||
userId: "u1",
|
||||
apiKey: "hash-1",
|
||||
},
|
||||
});
|
||||
|
||||
await screen.findByText("No key usage in this range.");
|
||||
expect(mockCacheLeakageKeysCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityIds: ["u1"], apiKey: "hash-1", includeCurrentUtcDay: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sorts by the clicked column, worst cache hit rate first", async () => {
|
||||
mockCacheLeakageKeysCall.mockResolvedValue({
|
||||
api_keys: [
|
||||
keyRow("hash-a", "alpha", {
|
||||
prompt_tokens: 10000,
|
||||
cache_read_input_tokens: 9000,
|
||||
prompt_caching_savings_spend: 9.0,
|
||||
}),
|
||||
"hash-b": key("bravo", {
|
||||
keyRow("hash-b", "bravo", {
|
||||
prompt_tokens: 500,
|
||||
cache_read_input_tokens: 50,
|
||||
prompt_caching_savings_spend: 0.05,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
],
|
||||
});
|
||||
renderWith([]);
|
||||
const firstDataRow = () => screen.getAllByRole("row")[1];
|
||||
|
||||
expect(await screen.findByText("alpha")).toBeInTheDocument();
|
||||
expect(firstDataRow()).toHaveTextContent("alpha");
|
||||
|
||||
fireEvent.click(screen.getByText("Cache hit rate"));
|
||||
|
|
@ -123,7 +157,7 @@ describe("CacheLeakageCard", () => {
|
|||
expect(firstDataRow()).toHaveTextContent("alpha");
|
||||
});
|
||||
|
||||
it("switches to the model view and lists models from every provider", () => {
|
||||
it("switches to the model view and lists models from every provider", async () => {
|
||||
renderWith([
|
||||
dayWithModels("2026-07-12", {
|
||||
"claude-sonnet-5": { prompt_tokens: 5000, cache_read_input_tokens: 0 },
|
||||
|
|
@ -131,51 +165,32 @@ describe("CacheLeakageCard", () => {
|
|||
}),
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByText("By model"));
|
||||
fireEvent.click(await screen.findByText("By model"));
|
||||
|
||||
expect(screen.getByText("Cache leakage by model")).toBeInTheDocument();
|
||||
expect(screen.getByText("claude-sonnet-5")).toBeInTheDocument();
|
||||
expect(screen.getByText("vertex_ai/gemini-2.5-pro")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state when no key used tokens in the range", () => {
|
||||
renderWith([dayWithKeys("2026-07-12", {})]);
|
||||
it("shows an empty state when no key used tokens in the range", async () => {
|
||||
renderWith([]);
|
||||
|
||||
expect(screen.getByText("No key usage in this range.")).toBeInTheDocument();
|
||||
expect(await screen.findByText("No key usage in this range.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tells the user the table is still filling in while fallback pages stream", () => {
|
||||
const day = dayWithKeys("2026-07-12", {
|
||||
"hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
|
||||
});
|
||||
renderWith([day], { isFetchingMore: true });
|
||||
it("reports a load failure instead of claiming the range is empty", async () => {
|
||||
mockCacheLeakageKeysCall.mockRejectedValue(new Error("route unavailable"));
|
||||
renderWith([]);
|
||||
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Data is still loading; rows and totals will update as the rest of the range arrives."),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("Could not load key usage for this range.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No key usage in this range.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the streaming note off while a fresh range loads over the previous range's rows", () => {
|
||||
const day = dayWithKeys("2026-07-12", {
|
||||
"hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
|
||||
});
|
||||
renderWith([day], { loading: true });
|
||||
it("shows a loading state while the key ranking is in flight", () => {
|
||||
mockCacheLeakageKeysCall.mockReturnValue(new Promise(() => {}));
|
||||
renderWith([]);
|
||||
|
||||
expect(
|
||||
screen.queryByText("Data is still loading; rows and totals will update as the rest of the range arrives."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops the streaming note once the range has settled", () => {
|
||||
const day = dayWithKeys("2026-07-12", {
|
||||
"hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
|
||||
});
|
||||
renderWith([day]);
|
||||
|
||||
expect(
|
||||
screen.queryByText("Data is still loading; rows and totals will update as the rest of the range arrives."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,8 +8,17 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { CacheLeakageDimension, CacheLeakageRow, computeCacheLeakage, pct, usd } from "./costOptimizationUtils";
|
||||
import {
|
||||
CacheLeakageDimension,
|
||||
CacheLeakageRow,
|
||||
computeCacheLeakage,
|
||||
leakageRowsFromKeyRows,
|
||||
netSavingsPerCachedToken,
|
||||
pct,
|
||||
usd,
|
||||
} from "./costOptimizationUtils";
|
||||
import { DailyActivityRange } from "./useDailyActivityRange";
|
||||
import { useCacheLeakageKeys } from "./useCacheLeakageKeys";
|
||||
|
||||
interface CacheLeakageCardProps {
|
||||
activity: DailyActivityRange;
|
||||
|
|
@ -80,11 +89,20 @@ const SortableHead = ({
|
|||
};
|
||||
|
||||
const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
|
||||
const { results, loading, isFetchingMore } = activity;
|
||||
const { results, loading } = activity;
|
||||
const [dimension, setDimension] = useState<CacheLeakageDimension>("key");
|
||||
const [sort, setSort] = useState<SortState>({ column: "potentialSavings", dir: "desc" });
|
||||
const leakage = useMemo(() => computeCacheLeakage(results, dimension), [results, dimension]);
|
||||
const rows = useMemo(() => [...leakage.rows].sort((a, b) => compareRows(a, b, sort)), [leakage.rows, sort]);
|
||||
const leakageRate = useMemo(() => netSavingsPerCachedToken(results), [results]);
|
||||
const keyLeakage = useCacheLeakageKeys(activity, dimension === "key");
|
||||
const unsortedRows = useMemo(
|
||||
() =>
|
||||
dimension === "key"
|
||||
? leakageRowsFromKeyRows(keyLeakage.rows, leakageRate)
|
||||
: computeCacheLeakage(results, "model").rows,
|
||||
[dimension, keyLeakage.rows, leakageRate, results],
|
||||
);
|
||||
const rows = useMemo(() => [...unsortedRows].sort((a, b) => compareRows(a, b, sort)), [unsortedRows, sort]);
|
||||
const rowsLoading = dimension === "key" ? keyLeakage.loading : loading;
|
||||
|
||||
const onSort = (column: SortColumn) =>
|
||||
setSort((prev) =>
|
||||
|
|
@ -96,6 +114,10 @@ const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
|
|||
const subject = dimension === "model" ? "Models" : "Keys";
|
||||
const firstColumn = dimension === "model" ? "Model" : "Key";
|
||||
const emptyNoun = dimension === "model" ? "model" : "key";
|
||||
const emptyMessage =
|
||||
dimension === "key" && keyLeakage.failed
|
||||
? "Could not load key usage for this range."
|
||||
: `No ${emptyNoun} usage in this range.`;
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={300}>
|
||||
|
|
@ -119,14 +141,9 @@ const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
|
|||
</Tabs>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rows.length > 0 && isFetchingMore && (
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
Data is still loading; rows and totals will update as the rest of the range arrives.
|
||||
</p>
|
||||
)}
|
||||
{rows.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{loading || isFetchingMore ? "Loading..." : `No ${emptyNoun} usage in this range.`}
|
||||
{rowsLoading ? "Loading..." : emptyMessage}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import { fireEvent, render, waitFor, screen } from "@testing-library/react";
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const mockUserDailyActivityCall = vi.fn();
|
||||
const mockUserDailyActivityAggregatedCall = vi.fn();
|
||||
const mockDailyActivityAggregatedCall = vi.fn();
|
||||
const { useAuthorizedMock, mockToolSpendResponse } = vi.hoisted(() => ({
|
||||
useAuthorizedMock: vi.fn(),
|
||||
mockToolSpendResponse: { by_tool: [], daily: [], start_date: null, end_date: null },
|
||||
|
|
@ -15,8 +14,8 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
userDailyActivityCall: (...args: unknown[]) => mockUserDailyActivityCall(...args),
|
||||
userDailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args),
|
||||
dailyActivityAggregatedCall: (...args: unknown[]) => mockDailyActivityAggregatedCall(...args),
|
||||
cacheLeakageKeysCall: vi.fn().mockResolvedValue({ api_keys: [] }),
|
||||
getToolSpend: vi.fn().mockResolvedValue(mockToolSpendResponse),
|
||||
getGeneralSettingsCall: vi.fn().mockResolvedValue([]),
|
||||
organizationListCall: vi.fn().mockResolvedValue([]),
|
||||
|
|
@ -53,7 +52,7 @@ const singlePage = {
|
|||
|
||||
describe("CostOptimizationView daily activity", () => {
|
||||
it("fetches daily activity once for the page and shares it with every tab that needs it", async () => {
|
||||
mockUserDailyActivityAggregatedCall.mockResolvedValue(singlePage);
|
||||
mockDailyActivityAggregatedCall.mockResolvedValue(singlePage);
|
||||
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "proxy_admin" });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
|
|
@ -63,25 +62,17 @@ describe("CostOptimizationView daily activity", () => {
|
|||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(mockDailyActivityAggregatedCall).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Prompt Caching" }));
|
||||
await screen.findByTestId("caching-settings");
|
||||
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1);
|
||||
expect(mockUserDailyActivityCall).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/Currently fetching spend data/)).not.toBeInTheDocument();
|
||||
expect(mockDailyActivityAggregatedCall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the fetch-progress banner while the paginated fallback streams pages in", async () => {
|
||||
mockUserDailyActivityAggregatedCall.mockReset();
|
||||
mockUserDailyActivityCall.mockReset();
|
||||
mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable"));
|
||||
mockUserDailyActivityCall.mockImplementation((...args: unknown[]) =>
|
||||
args[3] === 1
|
||||
? Promise.resolve({ results: [], metadata: { total_pages: 3, has_more: true, page: 1 } })
|
||||
: new Promise(() => {}),
|
||||
);
|
||||
it("surfaces a failure alert when the aggregated fetch fails", async () => {
|
||||
mockDailyActivityAggregatedCall.mockReset();
|
||||
mockDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable"));
|
||||
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "proxy_admin" });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
|
|
@ -91,7 +82,6 @@ describe("CostOptimizationView daily activity", () => {
|
|||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/Currently fetching spend data: fetched 1 \/ 3 pages/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Fetching spend data failed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,12 +11,7 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
|||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
organizationListCall: vi.fn().mockResolvedValue([]),
|
||||
userDailyActivityCall: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ results: [], metadata: { total_pages: 1, has_more: false, page: 1 } }),
|
||||
userDailyActivityAggregatedCall: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ results: [], metadata: { total_pages: 1, has_more: false, page: 1 } }),
|
||||
dailyActivityAggregatedCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("./UsageTab", () => ({ __esModule: true, default: () => <div data-testid="usage-tab" /> }));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import React from "react";
|
|||
import { Info, PiggyBank } from "lucide-react";
|
||||
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts";
|
||||
import { Alert, AlertDescription } from "@/components/shared/Alert";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PageHeader } from "@/components/shared/PageHeader";
|
||||
import UsageTab from "./UsageTab";
|
||||
|
|
@ -84,13 +84,14 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<PaginationStatusAlerts
|
||||
isFetchingMore={activity.isFetchingMore}
|
||||
cancelled={activity.cancelled}
|
||||
failed={activity.failed}
|
||||
progress={activity.progress}
|
||||
cancel={activity.cancel}
|
||||
/>
|
||||
{activity.failed && (
|
||||
<Alert variant="error">
|
||||
<AlertDescription className="text-inherit">
|
||||
Fetching spend data failed, so the savings below may be empty rather than final. Reload the page to try
|
||||
again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<TabsContent value="usage" keepMounted={visitedTabs.includes("usage")}>
|
||||
<UsageTab accessToken={accessToken} activity={activity} />
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { fireEvent, render, waitFor, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
|
||||
|
||||
const mockGetGeneralSettingsCall = vi.fn();
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
|
|
@ -46,12 +48,16 @@ describe("PromptCachingTab", () => {
|
|||
dateValue: {},
|
||||
onDateChange: vi.fn(),
|
||||
results: [],
|
||||
metadata: EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
loading: false,
|
||||
isFetchingMore: false,
|
||||
progress: { currentPage: 1, totalPages: 1 },
|
||||
cancelled: false,
|
||||
failed: false,
|
||||
cancel: vi.fn(),
|
||||
scope: {
|
||||
accessToken: "test-token",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
userId: null,
|
||||
apiKey: null,
|
||||
},
|
||||
};
|
||||
render(<PromptCachingTab accessToken="test-token" activity={activity} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ToolSpendResponse } from "@/components/networking";
|
||||
|
||||
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
|
||||
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
|
||||
|
||||
const mockGetToolSpend = vi.fn();
|
||||
|
|
@ -119,12 +120,16 @@ const renderWith = (results: DailyData[], options: RenderOptions = {}) => {
|
|||
dateValue: { from, to },
|
||||
onDateChange: vi.fn(),
|
||||
results,
|
||||
metadata: EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
loading: false,
|
||||
isFetchingMore: false,
|
||||
progress: { currentPage: 1, totalPages: 1 },
|
||||
cancelled: false,
|
||||
failed: false,
|
||||
cancel: vi.fn(),
|
||||
scope: {
|
||||
accessToken: "test-token",
|
||||
startTime: from,
|
||||
endTime: to,
|
||||
userId: null,
|
||||
apiKey: null,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const EMPTY_TOOL_SPEND: ToolSpendResponse = {
|
|||
const isoDay = (d: Date): string => d.toISOString().slice(0, 10);
|
||||
|
||||
const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
|
||||
const { dateValue, onDateChange, results, loading, isFetchingMore } = activity;
|
||||
const { dateValue, onDateChange, results, loading } = activity;
|
||||
|
||||
const startTime = dateValue.from ?? null;
|
||||
const endTime = dateValue.to ?? null;
|
||||
|
|
@ -130,7 +130,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
|
|||
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
|
||||
</div>
|
||||
|
||||
<SavingsTiles results={results} isLoading={loading || isFetchingMore} />
|
||||
<SavingsTiles results={results} isLoading={loading} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { KeySpendActivityRow } from "@/components/UsagePage/dailyActivityApi";
|
||||
import { DailyData, SpendMetrics } from "@/components/UsagePage/types";
|
||||
import { ToolSpendDailyEntry, ToolSpendEntry } from "@/components/networking";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
|
@ -101,6 +102,71 @@ const aggregateByModel = (results: readonly DailyData[]): Map<string, LeakageAcc
|
|||
return byModel;
|
||||
};
|
||||
|
||||
export const netSavingsPerCachedToken = (results: readonly DailyData[]): number | null => {
|
||||
const totals = [...aggregateByModel(results).values()].reduce(
|
||||
(agg, a) => ({
|
||||
cachedTokens: agg.cachedTokens + a.cacheReadTokens + a.cacheCreationTokens,
|
||||
realizedCachingSavings: agg.realizedCachingSavings + a.realizedCachingSavings,
|
||||
}),
|
||||
{ cachedTokens: 0, realizedCachingSavings: 0 },
|
||||
);
|
||||
const rate = totals.cachedTokens > 0 ? totals.realizedCachingSavings / totals.cachedTokens : null;
|
||||
return rate != null && rate > 0 ? rate : null;
|
||||
};
|
||||
|
||||
const toLeakageRow = (
|
||||
id: string,
|
||||
a: {
|
||||
alias: string | null;
|
||||
teamId: string | null;
|
||||
promptTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
},
|
||||
rate: number | null,
|
||||
dimension: CacheLeakageDimension,
|
||||
): CacheLeakageRow => {
|
||||
const uncachedPromptTokens = Math.max(0, a.promptTokens - a.cacheReadTokens - a.cacheCreationTokens);
|
||||
return {
|
||||
id,
|
||||
label: dimension === "model" ? id : a.alias ?? `${id.slice(0, 8)}...`,
|
||||
sublabel: dimension === "model" ? null : a.teamId,
|
||||
uncachedPromptTokens,
|
||||
cacheHitRatio: a.promptTokens > 0 ? a.cacheReadTokens / a.promptTokens : 0,
|
||||
potentialSavings: rate != null ? uncachedPromptTokens * rate : null,
|
||||
};
|
||||
};
|
||||
|
||||
const sortAndLimit = (rows: CacheLeakageRow[], rate: number | null, limit: number): CacheLeakageRow[] =>
|
||||
rows
|
||||
.filter((row) => row.uncachedPromptTokens > 0)
|
||||
.sort((x, y) =>
|
||||
rate != null
|
||||
? (y.potentialSavings ?? 0) - (x.potentialSavings ?? 0)
|
||||
: y.uncachedPromptTokens - x.uncachedPromptTokens,
|
||||
)
|
||||
.slice(0, limit);
|
||||
|
||||
export const leakageRowsFromKeyRows = (
|
||||
rows: readonly KeySpendActivityRow[],
|
||||
rate: number | null,
|
||||
limit = 10,
|
||||
): CacheLeakageRow[] =>
|
||||
sortAndLimit(
|
||||
rows.map((row) => {
|
||||
const metrics = {
|
||||
alias: row.metadata.key_alias ?? null,
|
||||
teamId: row.metadata.team_id ?? null,
|
||||
promptTokens: row.metrics.prompt_tokens ?? 0,
|
||||
cacheReadTokens: row.metrics.cache_read_input_tokens ?? 0,
|
||||
cacheCreationTokens: row.metrics.cache_creation_input_tokens ?? 0,
|
||||
};
|
||||
return toLeakageRow(row.api_key, metrics, rate, "key");
|
||||
}),
|
||||
rate,
|
||||
limit,
|
||||
);
|
||||
|
||||
export const computeCacheLeakage = (
|
||||
results: readonly DailyData[],
|
||||
dimension: CacheLeakageDimension = "key",
|
||||
|
|
@ -123,27 +189,13 @@ export const computeCacheLeakage = (
|
|||
// A non-positive rate prices no leakage: there is no saving to extrapolate from
|
||||
const rate = netSavingsPerCachedToken != null && netSavingsPerCachedToken > 0 ? netSavingsPerCachedToken : null;
|
||||
|
||||
const rows: CacheLeakageRow[] = [...byEntity.entries()]
|
||||
.map(([id, a]) => {
|
||||
const uncachedPromptTokens = Math.max(0, a.promptTokens - a.cacheReadTokens - a.cacheCreationTokens);
|
||||
return {
|
||||
id,
|
||||
label: dimension === "model" ? id : a.alias ?? `${id.slice(0, 8)}...`,
|
||||
sublabel: dimension === "model" ? null : a.teamId,
|
||||
uncachedPromptTokens,
|
||||
cacheHitRatio: a.promptTokens > 0 ? a.cacheReadTokens / a.promptTokens : 0,
|
||||
potentialSavings: rate != null ? uncachedPromptTokens * rate : null,
|
||||
};
|
||||
})
|
||||
.filter((row) => row.uncachedPromptTokens > 0);
|
||||
|
||||
const sorted = rows.sort((x, y) =>
|
||||
rate != null
|
||||
? (y.potentialSavings ?? 0) - (x.potentialSavings ?? 0)
|
||||
: y.uncachedPromptTokens - x.uncachedPromptTokens,
|
||||
const rows = sortAndLimit(
|
||||
[...byEntity.entries()].map(([id, a]) => toLeakageRow(id, a, rate, dimension)),
|
||||
rate,
|
||||
limit,
|
||||
);
|
||||
|
||||
return { rows: sorted.slice(0, limit), netSavingsPerCachedToken };
|
||||
return { rows, netSavingsPerCachedToken };
|
||||
};
|
||||
|
||||
export interface DailyToolSpendPoint {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cacheLeakageKeysCall } from "@/components/networking";
|
||||
import type { KeySpendActivityRow } from "@/components/UsagePage/dailyActivityApi";
|
||||
import type { DailyActivityRange } from "./useDailyActivityRange";
|
||||
|
||||
interface CacheLeakageKeysResult {
|
||||
rows: KeySpendActivityRow[];
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
interface SettledKeys {
|
||||
key: string;
|
||||
rows: KeySpendActivityRow[];
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
export const useCacheLeakageKeys = (range: DailyActivityRange, enabled: boolean): CacheLeakageKeysResult => {
|
||||
const { accessToken, startTime, endTime, userId, apiKey } = range.scope;
|
||||
const [settled, setSettled] = useState<SettledKeys | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const hasTimeRange = !!startTime && !!endTime;
|
||||
const scopeReady = enabled && !!accessToken && hasTimeRange;
|
||||
const scopeKey = scopeReady ? JSON.stringify([accessToken, startTime, endTime, userId, apiKey]) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!scopeKey) return;
|
||||
if (!accessToken || !startTime || !endTime) return;
|
||||
|
||||
const requestId = ++requestIdRef.current;
|
||||
const isStale = () => requestIdRef.current !== requestId;
|
||||
|
||||
const request = {
|
||||
accessToken,
|
||||
startTime,
|
||||
endTime,
|
||||
entityIds: userId ? [userId] : null,
|
||||
apiKey,
|
||||
includeCurrentUtcDay: true,
|
||||
};
|
||||
cacheLeakageKeysCall(request)
|
||||
.then((response) => {
|
||||
if (isStale()) return;
|
||||
setSettled({ key: scopeKey, rows: response.api_keys, failed: false });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isStale()) return;
|
||||
console.error("Failed to fetch cache leakage keys:", error);
|
||||
setSettled({ key: scopeKey, rows: [], failed: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
requestIdRef.current++;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- scopeKey serializes the scope
|
||||
}, [scopeKey]);
|
||||
|
||||
const current = scopeKey !== null && settled?.key === scopeKey ? settled : null;
|
||||
return {
|
||||
rows: current?.rows ?? [],
|
||||
loading: scopeKey !== null && current === null,
|
||||
failed: current?.failed ?? false,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,35 +1,35 @@
|
|||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockUsePaginatedDailyActivity = vi.fn();
|
||||
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
|
||||
|
||||
const mockCancel = vi.fn();
|
||||
const mockUseAggregatedDailyActivity = vi.fn();
|
||||
|
||||
vi.mock("@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity", () => ({
|
||||
usePaginatedDailyActivity: (args: unknown) => {
|
||||
mockUsePaginatedDailyActivity(args);
|
||||
vi.mock("@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity", () => ({
|
||||
useAggregatedDailyActivity: (options: unknown) => {
|
||||
mockUseAggregatedDailyActivity(options);
|
||||
return {
|
||||
data: { results: [] },
|
||||
data: { results: [], metadata: EMPTY_DAILY_ACTIVITY_METADATA },
|
||||
loading: false,
|
||||
isFetchingMore: false,
|
||||
progress: { currentPage: 4, totalPages: 9 },
|
||||
cancelled: false,
|
||||
failed: false,
|
||||
coversRange: true,
|
||||
cancel: mockCancel,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
userDailyActivityCall: vi.fn(),
|
||||
userDailyActivityAggregatedCall: vi.fn(),
|
||||
dailyActivityAggregatedCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }),
|
||||
}));
|
||||
|
||||
import { userDailyActivityAggregatedCall } from "@/components/networking";
|
||||
import { dailyActivityAggregatedCall } from "@/components/networking";
|
||||
import { useActivityDateRange, useDailyActivityRange } from "./useDailyActivityRange";
|
||||
|
||||
const argsOfLastCall = () => mockUsePaginatedDailyActivity.mock.calls.at(-1)?.[0].args as unknown[];
|
||||
interface CapturedOptions {
|
||||
fetch: () => Promise<unknown>;
|
||||
enabled: boolean;
|
||||
deps: unknown[];
|
||||
}
|
||||
|
||||
const lastOptions = () => mockUseAggregatedDailyActivity.mock.calls.at(-1)?.[0] as CapturedOptions;
|
||||
|
||||
describe("useDailyActivityRange", () => {
|
||||
it("offers date-range state without starting a daily-activity query", () => {
|
||||
|
|
@ -37,49 +37,53 @@ describe("useDailyActivityRange", () => {
|
|||
|
||||
expect(result.current.dateValue.from).toBeInstanceOf(Date);
|
||||
expect(result.current.dateValue.to).toBeInstanceOf(Date);
|
||||
expect(mockUsePaginatedDailyActivity).not.toHaveBeenCalled();
|
||||
expect(mockUseAggregatedDailyActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queries every user's activity for an admin", () => {
|
||||
it("fetches every user's activity for an admin through the aggregated endpoint", async () => {
|
||||
renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin"));
|
||||
|
||||
expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), null, true, null]);
|
||||
await lastOptions().fetch();
|
||||
expect(dailyActivityAggregatedCall).toHaveBeenCalledWith(
|
||||
"user",
|
||||
expect.objectContaining({
|
||||
accessToken: "test-token",
|
||||
entityIds: null,
|
||||
includeCurrentUtcDay: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes the query to the caller for a non-admin", () => {
|
||||
it("scopes the query to the caller for a non-admin", async () => {
|
||||
renderHook(() => useDailyActivityRange("test-token", "u1", "internal_user"));
|
||||
|
||||
expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1", true, null]);
|
||||
await lastOptions().fetch();
|
||||
expect(dailyActivityAggregatedCall).toHaveBeenCalledWith("user", expect.objectContaining({ entityIds: ["u1"] }));
|
||||
});
|
||||
|
||||
it.each(["org_admin", "Org Admin"])(
|
||||
"scopes the query to the caller for %s, who has no admin view on this endpoint",
|
||||
(role) => {
|
||||
async (role) => {
|
||||
renderHook(() => useDailyActivityRange("test-token", "u1", role));
|
||||
|
||||
expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1", true, null]);
|
||||
await lastOptions().fetch();
|
||||
expect(dailyActivityAggregatedCall).toHaveBeenCalledWith("user", expect.objectContaining({ entityIds: ["u1"] }));
|
||||
},
|
||||
);
|
||||
|
||||
it("fetches through the single-shot aggregated endpoint first so days never fragment across pages", () => {
|
||||
renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin"));
|
||||
|
||||
expect(mockUsePaginatedDailyActivity).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ aggregatedFetchFn: userDailyActivityAggregatedCall }),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards the pagination progress and cancel affordances instead of dropping them", () => {
|
||||
const { result } = renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin"));
|
||||
|
||||
expect(result.current.progress).toEqual({ currentPage: 4, totalPages: 9 });
|
||||
expect(result.current.cancelled).toBe(false);
|
||||
expect(result.current.cancel).toBe(mockCancel);
|
||||
});
|
||||
|
||||
it("stays disabled until an access token is available", () => {
|
||||
renderHook(() => useDailyActivityRange(null, "u1", "proxy_admin"));
|
||||
|
||||
expect(mockUsePaginatedDailyActivity).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: false }));
|
||||
expect(lastOptions().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes the request scope so sibling hooks fetch under the same filters", () => {
|
||||
const { result } = renderHook(() => useDailyActivityRange("test-token", "u1", "internal_user"));
|
||||
|
||||
expect(result.current.scope).toMatchObject({
|
||||
accessToken: "test-token",
|
||||
userId: "u1",
|
||||
apiKey: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { useMemo, useState } from "react";
|
||||
|
||||
import { userDailyActivityAggregatedCall, userDailyActivityCall } from "@/components/networking";
|
||||
import { dailyActivityAggregatedCall } from "@/components/networking";
|
||||
import {
|
||||
EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
toDailyData,
|
||||
type DailyActivityMetadata,
|
||||
type DailyActivityRequest,
|
||||
} from "@/components/UsagePage/dailyActivityApi";
|
||||
import { DailyData } from "@/components/UsagePage/types";
|
||||
import { spendScopeUserId } from "@/utils/roles";
|
||||
import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity";
|
||||
import { useAggregatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity";
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
|
|
@ -12,30 +18,22 @@ export interface DateRange {
|
|||
to?: Date;
|
||||
}
|
||||
|
||||
export interface DailyActivityScope {
|
||||
accessToken: string | null;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
userId: string | null;
|
||||
apiKey: string | null;
|
||||
}
|
||||
|
||||
export interface DailyActivityRange {
|
||||
dateValue: DateRange;
|
||||
onDateChange: (value: DateRange) => void;
|
||||
results: DailyData[];
|
||||
metadata: DailyActivityMetadata;
|
||||
loading: boolean;
|
||||
isFetchingMore: boolean;
|
||||
progress: { currentPage: number; totalPages: number };
|
||||
cancelled: boolean;
|
||||
failed: boolean;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which slice of daily activity to read. Both fields are passed straight through to the
|
||||
* endpoint as filters, so the caller — not this hook — decides what the viewer may see.
|
||||
*
|
||||
* `userId: null` asks for the whole proxy, which the backend only honours for admins;
|
||||
* a non-admin must send its own id or the request is rejected. That role decision lives in
|
||||
* `useDailyActivityRange` below rather than in here, so a caller scoping to one key is not
|
||||
* silently re-scoped to a user as well.
|
||||
*/
|
||||
export interface DailyActivityScope {
|
||||
userId: string | null;
|
||||
apiKey?: string | null;
|
||||
scope: DailyActivityScope;
|
||||
}
|
||||
|
||||
export type ActivityDateRange = Pick<DailyActivityRange, "dateValue" | "onDateChange">;
|
||||
|
|
@ -47,39 +45,49 @@ export const useActivityDateRange = (): ActivityDateRange => {
|
|||
return { dateValue, onDateChange: setDateValue };
|
||||
};
|
||||
|
||||
export interface ScopedActivityInput {
|
||||
userId: string | null;
|
||||
apiKey?: string | null;
|
||||
}
|
||||
|
||||
export const useScopedDailyActivityRange = (
|
||||
accessToken: string | null,
|
||||
scope: DailyActivityScope,
|
||||
scope: ScopedActivityInput,
|
||||
{ dateValue, onDateChange }: ActivityDateRange,
|
||||
): DailyActivityRange => {
|
||||
const startTime = dateValue.from ?? null;
|
||||
const endTime = dateValue.to ?? null;
|
||||
const { userId, apiKey = null } = scope;
|
||||
|
||||
const activityQueryOptions = {
|
||||
fetchFn: userDailyActivityCall,
|
||||
aggregatedFetchFn: userDailyActivityAggregatedCall,
|
||||
// Positional, and read by two functions whose signatures diverge at index 3: the paginated
|
||||
// call takes `page` there (injected by the hook) and the aggregated one does not. Anything
|
||||
// appended here must therefore be appended to BOTH networking signatures, in this order.
|
||||
args: [accessToken, startTime, endTime, userId, true, apiKey],
|
||||
enabled: !!accessToken && !!startTime && !!endTime,
|
||||
};
|
||||
const { data, loading, isFetchingMore, progress, cancelled, failed, coversRange, cancel } =
|
||||
usePaginatedDailyActivity(activityQueryOptions);
|
||||
const readUnavailable = failed || cancelled;
|
||||
const waitingForRange = activityQueryOptions.enabled && !coversRange && !readUnavailable;
|
||||
const request = useMemo<DailyActivityRequest | null>(
|
||||
() =>
|
||||
accessToken && startTime && endTime
|
||||
? {
|
||||
accessToken,
|
||||
startTime,
|
||||
endTime,
|
||||
entityIds: userId ? [userId] : null,
|
||||
apiKey,
|
||||
includeCurrentUtcDay: true,
|
||||
}
|
||||
: null,
|
||||
[accessToken, startTime, endTime, userId, apiKey],
|
||||
);
|
||||
|
||||
const { data, loading, failed } = useAggregatedDailyActivity({
|
||||
fetch: () => dailyActivityAggregatedCall("user", request as DailyActivityRequest),
|
||||
enabled: request !== null,
|
||||
deps: [accessToken, startTime, endTime, userId, apiKey],
|
||||
});
|
||||
|
||||
return {
|
||||
dateValue,
|
||||
onDateChange,
|
||||
results: data.results as DailyData[],
|
||||
loading: loading || waitingForRange,
|
||||
isFetchingMore,
|
||||
progress,
|
||||
cancelled,
|
||||
results: toDailyData(data),
|
||||
metadata: data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
loading,
|
||||
failed,
|
||||
cancel,
|
||||
scope: { accessToken, startTime, endTime, userId, apiKey },
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@ import type { ReactNode } from "react";
|
|||
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import useTeams from "@/app/(dashboard)/hooks/useTeams";
|
||||
import * as networking from "@/components/networking";
|
||||
import type { DailyData, KeyMetadata, KeyMetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types";
|
||||
import type {
|
||||
DailyData,
|
||||
KeyMetadata,
|
||||
KeyMetricWithMetadata,
|
||||
ModelActivityData,
|
||||
SpendMetrics,
|
||||
} from "@/components/UsagePage/types";
|
||||
import EntityUsage from "./EntityUsage";
|
||||
import { getGlobalTopKeys, getTopAPIKeys } from "./entityUsageAggregations";
|
||||
|
||||
|
|
@ -51,21 +57,31 @@ beforeAll(() => {
|
|||
|
||||
// Mock the networking module
|
||||
vi.mock("@/components/networking", () => ({
|
||||
tagDailyActivityCall: vi.fn(),
|
||||
teamDailyActivityCall: vi.fn(),
|
||||
teamDailyActivityAggregatedCall: vi.fn(),
|
||||
organizationDailyActivityCall: vi.fn(),
|
||||
customerDailyActivityCall: vi.fn(),
|
||||
agentDailyActivityCall: vi.fn(),
|
||||
userDailyActivityCall: vi.fn(),
|
||||
dailyActivityAggregatedCall: vi.fn(),
|
||||
dailyActivityKeyPageCall: vi.fn(),
|
||||
dailyActivityKeySearchCall: vi.fn(),
|
||||
dailyActivityModelTopKeysCall: vi.fn(),
|
||||
dailyActivityExportCall: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the child components to simplify testing
|
||||
vi.mock("@/components/activity_metrics", () => ({
|
||||
ActivityMetrics: ({ modelMetrics }: { modelMetrics?: { __source?: string } }) => (
|
||||
ActivityMetrics: ({
|
||||
modelMetrics,
|
||||
summaryMetrics,
|
||||
summaryTitle = "Overall Usage",
|
||||
fetchTopApiKeys,
|
||||
}: {
|
||||
modelMetrics?: { __source?: string };
|
||||
summaryMetrics?: ModelActivityData;
|
||||
summaryTitle?: string;
|
||||
fetchTopApiKeys?: (model: string) => Promise<unknown>;
|
||||
}) => (
|
||||
<div>
|
||||
<span>Activity Metrics</span>
|
||||
<span>{`metrics-source:${modelMetrics?.__source ?? "none"}`}</span>
|
||||
{summaryMetrics !== undefined && <span>{summaryTitle}</span>}
|
||||
{fetchTopApiKeys !== undefined && <span>{`top-keys-fetcher:${modelMetrics?.__source ?? "none"}`}</span>}
|
||||
</div>
|
||||
),
|
||||
processActivityData: (_data: unknown, key: string) => ({ __source: key }),
|
||||
|
|
@ -126,7 +142,9 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/components/common_components/team_multi_select", () => ({
|
||||
default: () => <div>Team Multi Select</div>,
|
||||
default: ({ onChange }: { onChange: (value: string[]) => void }) => (
|
||||
<button onClick={() => onChange(["team-1"])}>Team Multi Select</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock useTeams hook
|
||||
|
|
@ -138,13 +156,22 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
|
|||
}));
|
||||
|
||||
describe("EntityUsage", () => {
|
||||
const mockTagDailyActivityCall = vi.mocked(networking.tagDailyActivityCall);
|
||||
const mockTeamDailyActivityCall = vi.mocked(networking.teamDailyActivityCall);
|
||||
const mockTeamDailyActivityAggregatedCall = vi.mocked(networking.teamDailyActivityAggregatedCall);
|
||||
const mockOrganizationDailyActivityCall = vi.mocked(networking.organizationDailyActivityCall);
|
||||
const mockCustomerDailyActivityCall = vi.mocked(networking.customerDailyActivityCall);
|
||||
const mockAgentDailyActivityCall = vi.mocked(networking.agentDailyActivityCall);
|
||||
const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall);
|
||||
const mockDailyActivityAggregatedCall = vi.mocked(networking.dailyActivityAggregatedCall);
|
||||
const mockDailyActivityKeyPageCall = vi.mocked(networking.dailyActivityKeyPageCall);
|
||||
const mockTagDailyActivityCall = vi.fn();
|
||||
const mockTeamDailyActivityCall = vi.fn();
|
||||
const mockOrganizationDailyActivityCall = vi.fn();
|
||||
const mockCustomerDailyActivityCall = vi.fn();
|
||||
const mockAgentDailyActivityCall = vi.fn();
|
||||
const mockUserDailyActivityCall = vi.fn();
|
||||
const entityMocks: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
tag: mockTagDailyActivityCall,
|
||||
team: mockTeamDailyActivityCall,
|
||||
organization: mockOrganizationDailyActivityCall,
|
||||
customer: mockCustomerDailyActivityCall,
|
||||
agent: mockAgentDailyActivityCall,
|
||||
user: mockUserDailyActivityCall,
|
||||
};
|
||||
const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers);
|
||||
|
||||
const infiniteUsersResult = (users: { user_id: string; user_alias: string | null; user_email: string | null }[]) =>
|
||||
|
|
@ -440,16 +467,25 @@ describe("EntityUsage", () => {
|
|||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockTagDailyActivityCall.mockClear();
|
||||
mockTeamDailyActivityCall.mockClear();
|
||||
mockTeamDailyActivityAggregatedCall.mockClear();
|
||||
mockOrganizationDailyActivityCall.mockClear();
|
||||
mockCustomerDailyActivityCall.mockClear();
|
||||
mockAgentDailyActivityCall.mockClear();
|
||||
mockUserDailyActivityCall.mockClear();
|
||||
mockDailyActivityAggregatedCall.mockReset();
|
||||
mockDailyActivityKeyPageCall.mockReset();
|
||||
const emptyKeyPage = {
|
||||
api_keys: [],
|
||||
total_api_keys: 0,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
};
|
||||
mockDailyActivityKeyPageCall.mockResolvedValue(emptyKeyPage);
|
||||
mockDailyActivityAggregatedCall.mockImplementation((entity, request) =>
|
||||
(
|
||||
entityMocks[entity] as unknown as (
|
||||
req: typeof request,
|
||||
) => ReturnType<typeof networking.dailyActivityAggregatedCall>
|
||||
)(request),
|
||||
);
|
||||
Object.values(entityMocks).forEach((mock) => mock.mockClear());
|
||||
mockTagDailyActivityCall.mockResolvedValue(mockSpendData);
|
||||
mockTeamDailyActivityCall.mockResolvedValue(mockSpendData);
|
||||
mockTeamDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
|
||||
mockOrganizationDailyActivityCall.mockResolvedValue(mockSpendData);
|
||||
mockCustomerDailyActivityCall.mockResolvedValue(mockSpendData);
|
||||
mockAgentDailyActivityCall.mockResolvedValue(mockAgentSpendData);
|
||||
|
|
@ -534,7 +570,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that it shows team-specific label
|
||||
|
|
@ -650,6 +686,36 @@ describe("EntityUsage", () => {
|
|||
expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads key pages separately from the aggregate using the current entity scope", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
fireEvent.click(screen.getByText("Team Multi Select"));
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
fireEvent.click(screen.getByText("Key Activity"));
|
||||
await waitFor(() => {
|
||||
expect(mockDailyActivityKeyPageCall).toHaveBeenCalledWith(
|
||||
"team",
|
||||
expect.objectContaining({ entityIds: ["team-1"] }),
|
||||
0,
|
||||
50,
|
||||
);
|
||||
});
|
||||
expect(mockDailyActivityAggregatedCall.mock.lastCall?.[1]).not.toHaveProperty("apiKeyLimit");
|
||||
|
||||
const pageCallsBeforeFilterChange = mockDailyActivityKeyPageCall.mock.calls.length;
|
||||
fireEvent.click(screen.getByText("Team Multi Select"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDailyActivityKeyPageCall.mock.calls.length).toBeGreaterThan(pageCallsBeforeFilterChange);
|
||||
});
|
||||
expect(mockDailyActivityKeyPageCall.mock.lastCall?.[1]).toEqual(expect.objectContaining({ entityIds: ["team-1"] }));
|
||||
});
|
||||
|
||||
// An inactive tab panel is marked aria-selected="false" by one tab library and hidden by the
|
||||
// other, so treat either as "not on screen" and the assertion holds whichever one is rendering.
|
||||
const isShowing = (element: HTMLElement): boolean => {
|
||||
|
|
@ -671,7 +737,7 @@ describe("EntityUsage", () => {
|
|||
const NON_TEAM_PANELS: [string, string][] = [
|
||||
["Cost", "Tag Spend Overview"],
|
||||
["Model Activity", "metrics-source:model_groups"],
|
||||
["Key Activity", "metrics-source:api_keys"],
|
||||
["Key Activity", "Overall Usage"],
|
||||
["Endpoint Activity", "Endpoint Usage Panel"],
|
||||
];
|
||||
|
||||
|
|
@ -697,7 +763,7 @@ describe("EntityUsage", () => {
|
|||
["Cost", "Team Spend Overview"],
|
||||
["Model Activity", "metrics-source:model_groups"],
|
||||
["Agent Activity", "metrics-source:entities"],
|
||||
["Key Activity", "metrics-source:api_keys"],
|
||||
["Key Activity", "Overall Usage"],
|
||||
["Endpoint Activity", "Endpoint Usage Panel"],
|
||||
];
|
||||
|
||||
|
|
@ -705,7 +771,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
|
@ -878,7 +944,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Agent Activity")).toBeInTheDocument();
|
||||
|
|
@ -898,7 +964,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Top Agents Driving Spend")).toBeInTheDocument();
|
||||
|
|
@ -918,16 +984,56 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAgentDailyActivityCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
1,
|
||||
null,
|
||||
);
|
||||
expect(mockAgentDailyActivityCall).toHaveBeenCalledWith(expect.objectContaining({ accessToken: "test-token" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("offers per-model top keys in Model Activity but not in the agent breakdown", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Model Activity"));
|
||||
});
|
||||
expect(showingCount("top-keys-fetcher:model_groups")).toBeGreaterThan(0);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Agent Activity"));
|
||||
});
|
||||
expect(showingCount("metrics-source:entities")).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("top-keys-fetcher:entities")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a loader instead of zero totals while the aggregate is in flight", async () => {
|
||||
let resolveSpend: (value: typeof mockSpendData) => void = () => {};
|
||||
mockTagDailyActivityCall.mockReturnValue(
|
||||
new Promise<typeof mockSpendData>((resolve) => {
|
||||
resolveSpend = resolve;
|
||||
}),
|
||||
);
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.getAllByText("Loading chart data...")).toHaveLength(2);
|
||||
expect(screen.queryByText("Total Spend")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Overall Usage")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
resolveSpend(mockSpendData);
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Loading chart data...")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Overall Usage")).toBeInTheDocument();
|
||||
expect(screen.getByText("Total Spend")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should not fetch agent activity data for non-team entity types", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="tag" />);
|
||||
|
||||
|
|
@ -942,7 +1048,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const agentActivityTab = screen.getByText("Agent Activity");
|
||||
|
|
@ -1098,7 +1204,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityType="team" userRole="Internal User" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.getByText("Team Spend Overview")).toBeInTheDocument();
|
||||
|
||||
|
|
@ -1155,7 +1261,7 @@ describe("EntityUsage", () => {
|
|||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
};
|
||||
mockTeamDailyActivityAggregatedCall.mockResolvedValue({
|
||||
mockTeamDailyActivityCall.mockResolvedValue({
|
||||
...mockSpendData,
|
||||
results: [
|
||||
{
|
||||
|
|
@ -1183,31 +1289,71 @@ describe("EntityUsage", () => {
|
|||
expect(screen.getByText(/^top-models:Code Review Agent=/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the aggregated team endpoint and never drains paginated pages for teams", async () => {
|
||||
it("uses the aggregated team endpoint and makes a single bounded request", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
expect(mockDailyActivityAggregatedCall).toHaveBeenCalledWith("team", expect.anything());
|
||||
});
|
||||
expect(mockTeamDailyActivityCall).not.toHaveBeenCalled();
|
||||
expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "team")).toHaveLength(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the paginated team endpoint when the aggregated call fails", async () => {
|
||||
mockTeamDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable"));
|
||||
|
||||
it("does not scope the agent breakdown by the selected team ids", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockAgentDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Team Multi Select" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0);
|
||||
const teamRequests = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "team");
|
||||
expect(teamRequests.some((call) => call[1].entityIds?.includes("team-1"))).toBe(true);
|
||||
});
|
||||
|
||||
const agentRequests = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "agent");
|
||||
expect(agentRequests.length).toBeGreaterThan(0);
|
||||
agentRequests.forEach((call) => {
|
||||
expect(call[1].entityIds).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not refetch agent activity when the team selection changes", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAgentDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
const agentCallsBefore = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "agent").length;
|
||||
const teamCallsBefore = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "team").length;
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Team Multi Select" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "team").length).toBeGreaterThan(
|
||||
teamCallsBefore,
|
||||
);
|
||||
});
|
||||
expect(mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "agent")).toHaveLength(
|
||||
agentCallsBefore,
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces a failure alert when the aggregated call fails instead of retrying other routes", async () => {
|
||||
mockTeamDailyActivityCall.mockRejectedValue(new Error("aggregated unavailable"));
|
||||
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/Fetching spend data failed/).length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "team")).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe("user filter (LIT-5654)", () => {
|
||||
|
|
@ -1244,18 +1390,16 @@ describe("EntityUsage", () => {
|
|||
const user = userEvent.setup();
|
||||
await renderUserUsage();
|
||||
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalledWith("test-token", expect.any(Date), expect.any(Date), 1, null);
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ accessToken: "test-token", entityIds: null }),
|
||||
);
|
||||
|
||||
await user.click(userCombobox());
|
||||
await user.click(await screen.findByText("Alice (user-001)"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
1,
|
||||
"user-001",
|
||||
expect.objectContaining({ accessToken: "test-token", entityIds: ["user-001"] }),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1264,11 +1408,7 @@ describe("EntityUsage", () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
1,
|
||||
null,
|
||||
expect.objectContaining({ accessToken: "test-token", entityIds: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
getTopAgents,
|
||||
getTopAPIKeys,
|
||||
getTopModels,
|
||||
type ExtendedDailyData,
|
||||
type ProviderSpendRow,
|
||||
} from "./entityUsageAggregations";
|
||||
import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost, type SummaryTile } from "./entityUsageSummary";
|
||||
|
|
@ -17,27 +16,25 @@ 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 { Alert, AlertDescription } from "@/components/shared/Alert";
|
||||
import { ChartLoader } from "@/components/shared/chart_loader";
|
||||
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 React, { type ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import TeamMultiSelect from "@/components/common_components/team_multi_select";
|
||||
import UserDropdown from "@/components/common_components/UserDropdown";
|
||||
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
|
||||
import { UsageExportHeader } from "@/components/EntityUsageExport";
|
||||
import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||
import type { EntityType } from "@/components/EntityUsageExport/types";
|
||||
import {
|
||||
agentDailyActivityCall,
|
||||
customerDailyActivityCall,
|
||||
organizationDailyActivityCall,
|
||||
tagDailyActivityCall,
|
||||
teamDailyActivityAggregatedCall,
|
||||
teamDailyActivityCall,
|
||||
userDailyActivityCall,
|
||||
} from "@/components/networking";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { usePaginatedDailyActivity } from "../../hooks/usePaginatedDailyActivity";
|
||||
import { useAggregatedDailyActivity } from "../../hooks/useAggregatedDailyActivity";
|
||||
import { ENTITY_API } from "./entityFetchFns";
|
||||
import {
|
||||
EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
toDailyData,
|
||||
type DailyActivityRequest,
|
||||
} from "@/components/UsagePage/dailyActivityApi";
|
||||
import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData";
|
||||
import { EntityMetricWithMetadata } from "@/components/UsagePage/types";
|
||||
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
|
||||
import EndpointUsage from "../EndpointUsage/EndpointUsage";
|
||||
|
|
@ -62,18 +59,6 @@ interface EntityMetrics {
|
|||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
interface EntitySpendData {
|
||||
results: ExtendedDailyData[];
|
||||
metadata: {
|
||||
total_spend: number;
|
||||
total_flat_cost?: number;
|
||||
total_api_requests: number;
|
||||
total_successful_requests: number;
|
||||
total_failed_requests: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EntityList {
|
||||
label: string;
|
||||
value: string;
|
||||
|
|
@ -91,21 +76,6 @@ interface EntityUsageProps {
|
|||
isOrgAdmin?: boolean;
|
||||
}
|
||||
|
||||
const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
|
||||
tag: tagDailyActivityCall,
|
||||
team: teamDailyActivityCall,
|
||||
organization: organizationDailyActivityCall,
|
||||
customer: customerDailyActivityCall,
|
||||
agent: agentDailyActivityCall,
|
||||
user: userDailyActivityCall,
|
||||
};
|
||||
|
||||
// Single-shot endpoints returning the whole range in one response; entity types
|
||||
// without one fall back to page-draining the paginated endpoint.
|
||||
const ENTITY_AGGREGATED_FETCH_FNS: Partial<Record<EntityType, (...args: any[]) => Promise<any>>> = {
|
||||
team: teamDailyActivityAggregatedCall,
|
||||
};
|
||||
|
||||
const ENTITY_CAPABILITIES: Partial<Record<EntityType, Capability>> = {
|
||||
organization: "viewOrganizationUsage",
|
||||
agent: "viewAgentUsage",
|
||||
|
|
@ -121,6 +91,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
isOrgAdmin = false,
|
||||
}) => {
|
||||
const { teams } = useTeams();
|
||||
const teamList = useMemo(() => teams ?? [], [teams]);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [modelViewType, setModelViewType] = useState<ModelViewType>("groups");
|
||||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
|
|
@ -130,56 +101,103 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
|
||||
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 entityFilterArg = useMemo(() => {
|
||||
if (entityType === "user") return selectedTags.length > 0 ? selectedTags[0] : null;
|
||||
return selectedTags.length > 0 ? selectedTags : null;
|
||||
}, [entityType, selectedTags]);
|
||||
|
||||
const fetchFn = ENTITY_FETCH_FNS[entityType];
|
||||
const aggregatedFetchFn = ENTITY_AGGREGATED_FETCH_FNS[entityType];
|
||||
const api = ENTITY_API[entityType];
|
||||
const entityCapability = ENTITY_CAPABILITIES[entityType];
|
||||
const canViewEntity = entityCapability === undefined || hasCapability(userRole, entityCapability, isOrgAdmin);
|
||||
const showAgentBreakdown = entityType === "team" && hasCapability(userRole, "viewAgentUsage");
|
||||
const hasRequestWindow = !!accessToken && !!startTime && !!endTime;
|
||||
const enabled = hasRequestWindow && canViewEntity;
|
||||
|
||||
const request = useMemo<DailyActivityRequest | null>(
|
||||
() =>
|
||||
hasRequestWindow
|
||||
? {
|
||||
accessToken: accessToken as string,
|
||||
startTime: startTime as Date,
|
||||
endTime: endTime as Date,
|
||||
entityIds: selectedTags.length > 0 ? selectedTags : null,
|
||||
}
|
||||
: null,
|
||||
[hasRequestWindow, accessToken, startTime, endTime, selectedTags],
|
||||
);
|
||||
const agentRequest = useMemo<DailyActivityRequest | null>(
|
||||
() =>
|
||||
hasRequestWindow
|
||||
? {
|
||||
accessToken: accessToken as string,
|
||||
startTime: startTime as Date,
|
||||
endTime: endTime as Date,
|
||||
entityIds: null,
|
||||
}
|
||||
: null,
|
||||
[hasRequestWindow, accessToken, startTime, endTime],
|
||||
);
|
||||
|
||||
const {
|
||||
data: spendDataRaw,
|
||||
isFetchingMore,
|
||||
progress,
|
||||
cancelled,
|
||||
loading,
|
||||
failed,
|
||||
coversRange,
|
||||
cancel,
|
||||
} = usePaginatedDailyActivity({
|
||||
fetchFn,
|
||||
args: [accessToken, startTime, endTime, entityFilterArg],
|
||||
enabled,
|
||||
aggregatedFetchFn,
|
||||
} = useAggregatedDailyActivity({
|
||||
fetch: () => api.aggregated(request as DailyActivityRequest),
|
||||
enabled: enabled && request !== null,
|
||||
deps: [entityType, accessToken, startTime, endTime, selectedTags],
|
||||
});
|
||||
|
||||
const spendData = spendDataRaw as unknown as EntitySpendData;
|
||||
const spendData = useMemo(
|
||||
() => ({
|
||||
results: toDailyData(spendDataRaw),
|
||||
metadata: spendDataRaw.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
}),
|
||||
[spendDataRaw],
|
||||
);
|
||||
const summaryMetrics = useMemo(() => overallUsageMetrics(spendData.results, spendData.metadata), [spendData]);
|
||||
|
||||
const {
|
||||
data: agentSpendDataRaw,
|
||||
isFetchingMore: agentIsFetchingMore,
|
||||
progress: agentProgress,
|
||||
cancelled: agentCancelled,
|
||||
loading: agentLoading,
|
||||
failed: agentFailed,
|
||||
cancel: agentCancel,
|
||||
} = usePaginatedDailyActivity({
|
||||
fetchFn: agentDailyActivityCall,
|
||||
args: [accessToken, startTime, endTime, null],
|
||||
enabled: enabled && showAgentBreakdown,
|
||||
} = useAggregatedDailyActivity({
|
||||
fetch: () => ENTITY_API.agent.aggregated(agentRequest as DailyActivityRequest),
|
||||
enabled: enabled && showAgentBreakdown && agentRequest !== null,
|
||||
deps: [accessToken, startTime, endTime, showAgentBreakdown],
|
||||
});
|
||||
|
||||
const agentSpendData = agentSpendDataRaw as unknown as EntitySpendData;
|
||||
const agentSpendData = useMemo(
|
||||
() => ({
|
||||
results: toDailyData(agentSpendDataRaw),
|
||||
metadata: agentSpendDataRaw.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
}),
|
||||
[agentSpendDataRaw],
|
||||
);
|
||||
|
||||
const fetchTopApiKeys = useCallback(
|
||||
(model: string) => api.modelTopKeys(request as DailyActivityRequest, model, modelViewType === "groups"),
|
||||
[api, request, modelViewType],
|
||||
);
|
||||
const searchKeys = useCallback(
|
||||
(query: string) => (request === null ? Promise.resolve({ api_keys: [] }) : api.searchKeys(request, query)),
|
||||
[api, request],
|
||||
);
|
||||
const fetchKeyPage = useCallback(
|
||||
(offset: number, limit: number) =>
|
||||
request === null
|
||||
? Promise.resolve({ api_keys: [], total_api_keys: 0, offset, limit })
|
||||
: api.keyPage(request, offset, limit),
|
||||
[api, request],
|
||||
);
|
||||
const fetchKeyDetail = useCallback(
|
||||
(apiKey: string) =>
|
||||
request === null
|
||||
? Promise.resolve(undefined)
|
||||
: api
|
||||
.aggregated({ ...request, apiKey, apiKeyLimit: 1 })
|
||||
.then((response) => keyDetailFromResponse(response, apiKey, teamList)),
|
||||
[api, request, teamList],
|
||||
);
|
||||
|
||||
const modelBreakdownKey = modelViewType === "groups" ? "model_groups" : "models";
|
||||
const modelMetrics = processActivityData(spendData, modelBreakdownKey, teams || []);
|
||||
const keyMetrics = processActivityData(spendData, "api_keys", teams || []);
|
||||
const agentMetrics = showAgentBreakdown ? processActivityData(agentSpendData, "entities", teams || []) : {};
|
||||
const modelMetrics = processActivityData(spendData, modelBreakdownKey, teamList);
|
||||
const agentMetrics = showAgentBreakdown ? processActivityData(agentSpendData, "entities", teamList) : {};
|
||||
|
||||
const getAllTags = () => {
|
||||
if (entityList) {
|
||||
|
|
@ -393,7 +411,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
|
||||
const modelViewTitle = modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models";
|
||||
|
||||
const costPanel = (
|
||||
const costPanel = loading ? (
|
||||
<ChartLoader />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2 w-full">
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
|
|
@ -594,11 +614,15 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Top Agents Driving Spend</h3>
|
||||
<TopModelView
|
||||
topModels={getTopAgents(agentSpendData.results, topAgentsLimit)}
|
||||
topModelsLimit={topAgentsLimit}
|
||||
setTopModelsLimit={setTopAgentsLimit}
|
||||
/>
|
||||
{agentLoading ? (
|
||||
<ChartLoader />
|
||||
) : (
|
||||
<TopModelView
|
||||
topModels={getTopAgents(agentSpendData.results, topAgentsLimit)}
|
||||
topModelsLimit={topAgentsLimit}
|
||||
setTopModelsLimit={setTopAgentsLimit}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
|
@ -649,46 +673,67 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
<div className="flex justify-end mt-2 mb-4">
|
||||
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
|
||||
</div>
|
||||
<ActivityMetrics modelMetrics={modelMetrics} hidePromptCachingMetrics={entityType === "agent"} />
|
||||
<ActivityMetrics
|
||||
modelMetrics={modelMetrics}
|
||||
hidePromptCachingMetrics={entityType === "agent"}
|
||||
fetchTopApiKeys={request ? fetchTopApiKeys : undefined}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
...(showAgentBreakdown
|
||||
? [{ key: "agents", label: "Agent Activity", content: <ActivityMetrics modelMetrics={agentMetrics} /> }]
|
||||
? [
|
||||
{
|
||||
key: "agents",
|
||||
label: "Agent Activity",
|
||||
content: <ActivityMetrics modelMetrics={agentMetrics} />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "keys",
|
||||
label: "Key Activity",
|
||||
content: <KeyActivityPanel keyMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />,
|
||||
content: (
|
||||
<KeyActivityPanel
|
||||
summary={summaryMetrics}
|
||||
summaryLoading={loading}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={fetchKeyDetail}
|
||||
searchKeys={searchKeys}
|
||||
teams={teamList}
|
||||
hidePromptCachingMetrics={entityType === "agent"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
|
||||
];
|
||||
|
||||
const spendFetchState = { coversRange, cancelled, failed };
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%" }} className="relative">
|
||||
<PaginationStatusAlerts
|
||||
isFetchingMore={isFetchingMore}
|
||||
cancelled={cancelled}
|
||||
failed={failed}
|
||||
progress={progress}
|
||||
cancel={cancel}
|
||||
/>
|
||||
{showAgentBreakdown && (
|
||||
<PaginationStatusAlerts
|
||||
isFetchingMore={agentIsFetchingMore}
|
||||
cancelled={agentCancelled}
|
||||
failed={agentFailed}
|
||||
progress={agentProgress}
|
||||
cancel={agentCancel}
|
||||
subject="agent data"
|
||||
/>
|
||||
{failed && (
|
||||
<Alert variant="error" className="mb-2">
|
||||
<AlertDescription className="text-inherit">
|
||||
Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try
|
||||
again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{showAgentBreakdown && agentFailed && (
|
||||
<Alert variant="error" className="mb-2">
|
||||
<AlertDescription className="text-inherit">
|
||||
Fetching agent data failed, so the totals below may be empty rather than final. Reload the page to try
|
||||
again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<UsageExportHeader
|
||||
dateValue={dateValue}
|
||||
entityType={entityType}
|
||||
spendData={spendData}
|
||||
onExport={(exportType, format) =>
|
||||
request
|
||||
? api.exportRows(request, exportType, format)
|
||||
: Promise.reject(new Error("Select a date range to export"))
|
||||
}
|
||||
showFilters={filterSlot === undefined && entityList !== null}
|
||||
filterSlot={filterSlot}
|
||||
filterLabel={getFilterLabel(entityType)}
|
||||
|
|
@ -696,8 +741,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
selectedFilters={selectedTags}
|
||||
onFiltersChange={setSelectedTags}
|
||||
filterOptions={getAllTags() || undefined}
|
||||
teams={teams || []}
|
||||
exportBlockedReason={getExportBlockedReason(spendFetchState)}
|
||||
teams={teamList}
|
||||
/>
|
||||
<Tabs defaultValue={tabs[0].key}>
|
||||
<TabsList className="mt-1">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
dailyActivityAggregatedCall,
|
||||
dailyActivityExportCall,
|
||||
dailyActivityKeyPageCall,
|
||||
dailyActivityKeySearchCall,
|
||||
dailyActivityModelTopKeysCall,
|
||||
} from "@/components/networking";
|
||||
import type { EntityType } from "@/components/EntityUsageExport/types";
|
||||
import type {
|
||||
DailyActivityAggregatedResponse,
|
||||
DailyActivityEntity,
|
||||
DailyActivityKeySearchResponse,
|
||||
DailyActivityKeyPageResponse,
|
||||
DailyActivityRequest,
|
||||
ExportFormat,
|
||||
ExportType,
|
||||
ModelTopKeysResponse,
|
||||
} from "@/components/UsagePage/dailyActivityApi";
|
||||
|
||||
export interface EntityApi {
|
||||
aggregated(req: DailyActivityRequest): Promise<DailyActivityAggregatedResponse>;
|
||||
keyPage(req: DailyActivityRequest, offset: number, limit: number): Promise<DailyActivityKeyPageResponse>;
|
||||
searchKeys(req: DailyActivityRequest, search: string, limit?: number): Promise<DailyActivityKeySearchResponse>;
|
||||
modelTopKeys(
|
||||
req: DailyActivityRequest,
|
||||
model: string,
|
||||
byModelGroup: boolean,
|
||||
limit?: number,
|
||||
): Promise<ModelTopKeysResponse>;
|
||||
exportRows(req: DailyActivityRequest, exportType: ExportType, format: ExportFormat): Promise<Blob>;
|
||||
}
|
||||
|
||||
const entityApi = (
|
||||
entity: DailyActivityEntity,
|
||||
defaults?: Pick<DailyActivityRequest, "excludeEntityIds">,
|
||||
): EntityApi => ({
|
||||
aggregated: (req) => dailyActivityAggregatedCall(entity, { ...defaults, ...req }),
|
||||
keyPage: (req, offset, limit) => dailyActivityKeyPageCall(entity, { ...defaults, ...req }, offset, limit),
|
||||
searchKeys: (req, search, limit) => dailyActivityKeySearchCall(entity, { ...defaults, ...req }, search, limit),
|
||||
modelTopKeys: (req, model, byModelGroup, limit) =>
|
||||
dailyActivityModelTopKeysCall(entity, { ...defaults, ...req }, model, byModelGroup, limit),
|
||||
exportRows: (req, exportType, format) => dailyActivityExportCall(entity, { ...defaults, ...req }, exportType, format),
|
||||
});
|
||||
|
||||
export const ENTITY_API: Record<EntityType, EntityApi> = {
|
||||
tag: entityApi("tag"),
|
||||
team: entityApi("team", { excludeEntityIds: ["litellm-dashboard"] }),
|
||||
organization: entityApi("organization"),
|
||||
customer: entityApi("customer"),
|
||||
agent: entityApi("agent"),
|
||||
user: entityApi("user"),
|
||||
};
|
||||
|
|
@ -25,8 +25,11 @@ beforeAll(() => {
|
|||
|
||||
// Mock the networking module
|
||||
vi.mock("@/components/networking", () => ({
|
||||
userDailyActivityCall: vi.fn(),
|
||||
userDailyActivityAggregatedCall: vi.fn(),
|
||||
dailyActivityAggregatedCall: vi.fn(),
|
||||
dailyActivityKeyPageCall: vi.fn(),
|
||||
dailyActivityKeySearchCall: vi.fn(),
|
||||
dailyActivityModelTopKeysCall: vi.fn(),
|
||||
dailyActivityExportCall: vi.fn(),
|
||||
gatewayDailyActivityCall: vi.fn(),
|
||||
tagListCall: vi.fn(),
|
||||
}));
|
||||
|
|
@ -66,16 +69,25 @@ vi.mock("./EndpointUsage/EndpointUsage", () => ({
|
|||
|
||||
vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
|
||||
const React = await import("react");
|
||||
const UsageViewSelect = ({ value, onChange, canViewTagUsage = false }: any) => {
|
||||
const UsageViewSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
canViewTagUsage = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
canViewTagUsage?: boolean;
|
||||
}) => {
|
||||
const tagOption = canViewTagUsage ? React.createElement("option", { value: "tag" }, "Tag Usage") : null;
|
||||
const selectProps = {
|
||||
value,
|
||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => onChange?.(e.target.value),
|
||||
role: "combobox",
|
||||
"data-testid": "usage-view-select",
|
||||
};
|
||||
return React.createElement(
|
||||
"select",
|
||||
{
|
||||
value,
|
||||
onChange: (e: any) => onChange?.(e.target.value),
|
||||
role: "combobox",
|
||||
"data-testid": "usage-view-select",
|
||||
},
|
||||
selectProps,
|
||||
React.createElement("option", { value: "global" }, "Global Usage"),
|
||||
React.createElement("option", { value: "team" }, "Team Usage"),
|
||||
React.createElement("option", { value: "organization" }, "Organization Usage"),
|
||||
|
|
@ -157,8 +169,9 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
|
|||
}));
|
||||
|
||||
describe("UsagePage", () => {
|
||||
const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall);
|
||||
const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall);
|
||||
const mockUserDailyActivityAggregatedCall = vi.fn();
|
||||
const mockDailyActivityAggregatedCall = vi.mocked(networking.dailyActivityAggregatedCall);
|
||||
const mockDailyActivityKeyPageCall = vi.mocked(networking.dailyActivityKeyPageCall);
|
||||
const mockTagListCall = vi.mocked(networking.tagListCall);
|
||||
const mockGatewayDailyActivityCall = vi.mocked(networking.gatewayDailyActivityCall);
|
||||
const mockUseCustomers = vi.mocked(useCustomers);
|
||||
|
|
@ -374,7 +387,17 @@ describe("UsagePage", () => {
|
|||
error: null,
|
||||
} as any);
|
||||
mockUserDailyActivityAggregatedCall.mockClear();
|
||||
mockUserDailyActivityCall.mockClear();
|
||||
mockDailyActivityAggregatedCall.mockReset();
|
||||
mockDailyActivityKeyPageCall.mockReset();
|
||||
mockDailyActivityKeyPageCall.mockResolvedValue({
|
||||
api_keys: [],
|
||||
total_api_keys: 0,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
});
|
||||
mockDailyActivityAggregatedCall.mockImplementation((entity: string, request: unknown) =>
|
||||
entity === "user" ? mockUserDailyActivityAggregatedCall(request) : Promise.resolve({ results: [], metadata: {} }),
|
||||
);
|
||||
mockTagListCall.mockClear();
|
||||
mockGatewayDailyActivityCall.mockClear();
|
||||
mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
|
||||
|
|
@ -462,6 +485,11 @@ describe("UsagePage", () => {
|
|||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(screen.queryByText("75,000")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
|
||||
expect(await screen.findByText("425,151")).toBeInTheDocument();
|
||||
expect(screen.getByText("Total Tokens").closest('[data-slot="card"]')).not.toHaveTextContent(/\d/);
|
||||
expect(screen.queryByText("$0.0000")).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
releaseSecondFetch();
|
||||
|
|
@ -469,6 +497,28 @@ describe("UsagePage", () => {
|
|||
await waitFor(() => {
|
||||
expect(screen.getAllByText("75,000").length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.0838")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads key pages separately from the aggregate and refreshes them when the range changes", async () => {
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Key Activity"));
|
||||
await waitFor(() => {
|
||||
expect(mockDailyActivityKeyPageCall).toHaveBeenCalledWith("user", expect.any(Object), 0, 50);
|
||||
});
|
||||
expect(mockUserDailyActivityAggregatedCall.mock.lastCall?.[0]).not.toHaveProperty("apiKeyLimit");
|
||||
const pageCallsBeforeRangeChange = mockDailyActivityKeyPageCall.mock.calls.length;
|
||||
fireEvent.click(screen.getByTestId("pick-a-different-range"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDailyActivityKeyPageCall.mock.calls.length).toBeGreaterThan(pageCallsBeforeRangeChange);
|
||||
});
|
||||
expect(mockUserDailyActivityAggregatedCall.mock.lastCall?.[0]).not.toHaveProperty("apiKeyLimit");
|
||||
});
|
||||
|
||||
it("should fall back to the spend-derived count when the gateway endpoint is unavailable", async () => {
|
||||
|
|
@ -793,7 +843,7 @@ describe("UsagePage", () => {
|
|||
|
||||
it.each(["organization", "agent"])("should not render the %s usage view for an internal user", async (usageView) => {
|
||||
mockUseAuthorized.mockReturnValue(nonAdminSession);
|
||||
|
||||
mockDailyActivityKeyPageCall.mockImplementation(() => new Promise(() => {}));
|
||||
renderWithProviders(<UsagePage {...defaultProps} organizations={mockOrganizations} />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -924,10 +974,7 @@ describe("UsagePage", () => {
|
|||
|
||||
// Initially called with null (global view for admin)
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
null,
|
||||
expect.objectContaining({ accessToken: "test-token", entityIds: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1016,127 +1063,23 @@ describe("UsagePage", () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
"user-123",
|
||||
expect.objectContaining({ accessToken: "test-token", entityIds: ["user-123"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregated endpoint fallback", () => {
|
||||
it("should fall back to paginated calls when aggregated endpoint fails", async () => {
|
||||
describe("aggregated endpoint failure", () => {
|
||||
it("shows the failure alert instead of retrying other routes when the aggregated call fails", async () => {
|
||||
mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Aggregated endpoint not available"));
|
||||
mockUserDailyActivityCall.mockResolvedValue({
|
||||
...mockSpendData,
|
||||
metadata: {
|
||||
...mockSpendData.metadata,
|
||||
total_pages: 1,
|
||||
page: 1,
|
||||
},
|
||||
});
|
||||
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should still render the data from the paginated fallback, which lands a render after the call
|
||||
expect(await screen.findByText("75,000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should stop showing the previous range's paginated pages while a new range is in flight", async () => {
|
||||
// Same rule as the aggregate, one fallback further down. The flag that
|
||||
// decides whether these pages are read belongs to the range the failure
|
||||
// happened on, or the previous range's pages reach the tile through it.
|
||||
let releaseSecondAggregated: () => void = () => {};
|
||||
mockUserDailyActivityAggregatedCall.mockReset();
|
||||
mockUserDailyActivityAggregatedCall
|
||||
.mockRejectedValueOnce(new Error("Aggregated endpoint not available"))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
releaseSecondAggregated = () => reject(new Error("Aggregated endpoint not available"));
|
||||
}),
|
||||
);
|
||||
mockUserDailyActivityCall.mockResolvedValue({
|
||||
...mockSpendData,
|
||||
metadata: { ...mockSpendData.metadata, total_pages: 1, page: 1 },
|
||||
});
|
||||
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("75,000").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("pick-a-different-range"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(screen.queryByText("75,000")).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
releaseSecondAggregated();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("75,000").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should aggregate multiple pages when paginated endpoint has more than 1 page", async () => {
|
||||
mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Not available"));
|
||||
|
||||
const page1Data = {
|
||||
results: [mockSpendData.results[0]],
|
||||
metadata: {
|
||||
total_spend: 60,
|
||||
total_api_requests: 700,
|
||||
total_successful_requests: 680,
|
||||
total_failed_requests: 20,
|
||||
total_tokens: 35000,
|
||||
total_pages: 2,
|
||||
page: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const page2Data = {
|
||||
results: [
|
||||
{
|
||||
...mockSpendData.results[0],
|
||||
date: "2025-01-02",
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
total_spend: 65.75,
|
||||
total_api_requests: 800,
|
||||
total_successful_requests: 770,
|
||||
total_failed_requests: 30,
|
||||
total_tokens: 40000,
|
||||
total_pages: 2,
|
||||
page: 2,
|
||||
},
|
||||
};
|
||||
|
||||
mockUserDailyActivityCall.mockResolvedValueOnce(page1Data).mockResolvedValueOnce(page2Data);
|
||||
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Both pages should have been fetched
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Verify first page call
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalledWith("test-token", expect.any(Date), expect.any(Date), 1, null);
|
||||
|
||||
// Verify second page call
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalledWith("test-token", expect.any(Date), expect.any(Date), 2, null);
|
||||
expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "user")).toHaveLength(1);
|
||||
expect(await screen.findByText(/Fetching spend data failed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,15 +8,15 @@
|
|||
|
||||
import { ChevronDown, ChevronRight, Download, Info, Sparkles, X } from "lucide-react";
|
||||
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { BarChart } from "@/components/shared/charts";
|
||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
|
|
@ -30,23 +30,23 @@ import { ActivityMetrics, processActivityData } from "@/components/activity_metr
|
|||
import CloudZeroExportModal from "@/components/cloudzero_export_modal";
|
||||
import UserDropdown from "@/components/common_components/UserDropdown";
|
||||
import EntityUsageExportModal from "@/components/EntityUsageExport";
|
||||
import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import {
|
||||
gatewayDailyActivityCall,
|
||||
Organization,
|
||||
tagListCall,
|
||||
userDailyActivityAggregatedCall,
|
||||
userDailyActivityCall,
|
||||
} from "@/components/networking";
|
||||
import { gatewayDailyActivityCall, Organization, tagListCall } from "@/components/networking";
|
||||
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
|
||||
import { ChartLoader } from "@/components/shared/chart_loader";
|
||||
import { Tag } from "@/components/tag_management/types";
|
||||
import UserAgentActivity from "@/components/user_agent_activity";
|
||||
import ViewUserSpend from "@/components/view_user_spend";
|
||||
import { usePaginatedDailyActivity } from "../hooks/usePaginatedDailyActivity";
|
||||
import { DailyData, MetricWithMetadata } from "@/components/UsagePage/types";
|
||||
import { useAggregatedDailyActivity } from "../hooks/useAggregatedDailyActivity";
|
||||
import { ENTITY_API } from "./EntityUsage/entityFetchFns";
|
||||
import {
|
||||
EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
toDailyData,
|
||||
type DailyActivityRequest,
|
||||
} from "@/components/UsagePage/dailyActivityApi";
|
||||
import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData";
|
||||
import { MetricWithMetadata } from "@/components/UsagePage/types";
|
||||
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
|
||||
import {
|
||||
fetchedRangeKey,
|
||||
|
|
@ -72,18 +72,11 @@ interface UsagePageProps {
|
|||
organizations: Organization[];
|
||||
}
|
||||
|
||||
const MetricValue = ({ pending, className, children }: { pending: boolean; className: string; children: ReactNode }) =>
|
||||
pending ? <Skeleton className="h-8 w-24 mt-2" /> : <p className={className}>{children}</p>;
|
||||
|
||||
const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
||||
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
|
||||
// Aggregated endpoint: try first, fall back to paginated if unavailable
|
||||
const [aggregatedData, setAggregatedData] = useState<FetchedForRange<{
|
||||
results: DailyData[];
|
||||
metadata: any;
|
||||
}> | null>(null);
|
||||
// Stamped like the data itself: the flag decides whether the paginated
|
||||
// fallback is read, and a flag left over from the previous range would let
|
||||
// that fallback's own leftover rows through.
|
||||
const [aggregatedFailure, setAggregatedFailure] = useState<FetchedForRange<true> | null>(null);
|
||||
const [aggregatedLoading, setAggregatedLoading] = useState(false);
|
||||
const [gatewayActivityData, setGatewayActivityData] = useState<FetchedGatewayActivity | null>(null);
|
||||
|
||||
// Separate loading states for better UX
|
||||
|
|
@ -142,7 +135,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
|
||||
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
|
||||
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
|
||||
|
||||
// Stamped and selected during render like the request tiles below: the tag
|
||||
// filter reads "no tags" from an empty list, so a list left over from the
|
||||
// previous range would state that about a range nobody has measured yet.
|
||||
|
|
@ -181,30 +173,29 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
// can paint them. One source is not enough, since the tiles read the gateway
|
||||
// counts, fall through to the aggregate, and fall through again to the
|
||||
// paginated pages, so a stamp on any one of them is escaped by the next.
|
||||
const currentAggregatedRangeKey = fetchedRangeKey(startTime, endTime, effectiveUserId);
|
||||
const currentGatewayRangeKey = fetchedRangeKey(startTime, endTime);
|
||||
|
||||
// Try aggregated endpoint first, fall back to paginated on failure
|
||||
const aggregatedFetchIdRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!accessToken || !startTime || !endTime) return;
|
||||
const fetchId = ++aggregatedFetchIdRef.current;
|
||||
const rangeKey = currentAggregatedRangeKey;
|
||||
setAggregatedLoading(true);
|
||||
|
||||
userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId)
|
||||
.then((data) => {
|
||||
if (aggregatedFetchIdRef.current !== fetchId) return;
|
||||
setAggregatedData({ rangeKey, value: data });
|
||||
setAggregatedLoading(false);
|
||||
setIsDateChanging(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (aggregatedFetchIdRef.current !== fetchId) return;
|
||||
setAggregatedFailure({ rangeKey, value: true });
|
||||
setAggregatedLoading(false);
|
||||
});
|
||||
}, [accessToken, startTime, endTime, effectiveUserId, currentAggregatedRangeKey]);
|
||||
const dailyActivityRequest = useMemo<DailyActivityRequest | null>(
|
||||
() =>
|
||||
accessToken && startTime && endTime
|
||||
? {
|
||||
accessToken,
|
||||
startTime,
|
||||
endTime,
|
||||
entityIds: effectiveUserId ? [effectiveUserId] : null,
|
||||
}
|
||||
: null,
|
||||
[accessToken, startTime, endTime, effectiveUserId],
|
||||
);
|
||||
const {
|
||||
data: aggregatedRaw,
|
||||
loading: aggregatedLoading,
|
||||
failed: aggregatedFailed,
|
||||
} = useAggregatedDailyActivity({
|
||||
fetch: () => ENTITY_API.user.aggregated(dailyActivityRequest as DailyActivityRequest),
|
||||
enabled: dailyActivityRequest !== null,
|
||||
deps: [accessToken, startTime, endTime, effectiveUserId],
|
||||
});
|
||||
|
||||
// Gateway request counts (SGR). Admin-only: the source table is
|
||||
// deployment-wide, so a non-admin must not see it.
|
||||
|
|
@ -228,43 +219,28 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
}, [isAdmin, gatewayRequest, currentGatewayRangeKey]);
|
||||
|
||||
const gatewayActivity = selectGatewayActivity(isAdmin, gatewayActivityData, currentGatewayRangeKey);
|
||||
const activeAggregated = selectForRange(aggregatedData, currentAggregatedRangeKey);
|
||||
// A failure belongs to the range it happened on. Reading it through the same
|
||||
// rule keeps the paginated hook disabled while a new range is in flight, and
|
||||
// disabled is what empties it, so its previous rows never reach a tile.
|
||||
const aggregatedFailed = selectForRange(aggregatedFailure, currentAggregatedRangeKey) === true;
|
||||
|
||||
// Paginated fallback — only enabled when aggregated endpoint fails
|
||||
const paginatedResult = usePaginatedDailyActivity({
|
||||
fetchFn: userDailyActivityCall,
|
||||
args: [accessToken, startTime, endTime, effectiveUserId],
|
||||
enabled: aggregatedFailed && !!accessToken && !!startTime && !!endTime,
|
||||
});
|
||||
const userSpendData = useMemo(
|
||||
() => ({
|
||||
results: toDailyData(aggregatedRaw),
|
||||
metadata: aggregatedRaw.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
}),
|
||||
[aggregatedRaw],
|
||||
);
|
||||
|
||||
// Derive userSpendData from whichever source is active
|
||||
const userSpendData = useMemo(() => {
|
||||
if (activeAggregated) return activeAggregated;
|
||||
if (aggregatedFailed) return paginatedResult.data;
|
||||
return { results: [] as DailyData[], metadata: {} as any };
|
||||
}, [activeAggregated, aggregatedFailed, paginatedResult.data]);
|
||||
const loading = aggregatedLoading;
|
||||
const requestCountsPending = loading && gatewayActivity === null;
|
||||
|
||||
const loading = aggregatedLoading || paginatedResult.loading;
|
||||
const summaryMetrics = useMemo(
|
||||
() => overallUsageMetrics(userSpendData.results, userSpendData.metadata),
|
||||
[userSpendData],
|
||||
);
|
||||
|
||||
// Read through the same range stamp as the tiles, so the export is blocked from the first
|
||||
// render of a new range rather than from whenever the fetch effect gets around to running.
|
||||
const spendFetchState = {
|
||||
coversRange: activeAggregated !== null || paginatedResult.coversRange,
|
||||
cancelled: paginatedResult.cancelled,
|
||||
failed: paginatedResult.failed,
|
||||
};
|
||||
const exportBlockedReason = getExportBlockedReason(spendFetchState);
|
||||
|
||||
// Clear isDateChanging when paginated data starts arriving
|
||||
useEffect(() => {
|
||||
if (aggregatedFailed && !paginatedResult.loading && paginatedResult.data.results.length > 0) {
|
||||
if (!loading) {
|
||||
setIsDateChanging(false);
|
||||
}
|
||||
}, [aggregatedFailed, paginatedResult.loading, paginatedResult.data.results.length]);
|
||||
}, [loading]);
|
||||
|
||||
// Super responsive date change handler
|
||||
const handleDateChange = useCallback((newValue: DateRangePickerValue) => {
|
||||
|
|
@ -432,12 +408,43 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
() => processActivityData(userSpendData, modelViewType === "groups" ? "model_groups" : "models", teams),
|
||||
[userSpendData, modelViewType, teams],
|
||||
);
|
||||
const keyMetrics = useMemo(() => processActivityData(userSpendData, "api_keys", teams), [userSpendData, teams]);
|
||||
const mcpServerMetrics = useMemo(
|
||||
() => processActivityData(userSpendData, "mcp_servers", teams),
|
||||
[userSpendData, teams],
|
||||
);
|
||||
|
||||
const fetchTopApiKeys = useCallback(
|
||||
(model: string) =>
|
||||
ENTITY_API.user.modelTopKeys(dailyActivityRequest as DailyActivityRequest, model, modelViewType === "groups"),
|
||||
[dailyActivityRequest, modelViewType],
|
||||
);
|
||||
const searchKeys = useCallback(
|
||||
(query: string) =>
|
||||
dailyActivityRequest === null
|
||||
? Promise.resolve({ api_keys: [] })
|
||||
: ENTITY_API.user.searchKeys(dailyActivityRequest, query),
|
||||
[dailyActivityRequest],
|
||||
);
|
||||
const fetchKeyPage = useCallback(
|
||||
(offset: number, limit: number) => {
|
||||
if (dailyActivityRequest === null) {
|
||||
const emptyPage = { api_keys: [], total_api_keys: 0, offset, limit };
|
||||
return Promise.resolve(emptyPage);
|
||||
}
|
||||
return ENTITY_API.user.keyPage(dailyActivityRequest, offset, limit);
|
||||
},
|
||||
[dailyActivityRequest],
|
||||
);
|
||||
const fetchKeyDetail = useCallback(
|
||||
(apiKey: string) =>
|
||||
dailyActivityRequest === null
|
||||
? Promise.resolve(undefined)
|
||||
: ENTITY_API.user
|
||||
.aggregated({ ...dailyActivityRequest, apiKey, apiKeyLimit: 1 })
|
||||
.then((response) => keyDetailFromResponse(response, apiKey, teams)),
|
||||
[dailyActivityRequest, teams],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%" }} className="p-8 relative">
|
||||
{/* Global Date Picker and Tabs - Single Row */}
|
||||
|
|
@ -453,13 +460,14 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
/>
|
||||
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
|
||||
</div>
|
||||
<PaginationStatusAlerts
|
||||
isFetchingMore={paginatedResult.isFetchingMore}
|
||||
cancelled={paginatedResult.cancelled}
|
||||
failed={paginatedResult.failed}
|
||||
progress={paginatedResult.progress}
|
||||
cancel={paginatedResult.cancel}
|
||||
/>
|
||||
{aggregatedFailed && (
|
||||
<Alert variant="error" className="mb-2">
|
||||
<AlertDescription className="text-inherit">
|
||||
Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try
|
||||
again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* Your Usage / Global Usage Panel */}
|
||||
{(usageView === "global" || usageView === "my-usage") && (
|
||||
<>
|
||||
|
|
@ -493,16 +501,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<Sparkles />
|
||||
Ask AI
|
||||
</Button>
|
||||
<span title={exportBlockedReason}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={exportBlockedReason !== undefined}
|
||||
onClick={() => setIsGlobalExportModalOpen(true)}
|
||||
>
|
||||
<Download />
|
||||
Export Data
|
||||
</Button>
|
||||
</span>
|
||||
<Button variant="outline" onClick={() => setIsGlobalExportModalOpen(true)}>
|
||||
<Download />
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Cost Panel */}
|
||||
|
|
@ -532,11 +534,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<ViewUserSpend
|
||||
userSpend={totalSpend}
|
||||
selectedTeam={null}
|
||||
userMaxBudget={currentUser?.max_budget || null}
|
||||
/>
|
||||
{!loading && (
|
||||
<ViewUserSpend
|
||||
userSpend={totalSpend}
|
||||
selectedTeam={null}
|
||||
userMaxBudget={currentUser?.max_budget || null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
|
|
@ -547,12 +551,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Total Requests</h3>
|
||||
<p className="text-2xl font-bold mt-2">
|
||||
<MetricValue pending={requestCountsPending} className="text-2xl font-bold mt-2">
|
||||
{(gatewayActivity
|
||||
? gatewayActivity.total_successful_requests + gatewayActivity.total_failed_requests
|
||||
: userSpendData.metadata?.total_api_requests
|
||||
)?.toLocaleString() || 0}
|
||||
</p>
|
||||
</MetricValue>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard>
|
||||
|
|
@ -577,12 +581,15 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
today: a non-admin (who may not read deployment-wide counts)
|
||||
and an admin on a proxy whose table is still backfilling.
|
||||
*/}
|
||||
<p className="text-2xl font-bold mt-2 text-success">
|
||||
<MetricValue
|
||||
pending={requestCountsPending}
|
||||
className="text-2xl font-bold mt-2 text-success"
|
||||
>
|
||||
{(
|
||||
gatewayActivity?.total_successful_requests ??
|
||||
userSpendData.metadata?.total_successful_requests
|
||||
)?.toLocaleString() || 0}
|
||||
</p>
|
||||
</MetricValue>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard>
|
||||
|
|
@ -602,24 +609,27 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
</div>
|
||||
{/* Same source as Successful Requests: the two must agree, or the
|
||||
tile disagrees with the endpoint breakdown chart below it. */}
|
||||
<p className="text-2xl font-bold mt-2 text-destructive">
|
||||
<MetricValue
|
||||
pending={requestCountsPending}
|
||||
className="text-2xl font-bold mt-2 text-destructive"
|
||||
>
|
||||
{(
|
||||
gatewayActivity?.total_failed_requests ??
|
||||
userSpendData.metadata?.total_failed_requests
|
||||
)?.toLocaleString() || 0}
|
||||
</p>
|
||||
</MetricValue>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Average Cost per Request</h3>
|
||||
<p className="text-2xl font-bold mt-2">
|
||||
<MetricValue pending={loading} className="text-2xl font-bold mt-2">
|
||||
$
|
||||
{formatNumberWithCommas(
|
||||
(totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1),
|
||||
4,
|
||||
)}
|
||||
</p>
|
||||
</MetricValue>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard
|
||||
|
|
@ -635,9 +645,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-2">
|
||||
<MetricValue pending={loading} className="text-2xl font-bold mt-2">
|
||||
{userSpendData.metadata?.total_tokens?.toLocaleString() || 0}
|
||||
</p>
|
||||
</MetricValue>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
|
@ -646,33 +656,33 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Input Tokens</h3>
|
||||
<p className="text-2xl font-bold mt-2 text-info">
|
||||
<MetricValue pending={loading} className="text-2xl font-bold mt-2 text-info">
|
||||
{(userSpendData.metadata?.total_prompt_tokens || 0).toLocaleString()}
|
||||
</p>
|
||||
</MetricValue>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Output Tokens</h3>
|
||||
<p className="text-2xl font-bold mt-2 text-info">
|
||||
<MetricValue pending={loading} className="text-2xl font-bold mt-2 text-info">
|
||||
{userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0}
|
||||
</p>
|
||||
</MetricValue>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Cache Read Tokens</h3>
|
||||
<p className="text-2xl font-bold mt-2 text-success">
|
||||
<MetricValue pending={loading} className="text-2xl font-bold mt-2 text-success">
|
||||
{userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0}
|
||||
</p>
|
||||
</MetricValue>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Cache Write Tokens</h3>
|
||||
<p className="text-2xl font-bold mt-2 text-purple-600">
|
||||
<MetricValue pending={loading} className="text-2xl font-bold mt-2 text-purple-600">
|
||||
{userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0}
|
||||
</p>
|
||||
</MetricValue>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
|
@ -858,10 +868,20 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<div className="flex justify-end mt-2 mb-4">
|
||||
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
|
||||
</div>
|
||||
<ActivityMetrics modelMetrics={modelMetrics} />
|
||||
<ActivityMetrics
|
||||
modelMetrics={modelMetrics}
|
||||
fetchTopApiKeys={dailyActivityRequest ? fetchTopApiKeys : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="keys" keepMounted>
|
||||
<KeyActivityPanel keyMetrics={keyMetrics} />
|
||||
<KeyActivityPanel
|
||||
summary={summaryMetrics}
|
||||
summaryLoading={loading}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={fetchKeyDetail}
|
||||
teams={teams}
|
||||
searchKeys={searchKeys}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp" keepMounted>
|
||||
<ActivityMetrics modelMetrics={mcpServerMetrics} />
|
||||
|
|
@ -1004,11 +1024,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<EntityUsageExportModal
|
||||
isOpen={isGlobalExportModalOpen}
|
||||
onClose={() => setIsGlobalExportModalOpen(false)}
|
||||
entityType="team"
|
||||
spendData={{
|
||||
results: userSpendData.results,
|
||||
metadata: userSpendData.metadata,
|
||||
}}
|
||||
entityType="user"
|
||||
onExport={(exportType, format) =>
|
||||
dailyActivityRequest
|
||||
? ENTITY_API.user.exportRows(dailyActivityRequest, exportType, format)
|
||||
: Promise.reject(new Error("Missing access token or date range"))
|
||||
}
|
||||
dateRange={dateValue}
|
||||
selectedFilters={[]}
|
||||
customTitle="Export Usage Data"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
type DailyActivityAggregatedResponse,
|
||||
} from "@/components/UsagePage/dailyActivityApi";
|
||||
import { useAggregatedDailyActivity } from "./useAggregatedDailyActivity";
|
||||
|
||||
const response = (spend: number): DailyActivityAggregatedResponse => ({
|
||||
results: [],
|
||||
metadata: { ...EMPTY_DAILY_ACTIVITY_METADATA, total_spend: spend },
|
||||
});
|
||||
|
||||
describe("useAggregatedDailyActivity", () => {
|
||||
it("calls fetch once per deps change and exposes the data", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(response(12));
|
||||
const { result, rerender } = renderHook(
|
||||
({ dep }: { dep: string }) => useAggregatedDailyActivity({ fetch, enabled: true, deps: [dep] }),
|
||||
{ initialProps: { dep: "a" } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.data.metadata?.total_spend).toBe(12);
|
||||
|
||||
rerender({ dep: "b" });
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("does not fetch while disabled and returns empty data", () => {
|
||||
const fetch = vi.fn();
|
||||
const { result } = renderHook(() => useAggregatedDailyActivity({ fetch, enabled: false, deps: ["a"] }));
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(result.current.data.results).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.failed).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes failed on rejection", async () => {
|
||||
const fetch = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const { result } = renderHook(() => useAggregatedDailyActivity({ fetch, enabled: true, deps: ["a"] }));
|
||||
|
||||
await waitFor(() => expect(result.current.failed).toBe(true));
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores an out-of-order resolution from the previous deps", async () => {
|
||||
let resolveFirst: ((value: DailyActivityAggregatedResponse) => void) | undefined;
|
||||
const first = new Promise<DailyActivityAggregatedResponse>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => first)
|
||||
.mockResolvedValueOnce(response(99));
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ dep }: { dep: string }) => useAggregatedDailyActivity({ fetch, enabled: true, deps: [dep] }),
|
||||
{ initialProps: { dep: "a" } },
|
||||
);
|
||||
|
||||
rerender({ dep: "b" });
|
||||
await act(async () => {
|
||||
resolveFirst?.(response(1));
|
||||
});
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.data.metadata?.total_spend).toBe(99);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
EMPTY_DAILY_ACTIVITY_RESPONSE,
|
||||
type DailyActivityAggregatedResponse,
|
||||
} from "@/components/UsagePage/dailyActivityApi";
|
||||
|
||||
interface Options {
|
||||
fetch: () => Promise<DailyActivityAggregatedResponse>;
|
||||
enabled: boolean;
|
||||
deps: readonly unknown[];
|
||||
}
|
||||
|
||||
interface Result {
|
||||
data: DailyActivityAggregatedResponse;
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
interface SettledFetch {
|
||||
key: string;
|
||||
data: DailyActivityAggregatedResponse;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
export function useAggregatedDailyActivity({ fetch, enabled, deps }: Options): Result {
|
||||
const [settled, setSettled] = useState<SettledFetch | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
const fetchRef = useRef(fetch);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRef.current = fetch;
|
||||
});
|
||||
|
||||
const depsKey = JSON.stringify(deps);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const requestId = ++requestIdRef.current;
|
||||
const isStale = () => requestIdRef.current !== requestId;
|
||||
|
||||
fetchRef
|
||||
.current()
|
||||
.then((response) => {
|
||||
if (isStale()) return;
|
||||
setSettled({ key: depsKey, data: response, failed: false });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isStale()) return;
|
||||
console.error("Error fetching daily activity:", error);
|
||||
setSettled({ key: depsKey, data: EMPTY_DAILY_ACTIVITY_RESPONSE, failed: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
requestIdRef.current++;
|
||||
};
|
||||
}, [enabled, depsKey]);
|
||||
|
||||
const current = enabled && settled?.key === depsKey ? settled : null;
|
||||
return {
|
||||
data: current?.data ?? EMPTY_DAILY_ACTIVITY_RESPONSE,
|
||||
loading: enabled && current === null,
|
||||
failed: current?.failed ?? false,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DailyData, SpendMetrics } from "@/components/UsagePage/types";
|
||||
import { mergeDailyResults, sumMetadata, usePaginatedDailyActivity } from "./usePaginatedDailyActivity";
|
||||
|
||||
describe("sumMetadata", () => {
|
||||
it("sums flat cost across pages instead of keeping the first page's value", () => {
|
||||
// A team whose activity spans more than one page accrues flat cost on each of them.
|
||||
// Keeping page 1's value under-reports the Flat Cost and Total Cost tiles.
|
||||
const merged = sumMetadata({ total_spend: 1, total_flat_cost: 174.5 }, { total_spend: 2, total_flat_cost: 777 });
|
||||
|
||||
expect(merged.total_flat_cost).toBe(951.5);
|
||||
expect(merged.total_spend).toBe(3);
|
||||
});
|
||||
|
||||
it("treats a page missing the field as zero rather than dropping the running total", () => {
|
||||
expect(sumMetadata({ total_flat_cost: 480 }, {}).total_flat_cost).toBe(480);
|
||||
expect(sumMetadata({}, { total_flat_cost: 480 }).total_flat_cost).toBe(480);
|
||||
});
|
||||
|
||||
it("carries non-summable keys through from the first page", () => {
|
||||
const merged = sumMetadata(
|
||||
{ page: 1, total_pages: 3, total_spend: 1 },
|
||||
{ page: 2, total_pages: 3, total_spend: 2 },
|
||||
);
|
||||
|
||||
expect(merged.page).toBe(1);
|
||||
expect(merged.total_pages).toBe(3);
|
||||
});
|
||||
|
||||
it("sums every total_* metric the daily activity metadata exposes", () => {
|
||||
// Guards the class of bug rather than one field: a new backend total that nobody adds
|
||||
// to SUMMABLE_METADATA_KEYS freezes at page 1, and spend still looks right so it reads
|
||||
// as trustworthy.
|
||||
const page = {
|
||||
total_spend: 1,
|
||||
total_prompt_tokens: 1,
|
||||
total_completion_tokens: 1,
|
||||
total_tokens: 1,
|
||||
total_api_requests: 1,
|
||||
total_successful_requests: 1,
|
||||
total_failed_requests: 1,
|
||||
total_cache_read_input_tokens: 1,
|
||||
total_cache_creation_input_tokens: 1,
|
||||
total_flat_cost: 1,
|
||||
total_response_time_ms: 1,
|
||||
total_timed_requests: 1,
|
||||
};
|
||||
const merged = sumMetadata(page, page);
|
||||
|
||||
for (const key of Object.keys(page)) {
|
||||
expect(merged[key], `${key} must be summed across pages`).toBe(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const metricsOf = (spend: number): SpendMetrics => ({
|
||||
spend,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
api_requests: 1,
|
||||
successful_requests: 1,
|
||||
failed_requests: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
compression_savings_spend: spend,
|
||||
});
|
||||
|
||||
const dayOf = (date: string, spend: number, apiKey: string = "sk-1"): DailyData => ({
|
||||
date,
|
||||
metrics: metricsOf(spend),
|
||||
breakdown: {
|
||||
models: {
|
||||
"gpt-4o": {
|
||||
metrics: metricsOf(spend),
|
||||
metadata: {},
|
||||
api_key_breakdown: {
|
||||
[apiKey]: { metrics: metricsOf(spend), metadata: { key_alias: "alias-1", team_id: null } },
|
||||
},
|
||||
},
|
||||
},
|
||||
model_groups: {},
|
||||
mcp_servers: {},
|
||||
providers: {},
|
||||
api_keys: { [apiKey]: { metrics: metricsOf(spend), metadata: { key_alias: "alias-1", team_id: null } } },
|
||||
entities: {},
|
||||
},
|
||||
});
|
||||
|
||||
describe("mergeDailyResults", () => {
|
||||
it("collapses repeated dates into one entry with summed metrics (the LIT-5818 $2/$2/$1 case)", () => {
|
||||
const merged = mergeDailyResults(mergeDailyResults([dayOf("2026-08-16", 2)], [dayOf("2026-08-16", 2)]), [
|
||||
dayOf("2026-08-16", 1),
|
||||
]);
|
||||
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].metrics.spend).toBe(5);
|
||||
expect(merged[0].metrics.compression_savings_spend).toBe(5);
|
||||
});
|
||||
|
||||
it("appends unseen dates in arrival order", () => {
|
||||
const merged = mergeDailyResults([dayOf("2026-08-16", 2)], [dayOf("2026-08-15", 0.5)]);
|
||||
|
||||
expect(merged.map((d) => d.date)).toEqual(["2026-08-16", "2026-08-15"]);
|
||||
expect(merged[1].metrics.spend).toBe(0.5);
|
||||
});
|
||||
|
||||
it("merges every breakdown level including the nested per-key breakdown", () => {
|
||||
const merged = mergeDailyResults([dayOf("2026-08-16", 2, "sk-1")], [dayOf("2026-08-16", 3, "sk-1")]);
|
||||
|
||||
expect(merged[0].breakdown.models["gpt-4o"].metrics.spend).toBe(5);
|
||||
expect(merged[0].breakdown.models["gpt-4o"].api_key_breakdown["sk-1"].metrics.spend).toBe(5);
|
||||
expect(merged[0].breakdown.api_keys["sk-1"].metrics.spend).toBe(5);
|
||||
expect(merged[0].breakdown.api_keys["sk-1"].metadata.key_alias).toBe("alias-1");
|
||||
});
|
||||
|
||||
it("unions breakdown keys that appear on different pages", () => {
|
||||
const merged = mergeDailyResults([dayOf("2026-08-16", 2, "sk-1")], [dayOf("2026-08-16", 3, "sk-2")]);
|
||||
|
||||
expect(merged[0].breakdown.api_keys["sk-1"].metrics.spend).toBe(2);
|
||||
expect(merged[0].breakdown.api_keys["sk-2"].metrics.spend).toBe(3);
|
||||
});
|
||||
|
||||
it("sums metric keys it has never heard of so a future backend column cannot silently freeze", () => {
|
||||
const withExtra = (spend: number): DailyData => ({
|
||||
...dayOf("2026-08-16", spend),
|
||||
metrics: { ...metricsOf(spend), future_savings_spend: spend } as SpendMetrics,
|
||||
});
|
||||
const merged = mergeDailyResults([withExtra(2)], [withExtra(3)]);
|
||||
|
||||
expect((merged[0].metrics as Record<string, number>).future_savings_spend).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePaginatedDailyActivity page accumulation", () => {
|
||||
it("returns one entry per date when a date's rows span multiple pages", async () => {
|
||||
const pages = [
|
||||
{ results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 1, total_spend: 2 } },
|
||||
{ results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 2, total_spend: 2 } },
|
||||
{
|
||||
results: [dayOf("2026-08-16", 1), dayOf("2026-08-15", 0.5)],
|
||||
metadata: { total_pages: 3, page: 3, total_spend: 1.5 },
|
||||
},
|
||||
];
|
||||
const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => Promise.resolve(pages[page - 1]));
|
||||
const start = new Date("2026-08-10");
|
||||
const end = new Date("2026-08-17");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.data.metadata.page).toBe(3), { timeout: 5000 });
|
||||
|
||||
expect(result.current.data.results.map((d) => d.date)).toEqual(["2026-08-16", "2026-08-15"]);
|
||||
expect(result.current.data.results[0].metrics.spend).toBe(5);
|
||||
expect(result.current.data.metadata.total_spend).toBe(5.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePaginatedDailyActivity failure reporting", () => {
|
||||
const firstPage = { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 1, total_spend: 2 } };
|
||||
const start = new Date("2026-08-10");
|
||||
const end = new Date("2026-08-17");
|
||||
|
||||
it("reports a failed range so partial totals cannot pass as the whole range", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) =>
|
||||
page === 1 ? Promise.resolve(firstPage) : Promise.reject(new Error("page 2 never came back")),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 });
|
||||
|
||||
expect(result.current.isFetchingMore).toBe(false);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.data.metadata.total_spend).toBe(2);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("reports no pages loaded when the very first request is what failed", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const fetchFn = vi.fn(() => Promise.reject(new Error("page 1 never came back")));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 });
|
||||
|
||||
expect(result.current.progress).toEqual({ currentPage: 0, totalPages: 0 });
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("stays unfailed when every page arrives", async () => {
|
||||
const pages = [
|
||||
firstPage,
|
||||
{ results: [dayOf("2026-08-15", 1)], metadata: { total_pages: 2, page: 2, total_spend: 1 } },
|
||||
];
|
||||
const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) =>
|
||||
Promise.resolve({ ...pages[page - 1], metadata: { ...pages[page - 1].metadata, total_pages: 2 } }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.data.metadata.page).toBe(2), { timeout: 5000 });
|
||||
|
||||
expect(result.current.failed).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the failure when a new range is requested, so the banner cannot outlive it", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const fetchFn = vi.fn((...callArgs: unknown[]) => {
|
||||
const [, , , page, filter] = callArgs as [string, Date, Date, number, string | null];
|
||||
if (filter !== "broken")
|
||||
return Promise.resolve({ ...firstPage, metadata: { ...firstPage.metadata, total_pages: 1 } });
|
||||
if (page === 1) return Promise.resolve({ ...firstPage, metadata: { ...firstPage.metadata, total_pages: 2 } });
|
||||
return Promise.reject(new Error("page 2 never came back"));
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ filter }: { filter: string | null }) =>
|
||||
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, filter], enabled: true }),
|
||||
{ initialProps: { filter: "broken" as string | null } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 });
|
||||
|
||||
rerender({ filter: "healthy" });
|
||||
|
||||
await waitFor(() => expect(result.current.failed).toBe(false), { timeout: 5000 });
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePaginatedDailyActivity range coverage", () => {
|
||||
const start = new Date("2026-08-10");
|
||||
const end = new Date("2026-08-17");
|
||||
const singlePage = { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 1, page: 1, total_spend: 2 } };
|
||||
|
||||
it("does not cover the range while the hook is disabled", () => {
|
||||
const fetchFn = vi.fn(() => Promise.resolve(singlePage));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: false }),
|
||||
);
|
||||
|
||||
expect(result.current.coversRange).toBe(false);
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("covers the range only once every page of it has landed", async () => {
|
||||
const pages = [
|
||||
{ results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 2, page: 1, total_spend: 2 } },
|
||||
{ results: [dayOf("2026-08-15", 1)], metadata: { total_pages: 2, page: 2, total_spend: 1 } },
|
||||
];
|
||||
const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => Promise.resolve(pages[page - 1]));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }),
|
||||
);
|
||||
|
||||
expect(result.current.coversRange).toBe(false);
|
||||
await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 });
|
||||
});
|
||||
|
||||
it("never reports a range as covered while the data on screen is empty", async () => {
|
||||
// Disabling the hook empties the data. Re-enabling it asks for the same args the last
|
||||
// completed fetch used, so coverage that survives the disable would vouch for nothing.
|
||||
const seen: Array<{ coversRange: boolean; rows: number }> = [];
|
||||
const fetchFn = vi.fn(() => Promise.resolve(singlePage));
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }: { enabled: boolean }) => {
|
||||
const activity = usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled });
|
||||
seen.push({ coversRange: activity.coversRange, rows: activity.data.results.length });
|
||||
return activity;
|
||||
},
|
||||
{ initialProps: { enabled: true } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 });
|
||||
|
||||
rerender({ enabled: false });
|
||||
rerender({ enabled: true });
|
||||
|
||||
await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 });
|
||||
expect(seen.filter((render) => render.coversRange && render.rows === 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("stops covering the range on the very render the args change, not once an effect catches up", async () => {
|
||||
// The render after a filter change still holds the previous filter's rows, so resetting
|
||||
// coverage inside the fetch effect would leave a paint where the export reads them as the
|
||||
// new range. That paint is the whole thing the gate exists to stop.
|
||||
const seen: Array<{ filter: string; coversRange: boolean }> = [];
|
||||
const fetchFn = vi.fn(() => Promise.resolve(singlePage));
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ filter }: { filter: string }) => {
|
||||
const activity = usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, filter], enabled: true });
|
||||
seen.push({ filter, coversRange: activity.coversRange });
|
||||
return activity;
|
||||
},
|
||||
{ initialProps: { filter: "team-a" } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 });
|
||||
|
||||
rerender({ filter: "team-b" });
|
||||
|
||||
const rendersForNewFilter = seen.filter((render) => render.filter === "team-b");
|
||||
expect(rendersForNewFilter.length).toBeGreaterThan(0);
|
||||
expect(rendersForNewFilter.map((render) => render.coversRange)).not.toContain(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,374 +0,0 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
BreakdownMetrics,
|
||||
DailyData,
|
||||
KeyMetricWithMetadata,
|
||||
MetricWithMetadata,
|
||||
SpendMetrics,
|
||||
} from "@/components/UsagePage/types";
|
||||
|
||||
export interface PaginationProgress {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/** Delay between sequential page fetches (ms) to avoid overloading the backend. */
|
||||
const PAGE_FETCH_DELAY_MS = 300;
|
||||
|
||||
/** Number of pages to accumulate before flushing to React state (reduces re-renders). */
|
||||
const RENDER_BATCH_SIZE = 3;
|
||||
|
||||
/** The metadata fields returned by the daily activity API that should be summed across pages. */
|
||||
const SUMMABLE_METADATA_KEYS = [
|
||||
"total_spend",
|
||||
"total_prompt_tokens",
|
||||
"total_completion_tokens",
|
||||
"total_tokens",
|
||||
"total_api_requests",
|
||||
"total_successful_requests",
|
||||
"total_failed_requests",
|
||||
"total_cache_read_input_tokens",
|
||||
"total_cache_creation_input_tokens",
|
||||
"total_flat_cost",
|
||||
"total_response_time_ms",
|
||||
"total_timed_requests",
|
||||
] as const;
|
||||
|
||||
interface DailyActivityResponse {
|
||||
results: DailyData[];
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
type FetchPageFn = (...args: any[]) => Promise<DailyActivityResponse>;
|
||||
|
||||
interface UsePaginatedDailyActivityParams {
|
||||
/** The API call function (e.g., userDailyActivityCall). */
|
||||
fetchFn: FetchPageFn;
|
||||
/** Arguments to pass to fetchFn: [accessToken, startTime, endTime, ...extraArgs]. Page is injected by the hook at index 3. */
|
||||
args: any[];
|
||||
/** Whether the hook should fetch. Set to false to disable. */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Optional single-shot endpoint returning the whole range at once (e.g.
|
||||
* teamDailyActivityAggregatedCall). Called with `args` as-is (no page).
|
||||
* On success pagination is skipped entirely; on failure the hook falls
|
||||
* back to the paginated flow.
|
||||
*/
|
||||
aggregatedFetchFn?: (...args: any[]) => Promise<DailyActivityResponse>;
|
||||
}
|
||||
|
||||
interface UsePaginatedDailyActivityReturn {
|
||||
data: DailyActivityResponse;
|
||||
loading: boolean;
|
||||
isFetchingMore: boolean;
|
||||
progress: PaginationProgress;
|
||||
cancelled: boolean;
|
||||
failed: boolean;
|
||||
coversRange: boolean;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_DATA: DailyActivityResponse = {
|
||||
results: [],
|
||||
metadata: {
|
||||
total_spend: 0,
|
||||
total_prompt_tokens: 0,
|
||||
total_completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
total_api_requests: 0,
|
||||
total_successful_requests: 0,
|
||||
total_failed_requests: 0,
|
||||
total_cache_read_input_tokens: 0,
|
||||
total_cache_creation_input_tokens: 0,
|
||||
total_response_time_ms: 0,
|
||||
total_timed_requests: 0,
|
||||
total_pages: 1,
|
||||
has_more: false,
|
||||
page: 1,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Combine two pages of metadata. Only keys in SUMMABLE_METADATA_KEYS are added; anything
|
||||
* else keeps the first page's value, so a total the backend adds later is silently frozen
|
||||
* at page 1 until it is listed above. Exported so that contract can be tested directly.
|
||||
*/
|
||||
export function sumMetadata(a: Record<string, any>, b: Record<string, any>): Record<string, any> {
|
||||
const result = { ...a };
|
||||
for (const key of SUMMABLE_METADATA_KEYS) {
|
||||
result[key] = (a[key] || 0) + (b[key] || 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum the union of numeric metric keys so a metric column added to the backend
|
||||
* later is summed automatically instead of silently frozen at one page's value
|
||||
* (the drift hazard SUMMABLE_METADATA_KEYS documents above).
|
||||
*/
|
||||
const addMetrics = (a: SpendMetrics, b: SpendMetrics): SpendMetrics =>
|
||||
Object.fromEntries(
|
||||
Array.from(new Set([...Object.keys(a), ...Object.keys(b)])).map((key) => {
|
||||
const left = a[key as keyof SpendMetrics];
|
||||
const right = b[key as keyof SpendMetrics];
|
||||
if (typeof left !== "number" && typeof right !== "number") return [key, left ?? right];
|
||||
return [key, (typeof left === "number" ? left : 0) + (typeof right === "number" ? right : 0)];
|
||||
}),
|
||||
) as unknown as SpendMetrics;
|
||||
|
||||
const mergeBucketMaps = <T>(
|
||||
a: Record<string, T> | undefined,
|
||||
b: Record<string, T> | undefined,
|
||||
mergeEntry: (left: T, right: T) => T,
|
||||
): Record<string, T> => {
|
||||
const left = a ?? {};
|
||||
const right = b ?? {};
|
||||
return Object.fromEntries(
|
||||
Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).map((key) => {
|
||||
const leftEntry = left[key];
|
||||
const rightEntry = right[key];
|
||||
if (leftEntry === undefined) return [key, rightEntry];
|
||||
if (rightEntry === undefined) return [key, leftEntry];
|
||||
return [key, mergeEntry(leftEntry, rightEntry)];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const mergeKeyMetric = (a: KeyMetricWithMetadata, b: KeyMetricWithMetadata): KeyMetricWithMetadata => ({
|
||||
...a,
|
||||
metrics: addMetrics(a.metrics, b.metrics),
|
||||
});
|
||||
|
||||
const mergeMetricWithMetadata = (a: MetricWithMetadata, b: MetricWithMetadata): MetricWithMetadata => ({
|
||||
...a,
|
||||
metrics: addMetrics(a.metrics, b.metrics),
|
||||
api_key_breakdown: mergeBucketMaps(a.api_key_breakdown, b.api_key_breakdown, mergeKeyMetric),
|
||||
});
|
||||
|
||||
const mergeBreakdown = (a: BreakdownMetrics, b: BreakdownMetrics): BreakdownMetrics => ({
|
||||
models: mergeBucketMaps(a.models, b.models, mergeMetricWithMetadata),
|
||||
model_groups: mergeBucketMaps(a.model_groups, b.model_groups, mergeMetricWithMetadata),
|
||||
mcp_servers: mergeBucketMaps(a.mcp_servers, b.mcp_servers, mergeMetricWithMetadata),
|
||||
providers: mergeBucketMaps(a.providers, b.providers, mergeMetricWithMetadata),
|
||||
api_keys: mergeBucketMaps(a.api_keys, b.api_keys, mergeKeyMetric),
|
||||
entities: mergeBucketMaps(a.entities, b.entities, mergeMetricWithMetadata),
|
||||
...(a.endpoints || b.endpoints
|
||||
? { endpoints: mergeBucketMaps(a.endpoints, b.endpoints, mergeMetricWithMetadata) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The backend paginates over raw rows and re-groups per page, so a date whose
|
||||
* rows span pages arrives as one partial DailyData per page. Merge by date so
|
||||
* consumers never see the same date twice (LIT-5818: each day rendered as N
|
||||
* partial bars). Exported so the contract can be tested directly.
|
||||
*/
|
||||
export function mergeDailyResults(existing: readonly DailyData[], incoming: readonly DailyData[]): DailyData[] {
|
||||
return incoming.reduce<DailyData[]>(
|
||||
(acc, day) => {
|
||||
const index = acc.findIndex((existingDay) => existingDay.date === day.date);
|
||||
if (index === -1) return [...acc, day];
|
||||
return acc.map((existingDay, i) =>
|
||||
i === index
|
||||
? {
|
||||
...existingDay,
|
||||
metrics: addMetrics(existingDay.metrics, day.metrics),
|
||||
breakdown: mergeBreakdown(existingDay.breakdown, day.breakdown),
|
||||
}
|
||||
: existingDay,
|
||||
);
|
||||
},
|
||||
[...existing],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that auto-paginates daily activity endpoints, updating state in batches
|
||||
* so charts render progressively. Cancels on unmount, param changes, or
|
||||
* manual cancel().
|
||||
*
|
||||
* The `args` array should contain every argument the fetchFn expects EXCEPT
|
||||
* the `page` parameter. The hook injects `page` as the 4th argument (index 3),
|
||||
* matching the signature of all daily activity calls:
|
||||
* (accessToken, startTime, endTime, page, ...rest)
|
||||
*/
|
||||
export function usePaginatedDailyActivity({
|
||||
fetchFn,
|
||||
args,
|
||||
enabled,
|
||||
aggregatedFetchFn,
|
||||
}: UsePaginatedDailyActivityParams): UsePaginatedDailyActivityReturn {
|
||||
const [data, setData] = useState<DailyActivityResponse>(EMPTY_DATA);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isFetchingMore, setIsFetchingMore] = useState(false);
|
||||
const [progress, setProgress] = useState<PaginationProgress>({
|
||||
currentPage: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [completedKey, setCompletedKey] = useState<string | null>(null);
|
||||
|
||||
const fetchIdRef = useRef(0);
|
||||
const cancelledRef = useRef(false);
|
||||
const delayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Keep args in a ref so the effect can always read the latest values
|
||||
// without needing them in the dependency array.
|
||||
const argsRef = useRef(args);
|
||||
argsRef.current = args;
|
||||
|
||||
// Stable serialised key so the effect only re-runs when the arg *values* change.
|
||||
const argsKey = JSON.stringify(args);
|
||||
|
||||
// Stamped like the data itself and compared during render, so the render that follows an arg
|
||||
// change already reports the new range as uncovered. Clearing it inside the fetch effect would
|
||||
// be one render too late, leaving a paint where an export reads the previous range's rows.
|
||||
const coversRange = enabled && completedKey === argsKey;
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
cancelledRef.current = true;
|
||||
setCancelled(true);
|
||||
setIsFetchingMore(false);
|
||||
if (delayTimerRef.current !== null) {
|
||||
clearTimeout(delayTimerRef.current);
|
||||
delayTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setData(EMPTY_DATA);
|
||||
setLoading(false);
|
||||
setIsFetchingMore(false);
|
||||
setProgress({ currentPage: 0, totalPages: 0 });
|
||||
setCancelled(false);
|
||||
setFailed(false);
|
||||
setCompletedKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFetchId = ++fetchIdRef.current;
|
||||
cancelledRef.current = false;
|
||||
setCancelled(false);
|
||||
setFailed(false);
|
||||
|
||||
const isStale = () => fetchIdRef.current !== currentFetchId || cancelledRef.current;
|
||||
|
||||
/** Cancellable delay that clears itself on cleanup. */
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
delayTimerRef.current = setTimeout(() => {
|
||||
delayTimerRef.current = null;
|
||||
resolve();
|
||||
}, ms);
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
const currentArgs = argsRef.current;
|
||||
setLoading(true);
|
||||
setIsFetchingMore(false);
|
||||
setProgress({ currentPage: 0, totalPages: 0 });
|
||||
|
||||
if (aggregatedFetchFn) {
|
||||
try {
|
||||
const aggregated = await aggregatedFetchFn(...currentArgs);
|
||||
if (isStale()) return;
|
||||
setData(aggregated);
|
||||
setProgress({ currentPage: 1, totalPages: 1 });
|
||||
setLoading(false);
|
||||
setCompletedKey(argsKey);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isStale()) return;
|
||||
console.error("Aggregated daily activity failed, falling back to pagination:", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Inject page=1 as the 4th argument.
|
||||
const argsWithPage = [...currentArgs.slice(0, 3), 1, ...currentArgs.slice(3)];
|
||||
const firstPage = await fetchFn(...argsWithPage);
|
||||
|
||||
if (isStale()) return;
|
||||
|
||||
setData(firstPage);
|
||||
|
||||
const totalPages = firstPage.metadata?.total_pages || 1;
|
||||
|
||||
setProgress({ currentPage: 1, totalPages });
|
||||
|
||||
if (totalPages <= 1) {
|
||||
setLoading(false);
|
||||
setCompletedKey(argsKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// More pages — start fetching sequentially.
|
||||
setLoading(false);
|
||||
setIsFetchingMore(true);
|
||||
|
||||
let accumulatedResults = mergeDailyResults([], firstPage.results);
|
||||
let accumulatedMetadata = { ...firstPage.metadata };
|
||||
|
||||
for (let page = 2; page <= totalPages; page++) {
|
||||
if (isStale()) return;
|
||||
|
||||
// Small delay to avoid overwhelming the backend.
|
||||
await delay(PAGE_FETCH_DELAY_MS);
|
||||
|
||||
if (isStale()) return;
|
||||
|
||||
const argsForPage = [...currentArgs.slice(0, 3), page, ...currentArgs.slice(3)];
|
||||
const pageData = await fetchFn(...argsForPage);
|
||||
|
||||
if (isStale()) return;
|
||||
|
||||
accumulatedResults = mergeDailyResults(accumulatedResults, pageData.results);
|
||||
accumulatedMetadata = sumMetadata(accumulatedMetadata, pageData.metadata);
|
||||
accumulatedMetadata.total_pages = totalPages;
|
||||
accumulatedMetadata.has_more = page < totalPages;
|
||||
accumulatedMetadata.page = page;
|
||||
|
||||
// Flush accumulated data and progress to React state every
|
||||
// RENDER_BATCH_SIZE pages (or on the final page) to avoid
|
||||
// expensive per-page re-renders. Progress and data are updated
|
||||
// together so the counter never appears to decrement.
|
||||
const isLastPage = page === totalPages;
|
||||
const isBatchBoundary = (page - 1) % RENDER_BATCH_SIZE === 0;
|
||||
if (isLastPage || isBatchBoundary) {
|
||||
setData({
|
||||
results: accumulatedResults,
|
||||
metadata: accumulatedMetadata,
|
||||
});
|
||||
setProgress({ currentPage: page, totalPages });
|
||||
}
|
||||
}
|
||||
|
||||
setIsFetchingMore(false);
|
||||
setCompletedKey(argsKey);
|
||||
} catch (error) {
|
||||
if (!isStale()) {
|
||||
console.error("Error fetching daily activity:", error);
|
||||
setLoading(false);
|
||||
setIsFetchingMore(false);
|
||||
setFailed(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
|
||||
return () => {
|
||||
fetchIdRef.current++;
|
||||
if (delayTimerRef.current !== null) {
|
||||
clearTimeout(delayTimerRef.current);
|
||||
delayTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
// argsKey is a stable JSON string so the effect only re-fires when arg values change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, fetchFn, aggregatedFetchFn, argsKey]);
|
||||
|
||||
return { data, loading, isFetchingMore, progress, cancelled, failed, coversRange, cancel };
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@ const mockTeamInfoCall = vi.fn();
|
|||
const mockUserUpdateUserCall = vi.fn();
|
||||
const mockFetchMCPServers = vi.fn();
|
||||
const mockListMCPTools = vi.fn();
|
||||
const mockUserDailyActivityCall = vi.fn();
|
||||
const mockUserDailyActivityAggregatedCall = vi.fn();
|
||||
|
||||
const MCP_SERVER = { server_id: "srv-1", server_name: "GitHub MCP", alias: "GitHub MCP" };
|
||||
|
|
@ -65,8 +64,7 @@ vi.mock("@/components/networking", async (importOriginal) => {
|
|||
formatDate: original.formatDate,
|
||||
serverRootPath: "/",
|
||||
userGetInfoV2: (...args: unknown[]) => mockUserGetInfoV2(...args),
|
||||
userDailyActivityCall: (...args: unknown[]) => mockUserDailyActivityCall(...args),
|
||||
userDailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args),
|
||||
dailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args),
|
||||
userDeleteCall: vi.fn(),
|
||||
userUpdateUserCall: (...args: unknown[]) => mockUserUpdateUserCall(...args),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
|
|
@ -458,7 +456,6 @@ describe("UserInfoView savings", () => {
|
|||
Promise.resolve({ ...MOCK_USER_DATA_NO_TEAMS, user_id: userId }),
|
||||
);
|
||||
mockUserDailyActivityAggregatedCall.mockReset().mockResolvedValue(savingsResponse([]));
|
||||
mockUserDailyActivityCall.mockReset().mockResolvedValue(savingsResponse([]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -472,17 +469,18 @@ describe("UserInfoView savings", () => {
|
|||
const { rerender } = render(<UserInfoView {...props} userId="user-1" userRole={userRole} />);
|
||||
await user.click(await screen.findByRole("tab", { name: "Savings" }));
|
||||
expect(await screen.findByText("No usage recorded for this user in this range.")).toBeInTheDocument();
|
||||
expect(mockUserDailyActivityAggregatedCall.mock.calls[0][3]).toBe("user-1");
|
||||
expect(mockUserDailyActivityAggregatedCall.mock.calls[0]).toEqual([
|
||||
"user",
|
||||
expect.objectContaining({ entityIds: ["user-1"] }),
|
||||
]);
|
||||
|
||||
mockUserDailyActivityAggregatedCall.mockClear();
|
||||
mockUserDailyActivityCall.mockClear();
|
||||
rerender(<UserInfoView {...props} userId="another-user" userRole={userRole} />);
|
||||
await screen.findAllByText("another-user");
|
||||
expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.queryByRole("tab", { name: "Savings" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("No usage recorded for this user in this range.")).not.toBeInTheDocument();
|
||||
expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled();
|
||||
expect(mockUserDailyActivityCall).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -506,7 +504,6 @@ describe("UserInfoView savings", () => {
|
|||
render(<UserInfoView {...props} />);
|
||||
const savingsTab = await screen.findByRole("tab", { name: "Savings" });
|
||||
expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled();
|
||||
expect(mockUserDailyActivityCall).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(savingsTab);
|
||||
|
||||
|
|
@ -516,12 +513,12 @@ describe("UserInfoView savings", () => {
|
|||
expect(screen.getByTestId("summary-card-prompt-caching-savings")).toHaveTextContent("$1.00Total");
|
||||
expect(screen.getByTestId("summary-card-auto-router-savings")).toHaveTextContent("-$3.00");
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledExactlyOnceWith(
|
||||
"admin-token",
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
"user-123",
|
||||
true,
|
||||
null,
|
||||
"user",
|
||||
expect.objectContaining({
|
||||
accessToken: "admin-token",
|
||||
entityIds: ["user-123"],
|
||||
includeCurrentUtcDay: true,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByTestId("user-savings-scope-note")).toHaveTextContent("JWT-authenticated requests");
|
||||
await user.click(screen.getByRole("tab", { name: "Per day" }));
|
||||
|
|
@ -544,12 +541,12 @@ describe("UserInfoView savings", () => {
|
|||
expect(await screen.findByTestId("user-savings-empty")).toHaveTextContent("Loading savings");
|
||||
expect(screen.queryByTestId("summary-card-total-recorded-savings")).not.toBeInTheDocument();
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenLastCalledWith(
|
||||
"admin-token",
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
"user-456",
|
||||
true,
|
||||
null,
|
||||
"user",
|
||||
expect.objectContaining({
|
||||
accessToken: "admin-token",
|
||||
entityIds: ["user-456"],
|
||||
includeCurrentUtcDay: true,
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
nextUser.resolve(savingsResponse([savingsDay("2026-09-19", { autorouter_savings_spend: -7 })]));
|
||||
|
|
@ -596,27 +593,16 @@ describe("UserInfoView savings", () => {
|
|||
expect(await screen.findByTestId("summary-card-total-recorded-savings")).toHaveTextContent("-$7.00");
|
||||
});
|
||||
|
||||
it("reports an incomplete paginated read as unavailable instead of displaying a partial savings total", async () => {
|
||||
it("reports a failed read as unavailable instead of displaying a partial savings total", async () => {
|
||||
mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable"));
|
||||
mockUserDailyActivityCall
|
||||
.mockResolvedValueOnce({
|
||||
results: [savingsDay("2026-09-19", { compression_savings_spend: 42 })],
|
||||
metadata: { total_pages: 2, has_more: true, page: 1 },
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("next page unavailable"));
|
||||
const user = userEvent.setup();
|
||||
render(<UserInfoView {...props} />);
|
||||
await user.click(await screen.findByRole("tab", { name: "Savings" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Savings are unavailable for this range");
|
||||
expect(mockUserDailyActivityCall).toHaveBeenLastCalledWith(
|
||||
"admin-token",
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
2,
|
||||
"user-123",
|
||||
true,
|
||||
null,
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
|
||||
"user",
|
||||
expect.objectContaining({ entityIds: ["user-123"] }),
|
||||
);
|
||||
expect(screen.queryByTestId("summary-card-total-recorded-savings")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/No usage recorded/)).not.toBeInTheDocument();
|
||||
|
|
@ -639,6 +625,5 @@ describe("UserInfoView savings", () => {
|
|||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("this user has no ID");
|
||||
expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled();
|
||||
expect(mockUserDailyActivityCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,61 +1,22 @@
|
|||
/**
|
||||
* Tests for EntityUsageExportModal component
|
||||
*
|
||||
* Validates core export functionality:
|
||||
* - Renders modal with correct default state (CSV format, daily scope)
|
||||
* - User can select export type (daily vs daily_with_models)
|
||||
* - User can switch format (CSV vs JSON)
|
||||
* - Export button triggers data generation with correct parameters
|
||||
* - Modal closes after successful export
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import EntityUsageExportModal from "./EntityUsageExportModal";
|
||||
|
||||
// Mock utilities that format/export data so tests stay fast and deterministic
|
||||
vi.mock("./utils", () => {
|
||||
return {
|
||||
handleExportCSV: vi.fn(),
|
||||
handleExportJSON: vi.fn(),
|
||||
generateExportData: vi.fn(() => [{ Date: "2025-10-01" }]),
|
||||
generateMetadata: vi.fn(() => ({ meta: true })),
|
||||
};
|
||||
});
|
||||
const downloadBlob = vi.fn();
|
||||
|
||||
// Mock useTeams hook
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
useTeams: vi.fn(() => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
vi.mock("./utils", () => ({
|
||||
downloadBlob: (...args: unknown[]) => downloadBlob(...args),
|
||||
exportFilename: vi.fn(() => "tag_usage_daily_2025-10-01_2025-10-14.csv"),
|
||||
}));
|
||||
|
||||
// JSDOM stubs for download flow used by the modal
|
||||
// @ts-ignore
|
||||
global.URL.createObjectURL = vi.fn(() => "blob:mock");
|
||||
// @ts-ignore
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
|
||||
describe("EntityUsageExportModal", () => {
|
||||
const baseProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
entityType: "tag" as const,
|
||||
spendData: {
|
||||
results: [],
|
||||
metadata: {
|
||||
total_spend: 0,
|
||||
total_api_requests: 0,
|
||||
total_successful_requests: 0,
|
||||
total_failed_requests: 0,
|
||||
total_tokens: 0,
|
||||
},
|
||||
},
|
||||
onExport: vi.fn().mockResolvedValue(new Blob(["data"])),
|
||||
dateRange: { from: new Date("2025-10-01"), to: new Date("2025-10-14") },
|
||||
selectedFilters: [],
|
||||
customTitle: "Export Tag Usage",
|
||||
|
|
@ -63,55 +24,44 @@ describe("EntityUsageExportModal", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
baseProps.onExport.mockResolvedValue(new Blob(["data"]));
|
||||
});
|
||||
|
||||
it("renders default state and exports CSV (daily) successfully", async () => {
|
||||
/**
|
||||
* Tests the happy path: user opens modal and exports with defaults.
|
||||
* Verifies that handleExportCSV is called with correct parameters
|
||||
* and modal closes after export completes.
|
||||
*/
|
||||
it("exports through the onExport callback with the selected type and format, then closes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { handleExportCSV } = await import("./utils");
|
||||
|
||||
renderWithProviders(<EntityUsageExportModal {...baseProps} />);
|
||||
|
||||
// Default primary action reflects CSV export
|
||||
expect(screen.getByRole("button", { name: /Export CSV/i })).toBeInTheDocument();
|
||||
|
||||
// Click export
|
||||
await user.click(screen.getByRole("button", { name: /Export CSV/i }));
|
||||
|
||||
// Verifies export function was invoked with correct parameters
|
||||
expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily", "Tag", "tag", {});
|
||||
|
||||
// Modal closes after export
|
||||
expect(baseProps.onExport).toHaveBeenCalledWith("daily", "csv");
|
||||
expect(downloadBlob).toHaveBeenCalledWith(expect.any(Blob), "tag_usage_daily_2025-10-01_2025-10-14.csv");
|
||||
expect(baseProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exports with 'day-by-day by tag and model' scope when selected", async () => {
|
||||
/**
|
||||
* Tests that user can change export type (scope).
|
||||
* Verifies handleExportCSV receives 'daily_with_models' scope
|
||||
* when the second radio option is selected.
|
||||
*/
|
||||
it("forwards the export type the user picks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { handleExportCSV } = await import("./utils");
|
||||
|
||||
renderWithProviders(<EntityUsageExportModal {...baseProps} />);
|
||||
|
||||
// Choose the alternate export type - click the label to trigger radio
|
||||
const dailyModelLabel = screen.getByText(/Day-by-day by tag and model/i);
|
||||
await user.click(dailyModelLabel);
|
||||
await user.click(screen.getByRole("radio", { name: /Day-by-day breakdown by tag and key/i }));
|
||||
await user.click(screen.getByRole("button", { name: /Export CSV/i }));
|
||||
|
||||
// Export with default CSV format
|
||||
const exportBtn = screen.getByRole("button", { name: /Export CSV/i });
|
||||
await user.click(exportBtn);
|
||||
|
||||
// Ensure the selected scope flowed through
|
||||
expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily_with_models", "Tag", "tag", {});
|
||||
|
||||
// Modal closes after export
|
||||
expect(baseProps.onExport).toHaveBeenCalledWith("daily_with_keys", "csv");
|
||||
expect(baseProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the modal open and reports the error when onExport rejects", async () => {
|
||||
const user = userEvent.setup();
|
||||
baseProps.onExport.mockRejectedValueOnce(new Error("export failed"));
|
||||
|
||||
renderWithProviders(<EntityUsageExportModal {...baseProps} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Export CSV/i }));
|
||||
|
||||
expect(baseProps.onExport).toHaveBeenCalled();
|
||||
expect(downloadBlob).not.toHaveBeenCalled();
|
||||
expect(baseProps.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,47 +1,36 @@
|
|||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { createTeamAliasMap } from "@/utils/teamUtils";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "@/lib/toast";
|
||||
import ExportFormatSelector from "./ExportFormatSelector";
|
||||
import ExportSummary from "./ExportSummary";
|
||||
import ExportTypeSelector from "./ExportTypeSelector";
|
||||
import type { EntityUsageExportModalProps, ExportFormat, ExportScope } from "./types";
|
||||
import { handleExportCSV, handleExportJSON } from "./utils";
|
||||
import type { EntityUsageExportModalProps, ExportFormat, ExportType } from "./types";
|
||||
import { downloadBlob, exportFilename } from "./utils";
|
||||
|
||||
const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
entityType,
|
||||
spendData,
|
||||
onExport,
|
||||
dateRange,
|
||||
selectedFilters,
|
||||
selectedFilters = [],
|
||||
customTitle,
|
||||
}) => {
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>("csv");
|
||||
const [exportScope, setExportScope] = useState<ExportScope>("daily");
|
||||
const [exportType, setExportType] = useState<ExportType>("daily");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const { data: teams, isLoading: isLoadingTeams } = useTeams();
|
||||
|
||||
const entityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1);
|
||||
const modalTitle = customTitle || `Export ${entityLabel} Usage`;
|
||||
|
||||
// Cache team alias map using useMemo
|
||||
const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]);
|
||||
const handleExport = async (format?: ExportFormat) => {
|
||||
const formatToUse = format || exportFormat;
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
if (formatToUse === "csv") {
|
||||
handleExportCSV(spendData, exportScope, entityLabel, entityType, teamAliasMap);
|
||||
toast.success(`${entityLabel} usage data exported successfully as CSV`);
|
||||
} else {
|
||||
handleExportJSON(spendData, exportScope, entityLabel, entityType, dateRange, selectedFilters, teamAliasMap);
|
||||
toast.success(`${entityLabel} usage data exported successfully as JSON`);
|
||||
}
|
||||
const blob = await onExport(exportType, exportFormat);
|
||||
downloadBlob(blob, exportFilename(entityType, exportType, exportFormat, dateRange));
|
||||
toast.success(`${entityLabel} usage data exported successfully as ${exportFormat.toUpperCase()}`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error exporting data:", error);
|
||||
|
|
@ -63,36 +52,17 @@ const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
|
|||
<DialogTitle className="text-base font-semibold">{modalTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5 py-2">
|
||||
{isLoadingTeams ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ExportSummary dateRange={dateRange} selectedFilters={selectedFilters} />
|
||||
<ExportTypeSelector value={exportScope} onChange={setExportScope} entityType={entityType} />
|
||||
<ExportFormatSelector value={exportFormat} onChange={setExportFormat} />
|
||||
</>
|
||||
)}
|
||||
<ExportSummary dateRange={dateRange} selectedFilters={selectedFilters} />
|
||||
<ExportTypeSelector value={exportType} onChange={setExportType} entityType={entityType} />
|
||||
<ExportFormatSelector value={exportFormat} onChange={setExportFormat} />
|
||||
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
||||
{isLoadingTeams ? (
|
||||
<>
|
||||
<Skeleton className="h-9 w-20" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose} disabled={isExporting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => handleExport()} disabled={isExporting}>
|
||||
{isExporting && <Loader2 className="animate-spin" />}
|
||||
{isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" onClick={onClose} disabled={isExporting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={isExporting}>
|
||||
{isExporting && <Loader2 className="animate-spin" />}
|
||||
{isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import ExportTypeSelector from "./ExportTypeSelector";
|
||||
|
||||
describe("ExportTypeSelector", () => {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import React from "react";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import type { ExportScope, EntityType } from "./types";
|
||||
import type { ExportType, EntityType } from "./types";
|
||||
|
||||
interface ExportTypeSelectorProps {
|
||||
value: ExportScope;
|
||||
onChange: (value: ExportScope) => void;
|
||||
value: ExportType;
|
||||
onChange: (value: ExportType) => void;
|
||||
entityType: EntityType;
|
||||
}
|
||||
|
||||
const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({ value, onChange, entityType }) => {
|
||||
const allScopes: { value: ExportScope; title: string; description: string }[] = [
|
||||
const allScopes: { value: ExportType; title: string; description: string }[] = [
|
||||
{
|
||||
value: "daily",
|
||||
title: `Day-by-day breakdown by ${entityType}`,
|
||||
|
|
@ -36,7 +36,7 @@ const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({ value, onChange
|
|||
return (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground block mb-2">Export type</label>
|
||||
<RadioGroup value={value} onValueChange={(next) => onChange(next as ExportScope)} className="gap-2">
|
||||
<RadioGroup value={value} onValueChange={(next) => onChange(next as ExportType)} className="gap-2">
|
||||
{scopes.map((scope) => (
|
||||
<label
|
||||
key={scope.value}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { renderWithProviders, screen } from "../../../tests/test-utils";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { vi } from "vitest";
|
||||
import UsageExportHeader from "./UsageExportHeader";
|
||||
import type { EntitySpendData } from "./types";
|
||||
|
||||
vi.mock("./EntityUsageExportModal", () => ({
|
||||
default: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) =>
|
||||
|
|
@ -16,16 +15,7 @@ vi.mock("./EntityUsageExportModal", () => ({
|
|||
const defaultProps = {
|
||||
dateValue: { from: new Date("2025-01-01"), to: new Date("2025-01-31") },
|
||||
entityType: "team" as const,
|
||||
spendData: {
|
||||
results: [],
|
||||
metadata: {
|
||||
total_spend: 0,
|
||||
total_api_requests: 0,
|
||||
total_successful_requests: 0,
|
||||
total_failed_requests: 0,
|
||||
total_tokens: 0,
|
||||
},
|
||||
} satisfies EntitySpendData,
|
||||
onExport: vi.fn().mockResolvedValue(new Blob(["data"])),
|
||||
};
|
||||
|
||||
describe("UsageExportHeader", () => {
|
||||
|
|
@ -41,27 +31,6 @@ describe("UsageExportHeader", () => {
|
|||
expect(screen.getByTestId("export-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks the export while the data on screen does not cover the range", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<UsageExportHeader
|
||||
{...defaultProps}
|
||||
exportBlockedReason="Spend data is still loading, so an export would under-report. Wait for it to finish."
|
||||
/>,
|
||||
);
|
||||
|
||||
const exportButton = screen.getByRole("button", { name: /export data/i });
|
||||
expect(exportButton).toBeDisabled();
|
||||
await user.click(exportButton);
|
||||
expect(screen.queryByTestId("export-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains why the export is blocked on hover", () => {
|
||||
renderWithProviders(<UsageExportHeader {...defaultProps} exportBlockedReason="Spend data is still loading" />);
|
||||
|
||||
expect(screen.getByTitle("Spend data is still loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should close the export modal when onClose is called", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UsageExportHeader {...defaultProps} />);
|
||||
|
|
|
|||
|
|
@ -16,13 +16,13 @@ import {
|
|||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import EntityUsageExportModal from "./EntityUsageExportModal";
|
||||
import type { EntitySpendData, EntityType } from "./types";
|
||||
import type { EntityType, ExportFormat, ExportType } from "./types";
|
||||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
|
||||
interface UsageExportHeaderProps {
|
||||
dateValue: DateRangePickerValue;
|
||||
entityType: EntityType;
|
||||
spendData: EntitySpendData;
|
||||
onExport: (exportType: ExportType, format: ExportFormat) => Promise<Blob>;
|
||||
// Optional filter props
|
||||
showFilters?: boolean;
|
||||
filterLabel?: string;
|
||||
|
|
@ -34,13 +34,12 @@ interface UsageExportHeaderProps {
|
|||
customTitle?: string;
|
||||
compactLayout?: boolean;
|
||||
teams?: Team[];
|
||||
exportBlockedReason?: string;
|
||||
}
|
||||
|
||||
const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
||||
dateValue,
|
||||
entityType,
|
||||
spendData,
|
||||
onExport,
|
||||
showFilters = false,
|
||||
filterLabel,
|
||||
filterPlaceholder,
|
||||
|
|
@ -51,7 +50,6 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
customTitle,
|
||||
compactLayout = false,
|
||||
teams = [],
|
||||
exportBlockedReason,
|
||||
}) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
|
|
@ -123,12 +121,10 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
)}
|
||||
|
||||
<div className="justify-self-end">
|
||||
<span title={exportBlockedReason}>
|
||||
<Button disabled={exportBlockedReason !== undefined} onClick={() => setIsExportModalOpen(true)}>
|
||||
<Download />
|
||||
Export Data
|
||||
</Button>
|
||||
</span>
|
||||
<Button onClick={() => setIsExportModalOpen(true)}>
|
||||
<Download />
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -137,7 +133,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
isOpen={isExportModalOpen}
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
entityType={entityType}
|
||||
spendData={spendData}
|
||||
onExport={onExport}
|
||||
dateRange={dateValue}
|
||||
selectedFilters={selectedFilters}
|
||||
customTitle={customTitle}
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason";
|
||||
|
||||
const state = (overrides: Partial<UsageFetchState> = {}): UsageFetchState => ({
|
||||
coversRange: true,
|
||||
cancelled: false,
|
||||
failed: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("getExportBlockedReason", () => {
|
||||
it("lets the export through once the data on screen covers the range", () => {
|
||||
expect(getExportBlockedReason(state())).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks whenever the data on screen does not cover the range, which is when a CSV silently under-reports", () => {
|
||||
expect(getExportBlockedReason(state({ coversRange: false }))).toMatch(/still loading/i);
|
||||
});
|
||||
|
||||
it("blocks after a stopped fetch and says a reload is what fixes it", () => {
|
||||
const reason = getExportBlockedReason(state({ coversRange: false, cancelled: true }));
|
||||
|
||||
expect(reason).toMatch(/stopped/i);
|
||||
expect(reason).toMatch(/reload/i);
|
||||
});
|
||||
|
||||
it("blocks after a failed page and names the failure rather than the stop", () => {
|
||||
const reason = getExportBlockedReason(state({ coversRange: false, failed: true, cancelled: true }));
|
||||
|
||||
expect(reason).toMatch(/failed to load/i);
|
||||
expect(reason).not.toMatch(/stopped/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
export interface UsageFetchState {
|
||||
coversRange: boolean;
|
||||
cancelled: boolean;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
export const getExportBlockedReason = ({ coversRange, cancelled, failed }: UsageFetchState): string | undefined => {
|
||||
if (failed) return "Some spend data failed to load, so an export would under-report. Reload the page to try again.";
|
||||
if (cancelled)
|
||||
return "Loading was stopped before the whole range arrived, so an export would under-report. Reload the page to load it all.";
|
||||
if (!coversRange) return "Spend data is still loading, so an export would under-report. Wait for it to finish.";
|
||||
return undefined;
|
||||
};
|
||||
|
|
@ -1,67 +1,17 @@
|
|||
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
|
||||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
import type { DailyActivityEntity, ExportFormat, ExportType } from "@/components/UsagePage/dailyActivityApi";
|
||||
|
||||
export type ExportFormat = "csv" | "json";
|
||||
export type ExportScope = "daily" | "daily_with_keys" | "daily_with_models" | "daily_with_users";
|
||||
export type EntityType = "tag" | "team" | "organization" | "customer" | "agent" | "user";
|
||||
|
||||
export interface EntitySpendData {
|
||||
results: any[];
|
||||
metadata: {
|
||||
total_spend: number;
|
||||
total_flat_cost?: number;
|
||||
total_api_requests: number;
|
||||
total_successful_requests: number;
|
||||
total_failed_requests: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
export type { ExportFormat, ExportType };
|
||||
export type EntityType = DailyActivityEntity;
|
||||
|
||||
export interface EntityUsageExportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
entityType: EntityType;
|
||||
spendData: EntitySpendData;
|
||||
onExport: (exportType: ExportType, format: ExportFormat) => Promise<Blob>;
|
||||
dateRange: DateRangePickerValue;
|
||||
selectedFilters: string[];
|
||||
selectedFilters?: string[];
|
||||
customTitle?: string;
|
||||
teams?: Team[];
|
||||
}
|
||||
|
||||
export interface ExportMetadata {
|
||||
export_date: string;
|
||||
entity_type: string;
|
||||
date_range: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
filters_applied: string[] | string;
|
||||
export_scope: ExportScope;
|
||||
summary: {
|
||||
total_spend: number;
|
||||
total_flat_cost?: number;
|
||||
total_cost?: number;
|
||||
total_requests: number;
|
||||
successful_requests: number;
|
||||
failed_requests: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EntityBreakdown {
|
||||
metrics: {
|
||||
spend: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
api_requests: number;
|
||||
successful_requests: number;
|
||||
failed_requests: number;
|
||||
cache_read_input_tokens: number;
|
||||
cache_creation_input_tokens: number;
|
||||
};
|
||||
metadata: {
|
||||
alias: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,497 +1,23 @@
|
|||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
|
||||
import Papa from "papaparse";
|
||||
import { keyActivityLabel } from "@/components/UsagePage/keyActivityLabel";
|
||||
import type { EntityBreakdown, EntitySpendData, EntityType, ExportMetadata, ExportScope } from "./types";
|
||||
import type { EntityType, ExportFormat, ExportType } from "./types";
|
||||
|
||||
const resolveEntityDisplay = (
|
||||
entity: string,
|
||||
teamAliasMap: Record<string, string>,
|
||||
entityMetadata?: Record<string, any>,
|
||||
): { id: string; alias: string } => ({
|
||||
id: entity,
|
||||
alias:
|
||||
teamAliasMap[entity] ||
|
||||
entityMetadata?.team_alias ||
|
||||
entityMetadata?.user_email ||
|
||||
entityMetadata?.user_alias ||
|
||||
entity,
|
||||
});
|
||||
const fileDay = (date: Date | undefined): string =>
|
||||
date
|
||||
? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`
|
||||
: "all";
|
||||
|
||||
// Mirrors backend SpendMetrics fields (litellm/types/activity_tracking.py).
|
||||
// If the backend adds a field, add it here too.
|
||||
const METRIC_KEYS = [
|
||||
"spend",
|
||||
"api_requests",
|
||||
"successful_requests",
|
||||
"failed_requests",
|
||||
"total_tokens",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
] as const;
|
||||
|
||||
// When breakdown.entities is empty (aggregated endpoint), reconstruct entities
|
||||
// from breakdown.api_keys by grouping on metadata.team_id.
|
||||
const aggregateApiKeysIntoEntities = (breakdown: Record<string, any>): Record<string, any> => {
|
||||
const apiKeys = breakdown.api_keys;
|
||||
if (!apiKeys || Object.keys(apiKeys).length === 0) return {};
|
||||
|
||||
const grouped: Record<string, any> = {};
|
||||
|
||||
for (const [keyId, keyData] of Object.entries<any>(apiKeys)) {
|
||||
const teamId = keyData?.metadata?.team_id || "Unassigned";
|
||||
if (!grouped[teamId]) {
|
||||
grouped[teamId] = {
|
||||
metrics: Object.fromEntries(METRIC_KEYS.map((k) => [k, 0])),
|
||||
api_key_breakdown: {},
|
||||
};
|
||||
}
|
||||
const m = grouped[teamId].metrics;
|
||||
const km = keyData?.metrics || {};
|
||||
for (const k of METRIC_KEYS) {
|
||||
m[k] += km[k] || 0;
|
||||
}
|
||||
grouped[teamId].api_key_breakdown[keyId] = keyData;
|
||||
}
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
// Returns breakdown.entities if populated, otherwise falls back to
|
||||
// reconstructing entities from breakdown.api_keys.
|
||||
export const resolveEntities = (breakdown: Record<string, any>): Record<string, any> => {
|
||||
const entities = breakdown.entities;
|
||||
if (entities && Object.keys(entities).length > 0) return entities;
|
||||
return aggregateApiKeysIntoEntities(breakdown);
|
||||
};
|
||||
|
||||
export const getEntityBreakdown = (
|
||||
spendData: EntitySpendData,
|
||||
teamAliasMap: Record<string, string> = {},
|
||||
): EntityBreakdown[] => {
|
||||
const entitySpend: { [key: string]: EntityBreakdown } = {};
|
||||
|
||||
spendData.results.forEach((day) => {
|
||||
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
|
||||
const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata);
|
||||
|
||||
if (!entitySpend[entity]) {
|
||||
entitySpend[entity] = {
|
||||
metrics: {
|
||||
spend: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
api_requests: 0,
|
||||
successful_requests: 0,
|
||||
failed_requests: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
metadata: {
|
||||
alias,
|
||||
id,
|
||||
},
|
||||
};
|
||||
}
|
||||
entitySpend[entity].metrics.spend += data.metrics.spend;
|
||||
entitySpend[entity].metrics.api_requests += data.metrics.api_requests;
|
||||
entitySpend[entity].metrics.successful_requests += data.metrics.successful_requests;
|
||||
entitySpend[entity].metrics.failed_requests += data.metrics.failed_requests;
|
||||
entitySpend[entity].metrics.total_tokens += data.metrics.total_tokens;
|
||||
entitySpend[entity].metrics.prompt_tokens += data.metrics.prompt_tokens || 0;
|
||||
entitySpend[entity].metrics.completion_tokens += data.metrics.completion_tokens || 0;
|
||||
entitySpend[entity].metrics.cache_read_input_tokens += data.metrics.cache_read_input_tokens || 0;
|
||||
entitySpend[entity].metrics.cache_creation_input_tokens += data.metrics.cache_creation_input_tokens || 0;
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(entitySpend).sort((a, b) => b.metrics.spend - a.metrics.spend);
|
||||
};
|
||||
|
||||
// total_flat_cost defaults to 0 on every entity response, so only a non-zero value
|
||||
// means a PTU-configured team actually accrued flat cost worth exporting.
|
||||
const hasFlatCost = (spendData: EntitySpendData): boolean => (spendData.metadata.total_flat_cost ?? 0) > 0;
|
||||
|
||||
export const generateDailyData = (
|
||||
spendData: EntitySpendData,
|
||||
entityLabel: string,
|
||||
teamAliasMap: Record<string, string> = {},
|
||||
): any[] => {
|
||||
const dailyBreakdown: any[] = [];
|
||||
const includeFlatCost = hasFlatCost(spendData);
|
||||
|
||||
spendData.results.forEach((day) => {
|
||||
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
|
||||
const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata);
|
||||
|
||||
const row: Record<string, any> = {
|
||||
Date: day.date,
|
||||
[entityLabel]: alias,
|
||||
[`${entityLabel} ID`]: id,
|
||||
"Spend ($)": formatNumberWithCommas(data.metrics.spend, 4),
|
||||
};
|
||||
if (includeFlatCost) {
|
||||
const flatCost = data.metrics.flat_cost || 0;
|
||||
row["Flat Cost ($)"] = formatNumberWithCommas(flatCost, 4);
|
||||
row["Total Cost ($)"] = formatNumberWithCommas((data.metrics.spend || 0) + flatCost, 4);
|
||||
}
|
||||
row.Requests = data.metrics.api_requests;
|
||||
row["Successful Requests"] = data.metrics.successful_requests;
|
||||
row["Failed Requests"] = data.metrics.failed_requests;
|
||||
row["Total Tokens"] = data.metrics.total_tokens;
|
||||
row["Prompt Tokens"] = data.metrics.prompt_tokens || 0;
|
||||
row["Completion Tokens"] = data.metrics.completion_tokens || 0;
|
||||
row["Cache Read Input Tokens"] = data.metrics.cache_read_input_tokens || 0;
|
||||
row["Cache Creation Input Tokens"] = data.metrics.cache_creation_input_tokens || 0;
|
||||
dailyBreakdown.push(row);
|
||||
});
|
||||
});
|
||||
|
||||
return dailyBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime());
|
||||
};
|
||||
|
||||
export const generateDailyWithKeysData = (
|
||||
spendData: EntitySpendData,
|
||||
entityLabel: string,
|
||||
teamAliasMap: Record<string, string> = {},
|
||||
): any[] => {
|
||||
// Aggregate by unique (Date, Entity ID, Key ID) combination to prevent duplicates
|
||||
const aggregatedData: {
|
||||
[key: string]: {
|
||||
Date: string;
|
||||
entityId: string;
|
||||
entityAlias: string;
|
||||
keyId: string;
|
||||
keyAlias: string | null;
|
||||
userId: string | null;
|
||||
userEmail: string | null;
|
||||
metrics: {
|
||||
spend: number;
|
||||
api_requests: number;
|
||||
successful_requests: number;
|
||||
failed_requests: number;
|
||||
total_tokens: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
cache_read_input_tokens: number;
|
||||
cache_creation_input_tokens: number;
|
||||
};
|
||||
};
|
||||
} = {};
|
||||
|
||||
spendData.results.forEach((day) => {
|
||||
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
|
||||
const { id: entityId, alias: entityAlias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata);
|
||||
const apiKeyBreakdown = data.api_key_breakdown || {};
|
||||
|
||||
// Iterate through each API key in the breakdown
|
||||
Object.entries(apiKeyBreakdown).forEach(([keyId, keyData]: [string, any]) => {
|
||||
const keyAlias = keyActivityLabel(keyData?.metadata, "") || null;
|
||||
|
||||
// Create unique key for aggregation: Date_EntityID_KeyID
|
||||
const uniqueKey = `${day.date}_${entityId}_${keyId}`;
|
||||
|
||||
if (!aggregatedData[uniqueKey]) {
|
||||
// First time seeing this (Date, Entity ID, Key ID) combination
|
||||
aggregatedData[uniqueKey] = {
|
||||
Date: day.date,
|
||||
entityId,
|
||||
entityAlias,
|
||||
keyId,
|
||||
keyAlias,
|
||||
userId: keyData?.metadata?.user_id || null,
|
||||
userEmail: keyData?.metadata?.user_email || null,
|
||||
metrics: {
|
||||
spend: keyData.metrics?.spend || 0,
|
||||
api_requests: keyData.metrics?.api_requests || 0,
|
||||
successful_requests: keyData.metrics?.successful_requests || 0,
|
||||
failed_requests: keyData.metrics?.failed_requests || 0,
|
||||
total_tokens: keyData.metrics?.total_tokens || 0,
|
||||
prompt_tokens: keyData.metrics?.prompt_tokens || 0,
|
||||
completion_tokens: keyData.metrics?.completion_tokens || 0,
|
||||
cache_read_input_tokens: keyData.metrics?.cache_read_input_tokens || 0,
|
||||
cache_creation_input_tokens: keyData.metrics?.cache_creation_input_tokens || 0,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// Aggregate metrics for existing entry
|
||||
aggregatedData[uniqueKey].metrics.spend += keyData.metrics?.spend || 0;
|
||||
aggregatedData[uniqueKey].metrics.api_requests += keyData.metrics?.api_requests || 0;
|
||||
aggregatedData[uniqueKey].metrics.successful_requests += keyData.metrics?.successful_requests || 0;
|
||||
aggregatedData[uniqueKey].metrics.failed_requests += keyData.metrics?.failed_requests || 0;
|
||||
aggregatedData[uniqueKey].metrics.total_tokens += keyData.metrics?.total_tokens || 0;
|
||||
aggregatedData[uniqueKey].metrics.prompt_tokens += keyData.metrics?.prompt_tokens || 0;
|
||||
aggregatedData[uniqueKey].metrics.completion_tokens += keyData.metrics?.completion_tokens || 0;
|
||||
aggregatedData[uniqueKey].metrics.cache_read_input_tokens += keyData.metrics?.cache_read_input_tokens || 0;
|
||||
aggregatedData[uniqueKey].metrics.cache_creation_input_tokens +=
|
||||
keyData.metrics?.cache_creation_input_tokens || 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Convert aggregated data to array format
|
||||
const dailyKeyBreakdown = Object.values(aggregatedData).map((item) => ({
|
||||
Date: item.Date,
|
||||
[entityLabel]: item.entityAlias,
|
||||
[`${entityLabel} ID`]: item.entityId,
|
||||
"Key Alias": item.keyAlias || "-",
|
||||
"Key ID": item.keyId,
|
||||
...(entityLabel === "User" ? {} : { "User ID": item.userId || "-", "User Email": item.userEmail || "-" }),
|
||||
"Spend ($)": formatNumberWithCommas(item.metrics.spend, 4),
|
||||
Requests: item.metrics.api_requests,
|
||||
"Successful Requests": item.metrics.successful_requests,
|
||||
"Failed Requests": item.metrics.failed_requests,
|
||||
"Total Tokens": item.metrics.total_tokens,
|
||||
"Prompt Tokens": item.metrics.prompt_tokens,
|
||||
"Completion Tokens": item.metrics.completion_tokens,
|
||||
"Cache Read Input Tokens": item.metrics.cache_read_input_tokens,
|
||||
"Cache Creation Input Tokens": item.metrics.cache_creation_input_tokens,
|
||||
}));
|
||||
|
||||
return dailyKeyBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime());
|
||||
};
|
||||
|
||||
export const generateDailyWithUsersData = (
|
||||
spendData: EntitySpendData,
|
||||
entityLabel: string,
|
||||
teamAliasMap: Record<string, string> = {},
|
||||
): any[] => {
|
||||
const aggregatedData: {
|
||||
[key: string]: {
|
||||
Date: string;
|
||||
entityId: string;
|
||||
entityAlias: string;
|
||||
userId: string;
|
||||
userEmail: string | null;
|
||||
keyIds: Set<string>;
|
||||
metrics: Record<(typeof METRIC_KEYS)[number], number>;
|
||||
};
|
||||
} = {};
|
||||
|
||||
spendData.results.forEach((day) => {
|
||||
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
|
||||
const { id: entityId, alias: entityAlias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata);
|
||||
Object.entries(data.api_key_breakdown || {}).forEach(([keyId, keyData]: [string, any]) => {
|
||||
const userId = keyData?.metadata?.user_id || "Unassigned";
|
||||
const uniqueKey = JSON.stringify([day.date, entityId, userId]);
|
||||
if (!aggregatedData[uniqueKey]) {
|
||||
aggregatedData[uniqueKey] = {
|
||||
Date: day.date,
|
||||
entityId,
|
||||
entityAlias,
|
||||
userId,
|
||||
userEmail: null,
|
||||
keyIds: new Set(),
|
||||
metrics: Object.fromEntries(METRIC_KEYS.map((k) => [k, 0])) as Record<(typeof METRIC_KEYS)[number], number>,
|
||||
};
|
||||
}
|
||||
const bucket = aggregatedData[uniqueKey];
|
||||
bucket.userEmail = bucket.userEmail || keyData?.metadata?.user_email || null;
|
||||
bucket.keyIds.add(keyId);
|
||||
for (const k of METRIC_KEYS) {
|
||||
bucket.metrics[k] += keyData?.metrics?.[k] || 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(aggregatedData)
|
||||
.map((item) => ({
|
||||
Date: item.Date,
|
||||
[entityLabel]: item.entityAlias,
|
||||
[`${entityLabel} ID`]: item.entityId,
|
||||
"User ID": item.userId,
|
||||
"User Email": item.userEmail || "-",
|
||||
Keys: item.keyIds.size,
|
||||
"Spend ($)": formatNumberWithCommas(item.metrics.spend, 4),
|
||||
Requests: item.metrics.api_requests,
|
||||
"Successful Requests": item.metrics.successful_requests,
|
||||
"Failed Requests": item.metrics.failed_requests,
|
||||
"Total Tokens": item.metrics.total_tokens,
|
||||
"Prompt Tokens": item.metrics.prompt_tokens,
|
||||
"Completion Tokens": item.metrics.completion_tokens,
|
||||
"Cache Read Input Tokens": item.metrics.cache_read_input_tokens,
|
||||
"Cache Creation Input Tokens": item.metrics.cache_creation_input_tokens,
|
||||
}))
|
||||
.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime());
|
||||
};
|
||||
|
||||
export const generateDailyWithModelsData = (
|
||||
spendData: EntitySpendData,
|
||||
entityLabel: string,
|
||||
teamAliasMap: Record<string, string> = {},
|
||||
): any[] => {
|
||||
const dailyModelBreakdown: any[] = [];
|
||||
|
||||
spendData.results.forEach((day) => {
|
||||
const dailyEntityModels: { [key: string]: { [key: string]: any } } = {};
|
||||
const dailyEntityMetadata: { [key: string]: Record<string, any> | undefined } = {};
|
||||
|
||||
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, entityData]: [string, any]) => {
|
||||
if (!dailyEntityModels[entity]) {
|
||||
dailyEntityModels[entity] = {};
|
||||
}
|
||||
dailyEntityMetadata[entity] = entityData.metadata;
|
||||
|
||||
Object.entries(day.breakdown.models || {}).forEach(([model, modelData]: [string, any]) => {
|
||||
const entityApiKeys = entityData.api_key_breakdown || {};
|
||||
const modelApiKeys = modelData.api_key_breakdown || {};
|
||||
|
||||
Object.keys(entityApiKeys).forEach((apiKey) => {
|
||||
const keyMetrics = modelApiKeys[apiKey]?.metrics;
|
||||
if (!keyMetrics) return;
|
||||
|
||||
if (!dailyEntityModels[entity][model]) {
|
||||
dailyEntityModels[entity][model] = {
|
||||
spend: 0,
|
||||
requests: 0,
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
tokens: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
};
|
||||
}
|
||||
dailyEntityModels[entity][model].spend += keyMetrics.spend || 0;
|
||||
dailyEntityModels[entity][model].requests += keyMetrics.api_requests || 0;
|
||||
dailyEntityModels[entity][model].successful += keyMetrics.successful_requests || 0;
|
||||
dailyEntityModels[entity][model].failed += keyMetrics.failed_requests || 0;
|
||||
dailyEntityModels[entity][model].tokens += keyMetrics.total_tokens || 0;
|
||||
dailyEntityModels[entity][model].promptTokens += keyMetrics.prompt_tokens || 0;
|
||||
dailyEntityModels[entity][model].completionTokens += keyMetrics.completion_tokens || 0;
|
||||
dailyEntityModels[entity][model].cacheReadInputTokens += keyMetrics.cache_read_input_tokens || 0;
|
||||
dailyEntityModels[entity][model].cacheCreationInputTokens += keyMetrics.cache_creation_input_tokens || 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(dailyEntityModels).forEach(([entity, models]) => {
|
||||
const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, dailyEntityMetadata[entity]);
|
||||
|
||||
Object.entries(models).forEach(([model, metrics]: [string, any]) => {
|
||||
dailyModelBreakdown.push({
|
||||
Date: day.date,
|
||||
[entityLabel]: alias,
|
||||
[`${entityLabel} ID`]: id,
|
||||
Model: model,
|
||||
"Spend ($)": formatNumberWithCommas(metrics.spend, 4),
|
||||
Requests: metrics.requests,
|
||||
Successful: metrics.successful,
|
||||
Failed: metrics.failed,
|
||||
"Total Tokens": metrics.tokens,
|
||||
"Prompt Tokens": metrics.promptTokens,
|
||||
"Completion Tokens": metrics.completionTokens,
|
||||
"Cache Read Input Tokens": metrics.cacheReadInputTokens,
|
||||
"Cache Creation Input Tokens": metrics.cacheCreationInputTokens,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return dailyModelBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime());
|
||||
};
|
||||
|
||||
export const generateExportData = (
|
||||
spendData: EntitySpendData,
|
||||
exportScope: ExportScope,
|
||||
entityLabel: string,
|
||||
teamAliasMap: Record<string, string> = {},
|
||||
): any[] => {
|
||||
switch (exportScope) {
|
||||
case "daily":
|
||||
return generateDailyData(spendData, entityLabel, teamAliasMap);
|
||||
case "daily_with_keys":
|
||||
return generateDailyWithKeysData(spendData, entityLabel, teamAliasMap);
|
||||
case "daily_with_models":
|
||||
return generateDailyWithModelsData(spendData, entityLabel, teamAliasMap);
|
||||
case "daily_with_users":
|
||||
return generateDailyWithUsersData(spendData, entityLabel, teamAliasMap);
|
||||
default:
|
||||
return generateDailyData(spendData, entityLabel, teamAliasMap);
|
||||
}
|
||||
};
|
||||
|
||||
export const generateMetadata = (
|
||||
export const exportFilename = (
|
||||
entityType: EntityType,
|
||||
exportType: ExportType,
|
||||
format: ExportFormat,
|
||||
dateRange: DateRangePickerValue,
|
||||
selectedFilters: string[],
|
||||
exportScope: ExportScope,
|
||||
spendData: EntitySpendData,
|
||||
): ExportMetadata => {
|
||||
const summary: ExportMetadata["summary"] = {
|
||||
total_spend: spendData.metadata.total_spend,
|
||||
total_requests: spendData.metadata.total_api_requests,
|
||||
successful_requests: spendData.metadata.total_successful_requests,
|
||||
failed_requests: spendData.metadata.total_failed_requests,
|
||||
total_tokens: spendData.metadata.total_tokens,
|
||||
};
|
||||
if (hasFlatCost(spendData)) {
|
||||
const flatCost = spendData.metadata.total_flat_cost ?? 0;
|
||||
summary.total_flat_cost = flatCost;
|
||||
summary.total_cost = spendData.metadata.total_spend + flatCost;
|
||||
}
|
||||
return {
|
||||
export_date: new Date().toISOString(),
|
||||
entity_type: entityType,
|
||||
date_range: {
|
||||
from: dateRange.from?.toISOString(),
|
||||
to: dateRange.to?.toISOString(),
|
||||
},
|
||||
filters_applied: selectedFilters.length > 0 ? selectedFilters : "None",
|
||||
export_scope: exportScope,
|
||||
summary,
|
||||
};
|
||||
};
|
||||
): string => `${entityType}_usage_${exportType}_${fileDay(dateRange.from)}_${fileDay(dateRange.to)}.${format}`;
|
||||
|
||||
export const handleExportCSV = (
|
||||
spendData: EntitySpendData,
|
||||
exportScope: ExportScope,
|
||||
entityLabel: string,
|
||||
entityType: EntityType,
|
||||
teamAliasMap: Record<string, string> = {},
|
||||
): void => {
|
||||
const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap);
|
||||
const csv = Papa.unparse(data);
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
export const downloadBlob = (blob: Blob, filename: string): void => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const fileName = `${entityType}_usage_${exportScope}_${new Date().toISOString().split("T")[0]}.csv`;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const handleExportJSON = (
|
||||
spendData: EntitySpendData,
|
||||
exportScope: ExportScope,
|
||||
entityLabel: string,
|
||||
entityType: EntityType,
|
||||
dateRange: DateRangePickerValue,
|
||||
selectedFilters: string[],
|
||||
teamAliasMap: Record<string, string> = {},
|
||||
): void => {
|
||||
const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap);
|
||||
const metadata = generateMetadata(entityType, dateRange, selectedFilters, exportScope, spendData);
|
||||
const exportObject = {
|
||||
metadata,
|
||||
data,
|
||||
};
|
||||
const jsonString = JSON.stringify(exportObject, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const fileName = `${entityType}_usage_${exportScope}_${new Date().toISOString().split("T")[0]}.json`;
|
||||
a.download = fileName;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,674 @@
|
|||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import { type DailyActivityKeyPageResponse, type KeyActivityRow, type KeySpendActivityRow } from "../dailyActivityApi";
|
||||
import type { ModelActivityData } from "../types";
|
||||
import KeyActivityPanel from "./KeyActivityPanel";
|
||||
|
||||
let triggerIntersection: (() => void) | undefined;
|
||||
|
||||
const intersectSentinel = async () => {
|
||||
await waitFor(() => expect(triggerIntersection).toBeDefined());
|
||||
await act(async () => {
|
||||
triggerIntersection?.();
|
||||
});
|
||||
};
|
||||
|
||||
class TestIntersectionObserver implements IntersectionObserver {
|
||||
readonly root: Element | Document | null = null;
|
||||
readonly rootMargin = "";
|
||||
readonly thresholds: readonly number[] = [];
|
||||
|
||||
constructor(private readonly callback: IntersectionObserverCallback) {}
|
||||
|
||||
observe(target: Element): void {
|
||||
triggerIntersection = () =>
|
||||
this.callback(
|
||||
[
|
||||
{
|
||||
boundingClientRect: target.getBoundingClientRect(),
|
||||
intersectionRect: target.getBoundingClientRect(),
|
||||
intersectionRatio: 1,
|
||||
isIntersecting: true,
|
||||
rootBounds: null,
|
||||
target,
|
||||
time: 0,
|
||||
},
|
||||
],
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
unobserve(): void {}
|
||||
|
||||
disconnect(): void {
|
||||
triggerIntersection = undefined;
|
||||
}
|
||||
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const metrics: components["schemas"]["SpendMetrics"] = {
|
||||
api_requests: 2,
|
||||
autorouter_savings_spend: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
completion_tokens: 3,
|
||||
compression_saved_tokens: 0,
|
||||
compression_savings_spend: 0,
|
||||
failed_requests: 0,
|
||||
flat_cost: 0,
|
||||
gateway_injected_caching_savings_spend: 0,
|
||||
prompt_caching_savings_spend: 0,
|
||||
prompt_tokens: 4,
|
||||
spend: 1.25,
|
||||
successful_requests: 2,
|
||||
timed_requests: 0,
|
||||
total_response_time_ms: 0,
|
||||
total_tokens: 7,
|
||||
};
|
||||
|
||||
const pageRow = (apiKey: string): KeySpendActivityRow => ({
|
||||
api_key: apiKey,
|
||||
metrics: {
|
||||
api_requests: 2,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
completion_tokens: 3,
|
||||
failed_requests: 0,
|
||||
prompt_tokens: 4,
|
||||
spend: 1.25,
|
||||
successful_requests: 2,
|
||||
total_tokens: 7,
|
||||
},
|
||||
metadata: { key_alias: apiKey, team_id: null },
|
||||
});
|
||||
|
||||
const searchRow = (apiKey: string, alias: string): KeyActivityRow => ({
|
||||
api_key: apiKey,
|
||||
metrics,
|
||||
metadata: { key_alias: alias, team_id: null },
|
||||
});
|
||||
|
||||
const pageResponse = (apiKeys: KeySpendActivityRow[], total: number, offset = 0): DailyActivityKeyPageResponse => ({
|
||||
api_keys: apiKeys,
|
||||
total_api_keys: total,
|
||||
offset,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const summary: ModelActivityData = {
|
||||
label: "Overall Usage",
|
||||
total_requests: 200,
|
||||
total_successful_requests: 198,
|
||||
total_failed_requests: 2,
|
||||
total_cache_read_input_tokens: 0,
|
||||
total_cache_creation_input_tokens: 0,
|
||||
total_tokens: 700,
|
||||
prompt_tokens: 400,
|
||||
completion_tokens: 300,
|
||||
total_spend: 500,
|
||||
total_response_time_ms: 0,
|
||||
total_timed_requests: 0,
|
||||
top_models: [],
|
||||
daily_data: [
|
||||
{
|
||||
date: "2026-09-27",
|
||||
metrics: {
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 3,
|
||||
total_tokens: 7,
|
||||
api_requests: 2,
|
||||
spend: 1.25,
|
||||
successful_requests: 2,
|
||||
failed_requests: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const detail = (apiKey: string): ModelActivityData => ({
|
||||
label: apiKey,
|
||||
total_requests: 2,
|
||||
total_successful_requests: 2,
|
||||
total_failed_requests: 0,
|
||||
total_cache_read_input_tokens: 0,
|
||||
total_cache_creation_input_tokens: 0,
|
||||
total_tokens: 7,
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 3,
|
||||
total_spend: 1.25,
|
||||
total_response_time_ms: 0,
|
||||
total_timed_requests: 0,
|
||||
top_models: [
|
||||
{ model: "gpt-4o-mini", spend: 1.25, requests: 2, successful_requests: 2, failed_requests: 0, tokens: 7 },
|
||||
],
|
||||
daily_data: [
|
||||
{
|
||||
date: "2026-09-27",
|
||||
metrics: {
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 3,
|
||||
total_tokens: 7,
|
||||
api_requests: 2,
|
||||
spend: 1.25,
|
||||
successful_requests: 2,
|
||||
failed_requests: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
avg_response_time_ms: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
triggerIntersection = undefined;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IntersectionObserver", TestIntersectionObserver);
|
||||
});
|
||||
|
||||
describe("KeyActivityPanel", () => {
|
||||
it("loads the first page, appends the next page, and keeps limit copy out of the UI", async () => {
|
||||
let resolveNextPage: (response: DailyActivityKeyPageResponse) => void = () => {};
|
||||
const nextPage = new Promise<DailyActivityKeyPageResponse>((resolve) => {
|
||||
resolveNextPage = resolve;
|
||||
});
|
||||
const firstPageRows = Array.from({ length: 50 }, (_, index) => pageRow(`key-${index}`));
|
||||
const fetchKeyPage = vi.fn((offset: number, _limit: number) =>
|
||||
offset === 0 ? Promise.resolve(pageResponse(firstPageRows, 52)) : nextPage,
|
||||
);
|
||||
|
||||
render(
|
||||
<KeyActivityPanel
|
||||
summary={summary}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
|
||||
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("button", { name: /key-49/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("52 keys")).toBeInTheDocument();
|
||||
expect(screen.getByText("$500.00")).toBeInTheDocument();
|
||||
expect(fetchKeyPage).toHaveBeenCalledWith(0, 50);
|
||||
expect(screen.queryByText(/limit|truncat|highest-spend|load top/i)).not.toBeInTheDocument();
|
||||
|
||||
await intersectSentinel();
|
||||
expect(fetchKeyPage).toHaveBeenCalledWith(50, 50);
|
||||
expect(screen.getByText("Loading more keys...")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
resolveNextPage(pageResponse([pageRow("key-50"), pageRow("key-51")], 52, 50));
|
||||
await nextPage;
|
||||
});
|
||||
expect(await screen.findByRole("button", { name: /key-51/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading more keys...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fetches full detail on first expansion and renders charts with daily data", async () => {
|
||||
const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-chart")], 1));
|
||||
let resolveDetail: (metrics: ModelActivityData) => void = () => {};
|
||||
const detailResponse = new Promise<ModelActivityData>((resolve) => {
|
||||
resolveDetail = resolve;
|
||||
});
|
||||
const fetchKeyDetail = vi.fn().mockReturnValue(detailResponse);
|
||||
render(
|
||||
<KeyActivityPanel
|
||||
summary={summary}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={fetchKeyDetail}
|
||||
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /key-chart/ }));
|
||||
|
||||
expect(fetchKeyDetail).toHaveBeenCalledWith("key-chart");
|
||||
expect(await screen.findByText("Loading key details...")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
resolveDetail(detail("key-chart"));
|
||||
await detailResponse;
|
||||
});
|
||||
expect(await screen.findByText("Spend per day")).toBeInTheDocument();
|
||||
expect(screen.getByText("Requests per day")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No data")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a detail error with Retry when the detail request fails and refetches on retry", async () => {
|
||||
const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-broken")], 1));
|
||||
const fetchKeyDetail = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce(detail("key-broken"));
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
render(
|
||||
<KeyActivityPanel
|
||||
summary={summary}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={fetchKeyDetail}
|
||||
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /key-broken/ }));
|
||||
|
||||
expect(await screen.findByText(/Could not load key details\./)).toBeInTheDocument();
|
||||
expect(fetchKeyDetail).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("Spend per day")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(fetchKeyDetail).toHaveBeenCalledTimes(2);
|
||||
expect(fetchKeyDetail).toHaveBeenLastCalledWith("key-broken");
|
||||
expect(await screen.findByText("Spend per day")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Could not load key details\./)).not.toBeInTheDocument();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("shows a loader instead of zero totals while the summary is in flight", async () => {
|
||||
const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local")], 1));
|
||||
const zeroSummary: ModelActivityData = { ...summary, total_spend: 0, total_requests: 0, total_tokens: 0 };
|
||||
const props = {
|
||||
fetchKeyPage,
|
||||
fetchKeyDetail: vi.fn(),
|
||||
searchKeys: vi.fn().mockResolvedValue({ api_keys: [] }),
|
||||
teams: [],
|
||||
};
|
||||
const { rerender } = render(<KeyActivityPanel summary={zeroSummary} summaryLoading {...props} />);
|
||||
|
||||
expect(await screen.findByRole("button", { name: /key-local/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading chart data...")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Overall Usage")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<KeyActivityPanel summary={summary} summaryLoading={false} {...props} />);
|
||||
|
||||
expect(screen.queryByText("Loading chart data...")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Overall Usage")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("$500.00").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("loads details for remote search results and merges local matches", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local-remote")], 2));
|
||||
const fetchKeyDetail = vi.fn().mockResolvedValue(detail("key-remote"));
|
||||
const searchKeys = vi.fn().mockResolvedValue({
|
||||
api_keys: [searchRow("key-remote", "remote server result")],
|
||||
});
|
||||
render(
|
||||
<KeyActivityPanel
|
||||
summary={summary}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={fetchKeyDetail}
|
||||
searchKeys={searchKeys}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
expect(searchKeys).toHaveBeenCalledWith("remote");
|
||||
expect(screen.getByText("2 matching keys")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /key-local-remote/ })).toBeInTheDocument();
|
||||
const remoteButton = screen.getByRole("button", { name: /remote server result/ });
|
||||
fireEvent.click(remoteButton);
|
||||
|
||||
expect(fetchKeyDetail).toHaveBeenCalledWith("key-remote");
|
||||
expect(await screen.findByText("Spend per day")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No data")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error with Retry when the first page fails and recovers on retry", async () => {
|
||||
let resolveRetry: (page: DailyActivityKeyPageResponse) => void = () => undefined;
|
||||
const fetchKeyPage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<DailyActivityKeyPageResponse>((resolve) => {
|
||||
resolveRetry = resolve;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<KeyActivityPanel
|
||||
summary={summary}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
|
||||
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Could not load keys for this range.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("0 keys")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(screen.getByText("Loading keys...")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Could not load keys for this range.")).not.toBeInTheDocument();
|
||||
expect(fetchKeyPage).toHaveBeenCalledTimes(2);
|
||||
expect(fetchKeyPage).toHaveBeenNthCalledWith(2, 0, 50);
|
||||
|
||||
await act(async () => {
|
||||
resolveRetry(pageResponse([pageRow("key-after-retry")], 1));
|
||||
});
|
||||
expect(await screen.findByRole("button", { name: /key-after-retry/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading keys...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stops auto-retrying after a failed next page until Retry is clicked", async () => {
|
||||
const firstPageRows = Array.from({ length: 50 }, (_, index) => pageRow(`key-${index}`));
|
||||
const fetchKeyPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(pageResponse(firstPageRows, 52))
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValue(pageResponse([pageRow("key-50"), pageRow("key-51")], 52, 50));
|
||||
render(
|
||||
<KeyActivityPanel
|
||||
summary={summary}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
|
||||
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("button", { name: /key-49/ })).toBeInTheDocument();
|
||||
await intersectSentinel();
|
||||
expect(await screen.findByText("Could not load more keys.")).toBeInTheDocument();
|
||||
expect(fetchKeyPage).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
triggerIntersection?.();
|
||||
});
|
||||
expect(fetchKeyPage).toHaveBeenCalledTimes(2);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByRole("button", { name: /key-51/ })).toBeInTheDocument();
|
||||
expect(fetchKeyPage).toHaveBeenCalledTimes(3);
|
||||
expect(fetchKeyPage).toHaveBeenNthCalledWith(3, 50, 50);
|
||||
});
|
||||
|
||||
it("hides the next-page Retry while searching and refetches the failed page once the search is cleared", async () => {
|
||||
const firstPageRows = Array.from({ length: 50 }, (_, index) => pageRow(`key-${index}`));
|
||||
const fetchKeyPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(pageResponse(firstPageRows, 52))
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValue(pageResponse([pageRow("key-50"), pageRow("key-51")], 52, 50));
|
||||
render(
|
||||
<KeyActivityPanel
|
||||
summary={summary}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
|
||||
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("button", { name: /key-49/ })).toBeInTheDocument();
|
||||
await intersectSentinel();
|
||||
expect(await screen.findByText("Could not load more keys.")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "key-1" } });
|
||||
expect(screen.queryByText("Could not load more keys.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear key search" }));
|
||||
expect(await screen.findByText("Could not load more keys.")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByRole("button", { name: /key-51/ })).toBeInTheDocument();
|
||||
expect(fetchKeyPage).toHaveBeenCalledTimes(3);
|
||||
expect(fetchKeyPage).toHaveBeenNthCalledWith(3, 50, 50);
|
||||
});
|
||||
|
||||
it("shows server search matches even when the first key page failed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchKeyPage = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const searchKeys = vi.fn().mockResolvedValue({
|
||||
api_keys: [searchRow("key-remote", "remote server result")],
|
||||
});
|
||||
render(
|
||||
<KeyActivityPanel
|
||||
summary={summary}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={vi.fn().mockResolvedValue(detail("key-remote"))}
|
||||
searchKeys={searchKeys}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(screen.getByText("Could not load keys for this range.")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(searchKeys).toHaveBeenCalledWith("remote");
|
||||
expect(screen.getByRole("button", { name: /remote server result/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("1 matching keys")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Could not load keys for this range.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the loader and the first page error visible for a short query instead of No keys match", async () => {
|
||||
let resolveFirstPage: (page: DailyActivityKeyPageResponse) => void = () => undefined;
|
||||
const fetchKeyPage = vi.fn().mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<DailyActivityKeyPageResponse>((resolve) => {
|
||||
resolveFirstPage = resolve;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<KeyActivityPanel
|
||||
summary={summary}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
|
||||
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "a" } });
|
||||
expect(screen.getByText("Loading keys...")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No keys match/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("0 matching keys")).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
resolveFirstPage(pageResponse([pageRow("key-alpha")], 1));
|
||||
});
|
||||
expect(await screen.findByRole("button", { name: /key-alpha/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("1 matching keys")).toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
const failingFetch = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
render(
|
||||
<KeyActivityPanel
|
||||
summary={summary}
|
||||
fetchKeyPage={failingFetch}
|
||||
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
|
||||
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("Could not load keys for this range.")).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "a" } });
|
||||
expect(screen.getByText("Could not load keys for this range.")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No keys match/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("0 matching keys")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not restart an in-flight search when the parent passes a new teams array", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local")], 1));
|
||||
let resolveSearch: (response: { api_keys: KeyActivityRow[] }) => void = () => undefined;
|
||||
const searchKeys = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise<{ api_keys: KeyActivityRow[] }>((resolve) => {
|
||||
resolveSearch = resolve;
|
||||
}),
|
||||
);
|
||||
const props = {
|
||||
summary,
|
||||
fetchKeyPage,
|
||||
fetchKeyDetail: vi.fn().mockResolvedValue(detail("key-remote")),
|
||||
searchKeys,
|
||||
};
|
||||
const { rerender } = render(<KeyActivityPanel {...props} teams={[]} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
expect(searchKeys).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<KeyActivityPanel {...props} teams={[]} />);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
expect(searchKeys).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveSearch({ api_keys: [searchRow("key-remote", "remote server result")] });
|
||||
});
|
||||
vi.useRealTimers();
|
||||
expect(await screen.findByRole("button", { name: /remote server result/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("1 matching keys")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps paging past a page of already loaded keys by server offset instead of loaded row count", async () => {
|
||||
const fetchKeyPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(pageResponse([pageRow("key-1"), pageRow("key-2")], 5))
|
||||
.mockResolvedValueOnce(pageResponse([pageRow("key-1"), pageRow("key-2")], 5, 2))
|
||||
.mockResolvedValueOnce(pageResponse([pageRow("key-5")], 5, 4));
|
||||
render(
|
||||
<KeyActivityPanel
|
||||
summary={summary}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
|
||||
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByRole("button", { name: /key-2/ })).toBeInTheDocument();
|
||||
|
||||
await intersectSentinel();
|
||||
expect(fetchKeyPage).toHaveBeenNthCalledWith(2, 2, 50);
|
||||
await intersectSentinel();
|
||||
expect(fetchKeyPage).toHaveBeenNthCalledWith(3, 4, 50);
|
||||
expect(await screen.findByRole("button", { name: /key-5/ })).toBeInTheDocument();
|
||||
expect(fetchKeyPage).toHaveBeenCalledTimes(3);
|
||||
expect(screen.getAllByRole("button", { name: /key-/ })).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("stops requesting more keys when a later page is empty even though the total says more exist", async () => {
|
||||
const fetchKeyPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(pageResponse([pageRow("key-1"), pageRow("key-2")], 3))
|
||||
.mockResolvedValue(pageResponse([], 3, 2));
|
||||
render(
|
||||
<KeyActivityPanel
|
||||
summary={summary}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
|
||||
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByRole("button", { name: /key-2/ })).toBeInTheDocument();
|
||||
|
||||
await intersectSentinel();
|
||||
await act(async () => {
|
||||
triggerIntersection?.();
|
||||
});
|
||||
expect(fetchKeyPage).toHaveBeenCalledTimes(2);
|
||||
expect(fetchKeyPage).toHaveBeenLastCalledWith(2, 50);
|
||||
expect(triggerIntersection).toBeUndefined();
|
||||
expect(screen.getByText("3 keys")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a search error with Retry search instead of No keys match when the search fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local")], 1));
|
||||
const searchKeys = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValue({ api_keys: [searchRow("key-remote", "remote server result")] });
|
||||
render(
|
||||
<KeyActivityPanel
|
||||
summary={summary}
|
||||
fetchKeyPage={fetchKeyPage}
|
||||
fetchKeyDetail={vi.fn().mockResolvedValue(detail("key-remote"))}
|
||||
searchKeys={searchKeys}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
expect(searchKeys).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText(/Could not search keys\./)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No keys match/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("0 matching keys")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Search failed")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry search" }));
|
||||
expect(screen.getByText("Searching...")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(searchKeys).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByRole("button", { name: /remote server result/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Could not search keys\./)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("discards stale pages and clears loaded keys when the scope changes", async () => {
|
||||
let resolveOldPage: (response: DailyActivityKeyPageResponse) => void = () => {};
|
||||
const oldPage = new Promise<DailyActivityKeyPageResponse>((resolve) => {
|
||||
resolveOldPage = resolve;
|
||||
});
|
||||
const firstScopeFetch = vi.fn().mockReturnValue(oldPage);
|
||||
const secondScopeFetch = vi.fn().mockResolvedValue(pageResponse([pageRow("new-scope-key")], 1));
|
||||
const props = {
|
||||
summary,
|
||||
fetchKeyDetail: vi.fn().mockResolvedValue(detail("new-scope-key")),
|
||||
searchKeys: vi.fn().mockResolvedValue({ api_keys: [] }),
|
||||
teams: [],
|
||||
};
|
||||
const { rerender } = render(<KeyActivityPanel {...props} fetchKeyPage={firstScopeFetch} />);
|
||||
rerender(<KeyActivityPanel {...props} fetchKeyPage={secondScopeFetch} />);
|
||||
|
||||
expect(await screen.findByRole("button", { name: /new-scope-key/ })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
resolveOldPage(pageResponse([pageRow("old-scope-key")], 1));
|
||||
await oldPage;
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: /old-scope-key/ })).not.toBeInTheDocument();
|
||||
expect(secondScopeFetch).toHaveBeenCalledWith(0, 50);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ModelActivityData } from "../types";
|
||||
import KeyActivityPanel from "./KeyActivityPanel";
|
||||
|
||||
vi.mock("@/components/activity_metrics", () => ({
|
||||
ActivityMetrics: ({ modelMetrics }: { modelMetrics: Record<string, ModelActivityData> }) => (
|
||||
<ul data-testid="rendered-keys">
|
||||
{Object.keys(modelMetrics).map((hash) => (
|
||||
<li key={hash}>{hash}</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
}));
|
||||
|
||||
function activity(label: string, user_email: string | null, user_id: string | null): ModelActivityData {
|
||||
return {
|
||||
label,
|
||||
key_metadata: { key_alias: label, team_id: "team-1", user_id, user_email },
|
||||
total_requests: 1,
|
||||
total_successful_requests: 1,
|
||||
total_failed_requests: 0,
|
||||
total_cache_read_input_tokens: 0,
|
||||
total_cache_creation_input_tokens: 0,
|
||||
total_tokens: 10,
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 5,
|
||||
total_spend: 0.01,
|
||||
top_api_keys: [],
|
||||
top_models: [],
|
||||
daily_data: [],
|
||||
};
|
||||
}
|
||||
|
||||
const keyMetrics: Record<string, ModelActivityData> = {
|
||||
"hash-alice": activity("alice-key", "alice@example.com", "user-alice"),
|
||||
"hash-bob": activity("bob-key", "bob@example.com", "user-bob"),
|
||||
};
|
||||
|
||||
describe("KeyActivityPanel", () => {
|
||||
it("renders every key and the full count before searching", () => {
|
||||
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob");
|
||||
expect(screen.getByText("Showing 2 of 2 keys")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("narrows the rendered keys to those matching the user email", () => {
|
||||
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "bob@example.com" } });
|
||||
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-bob");
|
||||
expect(screen.getByTestId("rendered-keys")).not.toHaveTextContent("hash-alice");
|
||||
expect(screen.getByText("Showing 1 of 2 keys")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state instead of zeroed metrics when nothing matches", () => {
|
||||
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "carol" } });
|
||||
expect(screen.queryByTestId("rendered-keys")).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No keys match "carol" in this date range')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the search and restores every key", () => {
|
||||
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "user-alice" } });
|
||||
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alice");
|
||||
fireEvent.click(screen.getByLabelText("Clear key search"));
|
||||
expect(screen.getByLabelText("Search keys")).toHaveValue("");
|
||||
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,27 +1,397 @@
|
|||
import { Search, X } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ActivityMetrics } from "@/components/activity_metrics";
|
||||
import { ActivityMetrics, ModelCollapsible, ModelSection } from "@/components/activity_metrics";
|
||||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
import { ChartLoader } from "@/components/shared/chart_loader";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
|
||||
import type {
|
||||
DailyActivityKeyPageResponse,
|
||||
DailyActivityKeySearchResponse,
|
||||
KeyActivityRow,
|
||||
KeySpendActivityRow,
|
||||
} from "../dailyActivityApi";
|
||||
import { filterKeyActivity } from "../keyActivityFilter";
|
||||
import { keyActivityRowsToMetrics } from "./keySearch";
|
||||
import { mergeKeyActivityPages } from "../keyActivityData";
|
||||
import type { ModelActivityData } from "../types";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
const MIN_SEARCH_LENGTH = 2;
|
||||
|
||||
type FetchKeyPage = (offset: number, limit: number) => Promise<DailyActivityKeyPageResponse>;
|
||||
type FetchKeyDetail = (apiKey: string) => Promise<ModelActivityData | undefined>;
|
||||
type SearchKeys = (search: string) => Promise<DailyActivityKeySearchResponse>;
|
||||
|
||||
interface KeyActivityPanelProps {
|
||||
keyMetrics: Record<string, ModelActivityData>;
|
||||
summary: ModelActivityData;
|
||||
summaryLoading?: boolean;
|
||||
fetchKeyPage: FetchKeyPage;
|
||||
fetchKeyDetail: FetchKeyDetail;
|
||||
searchKeys: SearchKeys;
|
||||
teams: Team[];
|
||||
hidePromptCachingMetrics?: boolean;
|
||||
}
|
||||
|
||||
const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({ keyMetrics, hidePromptCachingMetrics = false }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = useMemo(() => filterKeyActivity(keyMetrics, query), [keyMetrics, query]);
|
||||
const totalKeys = Object.keys(keyMetrics).length;
|
||||
const shownKeys = Object.keys(filtered).length;
|
||||
const isFiltering = query.trim() !== "";
|
||||
interface KeyPageState {
|
||||
scope: FetchKeyPage;
|
||||
rows: KeySpendActivityRow[];
|
||||
total: number;
|
||||
nextOffset: number;
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
type KeyDetailState =
|
||||
| { status: "loading" }
|
||||
| { status: "failed" }
|
||||
| { status: "loaded"; metrics: ModelActivityData | undefined };
|
||||
|
||||
const keyCountText = (searching: boolean, isFiltering: boolean, shownKeys: number, total: number): string => {
|
||||
if (searching) return "Searching...";
|
||||
if (isFiltering) return `${shownKeys.toLocaleString()} matching keys`;
|
||||
return `${total.toLocaleString()} keys`;
|
||||
};
|
||||
|
||||
interface KeyDetailContentProps {
|
||||
apiKey: string;
|
||||
detail: KeyDetailState | undefined;
|
||||
hidePromptCachingMetrics: boolean;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
const KeyDetailContent: React.FC<KeyDetailContentProps> = ({ apiKey, detail, hidePromptCachingMetrics, onRetry }) => {
|
||||
if (detail?.status === "loading") {
|
||||
return <p className="py-4 text-sm text-muted-foreground">Loading key details...</p>;
|
||||
}
|
||||
if (detail?.status === "failed") {
|
||||
return (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
Could not load key details.{" "}
|
||||
<button type="button" className="font-medium text-foreground underline" onClick={onRetry}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (detail?.status !== "loaded" || detail.metrics === undefined) {
|
||||
return <p className="py-4 text-sm text-muted-foreground">No daily details available</p>;
|
||||
}
|
||||
return (
|
||||
<ModelSection modelName={apiKey} metrics={detail.metrics} hidePromptCachingMetrics={hidePromptCachingMetrics} />
|
||||
);
|
||||
};
|
||||
|
||||
const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
|
||||
summary,
|
||||
summaryLoading = false,
|
||||
fetchKeyPage,
|
||||
fetchKeyDetail,
|
||||
searchKeys,
|
||||
teams,
|
||||
hidePromptCachingMetrics = false,
|
||||
}) => {
|
||||
const [queryState, setQueryState] = useState<{ scope: FetchKeyPage; value: string } | null>(null);
|
||||
const query = queryState?.scope === fetchKeyPage ? queryState.value : "";
|
||||
const updateQuery = useCallback((value: string) => setQueryState({ scope: fetchKeyPage, value }), [fetchKeyPage]);
|
||||
const [pageState, setPageState] = useState<KeyPageState | null>(null);
|
||||
const [detailState, setDetailState] = useState<{
|
||||
scope: FetchKeyPage;
|
||||
details: Record<string, KeyDetailState>;
|
||||
} | null>(null);
|
||||
const [searchResult, setSearchResult] = useState<{
|
||||
scope: FetchKeyPage;
|
||||
term: string;
|
||||
searchKeys: SearchKeys;
|
||||
rows: KeyActivityRow[];
|
||||
failed: boolean;
|
||||
} | null>(null);
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
const [searchRetryToken, setSearchRetryToken] = useState(0);
|
||||
const searchIdRef = useRef(0);
|
||||
const pageRequestIdRef = useRef(0);
|
||||
const loadingMoreRef = useRef(false);
|
||||
const activeFetcherRef = useRef(fetchKeyPage);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
activeFetcherRef.current = fetchKeyPage;
|
||||
}, [fetchKeyPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = ++pageRequestIdRef.current;
|
||||
loadingMoreRef.current = false;
|
||||
void fetchKeyPage(0, PAGE_SIZE)
|
||||
.then((page) => {
|
||||
if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return;
|
||||
const loadedPageState = {
|
||||
scope: fetchKeyPage,
|
||||
rows: page.api_keys,
|
||||
total: page.total_api_keys,
|
||||
nextOffset: page.api_keys.length,
|
||||
hasMore: page.api_keys.length > 0 && page.api_keys.length < page.total_api_keys,
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
failed: false,
|
||||
};
|
||||
setPageState(loadedPageState);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return;
|
||||
console.error("Key activity page request failed:", error);
|
||||
const failedPageState = {
|
||||
scope: fetchKeyPage,
|
||||
rows: [],
|
||||
total: 0,
|
||||
nextOffset: 0,
|
||||
hasMore: false,
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
failed: true,
|
||||
};
|
||||
setPageState(failedPageState);
|
||||
});
|
||||
return () => {
|
||||
if (pageRequestIdRef.current === requestId) pageRequestIdRef.current += 1;
|
||||
};
|
||||
}, [fetchKeyPage, retryToken]);
|
||||
|
||||
const currentPageState = pageState?.scope === fetchKeyPage ? pageState : null;
|
||||
const pageRows = useMemo(() => currentPageState?.rows ?? [], [currentPageState]);
|
||||
const loading = currentPageState?.loading ?? true;
|
||||
const loadingMore = currentPageState?.loadingMore ?? false;
|
||||
const hasMore = currentPageState?.hasMore ?? false;
|
||||
const total = currentPageState?.total ?? 0;
|
||||
const failed = currentPageState?.failed ?? false;
|
||||
const pageMetrics = useMemo(() => keyActivityRowsToMetrics(pageRows, teams), [pageRows, teams]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (currentPageState === null) return;
|
||||
if (currentPageState.loading || currentPageState.loadingMore || !currentPageState.hasMore) return;
|
||||
if (query.trim() !== "" || loadingMoreRef.current) return;
|
||||
const requestId = pageRequestIdRef.current;
|
||||
const offset = currentPageState.nextOffset;
|
||||
loadingMoreRef.current = true;
|
||||
setPageState((current) => (current?.scope === fetchKeyPage ? { ...current, loadingMore: true } : current));
|
||||
void fetchKeyPage(offset, PAGE_SIZE)
|
||||
.then((page) => {
|
||||
if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return;
|
||||
setPageState((current) => {
|
||||
if (current?.scope !== fetchKeyPage) return current;
|
||||
const merged = mergeKeyActivityPages(current.rows, page.api_keys, page.total_api_keys, offset);
|
||||
return {
|
||||
...current,
|
||||
rows: merged.rows,
|
||||
total: page.total_api_keys,
|
||||
nextOffset: merged.nextOffset,
|
||||
hasMore: merged.hasMore,
|
||||
loadingMore: false,
|
||||
failed: false,
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return;
|
||||
console.error("Key activity page request failed:", error);
|
||||
setPageState((current) =>
|
||||
current?.scope === fetchKeyPage ? { ...current, loadingMore: false, failed: true } : current,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (pageRequestIdRef.current === requestId) loadingMoreRef.current = false;
|
||||
});
|
||||
}, [currentPageState, fetchKeyPage, query]);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (sentinel === null || currentPageState === null) return;
|
||||
if (currentPageState.failed) return;
|
||||
if (loading || loadingMore || !hasMore) return;
|
||||
if (query.trim() !== "") return;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) loadMore();
|
||||
});
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [currentPageState, hasMore, loadMore, loading, loadingMore, query]);
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
const searchTerm = trimmedQuery.length >= MIN_SEARCH_LENGTH ? trimmedQuery : null;
|
||||
useEffect(() => {
|
||||
if (searchTerm === null) return;
|
||||
const searchId = ++searchIdRef.current;
|
||||
const timer = setTimeout(() => {
|
||||
void searchKeys(searchTerm)
|
||||
.then((response) => {
|
||||
if (searchIdRef.current !== searchId || activeFetcherRef.current !== fetchKeyPage) return;
|
||||
const result = { scope: fetchKeyPage, term: searchTerm, searchKeys, rows: response.api_keys, failed: false };
|
||||
setSearchResult(result);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (searchIdRef.current !== searchId || activeFetcherRef.current !== fetchKeyPage) return;
|
||||
console.error("Key activity search failed:", error);
|
||||
const failedResult = { scope: fetchKeyPage, term: searchTerm, searchKeys, rows: [], failed: true };
|
||||
setSearchResult(failedResult);
|
||||
});
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (searchIdRef.current === searchId) searchIdRef.current += 1;
|
||||
};
|
||||
}, [fetchKeyPage, searchKeys, searchRetryToken, searchTerm]);
|
||||
|
||||
const currentSearch =
|
||||
searchResult?.scope === fetchKeyPage && searchResult.term === searchTerm && searchResult.searchKeys === searchKeys
|
||||
? searchResult
|
||||
: null;
|
||||
const searching = searchTerm !== null && currentSearch === null;
|
||||
const searchFailed = currentSearch?.failed ?? false;
|
||||
const searchRows = currentSearch?.rows;
|
||||
const searchMetrics = useMemo(() => keyActivityRowsToMetrics(searchRows ?? [], teams), [searchRows, teams]);
|
||||
const localFiltered = useMemo(() => filterKeyActivity(pageMetrics, query), [pageMetrics, query]);
|
||||
const filteredMetrics = useMemo(() => ({ ...searchMetrics, ...localFiltered }), [searchMetrics, localFiltered]);
|
||||
const shownKeys = Object.keys(filteredMetrics).length;
|
||||
const isFiltering = trimmedQuery.length > 0;
|
||||
const visibleDetails = detailState?.scope === fetchKeyPage ? detailState.details : {};
|
||||
const keyCountLabel =
|
||||
isFiltering && searchFailed && !searching && shownKeys === 0
|
||||
? "Search failed"
|
||||
: keyCountText(searching, isFiltering, shownKeys, total);
|
||||
|
||||
const retryFirstPage = () => {
|
||||
setPageState({
|
||||
scope: fetchKeyPage,
|
||||
rows: [],
|
||||
total: 0,
|
||||
nextOffset: 0,
|
||||
hasMore: false,
|
||||
loading: true,
|
||||
loadingMore: false,
|
||||
failed: false,
|
||||
});
|
||||
setRetryToken((token) => token + 1);
|
||||
};
|
||||
const retrySearch = () => {
|
||||
setSearchResult(null);
|
||||
setSearchRetryToken((token) => token + 1);
|
||||
};
|
||||
const retryNextPage = () => {
|
||||
setPageState((current) => (current?.scope === fetchKeyPage ? { ...current, failed: false } : current));
|
||||
loadMore();
|
||||
};
|
||||
const firstPageError = (
|
||||
<p className="p-4 text-sm text-muted-foreground">
|
||||
Could not load keys for this range.{" "}
|
||||
<button type="button" className="font-medium text-foreground underline" onClick={retryFirstPage}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
const emptyListBody = (() => {
|
||||
if (failed) return firstPageError;
|
||||
if (!loading) return null;
|
||||
return <p className="p-4 text-sm text-muted-foreground">Loading keys...</p>;
|
||||
})();
|
||||
const keyList = Object.entries(filteredMetrics).map(([apiKey, metrics]) => {
|
||||
const detail = visibleDetails[apiKey];
|
||||
return (
|
||||
<ModelCollapsible
|
||||
key={apiKey}
|
||||
defaultOpen={false}
|
||||
onFirstOpen={() => loadKeyDetail(apiKey)}
|
||||
header={
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<span className="truncate font-medium text-foreground">{metrics.label}</span>
|
||||
<span className="shrink-0 space-x-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
$
|
||||
{metrics.total_spend.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
<span>{metrics.total_requests.toLocaleString()} requests</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<KeyDetailContent
|
||||
apiKey={apiKey}
|
||||
detail={detail}
|
||||
hidePromptCachingMetrics={hidePromptCachingMetrics}
|
||||
onRetry={() => loadKeyDetail(apiKey)}
|
||||
/>
|
||||
</ModelCollapsible>
|
||||
);
|
||||
});
|
||||
const firstPageFailed = failed && pageRows.length === 0;
|
||||
const showNextPageRetry = failed && !firstPageFailed && !isFiltering;
|
||||
const localOnly = searchTerm === null;
|
||||
const pageUnavailable = localOnly && pageRows.length === 0 && (loading || firstPageFailed);
|
||||
const listBody = pageUnavailable || (!isFiltering && pageRows.length === 0) ? emptyListBody : keyList;
|
||||
const noMatches = isFiltering && !searching && !pageUnavailable && shownKeys === 0;
|
||||
const showSearchErrorNote = !noMatches && searchFailed && !searching;
|
||||
const searchRetryButton = (
|
||||
<button type="button" className="font-medium text-foreground underline" onClick={retrySearch}>
|
||||
Retry search
|
||||
</button>
|
||||
);
|
||||
const emptyFilterBody = searchFailed ? (
|
||||
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
|
||||
Could not search keys. {searchRetryButton}
|
||||
</p>
|
||||
) : (
|
||||
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
|
||||
No keys match "{trimmedQuery}" in this date range
|
||||
</p>
|
||||
);
|
||||
|
||||
const loadKeyDetail = useCallback(
|
||||
(apiKey: string) => {
|
||||
const existingDetails = detailState?.scope === fetchKeyPage ? detailState.details : {};
|
||||
const existing = existingDetails[apiKey];
|
||||
if (existing !== undefined && existing.status !== "failed") return;
|
||||
setDetailState((current) => {
|
||||
const currentDetails = current?.scope === fetchKeyPage ? current.details : {};
|
||||
return { scope: fetchKeyPage, details: { ...currentDetails, [apiKey]: { status: "loading" } } };
|
||||
});
|
||||
void fetchKeyDetail(apiKey)
|
||||
.then((metrics) => {
|
||||
setDetailState((current) => {
|
||||
if (current?.scope !== fetchKeyPage) return current;
|
||||
return { scope: fetchKeyPage, details: { ...current.details, [apiKey]: { status: "loaded", metrics } } };
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("Key activity detail request failed:", error);
|
||||
setDetailState((current) => {
|
||||
if (current?.scope !== fetchKeyPage) return current;
|
||||
return {
|
||||
scope: fetchKeyPage,
|
||||
details: { ...current.details, [apiKey]: { status: "failed" } },
|
||||
};
|
||||
});
|
||||
});
|
||||
},
|
||||
[detailState, fetchKeyDetail, fetchKeyPage],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
{summaryLoading ? (
|
||||
<ChartLoader />
|
||||
) : (
|
||||
<ActivityMetrics
|
||||
modelMetrics={{}}
|
||||
summaryMetrics={summary}
|
||||
hidePromptCachingMetrics={hidePromptCachingMetrics}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<InputGroup className="max-w-md">
|
||||
<InputGroupAddon>
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
|
|
@ -30,27 +400,36 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({ keyMetrics, hidePro
|
|||
aria-label="Search keys"
|
||||
placeholder="Search by key alias, key hash, user ID, or email"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onChange={(event) => updateQuery(event.target.value)}
|
||||
/>
|
||||
{isFiltering && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-xs" aria-label="Clear key search" onClick={() => setQuery("")}>
|
||||
<InputGroupButton size="icon-xs" aria-label="Clear key search" onClick={() => updateQuery("")}>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys
|
||||
</span>
|
||||
{!pageUnavailable && (
|
||||
<span className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{keyCountLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isFiltering && totalKeys > 0 && shownKeys === 0 ? (
|
||||
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
|
||||
No keys match "{query.trim()}" in this date range
|
||||
</p>
|
||||
) : (
|
||||
<ActivityMetrics modelMetrics={filtered} hidePromptCachingMetrics={hidePromptCachingMetrics} />
|
||||
{noMatches ? emptyFilterBody : <div className="rounded-lg border">{listBody}</div>}
|
||||
{showSearchErrorNote && (
|
||||
<p className="text-sm text-muted-foreground">Could not search all keys. {searchRetryButton}</p>
|
||||
)}
|
||||
{loadingMore && <p className="text-sm text-muted-foreground">Loading more keys...</p>}
|
||||
{showNextPageRetry && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Could not load more keys.{" "}
|
||||
<button type="button" className="font-medium text-foreground underline" onClick={retryNextPage}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{!isFiltering && <div ref={sentinelRef} aria-hidden="true" className="h-1" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
import { toKeyMetadata, type KeyActivityRow, type KeySpendActivityRow } from "../dailyActivityApi";
|
||||
import { formatKeyLabel } from "@/components/activity_metrics";
|
||||
import type { ModelActivityData } from "../types";
|
||||
|
||||
export const keyActivityRowsToMetrics = (
|
||||
rows: readonly (KeyActivityRow | KeySpendActivityRow)[],
|
||||
teams: Team[],
|
||||
): Record<string, ModelActivityData> =>
|
||||
Object.fromEntries(
|
||||
rows.map((row) => {
|
||||
const metadata = toKeyMetadata(row.metadata);
|
||||
const metrics: ModelActivityData = {
|
||||
label: formatKeyLabel({ metadata }, row.api_key, teams),
|
||||
key_metadata: metadata,
|
||||
total_requests: row.metrics.api_requests,
|
||||
total_successful_requests: row.metrics.successful_requests,
|
||||
total_failed_requests: row.metrics.failed_requests,
|
||||
total_cache_read_input_tokens: row.metrics.cache_read_input_tokens,
|
||||
total_cache_creation_input_tokens: row.metrics.cache_creation_input_tokens,
|
||||
total_tokens: row.metrics.total_tokens,
|
||||
prompt_tokens: row.metrics.prompt_tokens,
|
||||
completion_tokens: row.metrics.completion_tokens,
|
||||
total_spend: row.metrics.spend,
|
||||
total_response_time_ms:
|
||||
"total_response_time_ms" in row.metrics ? row.metrics.total_response_time_ms : undefined,
|
||||
total_timed_requests: "timed_requests" in row.metrics ? row.metrics.timed_requests : undefined,
|
||||
top_models: [],
|
||||
daily_data: [],
|
||||
};
|
||||
return [row.api_key, metrics];
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import type { components } from "@/lib/http/schema";
|
||||
import type { BreakdownMetrics, DailyData, KeyMetadata, KeyMetricWithMetadata, MetricWithMetadata } from "./types";
|
||||
|
||||
export type DailyActivityEntity = "user" | "team" | "tag" | "organization" | "customer" | "agent";
|
||||
export type DailyActivityAggregatedResponse = components["schemas"]["SpendAnalyticsPaginatedResponse"];
|
||||
export type DailyActivityMetadata = components["schemas"]["DailySpendMetadata"];
|
||||
export type ExportType = components["schemas"]["ExportType"];
|
||||
export type ExportFormat = "csv" | "json";
|
||||
|
||||
export type KeyActivityRow = components["schemas"]["KeyActivityRow"];
|
||||
export type KeySpendActivityRow = components["schemas"]["KeySpendActivityRow"];
|
||||
export type DailyActivityKeySearchResponse = components["schemas"]["DailyActivityKeySearchResponse"];
|
||||
export type DailyActivityKeyPageResponse = components["schemas"]["DailyActivityKeyPageResponse"];
|
||||
export type ModelTopKeysResponse = components["schemas"]["ModelTopKeysResponse"];
|
||||
export type CacheLeakageKeysResponse = components["schemas"]["CacheLeakageKeysResponse"];
|
||||
|
||||
export interface DailyActivityRequest {
|
||||
accessToken: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
entityIds?: readonly string[] | null;
|
||||
excludeEntityIds?: readonly string[];
|
||||
apiKey?: string | null;
|
||||
model?: string | null;
|
||||
includeCurrentUtcDay?: boolean;
|
||||
apiKeyLimit?: number;
|
||||
}
|
||||
|
||||
export const EMPTY_DAILY_ACTIVITY_METADATA: DailyActivityMetadata = {
|
||||
has_more: false,
|
||||
page: 1,
|
||||
total_pages: 1,
|
||||
total_spend: 0,
|
||||
total_flat_cost: 0,
|
||||
total_api_requests: 0,
|
||||
total_successful_requests: 0,
|
||||
total_failed_requests: 0,
|
||||
total_tokens: 0,
|
||||
total_prompt_tokens: 0,
|
||||
total_completion_tokens: 0,
|
||||
total_cache_read_input_tokens: 0,
|
||||
total_cache_creation_input_tokens: 0,
|
||||
total_compression_saved_tokens: 0,
|
||||
total_compression_savings_spend: 0,
|
||||
total_prompt_caching_savings_spend: 0,
|
||||
total_gateway_injected_caching_savings_spend: 0,
|
||||
total_autorouter_savings_spend: 0,
|
||||
total_response_time_ms: 0,
|
||||
total_timed_requests: 0,
|
||||
};
|
||||
|
||||
export const EMPTY_DAILY_ACTIVITY_RESPONSE: DailyActivityAggregatedResponse = {
|
||||
results: [],
|
||||
metadata: EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
};
|
||||
|
||||
type SchemaMetricWithMetadata = components["schemas"]["MetricWithMetadata"];
|
||||
type SchemaKeyMetricWithMetadata = components["schemas"]["KeyMetricWithMetadata"];
|
||||
|
||||
const toKeyMetric = (entry: SchemaKeyMetricWithMetadata): KeyMetricWithMetadata => ({
|
||||
metrics: entry.metrics,
|
||||
metadata: toKeyMetadata(entry.metadata),
|
||||
});
|
||||
|
||||
export const toKeyMetadata = (metadata: components["schemas"]["KeyMetadata"] | undefined): KeyMetadata => ({
|
||||
key_alias: metadata?.key_alias ?? null,
|
||||
team_id: metadata?.team_id ?? null,
|
||||
user_id: metadata?.user_id,
|
||||
user_email: metadata?.user_email,
|
||||
key_exists: metadata?.key_exists,
|
||||
});
|
||||
|
||||
const toMetric = (entry: SchemaMetricWithMetadata): MetricWithMetadata => ({
|
||||
metrics: entry.metrics,
|
||||
metadata: entry.metadata ?? {},
|
||||
api_key_breakdown: Object.fromEntries(
|
||||
Object.entries(entry.api_key_breakdown ?? {}).map(([key, value]) => [key, toKeyMetric(value)]),
|
||||
),
|
||||
});
|
||||
|
||||
const toMetricMap = (
|
||||
map: { [key: string]: SchemaMetricWithMetadata } | undefined,
|
||||
): { [key: string]: MetricWithMetadata } =>
|
||||
Object.fromEntries(Object.entries(map ?? {}).map(([key, value]) => [key, toMetric(value)]));
|
||||
|
||||
export const toDailyData = (response: DailyActivityAggregatedResponse): DailyData[] =>
|
||||
(response.results ?? []).map((day) => {
|
||||
const breakdown = day.breakdown;
|
||||
const normalized: BreakdownMetrics = {
|
||||
models: toMetricMap(breakdown?.models),
|
||||
model_groups: toMetricMap(breakdown?.model_groups),
|
||||
mcp_servers: toMetricMap(breakdown?.mcp_servers),
|
||||
providers: toMetricMap(breakdown?.providers),
|
||||
api_keys: Object.fromEntries(
|
||||
Object.entries(breakdown?.api_keys ?? {}).map(([key, value]) => [key, toKeyMetric(value)]),
|
||||
),
|
||||
entities: toMetricMap(breakdown?.entities),
|
||||
endpoints: toMetricMap(breakdown?.endpoints),
|
||||
};
|
||||
return { date: day.date, metrics: day.metrics, breakdown: normalized };
|
||||
});
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import {
|
||||
EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
type DailyActivityAggregatedResponse,
|
||||
type KeySpendActivityRow,
|
||||
toDailyData,
|
||||
} from "./dailyActivityApi";
|
||||
import { keyDetailFromResponse, mergeKeyActivityPages, overallUsageMetrics } from "./keyActivityData";
|
||||
|
||||
const completeMetrics: components["schemas"]["SpendMetrics"] = {
|
||||
api_requests: 2,
|
||||
autorouter_savings_spend: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
completion_tokens: 3,
|
||||
compression_saved_tokens: 0,
|
||||
compression_savings_spend: 0,
|
||||
failed_requests: 0,
|
||||
flat_cost: 0,
|
||||
gateway_injected_caching_savings_spend: 0,
|
||||
prompt_caching_savings_spend: 0,
|
||||
prompt_tokens: 4,
|
||||
spend: 1.25,
|
||||
successful_requests: 2,
|
||||
timed_requests: 0,
|
||||
total_response_time_ms: 0,
|
||||
total_tokens: 7,
|
||||
};
|
||||
|
||||
const apiKeyActivity = {
|
||||
metrics: completeMetrics,
|
||||
metadata: { key_alias: "visible-key", team_id: null },
|
||||
};
|
||||
|
||||
const aggregatedResponse: DailyActivityAggregatedResponse = {
|
||||
metadata: {
|
||||
...EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
total_api_requests: 200,
|
||||
total_successful_requests: 198,
|
||||
total_failed_requests: 2,
|
||||
total_tokens: 700,
|
||||
total_prompt_tokens: 400,
|
||||
total_completion_tokens: 300,
|
||||
total_spend: 500,
|
||||
},
|
||||
results: [
|
||||
{
|
||||
date: "2026-09-27",
|
||||
metrics: completeMetrics,
|
||||
breakdown: {
|
||||
api_keys: { "key-hash": apiKeyActivity },
|
||||
models: {
|
||||
"gpt-4o-mini": {
|
||||
metrics: completeMetrics,
|
||||
metadata: {},
|
||||
api_key_breakdown: { "key-hash": apiKeyActivity },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const pageRow = (api_key: string): KeySpendActivityRow => ({
|
||||
api_key,
|
||||
metrics: {
|
||||
api_requests: 1,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
completion_tokens: 2,
|
||||
failed_requests: 0,
|
||||
prompt_tokens: 3,
|
||||
spend: 1,
|
||||
successful_requests: 1,
|
||||
total_tokens: 5,
|
||||
},
|
||||
metadata: { key_alias: api_key, team_id: null },
|
||||
});
|
||||
|
||||
describe("key activity data", () => {
|
||||
it("builds overall totals from metadata and daily data from complete daily metrics", () => {
|
||||
const summary = overallUsageMetrics(
|
||||
toDailyData(aggregatedResponse),
|
||||
aggregatedResponse.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
);
|
||||
|
||||
expect(summary.total_requests).toBe(200);
|
||||
expect(summary.total_spend).toBe(500);
|
||||
expect(summary.daily_data).toStrictEqual([
|
||||
{
|
||||
date: "2026-09-27",
|
||||
metrics: {
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 3,
|
||||
total_tokens: 7,
|
||||
api_requests: 2,
|
||||
spend: 1.25,
|
||||
successful_requests: 2,
|
||||
failed_requests: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
avg_response_time_ms: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends pages without duplicate keys and compares the server offset to the total", () => {
|
||||
const merged = mergeKeyActivityPages(
|
||||
[pageRow("key-a"), pageRow("key-b")],
|
||||
[pageRow("key-b"), pageRow("key-c")],
|
||||
5,
|
||||
2,
|
||||
);
|
||||
|
||||
expect(merged.rows.map((row) => row.api_key)).toStrictEqual(["key-a", "key-b", "key-c"]);
|
||||
expect(merged.nextOffset).toBe(4);
|
||||
expect(merged.hasMore).toBe(true);
|
||||
expect(mergeKeyActivityPages(merged.rows, [pageRow("key-d")], 5, 4).hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("advances past a page of already loaded keys and stops only on an empty page", () => {
|
||||
const current = [pageRow("key-a"), pageRow("key-b")];
|
||||
const duplicates = mergeKeyActivityPages(current, [pageRow("key-a"), pageRow("key-b")], 5, 2);
|
||||
expect(duplicates).toEqual({ rows: current, nextOffset: 4, hasMore: true });
|
||||
expect(mergeKeyActivityPages(current, [], 5, 2)).toEqual({ rows: current, nextOffset: 2, hasMore: false });
|
||||
});
|
||||
|
||||
it("builds full daily details and top models for the requested key", () => {
|
||||
const detail = keyDetailFromResponse(aggregatedResponse, "key-hash", []);
|
||||
|
||||
expect(detail?.total_requests).toBe(2);
|
||||
expect(detail?.daily_data).toStrictEqual([
|
||||
{
|
||||
date: "2026-09-27",
|
||||
metrics: {
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 3,
|
||||
total_tokens: 7,
|
||||
api_requests: 2,
|
||||
spend: 1.25,
|
||||
successful_requests: 2,
|
||||
failed_requests: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
avg_response_time_ms: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(detail?.top_models.map((model) => model.model)).toStrictEqual(["gpt-4o-mini"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
import { processActivityData } from "@/components/activity_metrics";
|
||||
import type { DailyActivityAggregatedResponse, DailyActivityMetadata, KeySpendActivityRow } from "./dailyActivityApi";
|
||||
import { toDailyData } from "./dailyActivityApi";
|
||||
import type { ModelActivityData } from "./types";
|
||||
|
||||
export const overallUsageMetrics = (
|
||||
results: ReturnType<typeof toDailyData>,
|
||||
metadata: DailyActivityMetadata,
|
||||
): ModelActivityData => ({
|
||||
label: "Overall Usage",
|
||||
total_requests: metadata.total_api_requests,
|
||||
total_successful_requests: metadata.total_successful_requests,
|
||||
total_failed_requests: metadata.total_failed_requests,
|
||||
total_cache_read_input_tokens: metadata.total_cache_read_input_tokens,
|
||||
total_cache_creation_input_tokens: metadata.total_cache_creation_input_tokens,
|
||||
total_tokens: metadata.total_tokens,
|
||||
prompt_tokens: metadata.total_prompt_tokens,
|
||||
completion_tokens: metadata.total_completion_tokens,
|
||||
total_spend: metadata.total_spend,
|
||||
total_response_time_ms: metadata.total_response_time_ms,
|
||||
total_timed_requests: metadata.total_timed_requests,
|
||||
top_models: [],
|
||||
daily_data: results.map((day) => ({
|
||||
date: day.date,
|
||||
metrics: {
|
||||
prompt_tokens: day.metrics.prompt_tokens,
|
||||
completion_tokens: day.metrics.completion_tokens,
|
||||
total_tokens: day.metrics.total_tokens,
|
||||
api_requests: day.metrics.api_requests,
|
||||
spend: day.metrics.spend,
|
||||
successful_requests: day.metrics.successful_requests,
|
||||
failed_requests: day.metrics.failed_requests,
|
||||
cache_read_input_tokens: day.metrics.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: day.metrics.cache_creation_input_tokens,
|
||||
avg_response_time_ms:
|
||||
day.metrics.timed_requests && day.metrics.timed_requests > 0
|
||||
? (day.metrics.total_response_time_ms ?? 0) / day.metrics.timed_requests
|
||||
: null,
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
export const mergeKeyActivityPages = (
|
||||
current: readonly KeySpendActivityRow[],
|
||||
next: readonly KeySpendActivityRow[],
|
||||
total: number,
|
||||
offset: number,
|
||||
): { rows: KeySpendActivityRow[]; nextOffset: number; hasMore: boolean } => {
|
||||
const rows: KeySpendActivityRow[] = Array.from(
|
||||
new Map([...current, ...next].map((row) => [row.api_key, row])).values(),
|
||||
);
|
||||
const nextOffset = offset + next.length;
|
||||
return { rows, nextOffset, hasMore: next.length > 0 && nextOffset < total };
|
||||
};
|
||||
|
||||
export const keyDetailFromResponse = (
|
||||
response: DailyActivityAggregatedResponse,
|
||||
apiKey: string,
|
||||
teams: Team[],
|
||||
): ModelActivityData | undefined => processActivityData({ results: toDailyData(response) }, "api_keys", teams)[apiKey];
|
||||
|
|
@ -16,7 +16,6 @@ function activity(label: string, key_metadata?: KeyMetadata): ModelActivityData
|
|||
prompt_tokens: 5,
|
||||
completion_tokens: 5,
|
||||
total_spend: 0.01,
|
||||
top_api_keys: [],
|
||||
top_models: [],
|
||||
daily_data: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -54,16 +54,6 @@ export interface KeyMetadata {
|
|||
tags?: { tag: string; usage: number }[];
|
||||
}
|
||||
|
||||
export interface TopApiKeyData {
|
||||
api_key: string;
|
||||
key_alias: string | null;
|
||||
team_id: string | null;
|
||||
user: string | null;
|
||||
spend: number;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
export interface TopModelData {
|
||||
model: string;
|
||||
spend: number;
|
||||
|
|
@ -87,7 +77,6 @@ export interface ModelActivityData {
|
|||
total_spend: number;
|
||||
total_response_time_ms?: number;
|
||||
total_timed_requests?: number;
|
||||
top_api_keys: TopApiKeyData[];
|
||||
top_models: TopModelData[];
|
||||
daily_data: {
|
||||
date: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { ActivityMetrics, formatKeyLabel, processActivityData, ResponseTimeTooltip } from "./activity_metrics";
|
||||
|
|
@ -120,7 +120,6 @@ const createMockModelActivityData = (label: string, overrides: Partial<ModelActi
|
|||
total_spend: 100.5,
|
||||
total_cache_read_input_tokens: 1000,
|
||||
total_cache_creation_input_tokens: 500,
|
||||
top_api_keys: [],
|
||||
top_models: [],
|
||||
daily_data: [
|
||||
{
|
||||
|
|
@ -152,7 +151,6 @@ const GPT_35_MODEL_DATA: ModelActivityData = {
|
|||
total_spend: 25.25,
|
||||
total_cache_read_input_tokens: 500,
|
||||
total_cache_creation_input_tokens: 250,
|
||||
top_api_keys: [],
|
||||
top_models: [],
|
||||
daily_data: [
|
||||
{
|
||||
|
|
@ -244,88 +242,135 @@ describe("ActivityMetrics", () => {
|
|||
expect(tokenElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should not display Top Virtual Keys section when model has no top_api_keys", () => {
|
||||
render(<ActivityMetrics modelMetrics={mockModelMetrics} />);
|
||||
expect(screen.queryByText("Top Virtual Keys by Spend")).not.toBeInTheDocument();
|
||||
it("only fetches top keys for sections that have been expanded", async () => {
|
||||
const fetchTopApiKeys = vi.fn().mockResolvedValue({
|
||||
api_keys: [
|
||||
{
|
||||
api_key: "key-123",
|
||||
metrics: { ...EMPTY_SPEND_METRICS, spend: 50.25, api_requests: 25, total_tokens: 12500 },
|
||||
metadata: { key_alias: "Test Key", team_id: "team1" },
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<ActivityMetrics
|
||||
modelMetrics={{
|
||||
"gpt-4": { ...mockModelMetrics["gpt-4"], total_spend: 100 },
|
||||
"gpt-3.5": { ...GPT_35_MODEL_DATA, total_spend: 10 },
|
||||
}}
|
||||
fetchTopApiKeys={fetchTopApiKeys}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findAllByText("Test Key")).toHaveLength(1);
|
||||
expect(fetchTopApiKeys).toHaveBeenCalledTimes(1);
|
||||
expect(fetchTopApiKeys).toHaveBeenCalledWith("gpt-4");
|
||||
|
||||
fireEvent.click(screen.getAllByText("GPT-3.5")[0]);
|
||||
|
||||
await waitFor(() => expect(fetchTopApiKeys).toHaveBeenCalledWith("gpt-3.5"));
|
||||
expect(await screen.findAllByText("Test Key")).toHaveLength(2);
|
||||
expect(fetchTopApiKeys).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should display top API keys section when present", () => {
|
||||
const modelWithTopKeys: Record<string, ModelActivityData> = {
|
||||
"gpt-4": {
|
||||
...mockModelMetrics["gpt-4"],
|
||||
top_api_keys: [
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
team_id: "team1",
|
||||
user: "owner@example.com",
|
||||
spend: 50.25,
|
||||
requests: 25,
|
||||
tokens: 12500,
|
||||
},
|
||||
{
|
||||
api_key: "key-456",
|
||||
key_alias: "Owner Alias",
|
||||
team_id: null,
|
||||
user: "Owner Alias",
|
||||
spend: 40.25,
|
||||
requests: 20,
|
||||
tokens: 10000,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
it("renders the keys the model_top_keys route returns", async () => {
|
||||
const fetchTopApiKeys = vi.fn().mockResolvedValue({
|
||||
api_keys: [
|
||||
{
|
||||
api_key: "key-123",
|
||||
metrics: { ...EMPTY_SPEND_METRICS, spend: 50.25, api_requests: 25, total_tokens: 12500 },
|
||||
metadata: { key_alias: "Test Key", team_id: "team1", user_email: "owner@example.com" },
|
||||
},
|
||||
{
|
||||
api_key: "key-456",
|
||||
metrics: { ...EMPTY_SPEND_METRICS, spend: 40.25, api_requests: 20, total_tokens: 10000 },
|
||||
metadata: { key_alias: "Owner Alias", team_id: null, user_id: "Owner Alias" },
|
||||
},
|
||||
{
|
||||
api_key: "key-7890123456",
|
||||
metrics: { ...EMPTY_SPEND_METRICS, spend: 30, api_requests: 10, total_tokens: 5000 },
|
||||
metadata: { key_alias: null, team_id: null, user_id: "owner-id-3" },
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<ActivityMetrics modelMetrics={mockModelMetrics} fetchTopApiKeys={fetchTopApiKeys} />);
|
||||
|
||||
render(<ActivityMetrics modelMetrics={modelWithTopKeys} />);
|
||||
expect(screen.getByText("Top Virtual Keys by Spend")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Top Virtual Keys by Spend")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Test Key")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Team: team1/)).toBeInTheDocument();
|
||||
expect(screen.getByText("User: owner@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("Owner Alias")).toBeInTheDocument();
|
||||
expect(screen.queryByText("User: Owner Alias")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/key-789012/)).toBeInTheDocument();
|
||||
expect(screen.getByText("User: owner-id-3")).toBeInTheDocument();
|
||||
expect(fetchTopApiKeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should display API key hash when alias is missing", () => {
|
||||
const modelWithTopKeys: Record<string, ModelActivityData> = {
|
||||
"gpt-4": {
|
||||
...mockModelMetrics["gpt-4"],
|
||||
top_api_keys: [
|
||||
{
|
||||
api_key: "key-1234567890",
|
||||
key_alias: null,
|
||||
team_id: null,
|
||||
user: null,
|
||||
spend: 50.25,
|
||||
requests: 25,
|
||||
tokens: 12500,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
it("refetches and shows loading again when the fetcher identity changes on an expanded model", async () => {
|
||||
const firstFetch = vi.fn().mockResolvedValue({
|
||||
api_keys: [
|
||||
{
|
||||
api_key: "key-old",
|
||||
metrics: { ...EMPTY_SPEND_METRICS, spend: 10, api_requests: 5, total_tokens: 100 },
|
||||
metadata: { key_alias: "Old Scope Key", team_id: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
const secondFetch = vi.fn().mockResolvedValue({
|
||||
api_keys: [
|
||||
{
|
||||
api_key: "key-new",
|
||||
metrics: { ...EMPTY_SPEND_METRICS, spend: 20, api_requests: 7, total_tokens: 200 },
|
||||
metadata: { key_alias: "New Scope Key", team_id: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
const { rerender } = render(<ActivityMetrics modelMetrics={mockModelMetrics} fetchTopApiKeys={firstFetch} />);
|
||||
|
||||
render(<ActivityMetrics modelMetrics={modelWithTopKeys} />);
|
||||
expect(screen.getByText(/key-123456/)).toBeInTheDocument();
|
||||
expect(await screen.findByText("Old Scope Key")).toBeInTheDocument();
|
||||
expect(firstFetch).toHaveBeenCalledWith("gpt-4");
|
||||
|
||||
rerender(<ActivityMetrics modelMetrics={mockModelMetrics} fetchTopApiKeys={secondFetch} />);
|
||||
|
||||
expect(screen.getByText("Loading top keys...")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Old Scope Key")).not.toBeInTheDocument();
|
||||
expect(await screen.findByText("New Scope Key")).toBeInTheDocument();
|
||||
expect(secondFetch).toHaveBeenCalledWith("gpt-4");
|
||||
});
|
||||
|
||||
it("should display team information for top API keys", () => {
|
||||
const modelWithTopKeys: Record<string, ModelActivityData> = {
|
||||
"gpt-4": {
|
||||
...mockModelMetrics["gpt-4"],
|
||||
top_api_keys: [
|
||||
it("shows an error with Retry when the top keys request fails and refetches on retry", async () => {
|
||||
const fetchTopApiKeys = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce({
|
||||
api_keys: [
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
team_id: "team1",
|
||||
user: null,
|
||||
spend: 50.25,
|
||||
requests: 25,
|
||||
tokens: 12500,
|
||||
api_key: "key-retry",
|
||||
metrics: { ...EMPTY_SPEND_METRICS, spend: 5, api_requests: 1, total_tokens: 10 },
|
||||
metadata: { key_alias: "Retried Key", team_id: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
render(<ActivityMetrics modelMetrics={mockModelMetrics} fetchTopApiKeys={fetchTopApiKeys} />);
|
||||
|
||||
render(<ActivityMetrics modelMetrics={modelWithTopKeys} />);
|
||||
expect(screen.getByText(/Team: team1/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Could not load top keys\./)).toBeInTheDocument();
|
||||
expect(screen.getByText("Top Virtual Keys by Spend")).toBeInTheDocument();
|
||||
expect(fetchTopApiKeys).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(screen.getByText("Loading top keys...")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Retried Key")).toBeInTheDocument();
|
||||
expect(fetchTopApiKeys).toHaveBeenCalledTimes(2);
|
||||
expect(fetchTopApiKeys).toHaveBeenLastCalledWith("gpt-4");
|
||||
expect(screen.queryByText(/Could not load top keys\./)).not.toBeInTheDocument();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("hides the top keys section without a fetcher", () => {
|
||||
render(<ActivityMetrics modelMetrics={mockModelMetrics} />);
|
||||
expect(screen.queryByText("Top Virtual Keys by Spend")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Model Usage when model has top_models", () => {
|
||||
|
|
@ -386,7 +431,6 @@ describe("ActivityMetrics", () => {
|
|||
|
||||
render(<ActivityMetrics modelMetrics={multipleModels} />);
|
||||
|
||||
// Only the highest-spend section is expanded initially, so only its body is mounted.
|
||||
const sectionsMounted = () => screen.getAllByText("Spend per day").length;
|
||||
expect(sectionsMounted()).toBe(1);
|
||||
|
||||
|
|
@ -788,8 +832,6 @@ describe("processActivityData", () => {
|
|||
expect(Object.keys(result).sort()).toEqual(["gpt-5.2", "gpt-5.2-eu"]);
|
||||
expect(result["gpt-5.2-eu"].label).toBe("gpt-5.2-eu");
|
||||
expect(result["gpt-5.2-eu"].total_spend).toBe(7);
|
||||
expect(result["gpt-5.2-eu"].top_api_keys).toHaveLength(1);
|
||||
expect(result["gpt-5.2-eu"].top_api_keys[0].key_alias).toBe("eu-key");
|
||||
expect(result["gpt-5.2"].total_spend).toBe(3);
|
||||
expect(result["gpt-5.2"].total_requests).toBe(3);
|
||||
});
|
||||
|
|
@ -1107,151 +1149,6 @@ describe("processActivityData", () => {
|
|||
};
|
||||
|
||||
const result = processActivityData(dailyActivityWithBreakdown, "models");
|
||||
|
||||
expect(result["gpt-4"].top_api_keys).toHaveLength(2);
|
||||
expect(result["gpt-4"].top_api_keys[0].spend).toBe(60.0);
|
||||
expect(result["gpt-4"].top_api_keys[0].api_key).toBe("key-1");
|
||||
expect(result["gpt-4"].top_api_keys[1].spend).toBe(40.5);
|
||||
expect(result["gpt-4"].top_api_keys.map(({ api_key, user }) => [api_key, user])).toEqual([
|
||||
["key-1", "owner-1@example.com"],
|
||||
["key-2", "owner-id-2"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("should limit top_api_keys to 5 entries", () => {
|
||||
const dailyActivityWithManyKeys: { results: DailyData[] } = {
|
||||
results: [
|
||||
{
|
||||
date: "2025-01-01",
|
||||
metrics: {
|
||||
spend: 100.5,
|
||||
prompt_tokens: 30000,
|
||||
completion_tokens: 20000,
|
||||
total_tokens: 50000,
|
||||
api_requests: 100,
|
||||
successful_requests: 95,
|
||||
failed_requests: 5,
|
||||
cache_read_input_tokens: 1000,
|
||||
cache_creation_input_tokens: 500,
|
||||
},
|
||||
breakdown: {
|
||||
models: {
|
||||
"gpt-4": {
|
||||
metrics: {
|
||||
spend: 100.5,
|
||||
prompt_tokens: 30000,
|
||||
completion_tokens: 20000,
|
||||
total_tokens: 50000,
|
||||
api_requests: 100,
|
||||
successful_requests: 95,
|
||||
failed_requests: 5,
|
||||
cache_read_input_tokens: 1000,
|
||||
cache_creation_input_tokens: 500,
|
||||
},
|
||||
metadata: {},
|
||||
api_key_breakdown: {
|
||||
"key-1": {
|
||||
metrics: {
|
||||
spend: 20.0,
|
||||
prompt_tokens: 6000,
|
||||
completion_tokens: 4000,
|
||||
total_tokens: 10000,
|
||||
api_requests: 20,
|
||||
successful_requests: 19,
|
||||
failed_requests: 1,
|
||||
cache_read_input_tokens: 200,
|
||||
cache_creation_input_tokens: 100,
|
||||
},
|
||||
metadata: { key_alias: "key-1", team_id: null },
|
||||
},
|
||||
"key-2": {
|
||||
metrics: {
|
||||
spend: 19.0,
|
||||
prompt_tokens: 5700,
|
||||
completion_tokens: 3800,
|
||||
total_tokens: 9500,
|
||||
api_requests: 19,
|
||||
successful_requests: 18,
|
||||
failed_requests: 1,
|
||||
cache_read_input_tokens: 190,
|
||||
cache_creation_input_tokens: 95,
|
||||
},
|
||||
metadata: { key_alias: "key-2", team_id: null },
|
||||
},
|
||||
"key-3": {
|
||||
metrics: {
|
||||
spend: 18.0,
|
||||
prompt_tokens: 5400,
|
||||
completion_tokens: 3600,
|
||||
total_tokens: 9000,
|
||||
api_requests: 18,
|
||||
successful_requests: 17,
|
||||
failed_requests: 1,
|
||||
cache_read_input_tokens: 180,
|
||||
cache_creation_input_tokens: 90,
|
||||
},
|
||||
metadata: { key_alias: "key-3", team_id: null },
|
||||
},
|
||||
"key-4": {
|
||||
metrics: {
|
||||
spend: 17.0,
|
||||
prompt_tokens: 5100,
|
||||
completion_tokens: 3400,
|
||||
total_tokens: 8500,
|
||||
api_requests: 17,
|
||||
successful_requests: 16,
|
||||
failed_requests: 1,
|
||||
cache_read_input_tokens: 170,
|
||||
cache_creation_input_tokens: 85,
|
||||
},
|
||||
metadata: { key_alias: "key-4", team_id: null },
|
||||
},
|
||||
"key-5": {
|
||||
metrics: {
|
||||
spend: 16.0,
|
||||
prompt_tokens: 4800,
|
||||
completion_tokens: 3200,
|
||||
total_tokens: 8000,
|
||||
api_requests: 16,
|
||||
successful_requests: 15,
|
||||
failed_requests: 1,
|
||||
cache_read_input_tokens: 160,
|
||||
cache_creation_input_tokens: 80,
|
||||
},
|
||||
metadata: { key_alias: "key-5", team_id: null },
|
||||
},
|
||||
"key-6": {
|
||||
metrics: {
|
||||
spend: 15.0,
|
||||
prompt_tokens: 4500,
|
||||
completion_tokens: 3000,
|
||||
total_tokens: 7500,
|
||||
api_requests: 15,
|
||||
successful_requests: 14,
|
||||
failed_requests: 1,
|
||||
cache_read_input_tokens: 150,
|
||||
cache_creation_input_tokens: 75,
|
||||
},
|
||||
metadata: { key_alias: "key-6", team_id: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
model_groups: {},
|
||||
mcp_servers: {},
|
||||
providers: {},
|
||||
api_keys: {},
|
||||
entities: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = processActivityData(dailyActivityWithManyKeys, "models");
|
||||
|
||||
expect(result["gpt-4"].top_api_keys).toHaveLength(5);
|
||||
expect(result["gpt-4"].top_api_keys[0].spend).toBe(20.0);
|
||||
expect(result["gpt-4"].top_api_keys[4].spend).toBe(16.0);
|
||||
});
|
||||
|
||||
it("should return empty object when results array is empty", () => {
|
||||
|
|
@ -1368,8 +1265,6 @@ describe("processActivityData", () => {
|
|||
};
|
||||
|
||||
const result = processActivityData(dailyActivityWithBreakdown, "api_keys", MOCK_TEAMS);
|
||||
|
||||
expect(result["key-1"].top_api_keys).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle missing cache tokens gracefully", () => {
|
||||
|
|
|
|||
|
|
@ -13,16 +13,20 @@ import { resolveTeamAliasFromTeamID } from "@/utils/teamUtils";
|
|||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Team } from "./key_team_helpers/key_list";
|
||||
import KeyModelUsageView from "./UsagePage/components/KeyModelUsageView";
|
||||
import { keyActivityLabel } from "./UsagePage/keyActivityLabel";
|
||||
import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopApiKeyData, TopModelData } from "./UsagePage/types";
|
||||
import type { ModelTopKeysResponse } from "./UsagePage/dailyActivityApi";
|
||||
import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopModelData } from "./UsagePage/types";
|
||||
import { averageResponseTimeMs, formatResponseTime, valueFormatter } from "./UsagePage/utils/value_formatters";
|
||||
|
||||
interface ActivityMetricsProps {
|
||||
modelMetrics: Record<string, ModelActivityData>;
|
||||
summaryMetrics?: ModelActivityData;
|
||||
summaryTitle?: string;
|
||||
hidePromptCachingMetrics?: boolean;
|
||||
fetchTopApiKeys?: (model: string) => Promise<ModelTopKeysResponse>;
|
||||
}
|
||||
|
||||
const modelAverageResponseTimeMs = (metrics: ModelActivityData): number | null =>
|
||||
|
|
@ -37,14 +41,142 @@ export const ResponseTimeTooltip = ({ active, payload, label }: ChartTooltipProp
|
|||
/>
|
||||
);
|
||||
|
||||
const ModelSection = ({
|
||||
const ModelTopKeys = ({
|
||||
modelName,
|
||||
fetchTopApiKeys,
|
||||
}: {
|
||||
modelName: string;
|
||||
fetchTopApiKeys: (model: string) => Promise<ModelTopKeysResponse>;
|
||||
}) => {
|
||||
interface ModelTopKeyRow {
|
||||
api_key: string;
|
||||
key_alias: string | null;
|
||||
team_id: string | null;
|
||||
user: string | null;
|
||||
spend: number;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
}
|
||||
const [settled, setSettled] = useState<{
|
||||
modelName: string;
|
||||
fetchTopApiKeys: (model: string) => Promise<ModelTopKeysResponse>;
|
||||
rows: ModelTopKeyRow[];
|
||||
failed: boolean;
|
||||
} | null>(null);
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchTopApiKeys(modelName)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setSettled({
|
||||
modelName,
|
||||
fetchTopApiKeys,
|
||||
rows: response.api_keys.map((row) => ({
|
||||
api_key: row.api_key,
|
||||
key_alias: row.metadata.key_alias ?? null,
|
||||
team_id: row.metadata.team_id ?? null,
|
||||
user: row.metadata.user_email ?? row.metadata.user_id ?? null,
|
||||
spend: row.metrics.spend,
|
||||
requests: row.metrics.api_requests,
|
||||
tokens: row.metrics.total_tokens,
|
||||
})),
|
||||
failed: false,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
console.error(`Failed to fetch top keys for ${modelName}:`, error);
|
||||
setSettled({ modelName, fetchTopApiKeys, rows: [], failed: true });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [modelName, fetchTopApiKeys, retryToken]);
|
||||
|
||||
const current = settled?.modelName === modelName && settled.fetchTopApiKeys === fetchTopApiKeys ? settled : null;
|
||||
|
||||
if (current === null) {
|
||||
return (
|
||||
<Card className="mt-4">
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys by Spend</h3>
|
||||
<p className="mt-3 text-sm text-muted-foreground">Loading top keys...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (current.failed) {
|
||||
return (
|
||||
<Card className="mt-4">
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys by Spend</h3>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Could not load top keys.{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-foreground underline"
|
||||
onClick={() => {
|
||||
setSettled(null);
|
||||
setRetryToken((token) => token + 1);
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = current.rows;
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="mt-4">
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys by Spend</h3>
|
||||
<div className="mt-3">
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{rows.map((keyData) => {
|
||||
const keyLabel = keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`;
|
||||
return (
|
||||
<div key={keyData.api_key} className="flex justify-between items-center p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{keyLabel}</p>
|
||||
{keyData.team_id && <p className="text-xs text-muted-foreground">Team: {keyData.team_id}</p>}
|
||||
{keyData.user && keyData.user !== keyLabel && (
|
||||
<p className="text-xs text-muted-foreground">User: {keyData.user}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">${formatNumberWithCommas(keyData.spend, 2)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModelSection = ({
|
||||
modelName,
|
||||
metrics,
|
||||
hidePromptCachingMetrics = false,
|
||||
fetchTopApiKeys,
|
||||
}: {
|
||||
modelName: string;
|
||||
metrics: ModelActivityData;
|
||||
hidePromptCachingMetrics?: boolean;
|
||||
fetchTopApiKeys?: (model: string) => Promise<ModelTopKeysResponse>;
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -96,37 +228,7 @@ const ModelSection = ({
|
|||
</Card>
|
||||
</div>
|
||||
|
||||
{metrics.top_api_keys && metrics.top_api_keys.length > 0 && (
|
||||
<Card className="mt-4">
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys by Spend</h3>
|
||||
<div className="mt-3">
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{metrics.top_api_keys.map((keyData) => {
|
||||
const keyLabel = keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`;
|
||||
return (
|
||||
<div key={keyData.api_key} className="flex justify-between items-center p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{keyLabel}</p>
|
||||
{keyData.team_id && <p className="text-xs text-muted-foreground">Team: {keyData.team_id}</p>}
|
||||
{keyData.user && keyData.user !== keyLabel && (
|
||||
<p className="text-xs text-muted-foreground">User: {keyData.user}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">${formatNumberWithCommas(keyData.spend, 2)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{fetchTopApiKeys && <ModelTopKeys modelName={modelName} fetchTopApiKeys={fetchTopApiKeys} />}
|
||||
|
||||
{metrics.top_models && metrics.top_models.length > 0 && <KeyModelUsageView topModels={metrics.top_models} />}
|
||||
|
||||
|
|
@ -272,24 +374,33 @@ const ModelSection = ({
|
|||
);
|
||||
};
|
||||
|
||||
const ModelCollapsible = ({
|
||||
export const ModelCollapsible = ({
|
||||
defaultOpen,
|
||||
header,
|
||||
children,
|
||||
onFirstOpen,
|
||||
}: {
|
||||
defaultOpen: boolean;
|
||||
header: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
onFirstOpen?: () => void;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [everOpened, setEverOpened] = useState(defaultOpen);
|
||||
const firstOpenRef = useRef(defaultOpen);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(next: boolean) => {
|
||||
setOpen(next);
|
||||
if (next) setEverOpened(true);
|
||||
if (next) {
|
||||
setEverOpened(true);
|
||||
if (!firstOpenRef.current) {
|
||||
firstOpenRef.current = true;
|
||||
onFirstOpen?.();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="border-b last:border-b-0"
|
||||
>
|
||||
|
|
@ -306,7 +417,13 @@ const ModelCollapsible = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics, hidePromptCachingMetrics = false }) => {
|
||||
export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({
|
||||
modelMetrics,
|
||||
summaryMetrics,
|
||||
summaryTitle = "Overall Usage",
|
||||
hidePromptCachingMetrics = false,
|
||||
fetchTopApiKeys,
|
||||
}) => {
|
||||
const modelNames = Object.keys(modelMetrics).sort((a, b) => {
|
||||
if (a === "") return 1;
|
||||
if (b === "") return -1;
|
||||
|
|
@ -374,42 +491,44 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics,
|
|||
});
|
||||
|
||||
// Convert daily_data object to array and sort by date
|
||||
const sortedDailyData = Object.entries(totalMetrics.daily_data)
|
||||
.map(([date, metrics]) => ({ date, metrics }))
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
const sortedDailyData =
|
||||
summaryMetrics?.daily_data ??
|
||||
Object.entries(totalMetrics.daily_data)
|
||||
.map(([date, metrics]) => ({ date, metrics }))
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
const totalRequests = summaryMetrics?.total_requests ?? totalMetrics.total_requests;
|
||||
const totalSuccessfulRequests = summaryMetrics?.total_successful_requests ?? totalMetrics.total_successful_requests;
|
||||
const totalTokens = summaryMetrics?.total_tokens ?? totalMetrics.total_tokens;
|
||||
const totalSpend = summaryMetrics?.total_spend ?? totalMetrics.total_spend;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Global Summary */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-foreground">Overall Usage</h3>
|
||||
<h3 className="text-lg font-medium text-foreground">{summaryTitle}</h3>
|
||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Total Requests</p>
|
||||
<h3 className="text-lg font-medium text-foreground">{totalMetrics.total_requests.toLocaleString()}</h3>
|
||||
<h3 className="text-lg font-medium text-foreground">{totalRequests.toLocaleString()}</h3>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Total Successful Requests</p>
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{totalMetrics.total_successful_requests.toLocaleString()}
|
||||
</h3>
|
||||
<h3 className="text-lg font-medium text-foreground">{totalSuccessfulRequests.toLocaleString()}</h3>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Total Tokens</p>
|
||||
<h3 className="text-lg font-medium text-foreground">{totalMetrics.total_tokens.toLocaleString()}</h3>
|
||||
<h3 className="text-lg font-medium text-foreground">{totalTokens.toLocaleString()}</h3>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Total Spend</p>
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
${formatNumberWithCommas(totalMetrics.total_spend, 2)}
|
||||
</h3>
|
||||
<h3 className="text-lg font-medium text-foreground">${formatNumberWithCommas(totalSpend, 2)}</h3>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -463,40 +582,49 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics,
|
|||
</div>
|
||||
|
||||
{/* Individual Model Sections */}
|
||||
<div className="rounded-lg border">
|
||||
{modelNames.map((modelName) => (
|
||||
<ModelCollapsible
|
||||
key={modelName}
|
||||
defaultOpen={modelName === modelNames[0]}
|
||||
header={
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{modelMetrics[modelName].label || "Unknown Item"}
|
||||
</h3>
|
||||
<div className="flex space-x-4 text-sm text-muted-foreground">
|
||||
<span>${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)}</span>
|
||||
<span>{modelMetrics[modelName].total_requests.toLocaleString()} requests</span>
|
||||
{modelAverageResponseTimeMs(modelMetrics[modelName]) != null && (
|
||||
<span>{formatResponseTime(modelAverageResponseTimeMs(modelMetrics[modelName]))} avg response</span>
|
||||
)}
|
||||
{modelNames.length > 0 && (
|
||||
<div className="rounded-lg border">
|
||||
{modelNames.map((modelName) => (
|
||||
<ModelCollapsible
|
||||
key={modelName}
|
||||
defaultOpen={modelName === modelNames[0]}
|
||||
header={
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{modelMetrics[modelName].label || "Unknown Item"}
|
||||
</h3>
|
||||
<div className="flex space-x-4 text-sm text-muted-foreground">
|
||||
<span>${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)}</span>
|
||||
<span>{modelMetrics[modelName].total_requests.toLocaleString()} requests</span>
|
||||
{modelAverageResponseTimeMs(modelMetrics[modelName]) != null && (
|
||||
<span>
|
||||
{formatResponseTime(modelAverageResponseTimeMs(modelMetrics[modelName]))} avg response
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ModelSection
|
||||
modelName={modelName || "Unknown Model"}
|
||||
metrics={modelMetrics[modelName]}
|
||||
hidePromptCachingMetrics={hidePromptCachingMetrics}
|
||||
/>
|
||||
</ModelCollapsible>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ModelSection
|
||||
modelName={modelName || "Unknown Model"}
|
||||
metrics={modelMetrics[modelName]}
|
||||
hidePromptCachingMetrics={hidePromptCachingMetrics}
|
||||
fetchTopApiKeys={fetchTopApiKeys}
|
||||
/>
|
||||
</ModelCollapsible>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to format key label
|
||||
export const formatKeyLabel = (modelData: KeyMetricWithMetadata, model: string, teams: Team[]): string => {
|
||||
export const formatKeyLabel = (
|
||||
modelData: Pick<KeyMetricWithMetadata, "metadata">,
|
||||
model: string,
|
||||
teams: Team[],
|
||||
): string => {
|
||||
const keyAlias = keyActivityLabel(modelData.metadata, `key-hash-${model}`);
|
||||
const teamId = modelData.metadata.team_id;
|
||||
if (teamId) {
|
||||
|
|
@ -536,7 +664,6 @@ export const processActivityData = (
|
|||
total_cache_creation_input_tokens: 0,
|
||||
total_response_time_ms: 0,
|
||||
total_timed_requests: 0,
|
||||
top_api_keys: [],
|
||||
top_models: [],
|
||||
daily_data: [],
|
||||
};
|
||||
|
|
@ -576,42 +703,6 @@ export const processActivityData = (
|
|||
});
|
||||
});
|
||||
|
||||
// Process Virtual Key breakdowns for each metric (skip if key is 'api_keys' to avoid duplication)
|
||||
if (key !== "api_keys") {
|
||||
Object.entries(modelMetrics).forEach(([model, _]) => {
|
||||
const apiKeyBreakdown: Record<string, TopApiKeyData> = {};
|
||||
|
||||
// Aggregate Virtual Key data across all days
|
||||
dailyActivity.results.forEach((day) => {
|
||||
const modelData = day.breakdown[key]?.[model];
|
||||
if (modelData && "api_key_breakdown" in modelData) {
|
||||
Object.entries(modelData.api_key_breakdown || {}).forEach(([apiKey, keyData]) => {
|
||||
if (!apiKeyBreakdown[apiKey]) {
|
||||
apiKeyBreakdown[apiKey] = {
|
||||
api_key: apiKey,
|
||||
key_alias: keyActivityLabel(keyData.metadata, "") || null,
|
||||
team_id: keyData.metadata.team_id,
|
||||
user: keyData.metadata.user_email ?? keyData.metadata.user_id ?? null,
|
||||
spend: 0,
|
||||
requests: 0,
|
||||
tokens: 0,
|
||||
};
|
||||
}
|
||||
|
||||
apiKeyBreakdown[apiKey].spend += keyData.metrics.spend;
|
||||
apiKeyBreakdown[apiKey].requests += keyData.metrics.api_requests;
|
||||
apiKeyBreakdown[apiKey].tokens += keyData.metrics.total_tokens;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by spend and take top 5
|
||||
modelMetrics[model].top_api_keys = Object.values(apiKeyBreakdown)
|
||||
.sort((a, b) => b.spend - a.spend)
|
||||
.slice(0, 5);
|
||||
});
|
||||
}
|
||||
|
||||
// Process Model breakdowns for each API key (only when key is 'api_keys')
|
||||
if (key === "api_keys") {
|
||||
Object.entries(modelMetrics).forEach(([apiKeyHash, _]) => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { useState } from "react";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { userDailyActivityAggregatedCall } from "../networking";
|
||||
import { dailyActivityAggregatedCall } from "../networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
|
|
@ -95,7 +95,15 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
|
|||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [USAGE_QUERY_KEY, accessToken, userId, timeRange],
|
||||
queryFn: () => userDailyActivityAggregatedCall(accessToken, start, end, userId),
|
||||
queryFn: () => {
|
||||
const request = {
|
||||
accessToken,
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
entityIds: userId ? [userId] : null,
|
||||
};
|
||||
return dailyActivityAggregatedCall("user", request);
|
||||
},
|
||||
enabled: !!accessToken,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
cacheLeakageKeysCall,
|
||||
dailyActivityAggregatedCall,
|
||||
dailyActivityExportCall,
|
||||
dailyActivityKeySearchCall,
|
||||
dailyActivityModelTopKeysCall,
|
||||
} from "./networking";
|
||||
import type { DailyActivityEntity, DailyActivityRequest } from "./UsagePage/dailyActivityApi";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
const captureFetch = () => {
|
||||
const mockFetch = vi.fn<typeof fetch>().mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ results: [], metadata: {}, api_keys: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
global.fetch = mockFetch;
|
||||
return mockFetch;
|
||||
};
|
||||
|
||||
const requestedUrl = (mockFetch: ReturnType<typeof captureFetch>, callIndex = 0): URL =>
|
||||
new URL(String(mockFetch.mock.calls[callIndex][0]), "http://example.com");
|
||||
|
||||
const start = new Date("2025-01-05T00:00:00Z");
|
||||
const end = new Date("2025-01-31T00:00:00Z");
|
||||
|
||||
const req = (overrides: Partial<DailyActivityRequest> = {}): DailyActivityRequest => ({
|
||||
accessToken: "sk-key",
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("dailyActivityAggregatedCall", () => {
|
||||
it.each<[DailyActivityEntity, string]>([
|
||||
["user", "user_id"],
|
||||
["team", "team_ids"],
|
||||
["tag", "tags"],
|
||||
["organization", "organization_ids"],
|
||||
["customer", "end_user_ids"],
|
||||
["agent", "agent_ids"],
|
||||
])("GETs /%s/daily/activity/aggregated with %s filters", async (entity, param) => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityAggregatedCall(entity, req({ entityIds: ["e1", "e2"] }));
|
||||
|
||||
const url = requestedUrl(mockFetch);
|
||||
expect(url.pathname).toBe(`/${entity}/daily/activity/aggregated`);
|
||||
const expected = entity === "user" ? "e1" : "e1,e2";
|
||||
expect(url.searchParams.get(param)).toBe(expected);
|
||||
expect(url.searchParams.get("start_date")).toBe("2025-01-05");
|
||||
expect(url.searchParams.get("end_date")).toBe("2025-01-31");
|
||||
expect(url.searchParams.has("timezone")).toBe(true);
|
||||
expect(url.searchParams.has("page")).toBe(false);
|
||||
expect(url.searchParams.has("page_size")).toBe(false);
|
||||
});
|
||||
|
||||
it("omits the entity filter when no ids are given", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityAggregatedCall("team", req({ entityIds: null }));
|
||||
|
||||
const url = requestedUrl(mockFetch);
|
||||
expect(url.searchParams.has("team_ids")).toBe(false);
|
||||
expect(url.searchParams.has("exclude_team_ids")).toBe(false);
|
||||
});
|
||||
|
||||
it.each<[DailyActivityEntity, string]>([
|
||||
["team", "exclude_team_ids"],
|
||||
["organization", "exclude_organization_ids"],
|
||||
["customer", "exclude_end_user_ids"],
|
||||
["agent", "exclude_agent_ids"],
|
||||
])("sends exclude ids comma-joined under %s", async (entity, param) => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityAggregatedCall(
|
||||
entity,
|
||||
req({ entityIds: ["e1"], excludeEntityIds: ["litellm-dashboard", "other"] }),
|
||||
);
|
||||
|
||||
expect(requestedUrl(mockFetch).searchParams.get(param)).toBe("litellm-dashboard,other");
|
||||
});
|
||||
|
||||
it.each<[DailyActivityEntity]>([["tag"], ["user"]])("emits no exclude param for %s", async (entity) => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityAggregatedCall(entity, req({ entityIds: ["e1"], excludeEntityIds: ["litellm-dashboard"] }));
|
||||
|
||||
const params = [...requestedUrl(mockFetch).searchParams.keys()];
|
||||
expect(params.some((key) => key.startsWith("exclude_"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an empty api_key as a filter rather than widening the read", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityAggregatedCall("user", req({ apiKey: "" }));
|
||||
|
||||
expect(requestedUrl(mockFetch).searchParams.get("api_key")).toBe("");
|
||||
});
|
||||
|
||||
it("omits api_key entirely when none is given", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityAggregatedCall("user", req({ apiKey: null }));
|
||||
|
||||
expect(requestedUrl(mockFetch).searchParams.has("api_key")).toBe(false);
|
||||
});
|
||||
|
||||
it("sends include_current_utc_day only when set", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityAggregatedCall("user", req({ includeCurrentUtcDay: true }));
|
||||
|
||||
expect(requestedUrl(mockFetch).searchParams.get("include_current_utc_day")).toBe("true");
|
||||
});
|
||||
|
||||
it("sends api_key_limit only when provided", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityAggregatedCall("user", req());
|
||||
await dailyActivityAggregatedCall("user", req({ apiKeyLimit: 250 }));
|
||||
|
||||
expect(requestedUrl(mockFetch, 0).searchParams.has("api_key_limit")).toBe(false);
|
||||
expect(requestedUrl(mockFetch, 1).searchParams.get("api_key_limit")).toBe("250");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dailyActivityKeySearchCall", () => {
|
||||
it("GETs the search route with the search term", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityKeySearchCall("team", req({ entityIds: ["t1"] }), "alice");
|
||||
|
||||
const url = requestedUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/team/daily/activity/aggregated/search");
|
||||
expect(url.searchParams.get("search")).toBe("alice");
|
||||
expect(url.searchParams.get("team_ids")).toBe("t1");
|
||||
expect(url.searchParams.has("limit")).toBe(false);
|
||||
});
|
||||
|
||||
it("sends the optional limit", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityKeySearchCall("team", req(), "alice", 50);
|
||||
|
||||
expect(requestedUrl(mockFetch).searchParams.get("limit")).toBe("50");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dailyActivityModelTopKeysCall", () => {
|
||||
it("GETs model_top_keys with model_group and by_model_group", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityModelTopKeysCall("user", req(), "gpt-4o", true);
|
||||
|
||||
const url = requestedUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/user/daily/activity/aggregated/model_top_keys");
|
||||
expect(url.searchParams.get("model_group")).toBe("gpt-4o");
|
||||
expect(url.searchParams.get("by_model_group")).toBe("true");
|
||||
expect(url.searchParams.has("limit")).toBe(false);
|
||||
});
|
||||
|
||||
it("sends by_model_group=false for the models view", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityModelTopKeysCall("user", req(), "gpt-4o", false);
|
||||
|
||||
expect(requestedUrl(mockFetch).searchParams.get("by_model_group")).toBe("false");
|
||||
});
|
||||
|
||||
it("sends the optional limit", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityModelTopKeysCall("user", req(), "gpt-4o", true, 25);
|
||||
|
||||
expect(requestedUrl(mockFetch).searchParams.get("limit")).toBe("25");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dailyActivityExportCall", () => {
|
||||
it("GETs the export route with export_type and format", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await dailyActivityExportCall("organization", req({ entityIds: ["o1"] }), "daily_with_keys", "csv");
|
||||
|
||||
const url = requestedUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/organization/daily/activity/export");
|
||||
expect(url.searchParams.get("export_type")).toBe("daily_with_keys");
|
||||
expect(url.searchParams.get("format")).toBe("csv");
|
||||
expect(url.searchParams.get("organization_ids")).toBe("o1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cacheLeakageKeysCall", () => {
|
||||
it("GETs the user cache_leakage_keys route with the user scope", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await cacheLeakageKeysCall(req({ entityIds: ["u1"], apiKey: "hash-1", includeCurrentUtcDay: true }));
|
||||
|
||||
const url = requestedUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/user/daily/activity/aggregated/cache_leakage_keys");
|
||||
expect(url.searchParams.get("user_id")).toBe("u1");
|
||||
expect(url.searchParams.get("api_key")).toBe("hash-1");
|
||||
expect(url.searchParams.get("include_current_utc_day")).toBe("true");
|
||||
expect(url.searchParams.has("limit")).toBe(false);
|
||||
});
|
||||
|
||||
it("sends the optional limit", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await cacheLeakageKeysCall(req(), 75);
|
||||
|
||||
expect(requestedUrl(mockFetch).searchParams.get("limit")).toBe("75");
|
||||
});
|
||||
});
|
||||
|
|
@ -157,60 +157,6 @@ describe("modelInfoCall", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("daily activity helpers", () => {
|
||||
const startTime = new Date("2025-02-12T00:00:00.000Z");
|
||||
const endTime = new Date("2025-02-19T00:00:00.000Z");
|
||||
let currentFetch: typeof global.fetch;
|
||||
|
||||
const setupSuccessfulFetch = () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ data: [] }),
|
||||
} as any);
|
||||
global.fetch = mockFetch as any;
|
||||
return mockFetch;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
currentFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = currentFetch;
|
||||
});
|
||||
|
||||
it("appends tag list when tags argument is provided", async () => {
|
||||
const mockFetch = setupSuccessfulFetch();
|
||||
|
||||
await Networking.tagDailyActivityCall("token", startTime, endTime, 2, ["alpha", "beta"]);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||
const parsed = new URL(calledUrl, "http://example.com");
|
||||
|
||||
expect(parsed.pathname).toBe("/tag/daily/activity");
|
||||
expect(parsed.searchParams.get("tags")).toBe("alpha,beta");
|
||||
});
|
||||
|
||||
it("always includes exclude_team_ids but only adds team_ids when given", async () => {
|
||||
const mockFetchWithoutTeams = setupSuccessfulFetch();
|
||||
|
||||
await Networking.teamDailyActivityCall("token", startTime, endTime, 1, null);
|
||||
const urlWithoutTeams = new URL(mockFetchWithoutTeams.mock.calls[0][0] as string, "http://example.com");
|
||||
|
||||
expect(urlWithoutTeams.searchParams.get("exclude_team_ids")).toBe("litellm-dashboard");
|
||||
expect(urlWithoutTeams.searchParams.has("team_ids")).toBe(false);
|
||||
|
||||
const mockFetchWithTeams = setupSuccessfulFetch();
|
||||
await Networking.teamDailyActivityCall("token", startTime, endTime, 3, ["team-a", "team-b"]);
|
||||
const urlWithTeams = new URL(mockFetchWithTeams.mock.calls[0][0] as string, "http://example.com");
|
||||
|
||||
expect(urlWithTeams.searchParams.get("team_ids")).toBe("team-a,team-b");
|
||||
expect(urlWithTeams.searchParams.get("exclude_team_ids")).toBe("litellm-dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UI config and public endpoints", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
|
|
@ -772,88 +718,6 @@ describe("getAutoRouterClassifierDefaultPromptCall", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("daily activity api_key filter", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
const captureFetch = () => {
|
||||
const mockFetch = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify({ results: [], metadata: {} }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
global.fetch = mockFetch;
|
||||
return mockFetch;
|
||||
};
|
||||
|
||||
const requestedUrl = (mockFetch: ReturnType<typeof captureFetch>): string => String(mockFetch.mock.calls[0][0]);
|
||||
|
||||
const start = new Date("2025-01-01T00:00:00Z");
|
||||
const end = new Date("2025-01-31T00:00:00Z");
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("sends the key hash as api_key from the paginated caller", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, "hash-abc");
|
||||
|
||||
expect(requestedUrl(mockFetch)).toContain("api_key=hash-abc");
|
||||
});
|
||||
|
||||
it("sends the key hash as api_key from the aggregated caller", async () => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, "hash-abc");
|
||||
|
||||
expect(requestedUrl(mockFetch)).toContain("api_key=hash-abc");
|
||||
});
|
||||
|
||||
// The two wrappers serialize the same optional filters through different transports, so an
|
||||
// absent key has to drop the param in both. Dropping it on one side and sending it empty on
|
||||
// the other would widen a key-scoped read into an unscoped one.
|
||||
it.each([
|
||||
["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, null)],
|
||||
["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, null)],
|
||||
])("omits api_key entirely from the %s caller when no key is given", async (_label, call) => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await call();
|
||||
|
||||
expect(requestedUrl(mockFetch)).not.toContain("api_key");
|
||||
});
|
||||
|
||||
// An empty key must not be coerced into "no filter". Dropping it would turn a key-scoped read
|
||||
// into a proxy-wide one and report every key's savings as this key's, so both callers send it
|
||||
// through and let the filter match nothing instead.
|
||||
it.each([
|
||||
["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, "")],
|
||||
["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, "")],
|
||||
])("keeps an empty api_key as a filter rather than widening the %s read", async (_label, call) => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await call();
|
||||
|
||||
expect(requestedUrl(mockFetch)).toContain("api_key=");
|
||||
});
|
||||
|
||||
// user_id rides the same two transports and widens the same way, so it gets the same guard.
|
||||
// The aggregated caller used to drop "" via `||`; without this the two filters could drift
|
||||
// apart again on one side only.
|
||||
it.each([
|
||||
["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, "", false, null)],
|
||||
["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, "", false, null)],
|
||||
])("keeps an empty user_id as a filter rather than widening the %s read", async (_label, call) => {
|
||||
const mockFetch = captureFetch();
|
||||
|
||||
await call();
|
||||
|
||||
expect(requestedUrl(mockFetch)).toContain("user_id=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("userListCall search serialization", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,19 @@ import {
|
|||
deriveErrorMessage,
|
||||
extractProxyErrorMessage,
|
||||
unwrapProxyErrorMessage,
|
||||
type QueryParams,
|
||||
} from "@/lib/http/client";
|
||||
import type {
|
||||
CacheLeakageKeysResponse,
|
||||
DailyActivityAggregatedResponse,
|
||||
DailyActivityEntity,
|
||||
DailyActivityKeyPageResponse,
|
||||
DailyActivityKeySearchResponse,
|
||||
DailyActivityRequest,
|
||||
ExportFormat,
|
||||
ExportType,
|
||||
ModelTopKeysResponse,
|
||||
} from "./UsagePage/dailyActivityApi";
|
||||
import { resolveApiBase } from "@/lib/http/resolveApiBase";
|
||||
import {
|
||||
registerAuthHeaderNameGetter,
|
||||
|
|
@ -1281,192 +1293,110 @@ export const transformRequestCall = async (accessToken: string, request: object)
|
|||
}
|
||||
};
|
||||
|
||||
type DailyActivityQueryValue = string | number | string[] | null | undefined;
|
||||
|
||||
const DEFAULT_DAILY_ACTIVITY_PAGE_SIZE = "1000";
|
||||
|
||||
const appendDailyActivityQueryParam = (params: URLSearchParams, key: string, value: DailyActivityQueryValue) => {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
params.append(key, value.join(","));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
params.append(key, `${value}`);
|
||||
const ENTITY_ID_QUERY_PARAM: Record<DailyActivityEntity, string> = {
|
||||
user: "user_id",
|
||||
team: "team_ids",
|
||||
tag: "tags",
|
||||
organization: "organization_ids",
|
||||
customer: "end_user_ids",
|
||||
agent: "agent_ids",
|
||||
};
|
||||
|
||||
const buildDailyActivityUrl = (
|
||||
endpoint: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
page: number,
|
||||
extraQueryParams?: Record<string, DailyActivityQueryValue>,
|
||||
) => {
|
||||
const resolvedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||||
const baseUrl = proxyBaseUrl ? `${proxyBaseUrl}${resolvedEndpoint}` : resolvedEndpoint;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("start_date", formatDate(startTime));
|
||||
params.append("end_date", formatDate(endTime));
|
||||
params.append("page_size", DEFAULT_DAILY_ACTIVITY_PAGE_SIZE);
|
||||
params.append("page", page.toString());
|
||||
// Send timezone offset so backend can adjust date range for UTC storage
|
||||
params.append("timezone", new Date().getTimezoneOffset().toString());
|
||||
|
||||
if (extraQueryParams) {
|
||||
Object.entries(extraQueryParams).forEach(([key, value]) => {
|
||||
appendDailyActivityQueryParam(params, key, value);
|
||||
});
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
const EXCLUDE_ENTITY_ID_QUERY_PARAM: Partial<Record<DailyActivityEntity, string>> = {
|
||||
team: "exclude_team_ids",
|
||||
organization: "exclude_organization_ids",
|
||||
customer: "exclude_end_user_ids",
|
||||
agent: "exclude_agent_ids",
|
||||
};
|
||||
|
||||
type DailyActivityCallOptions = {
|
||||
accessToken: string;
|
||||
endpoint: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
page?: number;
|
||||
extraQueryParams?: Record<string, DailyActivityQueryValue>;
|
||||
const dailyActivityQuery = (
|
||||
entity: DailyActivityEntity,
|
||||
req: DailyActivityRequest,
|
||||
extra: QueryParams = {},
|
||||
): QueryParams => {
|
||||
const entityIds = req.entityIds ?? undefined;
|
||||
const excludeEntityIds = req.excludeEntityIds;
|
||||
const joinedEntityIds = entityIds && entityIds.length > 0 ? entityIds.join(",") : undefined;
|
||||
const entityIdValue = entity === "user" ? entityIds?.[0] : joinedEntityIds;
|
||||
const excludeParam = EXCLUDE_ENTITY_ID_QUERY_PARAM[entity];
|
||||
return {
|
||||
start_date: formatDate(req.startTime),
|
||||
end_date: formatDate(req.endTime),
|
||||
model: req.model,
|
||||
api_key: req.apiKey,
|
||||
[ENTITY_ID_QUERY_PARAM[entity]]: entityIdValue,
|
||||
...(excludeParam
|
||||
? { [excludeParam]: excludeEntityIds && excludeEntityIds.length > 0 ? excludeEntityIds.join(",") : undefined }
|
||||
: {}),
|
||||
timezone: new Date().getTimezoneOffset().toString(),
|
||||
include_current_utc_day: entity === "user" && req.includeCurrentUtcDay ? "true" : undefined,
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
const fetchDailyActivity = async ({
|
||||
accessToken,
|
||||
endpoint,
|
||||
startTime,
|
||||
endTime,
|
||||
page = 1,
|
||||
extraQueryParams,
|
||||
}: DailyActivityCallOptions) => {
|
||||
try {
|
||||
const url = buildDailyActivityUrl(endpoint, startTime, endTime, page, extraQueryParams);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch daily activity (${endpoint}):`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const userDailyActivityCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
page: number = 1,
|
||||
userId: string | null = null,
|
||||
includeCurrentUtcDay: boolean = false,
|
||||
apiKey: string | null = null,
|
||||
) => {
|
||||
/**
|
||||
* Get daily user activity on proxy
|
||||
*/
|
||||
return fetchDailyActivity({
|
||||
accessToken,
|
||||
endpoint: "/user/daily/activity",
|
||||
startTime,
|
||||
endTime,
|
||||
page,
|
||||
extraQueryParams: {
|
||||
user_id: userId,
|
||||
include_current_utc_day: includeCurrentUtcDay ? "true" : undefined,
|
||||
api_key: apiKey,
|
||||
},
|
||||
export const dailyActivityAggregatedCall = (
|
||||
entity: DailyActivityEntity,
|
||||
req: DailyActivityRequest,
|
||||
): Promise<DailyActivityAggregatedResponse> =>
|
||||
apiClient.get<DailyActivityAggregatedResponse>(`/${entity}/daily/activity/aggregated`, {
|
||||
accessToken: req.accessToken,
|
||||
query: dailyActivityQuery(entity, req, req.apiKeyLimit === undefined ? {} : { api_key_limit: req.apiKeyLimit }),
|
||||
});
|
||||
};
|
||||
|
||||
export const tagDailyActivityCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
page: number = 1,
|
||||
tags: string[] | null = null,
|
||||
) => {
|
||||
/**
|
||||
* Get daily user activity on proxy
|
||||
*/
|
||||
return fetchDailyActivity({
|
||||
accessToken,
|
||||
endpoint: "/tag/daily/activity",
|
||||
startTime,
|
||||
endTime,
|
||||
page,
|
||||
extraQueryParams: {
|
||||
tags,
|
||||
},
|
||||
export const dailyActivityKeyPageCall = (
|
||||
entity: DailyActivityEntity,
|
||||
req: DailyActivityRequest,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): Promise<DailyActivityKeyPageResponse> =>
|
||||
apiClient.get<DailyActivityKeyPageResponse>(`/${entity}/daily/activity/aggregated/keys`, {
|
||||
accessToken: req.accessToken,
|
||||
query: dailyActivityQuery(entity, req, { offset, limit }),
|
||||
});
|
||||
};
|
||||
|
||||
export const teamDailyActivityCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
page: number = 1,
|
||||
teamIds: string[] | null = null,
|
||||
) => {
|
||||
/**
|
||||
* Get daily user activity on proxy
|
||||
*/
|
||||
return fetchDailyActivity({
|
||||
accessToken,
|
||||
endpoint: "/team/daily/activity",
|
||||
startTime,
|
||||
endTime,
|
||||
page,
|
||||
extraQueryParams: {
|
||||
team_ids: teamIds,
|
||||
exclude_team_ids: "litellm-dashboard",
|
||||
},
|
||||
export const dailyActivityKeySearchCall = (
|
||||
entity: DailyActivityEntity,
|
||||
req: DailyActivityRequest,
|
||||
search: string,
|
||||
limit?: number,
|
||||
): Promise<DailyActivityKeySearchResponse> =>
|
||||
apiClient.get<DailyActivityKeySearchResponse>(`/${entity}/daily/activity/aggregated/search`, {
|
||||
accessToken: req.accessToken,
|
||||
query: dailyActivityQuery(entity, req, { search, ...(limit === undefined ? {} : { limit }) }),
|
||||
});
|
||||
};
|
||||
|
||||
export const teamDailyActivityAggregatedCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
teamIds: string[] | null = null,
|
||||
) => {
|
||||
/**
|
||||
* Get aggregated daily team activity with per-team breakdown (no pagination)
|
||||
*/
|
||||
try {
|
||||
return await apiClient.get(`/team/daily/activity/aggregated`, {
|
||||
accessToken,
|
||||
query: {
|
||||
start_date: formatDate(startTime),
|
||||
end_date: formatDate(endTime),
|
||||
timezone: new Date().getTimezoneOffset().toString(),
|
||||
team_ids: teamIds && teamIds.length > 0 ? teamIds.join(",") : undefined,
|
||||
exclude_team_ids: "litellm-dashboard",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch aggregated team daily activity:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
export const dailyActivityModelTopKeysCall = (
|
||||
entity: DailyActivityEntity,
|
||||
req: DailyActivityRequest,
|
||||
model: string,
|
||||
byModelGroup: boolean,
|
||||
limit?: number,
|
||||
): Promise<ModelTopKeysResponse> =>
|
||||
apiClient.get<ModelTopKeysResponse>(`/${entity}/daily/activity/aggregated/model_top_keys`, {
|
||||
accessToken: req.accessToken,
|
||||
query: dailyActivityQuery(entity, req, {
|
||||
model_group: model,
|
||||
by_model_group: byModelGroup ? "true" : "false",
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const dailyActivityExportCall = (
|
||||
entity: DailyActivityEntity,
|
||||
req: DailyActivityRequest,
|
||||
exportType: ExportType,
|
||||
format: ExportFormat,
|
||||
): Promise<Blob> =>
|
||||
apiClient.getBlob(`/${entity}/daily/activity/export`, {
|
||||
accessToken: req.accessToken,
|
||||
query: dailyActivityQuery(entity, req, { export_type: exportType, format }),
|
||||
});
|
||||
|
||||
export const cacheLeakageKeysCall = (req: DailyActivityRequest, limit?: number): Promise<CacheLeakageKeysResponse> =>
|
||||
apiClient.get<CacheLeakageKeysResponse>(`/user/daily/activity/aggregated/cache_leakage_keys`, {
|
||||
accessToken: req.accessToken,
|
||||
query: dailyActivityQuery("user", req, limit === undefined ? {} : { limit }),
|
||||
});
|
||||
|
||||
export type TeamUserSpendResponse = components["schemas"]["TeamUserSpendResponse"];
|
||||
|
||||
|
|
@ -1485,63 +1415,6 @@ export const teamSpendByUserCall = async (
|
|||
},
|
||||
});
|
||||
|
||||
export const organizationDailyActivityCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
page: number = 1,
|
||||
organizationIds: string[] | null = null,
|
||||
) => {
|
||||
return fetchDailyActivity({
|
||||
accessToken,
|
||||
endpoint: "/organization/daily/activity",
|
||||
startTime,
|
||||
endTime,
|
||||
page,
|
||||
extraQueryParams: {
|
||||
organization_ids: organizationIds,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const customerDailyActivityCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
page: number = 1,
|
||||
customerIds: string[] | null = null,
|
||||
) => {
|
||||
return fetchDailyActivity({
|
||||
accessToken,
|
||||
endpoint: "/customer/daily/activity",
|
||||
startTime,
|
||||
endTime,
|
||||
page,
|
||||
extraQueryParams: {
|
||||
end_user_ids: customerIds,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const agentDailyActivityCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
page: number = 1,
|
||||
agentIds: string[] | null = null,
|
||||
) => {
|
||||
return fetchDailyActivity({
|
||||
accessToken,
|
||||
endpoint: "/agent/daily/activity",
|
||||
startTime,
|
||||
endTime,
|
||||
page,
|
||||
extraQueryParams: {
|
||||
agent_ids: agentIds,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getOnboardingCredentials = async (inviteUUID: string) => {
|
||||
/**
|
||||
* Get all models on proxy
|
||||
|
|
@ -2571,43 +2444,6 @@ export const keyAliasesCall = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const userDailyActivityAggregatedCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
...options: [userId?: string | null, includeCurrentUtcDay?: boolean, apiKey?: string | null]
|
||||
) => {
|
||||
/**
|
||||
* Get aggregated daily user activity (no pagination)
|
||||
*/
|
||||
const [userId = null, includeCurrentUtcDay = false, apiKey = null] = options;
|
||||
try {
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
return await apiClient.get(`/user/daily/activity/aggregated`, {
|
||||
accessToken,
|
||||
query: {
|
||||
start_date: formatDate(startTime),
|
||||
end_date: formatDate(endTime),
|
||||
timezone: new Date().getTimezoneOffset().toString(),
|
||||
// Passed raw, matching the paginated caller: both serializers drop null and undefined,
|
||||
// and both keep "". An empty filter must not vanish, or a request scoped to one user or
|
||||
// key would silently widen into an unscoped, proxy-wide read.
|
||||
user_id: userId,
|
||||
include_current_utc_day: includeCurrentUtcDay ? "true" : undefined,
|
||||
api_key: apiKey,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch aggregated user daily activity:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const gatewayDailyActivityCall = async (accessToken: string, startTime: Date, endTime: Date) => {
|
||||
/**
|
||||
* Get gateway request counts (SGR) recorded by the proxy middleware.
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import PaginationStatusAlerts from "./PaginationStatusAlerts";
|
||||
|
||||
describe("PaginationStatusAlerts", () => {
|
||||
it("shows page progress and wires the Stop button while fetching", () => {
|
||||
const cancel = vi.fn();
|
||||
render(
|
||||
<PaginationStatusAlerts
|
||||
isFetchingMore={true}
|
||||
cancelled={false}
|
||||
progress={{ currentPage: 7, totalPages: 42 }}
|
||||
cancel={cancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Currently fetching spend data: fetched 7 \/ 42 pages/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the partial-data notice after a cancel, frozen at the last fetched page", () => {
|
||||
render(
|
||||
<PaginationStatusAlerts
|
||||
isFetchingMore={false}
|
||||
cancelled={true}
|
||||
progress={{ currentPage: 7, totalPages: 42 }}
|
||||
cancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Showing partial spend data (7/42 pages loaded)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls out a failed page as an error so partial totals do not read as final", () => {
|
||||
render(
|
||||
<PaginationStatusAlerts
|
||||
isFetchingMore={false}
|
||||
cancelled={false}
|
||||
failed={true}
|
||||
progress={{ currentPage: 7, totalPages: 42 }}
|
||||
cancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Fetching spend data failed, so the totals below cover only 7 of 42 pages of the range/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not claim a page loaded when the very first request is what failed", () => {
|
||||
render(
|
||||
<PaginationStatusAlerts
|
||||
isFetchingMore={false}
|
||||
cancelled={false}
|
||||
failed={true}
|
||||
progress={{ currentPage: 0, totalPages: 0 }}
|
||||
cancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/failed before any of it arrived/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/pages of the range/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows only the failure when a stopped fetch also failed", () => {
|
||||
render(
|
||||
<PaginationStatusAlerts
|
||||
isFetchingMore={false}
|
||||
cancelled={true}
|
||||
failed={true}
|
||||
progress={{ currentPage: 7, totalPages: 42 }}
|
||||
cancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Fetching spend data failed/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Showing partial spend data/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the subject it is fetching", () => {
|
||||
render(
|
||||
<PaginationStatusAlerts
|
||||
isFetchingMore={true}
|
||||
cancelled={false}
|
||||
progress={{ currentPage: 1, totalPages: 3 }}
|
||||
cancel={vi.fn()}
|
||||
subject="agent data"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Currently fetching agent data: fetched 1 \/ 3 pages/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when idle", () => {
|
||||
const { container } = render(
|
||||
<PaginationStatusAlerts
|
||||
isFetchingMore={false}
|
||||
cancelled={false}
|
||||
progress={{ currentPage: 1, totalPages: 1 }}
|
||||
cancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { ExternalLink, Loader2 } from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/shared/Alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface PaginationStatusAlertsProps {
|
||||
isFetchingMore: boolean;
|
||||
cancelled: boolean;
|
||||
progress: { currentPage: number; totalPages: number };
|
||||
cancel: () => void;
|
||||
subject?: string;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
const failureMessage = (subject: string, progress: { currentPage: number; totalPages: number }) =>
|
||||
progress.currentPage === 0
|
||||
? `Fetching ${subject} failed before any of it arrived, so the totals below are empty rather than final. Reload the page to try again.`
|
||||
: `Fetching ${subject} failed, so the totals below cover only ${progress.currentPage} of ${progress.totalPages} pages of the range. Reload the page to try again.`;
|
||||
|
||||
const PaginationStatusAlerts = ({
|
||||
isFetchingMore,
|
||||
cancelled,
|
||||
progress,
|
||||
cancel,
|
||||
subject = "spend data",
|
||||
failed = false,
|
||||
}: PaginationStatusAlertsProps) => (
|
||||
<>
|
||||
{isFetchingMore && (
|
||||
<Alert variant="warning" className="mb-2">
|
||||
<AlertDescription className="flex items-center justify-between text-inherit">
|
||||
<span>
|
||||
<Loader2 className="mr-2 inline size-4 animate-spin align-text-bottom" />
|
||||
Currently fetching {subject}: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will
|
||||
update periodically as data loads. Moving off of this page will stop and reset this. To continue using the
|
||||
UI in the meantime,{" "}
|
||||
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
|
||||
open a new tab <ExternalLink className="inline size-3.5 align-text-bottom" />
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<Button variant="destructive" onClick={cancel}>
|
||||
Stop
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{failed && (
|
||||
<Alert variant="error" className="mb-2">
|
||||
<AlertDescription className="text-inherit">{failureMessage(subject, progress)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{cancelled && !failed && (
|
||||
<Alert variant="info" className="mb-2">
|
||||
<AlertDescription className="text-inherit">
|
||||
Showing partial {subject} ({progress.currentPage}/{progress.totalPages} pages loaded)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
export default PaginationStatusAlerts;
|
||||
|
|
@ -24,19 +24,19 @@ import {
|
|||
import {
|
||||
useScopedDailyActivityRange,
|
||||
type ActivityDateRange,
|
||||
type DailyActivityScope,
|
||||
type ScopedActivityInput,
|
||||
} from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange";
|
||||
|
||||
interface ScopedSavingsTabProps {
|
||||
accessToken: string | null;
|
||||
scope: DailyActivityScope;
|
||||
scope: ScopedActivityInput;
|
||||
activity: ActivityDateRange;
|
||||
entityType: "key" | "user";
|
||||
scopeNote?: string;
|
||||
}
|
||||
|
||||
const ScopedSavingsTab = ({ accessToken, scope, activity, entityType, scopeNote }: ScopedSavingsTabProps) => {
|
||||
const { dateValue, onDateChange, results, loading, isFetchingMore, failed, cancelled } = useScopedDailyActivityRange(
|
||||
const { dateValue, onDateChange, results, loading, failed } = useScopedDailyActivityRange(
|
||||
accessToken,
|
||||
scope,
|
||||
activity,
|
||||
|
|
@ -63,8 +63,8 @@ const ScopedSavingsTab = ({ accessToken, scope, activity, entityType, scopeNote
|
|||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
const isLoading = loading || isFetchingMore;
|
||||
const unavailable = failed || cancelled;
|
||||
const isLoading = loading;
|
||||
const unavailable = failed;
|
||||
const showResults = !isLoading && !unavailable;
|
||||
const hasRows = results.length > 0;
|
||||
const showEmpty = !unavailable && (isLoading || !hasRows);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import KeySavingsTab from "./KeySavingsTab";
|
||||
import { DailyData, SpendMetrics } from "@/components/UsagePage/types";
|
||||
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
|
||||
import * as useScopedDailyActivityRangeModule from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange";
|
||||
|
||||
const metrics = (overrides: Partial<SpendMetrics>): SpendMetrics => ({
|
||||
|
|
@ -36,12 +37,16 @@ const mockActivity = (
|
|||
dateValue: { from: new Date("2025-01-01"), to: new Date("2025-01-31") },
|
||||
onDateChange: vi.fn(),
|
||||
results: [] as DailyData[],
|
||||
metadata: EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
loading: false,
|
||||
isFetchingMore: false,
|
||||
progress: { currentPage: 1, totalPages: 1 },
|
||||
cancelled: false,
|
||||
failed: false,
|
||||
cancel: vi.fn(),
|
||||
scope: {
|
||||
accessToken: "test-token",
|
||||
startTime: new Date("2025-01-01"),
|
||||
endTime: new Date("2025-01-31"),
|
||||
userId: null,
|
||||
apiKey: null,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,25 @@ describe("createApiClient", () => {
|
|||
expect(init.headers).toEqual({ "Content-Type": "application/json" });
|
||||
});
|
||||
|
||||
it("getBlob returns the response body as a Blob on success", async () => {
|
||||
const blob = new Blob(["csv,data"], { type: "text/csv" });
|
||||
const fetchImpl = vi.fn(async () => ({ ok: true, status: 200, blob: async () => blob }) as unknown as Response);
|
||||
const client = createApiClient({ getBaseUrl: () => "https://proxy.example", fetchImpl });
|
||||
|
||||
const result = await client.getBlob("/user/daily/activity/export", { accessToken: "sk" });
|
||||
|
||||
expect(result).toBe(blob);
|
||||
const [, blobInit] = fetchImpl.mock.calls[0] as unknown as [unknown, RequestInit];
|
||||
expect(blobInit.method).toBe("GET");
|
||||
});
|
||||
|
||||
it("getBlob throws ApiError on a non-2xx response", async () => {
|
||||
const fetchImpl = vi.fn(async () => errorResponse(500, { error: "export failed" }));
|
||||
const client = createApiClient({ getBaseUrl: () => "", fetchImpl });
|
||||
|
||||
await expect(client.getBlob("/user/daily/activity/export", { accessToken: "sk" })).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
|
||||
it("resolves the global fetch per call, so a swap after construction takes effect", async () => {
|
||||
const client = createApiClient({ getBaseUrl: () => "" });
|
||||
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export interface ApiClientConfig {
|
|||
export interface ApiClient {
|
||||
request<T = any>(method: HttpMethod, path: string, options?: RequestOptions): Promise<T>;
|
||||
get<T = any>(path: string, options?: RequestOptions): Promise<T>;
|
||||
getBlob(path: string, options?: RequestOptions): Promise<Blob>;
|
||||
post<T = any>(path: string, options?: RequestOptions): Promise<T>;
|
||||
put<T = any>(path: string, options?: RequestOptions): Promise<T>;
|
||||
delete<T = any>(path: string, options?: RequestOptions): Promise<T>;
|
||||
|
|
@ -137,7 +138,7 @@ export function createApiClient(config: ApiClientConfig): ApiClient {
|
|||
const { getBaseUrl, getAuthHeaderName, onError, fetchImpl } = config;
|
||||
const doFetch: typeof fetch = (input, init) => (fetchImpl ?? fetch)(input, init);
|
||||
|
||||
async function request<T = any>(method: HttpMethod, path: string, options: RequestOptions = {}): Promise<T> {
|
||||
async function fetchChecked(method: HttpMethod, path: string, options: RequestOptions = {}): Promise<Response> {
|
||||
const { accessToken, body, rawBody, query, headers: extraHeaders, signal, credentials } = options;
|
||||
|
||||
const url = appendQuery(`${getBaseUrl()}${path}`, query);
|
||||
|
|
@ -177,13 +178,24 @@ export function createApiClient(config: ApiClientConfig): ApiClient {
|
|||
throw new ApiError(message, response.status, errorBody);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function request<T = any>(method: HttpMethod, path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const response = await fetchChecked(method, path, options);
|
||||
const text = await response.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
|
||||
async function getBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
||||
const response = await fetchChecked("GET", path, options);
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
get: (path, options) => request("GET", path, options),
|
||||
getBlob,
|
||||
post: (path, options) => request("POST", path, options),
|
||||
put: (path, options) => request("PUT", path, options),
|
||||
delete: (path, options) => request("DELETE", path, options),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue