feat(ui): add Activity tabs to resource details (#50630)

* feat(ui): add Activity tabs to resource details

* fix(ui): preserve Activity dates and model breakdowns

* fix(ui): clarify team activity visibility

* fix(ui): skip unused entity breakdown in model activity
This commit is contained in:
moe-berri 2026-11-12 10:23:01 +08:00 • committed by GitHub
parent d40dedf23b
commit b5a70cbc18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 605 additions and 58 deletions

View file

@ -5146,6 +5146,18 @@
"type": "integer"
}
},
{
"description": "Include per-entity usage and key counts.",
"in": "query",
"name": "include_entity_breakdown",
"required": false,
"schema": {
"default": true,
"description": "Include per-entity usage and key counts.",
"title": "Include Entity Breakdown",
"type": "boolean"
}
},
{
"in": "query",
"name": "agent_ids",

View file

@ -273,6 +273,7 @@ def _register_aggregated_route(router: APIRouter, resolver: EntityScopeResolver,
api_key_limit: Annotated[
int, Query(ge=1, le=constants.USAGE_TOP_API_KEYS_MAX)
] = constants.USAGE_TOP_API_KEYS_DEFAULT,
include_entity_breakdown: Annotated[bool, Query(description="Include per-entity usage and key counts.")] = True,
) -> SpendAnalyticsPaginatedResponse:
try:
resolved: ResolvedScope = await _resolved_scope(
@ -286,7 +287,7 @@ def _register_aggregated_route(router: APIRouter, resolver: EntityScopeResolver,
repository,
resolved.scope,
entity_metadata_field=resolved.entity_metadata,
include_entity_breakdown=resolver.include_entity_breakdown,
include_entity_breakdown=resolver.include_entity_breakdown and include_entity_breakdown,
include_guardrail_breakdown=resolver.include_guardrail_breakdown,
api_key_limit=api_key_limit,
)

View file

@ -655,6 +655,26 @@ def test_aggregated_routes_return_scoped_results(
assert len(body["results"]) == 2, response.text
@pytest.mark.parametrize("model", (None, "popular"))
def test_aggregated_activity_can_omit_entities_without_changing_chart_data(
daily_activity_client: tuple[TestClient, _FakeRepository], model: str | None
) -> None:
client, _ = daily_activity_client
params: Final = {**_DATE_PARAMS, **({"model": model} if model else {})}
full: Final = client.get("/user/daily/activity/aggregated", params=params)
compact: Final = client.get(
"/user/daily/activity/aggregated", params={**params, "include_entity_breakdown": "false"}
)
assert full.status_code == compact.status_code == 200
body: Final = full.json()
assert all(day["breakdown"]["entities"] for day in body["results"])
assert compact.json() == {
**body,
"metadata": {**body["metadata"], "entity_total_api_keys": None},
"results": [{**day, "breakdown": {**day["breakdown"], "entities": {}}} for day in body["results"]],
}
def _ptu_sized_router(
model_group: str, shares: Mapping[str, int] = MappingProxyType({"team-a": 30, "team-b": 20})
) -> Router:

View file

@ -0,0 +1,36 @@
import type { ParseKeys } from "i18next";
import { useTranslation } from "react-i18next";
import { Field, FieldLabel } from "@/components/ui/field";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { SimpleTooltip } from "@/components/ui/tooltip";
const ADD_TEAM_ROLE_FIELD_ID = "add-team-role";
const MEMBER_ROLE_OPTIONS: ReadonlyArray<{ value: string; hintKey: ParseKeys }> = [
{ value: "user", hintKey: "users.userInfo.memberRoles.userHint" },
{ value: "admin", hintKey: "users.userInfo.memberRoles.adminHint" },
];
export function MemberRoleSelect({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const { t } = useTranslation();
return (
<Field>
<FieldLabel htmlFor={ADD_TEAM_ROLE_FIELD_ID}>{t("users.userInfo.addTeamDialog.memberRole")}</FieldLabel>
<Select value={value} onValueChange={(value) => value !== null && onChange(value)}>
<SelectTrigger id={ADD_TEAM_ROLE_FIELD_ID} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MEMBER_ROLE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} title={option.value}>
<SimpleTooltip content={t(option.hintKey)}>
<span className="font-medium">{option.value}</span>
<span className="ml-2 text-muted-foreground text-sm">- {t(option.hintKey)}</span>
</SimpleTooltip>
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}

View file

@ -10,6 +10,7 @@ import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { Profiler } from "react";
import UserInfoView from "./user_info_view";
import { EMPTY_DAILY_ACTIVITY_RESPONSE } from "@/components/UsagePage/dailyActivityApi";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import type { AutoRouterBenchmarksResponse } from "@/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks";
@ -122,6 +123,41 @@ describe("UserInfoView add-to-team form", () => {
mockListMCPTools.mockResolvedValue({ tools: [] });
});
it("loads only the selected user's Activity, after opening the tab", async () => {
mockUserDailyActivityAggregatedCall.mockResolvedValue(EMPTY_DAILY_ACTIVITY_RESPONSE);
render(<UserInfoView {...defaultProps} />);
const activityTab = await screen.findByRole("tab", { name: "Activity" });
expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled();
fireEvent.click(activityTab);
expect(await screen.findByText("No daily details available")).toBeInTheDocument();
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
"user",
expect.objectContaining({ entityIds: [defaultProps.userId], apiKey: undefined }),
);
vi.stubGlobal("requestIdleCallback", (callback: () => void) => {
callback();
return 0;
});
fireEvent.click(screen.getByRole("button", { name: /\d.* - .*\d/ }));
fireEvent.click(screen.getByRole("button", { name: /Last 7 Days/i }));
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2));
const selected = vi.mocked(mockUserDailyActivityAggregatedCall).mock.calls.at(-1)![1];
fireEvent.click(screen.getByRole("tab", { name: "Overview" }));
fireEvent.click(activityTab);
await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(3));
expect(mockUserDailyActivityAggregatedCall).toHaveBeenLastCalledWith(
"user",
expect.objectContaining({
startTime: selected.startTime,
endTime: selected.endTime,
}),
);
vi.unstubAllGlobals();
});
const setup = () => userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
const openAddTeam = async (user: ReturnType<typeof userEvent.setup>, anchorTeam = "Alpha Team") => {

View file

@ -1,4 +1,5 @@
import type { ParseKeys } from "i18next";
import ResourceActivity from "@/components/UsagePage/ResourceActivity";
import { MemberRoleSelect } from "./MemberRoleSelect";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@ -19,7 +20,6 @@ import {
teamMemberDeleteCall,
Member,
} from "@/components/networking";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import {
Combobox,
@ -29,7 +29,6 @@ import {
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { hasProxyWideSpendView, rolesWithWriteAccess } from "@/utils/roles";
import { teamDetailHref } from "@/utils/entityLinks";
import { BadgeLink } from "@/components/shared/BadgeLink";
@ -69,18 +68,12 @@ interface TeamDisplayInfo {
}
const ADD_TEAM_FIELD_ID = "add-team-team";
const ADD_TEAM_ROLE_FIELD_ID = "add-team-role";
interface TeamOption {
team_id: string;
team_alias: string;
}
const MEMBER_ROLE_OPTIONS: ReadonlyArray<{ value: string; hintKey: ParseKeys }> = [
{ value: "user", hintKey: "users.userInfo.memberRoles.userHint" },
{ value: "admin", hintKey: "users.userInfo.memberRoles.adminHint" },
];
export default function UserInfoView({
userId,
onClose,
@ -94,7 +87,7 @@ export default function UserInfoView({
const { t } = useTranslation();
const { premiumUser, userId: signedInUserId } = useAuthorized();
const canViewAutoRouterUsage = hasProxyWideSpendView(userRole);
const canViewSavings = canViewAutoRouterUsage || (Boolean(userId.trim()) && userId === signedInUserId);
const canViewUserUsage = canViewAutoRouterUsage || (Boolean(userId.trim()) && userId === signedInUserId);
const activityDateRange = useActivityDateRange();
const [userData, setUserData] = useState<UserInfoV2Response | null>(null);
const [teamDetails, setTeamDetails] = useState<TeamDisplayInfo[]>([]);
@ -107,7 +100,7 @@ export default function UserInfoView({
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
const [baseUrl, setBaseUrl] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>(initialTab === 1 ? "details" : "overview");
const hiddenSavingsTab = activeTab === "savings" && !canViewSavings;
const hiddenUsageTab = (activeTab === "savings" || activeTab === "activity") && !canViewUserUsage;
const hiddenRouterTab = activeTab === "auto-router-usage" && !canViewAutoRouterUsage;
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const [isTeamsExpanded, setIsTeamsExpanded] = useState(false);
@ -490,7 +483,7 @@ export default function UserInfoView({
/>
<Tabs
value={hiddenSavingsTab || hiddenRouterTab ? "overview" : activeTab}
value={hiddenUsageTab || hiddenRouterTab ? "overview" : activeTab}
onValueChange={(v: unknown) => setActiveTab(String(v))}
className="gap-0"
>
@ -501,7 +494,12 @@ export default function UserInfoView({
<TabsTrigger value="details" className="flex-none data-active:text-primary after:bg-primary">
{t("users.userInfo.tabs.details")}
</TabsTrigger>
{canViewSavings && (
{canViewUserUsage && (
<TabsTrigger value="activity" className="flex-none data-active:text-primary after:bg-primary">
{t("shared.resourceActivity.title")}
</TabsTrigger>
)}
{canViewUserUsage && (
<TabsTrigger value="savings" className="flex-none data-active:text-primary after:bg-primary">
{t("users.userInfo.tabs.savings")}
</TabsTrigger>
@ -513,6 +511,12 @@ export default function UserInfoView({
)}
</TabsList>
{canViewUserUsage && (
<TabsContent value="activity">
<ResourceActivity scope="user" id={userId} activity={activityDateRange} />
</TabsContent>
)}
{/* Overview Panel */}
<TabsContent value="overview" keepMounted>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
@ -731,7 +735,7 @@ export default function UserInfoView({
)}
</Card>
</TabsContent>
{canViewSavings && (
{canViewUserUsage && (
<TabsContent value="savings">
{activeTab === "savings" &&
(userId.trim() ? (
@ -829,24 +833,7 @@ export default function UserInfoView({
</Combobox>
</Field>
<Field>
<FieldLabel htmlFor={ADD_TEAM_ROLE_FIELD_ID}>{t("users.userInfo.addTeamDialog.memberRole")}</FieldLabel>
<Select value={selectedRole} onValueChange={(value) => value !== null && setSelectedRole(value)}>
<SelectTrigger id={ADD_TEAM_ROLE_FIELD_ID} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MEMBER_ROLE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} title={option.value}>
<SimpleTooltip content={t(option.hintKey)}>
<span className="font-medium">{option.value}</span>
<span className="ml-2 text-muted-foreground text-sm">- {t(option.hintKey)}</span>
</SimpleTooltip>
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<MemberRoleSelect value={selectedRole} onChange={setSelectedRole} />
</FieldGroup>
<div className="text-right mt-4">

View file

@ -0,0 +1,207 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import * as networking from "@/components/networking";
import { EMPTY_DAILY_ACTIVITY_METADATA, type DailyActivityAggregatedResponse } from "./dailyActivityApi";
import ResourceActivity from "./ResourceActivity";
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: vi.fn() }));
const auth = {
accessToken: "test-session",
userId: "user-1",
userRole: "Admin",
} as ReturnType<typeof useAuthorized>;
const response: DailyActivityAggregatedResponse = {
results: [
{
date: "2026-11-01",
metrics: {
spend: 12.34,
api_requests: 42,
successful_requests: 40,
failed_requests: 2,
total_tokens: 4200,
prompt_tokens: 3000,
completion_tokens: 1200,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
breakdown: {},
},
],
metadata: {
...EMPTY_DAILY_ACTIVITY_METADATA,
total_spend: 12.34,
total_api_requests: 42,
total_successful_requests: 40,
total_failed_requests: 2,
total_tokens: 4200,
},
};
describe("ResourceActivity", () => {
afterEach(() => vi.useRealTimers());
it.each(["team", "user"] as const)("shows the returned model breakdown for a %s", async (scope) => {
vi.mocked(networking.dailyActivityAggregatedCall).mockResolvedValue({
...response,
results: [
{
...response.results[0],
breakdown: {
models: {
"example-model": { metrics: response.results[0].metrics },
},
},
},
],
});
render(<ResourceActivity scope={scope} id="resource-1" />);
expect(await screen.findByRole("cell", { name: "example-model" })).toBeInTheDocument();
});
it("shows matching recorded models and their totals for a wildcard, in one request", async () => {
vi.mocked(networking.dailyActivityAggregatedCall).mockResolvedValue({
...response,
metadata: { ...response.metadata, total_spend: 124.68 },
results: [
{
...response.results[0],
breakdown: {
models: {
"openai/model-one": { metrics: response.results[0].metrics },
"openai/model-two": { metrics: response.results[0].metrics },
"anthropic/other-model": { metrics: { ...response.results[0].metrics, spend: 100 } },
},
},
},
],
});
render(<ResourceActivity scope="model" id="openai/*" />);
expect(await screen.findByText("$24.68")).toBeInTheDocument();
expect(screen.getByText("openai/model-one")).toBeInTheDocument();
expect(screen.getByText("openai/model-two")).toBeInTheDocument();
expect(screen.queryByText("anthropic/other-model")).not.toBeInTheDocument();
expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledExactlyOnceWith(
"user",
expect.objectContaining({ model: undefined, includeEntityBreakdown: false }),
);
});
it("keeps wildcard usage within the viewer's scope and shows empty when no model matches", async () => {
vi.mocked(useAuthorized).mockReturnValue({ ...auth, userRole: "Internal User" });
render(<ResourceActivity scope="model" id="openai/*" />);
expect(await screen.findByText("No daily details available")).toBeInTheDocument();
expect(screen.queryByText("$12.34")).not.toBeInTheDocument();
expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledWith(
"user",
expect.objectContaining({ model: undefined, entityIds: [auth.userId] }),
);
});
it("includes today's UTC bucket for a team when the local date is still yesterday", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-11-09T01:00:00Z"));
vi.spyOn(networking, "formatDate").mockImplementation((date) =>
new Date(date.getTime() - 8 * 60 * 60 * 1000).toISOString().slice(0, 10),
);
render(<ResourceActivity scope="team" id="team-1" />);
expect(await screen.findByText("$12.34")).toBeInTheDocument();
const request = vi.mocked(networking.dailyActivityAggregatedCall).mock.calls[0][1];
expect(request.endTime.getFullYear()).toBe(2026);
expect(request.endTime.getMonth()).toBe(10);
expect(request.endTime.getDate()).toBe(9);
});
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(useAuthorized).mockReturnValue(auth);
vi.spyOn(networking, "dailyActivityAggregatedCall").mockResolvedValue(response);
});
it.each([
{ scope: "key", id: "key-1", entity: "user", filter: { apiKey: "key-1", entityIds: null } },
{ scope: "team", id: "team-1", entity: "team", filter: { entityIds: ["team-1"] } },
{ scope: "user", id: "user-2", entity: "user", filter: { entityIds: ["user-2"] } },
{ scope: "model", id: "example-model", entity: "user", filter: { model: "example-model", entityIds: null } },
] as const)("shows existing Activity metrics filtered to the $scope", async ({ scope, id, entity, filter }) => {
render(<ResourceActivity scope={scope} id={id} />);
expect(await screen.findByText("$12.34")).toBeInTheDocument();
expect(screen.getByText("42")).toBeInTheDocument();
const expectedRequest = {
...filter,
includeEntityBreakdown: scope === "model" ? false : undefined,
startTime: expect.any(Date),
endTime: expect.any(Date),
};
expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledWith(
entity,
expect.objectContaining(expectedRequest),
);
});
it("keeps a non-admin key view within that user's spend scope", async () => {
vi.mocked(useAuthorized).mockReturnValue({ ...auth, userRole: "Internal User" });
render(<ResourceActivity scope="key" id="key-1" />);
expect(await screen.findByText("$12.34")).toBeInTheDocument();
expect(screen.getByText("Showing your usage only.")).toBeInTheDocument();
expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledWith(
"user",
expect.objectContaining({ entityIds: ["user-1"], apiKey: "key-1" }),
);
});
it("explains that team Activity is limited to the keys the viewer can access", async () => {
vi.mocked(useAuthorized).mockReturnValue({ ...auth, userRole: "Internal User" });
render(<ResourceActivity scope="team" id="team-1" />);
expect(await screen.findByText("$12.34")).toBeInTheDocument();
expect(screen.getByText("Showing usage for the team keys you have permission to view.")).toBeInTheDocument();
expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledWith(
"team",
expect.objectContaining({ entityIds: ["team-1"] }),
);
});
it("does not query another user's spend for a non-admin", () => {
vi.mocked(useAuthorized).mockReturnValue({ ...auth, userRole: "Internal User" });
render(<ResourceActivity scope="user" id="user-2" />);
expect(networking.dailyActivityAggregatedCall).not.toHaveBeenCalled();
expect(screen.queryByText("$12.34")).not.toBeInTheDocument();
});
it("shows a retryable error instead of zero usage when the request fails", async () => {
vi.mocked(networking.dailyActivityAggregatedCall).mockRejectedValueOnce(new Error("Activity unavailable"));
render(<ResourceActivity scope="team" id="team-1" />);
expect(await screen.findByRole("alert")).toHaveTextContent("Activity unavailable");
expect(screen.queryByText("$12.34")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
expect(await screen.findByText("$12.34")).toBeInTheDocument();
});
it("replaces the previous resource's data when navigating to another resource", async () => {
const { rerender } = render(<ResourceActivity scope="team" id="team-1" />);
expect(await screen.findByText("$12.34")).toBeInTheDocument();
vi.mocked(networking.dailyActivityAggregatedCall).mockResolvedValue({
results: [],
metadata: EMPTY_DAILY_ACTIVITY_METADATA,
});
rerender(<ResourceActivity scope="team" id="team-2" />);
expect(await screen.findByText("No daily details available")).toBeInTheDocument();
expect(screen.queryByText("$12.34")).not.toBeInTheDocument();
await waitFor(() =>
expect(networking.dailyActivityAggregatedCall).toHaveBeenLastCalledWith(
"team",
expect.objectContaining({ entityIds: ["team-2"] }),
),
);
});
});

View file

@ -0,0 +1,117 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import {
useActivityDateRange,
type ActivityDateRange,
} from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange";
import { benchmarksWindow } from "@/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks";
import { useAggregatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity";
import { getTopModels } from "@/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageAggregations";
import { ActivityMetrics, ModelSection, processActivityData } from "@/components/activity_metrics";
import { dailyActivityAggregatedCall } from "@/components/networking";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { ChartLoader } from "@/components/shared/chart_loader";
import { ErrorState } from "@/components/shared/ErrorState";
import { hasProxyWideSpendView, spendScopeUserId } from "@/utils/roles";
import { EMPTY_DAILY_ACTIVITY_METADATA, toDailyData, type DailyActivityRequest } from "./dailyActivityApi";
import { keyDetailFromResponse, overallUsageMetrics, reportingKeyId } from "./keyActivityData";
import { matchesWildcard } from "@/lib/wildcard_routes";
interface ResourceActivityProps {
scope: "key" | "team" | "user" | "model";
id: string;
activity?: ActivityDateRange;
}
export default function ResourceActivity({ scope, id, activity }: ResourceActivityProps) {
const { t } = useTranslation();
const { accessToken, userId, userRole } = useAuthorized();
const localActivity = useActivityDateRange();
const { dateValue, onDateChange } = activity ?? localActivity;
const wildcard = scope === "model" && id.includes("*");
const entity = scope === "team" ? "team" : "user";
const entityId = scope === "team" || scope === "user" ? id : spendScopeUserId(userRole, userId);
const apiKey = scope === "key" ? reportingKeyId(id) : undefined;
const canView = scope !== "user" || hasProxyWideSpendView(userRole) || id === userId;
const hasAccess = accessToken && id && canView;
const scopedToViewer = scope !== "user" && !hasProxyWideSpendView(userRole);
const request: DailyActivityRequest | null =
hasAccess && dateValue.from && dateValue.to
? {
accessToken,
startTime: dateValue.from,
endTime:
scope === "team" ? new Date(`${benchmarksWindow(dateValue, new Date()).end_date}T12:00:00`) : dateValue.to,
entityIds: entityId ? [entityId] : null,
apiKey,
apiKeyLimit: 1,
includeEntityBreakdown: scope === "model" ? false : undefined,
model: scope === "model" && !wildcard ? id : undefined,
includeCurrentUtcDay: true,
}
: null;
const { data, loading, failed, errorMessage, reload } = useAggregatedDailyActivity({
fetch: () => dailyActivityAggregatedCall(entity, request as DailyActivityRequest),
enabled: request !== null,
deps: [accessToken, scope, id, entityId, dateValue.from, dateValue.to],
});
const results = useMemo(() => toDailyData(data), [data]);
const metrics = useMemo(
() =>
(apiKey ? keyDetailFromResponse(data, apiKey, []) : undefined) ?? {
...overallUsageMetrics(results, data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA),
top_models:
scope === "team" || scope === "user"
? getTopModels(results, "models", Infinity).map(({ key, ...model }) => ({ model: key, ...model }))
: [],
},
[data, results, apiKey, scope],
);
const modelMetrics = useMemo(
() =>
wildcard
? Object.fromEntries(
Object.entries(processActivityData({ results }, "models", [])).filter(([model]) =>
matchesWildcard(id, model),
),
)
: {},
[results, wildcard, id],
);
if (!request) return null;
const content = (() => {
if (loading) return <ChartLoader />;
if (failed) return <ErrorState message={errorMessage} onRetry={reload} />;
if (data.results.length === 0 || (wildcard && Object.keys(modelMetrics).length === 0)) {
return <p className="py-8 text-center text-muted-foreground">{t("usage.keyActivity.noDailyDetails")}</p>;
}
return wildcard ? (
<ActivityMetrics modelMetrics={modelMetrics} />
) : (
<ModelSection modelName={id} metrics={metrics} />
);
})();
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">{t("shared.scopedSavingsTab.bucketedByUtc")}</span>
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
</div>
{scopedToViewer && (
<p className="text-sm text-muted-foreground">
{t(scope === "team" ? "shared.resourceActivity.teamScope" : "shared.resourceActivity.ownUsage")}
</p>
)}
{scope === "model" && (
<p className="text-sm text-muted-foreground">{t("shared.resourceActivity.modelScope", { model: id })}</p>
)}
{content}
</div>
);
}

View file

@ -26,6 +26,7 @@ export interface DailyActivityRequest {
model?: string | null;
includeCurrentUtcDay?: boolean;
apiKeyLimit?: number;
includeEntityBreakdown?: boolean;
}
export const EMPTY_DAILY_ACTIVITY_METADATA: DailyActivityMetadata = {

View file

@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import type { CredentialItem } from "@/components/networking";
import { CredentialIdentity } from "@/components/shared/CredentialIdentity";
import { credentialLabelsByName } from "@/components/shared/credentialOptions";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export function ExistingCredentialDialog({
open,
onClose,
credentialName,
credentials,
}: {
open: boolean;
onClose: () => void;
credentialName: string;
credentials: CredentialItem[];
}) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t("modelInfoView.existingCredentialDialog.title")}</DialogTitle>
</DialogHeader>
<CredentialIdentity
className="text-sm"
credentialName={credentialName}
label={credentialLabelsByName(credentials).get(credentialName)}
/>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("modelInfoView.existingCredentialDialog.cancel")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
import React, { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ModelInfoView from "./model_info_view";
import { EMPTY_DAILY_ACTIVITY_RESPONSE } from "./UsagePage/dailyActivityApi";
import { toast } from "@/lib/toast";
import * as networking from "./networking";
import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers";
@ -20,6 +21,7 @@ vi.mock("../../utils/dataUtils", () => ({
}));
vi.mock("./networking", () => ({
dailyActivityAggregatedCall: vi.fn(),
credentialGetCall: vi.fn(),
credentialListCall: vi.fn(),
getGuardrailsList: vi.fn(),
@ -198,6 +200,41 @@ describe("ModelInfoView", () => {
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("loads Activity for the underlying model only when its tab is opened", async () => {
vi.mocked(networking.dailyActivityAggregatedCall).mockReset().mockResolvedValue(EMPTY_DAILY_ACTIVITY_RESPONSE);
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
const activityTab = await screen.findByRole("tab", { name: "Activity" });
expect(networking.dailyActivityAggregatedCall).not.toHaveBeenCalled();
fireEvent.click(activityTab);
expect(await screen.findByText("No daily details available")).toBeInTheDocument();
expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledWith(
"user",
expect.objectContaining({ model: defaultModelData.litellm_params.model }),
);
vi.stubGlobal("requestIdleCallback", (callback: () => void) => {
callback();
return 0;
});
fireEvent.click(screen.getByRole("button", { name: /\d.* - .*\d/ }));
fireEvent.click(screen.getByRole("button", { name: /Last 7 Days/i }));
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
await waitFor(() => expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledTimes(2));
const selected = vi.mocked(networking.dailyActivityAggregatedCall).mock.calls.at(-1)![1];
fireEvent.click(screen.getByRole("tab", { name: "Overview" }));
fireEvent.click(activityTab);
await waitFor(() => expect(networking.dailyActivityAggregatedCall).toHaveBeenCalledTimes(3));
expect(networking.dailyActivityAggregatedCall).toHaveBeenLastCalledWith(
"user",
expect.objectContaining({
startTime: selected.startTime,
endTime: selected.endTime,
}),
);
vi.unstubAllGlobals();
});
it("should render", async () => {
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
await waitFor(() => {

View file

@ -1,3 +1,5 @@
import ResourceActivity from "@/components/UsagePage/ResourceActivity";
import { useActivityDateRange } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange";
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { useModelHub, useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { useQueryClient } from "@tanstack/react-query";
@ -31,8 +33,7 @@ import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal";
import ReuseCredentialsModal from "./model_add/reuse_credentials";
import { credentialLabelsByName } from "./shared/credentialOptions";
import { CredentialIdentity } from "./shared/CredentialIdentity";
import { ExistingCredentialDialog } from "./model_info/ExistingCredentialDialog";
import { toast } from "@/lib/toast";
import {
CredentialItem,
@ -78,6 +79,7 @@ export default function ModelInfoView({
}: ModelInfoViewProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const activityDateRange = useActivityDateRange();
const [localModelData, setLocalModelData] = useState<any>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
@ -615,6 +617,9 @@ export default function ModelInfoView({
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
{t("modelInfoView.tabs.overview")}
</TabsTrigger>
<TabsTrigger value="activity" className="flex-none rounded-none px-4 py-2">
{t("shared.resourceActivity.title")}
</TabsTrigger>
<TabsTrigger value="raw" className="flex-none rounded-none px-4 py-2">
{t("modelInfoView.tabs.rawJson")}
</TabsTrigger>
@ -751,6 +756,10 @@ export default function ModelInfoView({
</Card>
</TabsContent>
<TabsContent value="activity">
<ResourceActivity scope="model" id={modelData.litellm_model_name} activity={activityDateRange} />
</TabsContent>
<TabsContent value="raw" keepMounted>
<Card className="block p-6">
<pre className="bg-muted p-4 rounded-sm text-xs overflow-auto">
@ -803,25 +812,12 @@ export default function ModelInfoView({
setIsCredentialModalOpen={setIsCredentialModalOpen}
/>
) : (
<Dialog open={isCredentialModalOpen} onOpenChange={(open) => !open && setIsCredentialModalOpen(false)}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t("modelInfoView.existingCredentialDialog.title")}</DialogTitle>
</DialogHeader>
<CredentialIdentity
className="text-sm"
credentialName={modelData.litellm_params.litellm_credential_name ?? ""}
label={credentialLabelsByName(credentialsList).get(
modelData.litellm_params.litellm_credential_name ?? "",
)}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCredentialModalOpen(false)}>
{t("modelInfoView.existingCredentialDialog.cancel")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ExistingCredentialDialog
open={isCredentialModalOpen}
onClose={() => setIsCredentialModalOpen(false)}
credentialName={modelData.litellm_params.litellm_credential_name ?? ""}
credentials={credentialsList}
/>
)}
{isUpdateCredentialsModalOpen && accessToken && (

View file

@ -41,6 +41,14 @@ afterEach(() => {
});
describe("dailyActivityAggregatedCall", () => {
it.each([undefined, true, false])("sends the entity breakdown choice %s without changing defaults", async (value) => {
const mockFetch = captureFetch();
await dailyActivityAggregatedCall("user", req({ includeEntityBreakdown: value }));
expect(requestedUrl(mockFetch).searchParams.get("include_entity_breakdown")).toBe(
value === undefined ? null : String(value),
);
});
it.each<[DailyActivityEntity, string]>([
["user", "user_id"],
["team", "team_ids"],

View file

@ -1395,7 +1395,10 @@ export const dailyActivityAggregatedCall = (
): Promise<DailyActivityAggregatedResponse> =>
apiClient.get<DailyActivityAggregatedResponse>(`/${entity}/daily/activity/aggregated`, {
accessToken: req.accessToken,
query: dailyActivityQuery(entity, req, req.apiKeyLimit === undefined ? {} : { api_key_limit: req.apiKeyLimit }),
query: dailyActivityQuery(entity, req, {
api_key_limit: req.apiKeyLimit,
include_entity_breakdown: req.includeEntityBreakdown,
}),
});
export const subscriptionUsageCall = (

View file

@ -1,3 +1,4 @@
import ResourceActivity from "@/components/UsagePage/ResourceActivity";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import type { components } from "@/lib/http/schema";
import useCan from "@/app/(dashboard)/hooks/useCan";
@ -1607,6 +1608,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</div>
),
},
{
key: TEAM_INFO_TAB_KEYS.ACTIVITY,
label: t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.ACTIVITY]),
children: <ResourceActivity scope="team" id={teamId} />,
},
{
key: TEAM_INFO_TAB_KEYS.MY_USER,
label: t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MY_USER]),

View file

@ -23,13 +23,19 @@ describe("team_info_tabs", () => {
describe("getTeamInfoVisibleTabs", () => {
it("returns overview, my user, and virtual keys when user cannot edit team", () => {
const tabs = getTeamInfoVisibleTabs(false);
expect(tabs).toEqual([TEAM_INFO_TAB_KEYS.OVERVIEW, TEAM_INFO_TAB_KEYS.MY_USER, TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]);
expect(tabs).toEqual([
TEAM_INFO_TAB_KEYS.OVERVIEW,
TEAM_INFO_TAB_KEYS.ACTIVITY,
TEAM_INFO_TAB_KEYS.MY_USER,
TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS,
]);
});
it("returns all tabs when user can edit team", () => {
const tabs = getTeamInfoVisibleTabs(true);
expect(tabs).toEqual([
TEAM_INFO_TAB_KEYS.OVERVIEW,
TEAM_INFO_TAB_KEYS.ACTIVITY,
TEAM_INFO_TAB_KEYS.MY_USER,
TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS,
TEAM_INFO_TAB_KEYS.MEMBERS,

View file

@ -7,6 +7,7 @@ import type { ParseKeys } from "i18next";
export const TEAM_INFO_TAB_KEYS = {
OVERVIEW: "overview",
ACTIVITY: "activity",
MY_USER: "my-user",
VIRTUAL_KEYS: "virtual-keys",
MEMBERS: "members",
@ -15,6 +16,7 @@ export const TEAM_INFO_TAB_KEYS = {
} as const;
export const TEAM_INFO_TAB_LABELS: Record<string, ParseKeys> = {
[TEAM_INFO_TAB_KEYS.ACTIVITY]: "shared.resourceActivity.title",
[TEAM_INFO_TAB_KEYS.OVERVIEW]: "teams.teamInfo.tabs.overview",
[TEAM_INFO_TAB_KEYS.MY_USER]: "teams.teamInfo.tabs.myUser",
[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]: "teams.teamInfo.tabs.virtualKeys",
@ -29,7 +31,12 @@ export const TEAM_INFO_TAB_LABELS: Record<string, ParseKeys> = {
* - Members, Member Permissions, Settings: only when canEditTeam is true
*/
export function getTeamInfoVisibleTabs(canEditTeam: boolean): readonly string[] {
const baseTabs = [TEAM_INFO_TAB_KEYS.OVERVIEW, TEAM_INFO_TAB_KEYS.MY_USER, TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS];
const baseTabs = [
TEAM_INFO_TAB_KEYS.OVERVIEW,
TEAM_INFO_TAB_KEYS.ACTIVITY,
TEAM_INFO_TAB_KEYS.MY_USER,
TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS,
];
if (canEditTeam) {
return [
...baseTabs,

View file

@ -1,3 +1,4 @@
import ResourceActivity from "@/components/UsagePage/ResourceActivity";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useModels";
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
@ -746,6 +747,9 @@ export default function KeyInfoView({
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
{t("keyDetail.keyInfoView.tabs.overview")}
</TabsTrigger>
<TabsTrigger value="activity" className="flex-none rounded-none px-4 py-2">
{t("shared.resourceActivity.title")}
</TabsTrigger>
<TabsTrigger value="savings" className="flex-none rounded-none px-4 py-2">
{t("keyDetail.keyInfoView.tabs.savings")}
</TabsTrigger>
@ -950,6 +954,10 @@ export default function KeyInfoView({
</div>
</TabsContent>
<TabsContent value="activity">
<ResourceActivity scope="key" id={currentKeyData.token} activity={activityDateRange} />
</TabsContent>
{/* Savings Panel. No keepMounted: this tab sweeps the daily rollup, and staying mounted
would fire that request on every key page open for people who never look at it. */}
<TabsContent value="savings">

View file

@ -8610,6 +8610,12 @@
}
},
"shared": {
"resourceActivity": {
"title": "Activity",
"ownUsage": "Showing your usage only.",
"teamScope": "Showing usage for the team keys you have permission to view.",
"modelScope": "Usage for {{model}} across deployments of this model."
},
"credentialOptions": {
"none": "None"
},

View file

@ -54218,6 +54218,8 @@ export interface operations {
parameters: {
query?: {
api_key_limit?: number;
/** @description Include per-entity usage and key counts. */
include_entity_breakdown?: boolean;
agent_ids?: string | null;
start_date?: string | null;
end_date?: string | null;
@ -59906,6 +59908,8 @@ export interface operations {
parameters: {
query?: {
api_key_limit?: number;
/** @description Include per-entity usage and key counts. */
include_entity_breakdown?: boolean;
end_user_ids?: string | null;
start_date?: string | null;
end_date?: string | null;
@ -60963,6 +60967,8 @@ export interface operations {
parameters: {
query?: {
api_key_limit?: number;
/** @description Include per-entity usage and key counts. */
include_entity_breakdown?: boolean;
end_user_ids?: string | null;
start_date?: string | null;
end_date?: string | null;
@ -70582,6 +70588,8 @@ export interface operations {
parameters: {
query?: {
api_key_limit?: number;
/** @description Include per-entity usage and key counts. */
include_entity_breakdown?: boolean;
organization_ids?: string | null;
start_date?: string | null;
end_date?: string | null;
@ -76854,6 +76862,8 @@ export interface operations {
parameters: {
query?: {
api_key_limit?: number;
/** @description Include per-entity usage and key counts. */
include_entity_breakdown?: boolean;
start_date?: string | null;
end_date?: string | null;
model?: string | null;
@ -77544,6 +77554,8 @@ export interface operations {
parameters: {
query?: {
api_key_limit?: number;
/** @description Include per-entity usage and key counts. */
include_entity_breakdown?: boolean;
team_ids?: string | null;
start_date?: string | null;
end_date?: string | null;
@ -79967,6 +79979,8 @@ export interface operations {
parameters: {
query?: {
api_key_limit?: number;
/** @description Include per-entity usage and key counts. */
include_entity_breakdown?: boolean;
/** @description Start date in YYYY-MM-DD format */
start_date?: string | null;
/** @description End date in YYYY-MM-DD format */

View file

@ -7,7 +7,7 @@ export type WildcardRoute = { readonly model: string; readonly sourceGroup: stri
// A linear glob scan rather than a RegExp: patterns are admin-controlled model_name values, and a
// backtracking regex built from one ("a*a*a*...") can freeze another admin's dashboard.
const matchesWildcard = (pattern: string, name: string): boolean => {
export const matchesWildcard = (pattern: string, name: string): boolean => {
const parts = pattern.split("*");
const head = parts[0];
const tail = parts[parts.length - 1];