mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
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:
parent
d40dedf23b
commit
b5a70cbc18
21 changed files with 605 additions and 58 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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") => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"] }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ export interface DailyActivityRequest {
|
|||
model?: string | null;
|
||||
includeCurrentUtcDay?: boolean;
|
||||
apiKeyLimit?: number;
|
||||
includeEntityBreakdown?: boolean;
|
||||
}
|
||||
|
||||
export const EMPTY_DAILY_ACTIVITY_METADATA: DailyActivityMetadata = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
14
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
14
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue