mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
feat(ui): shareable URL state for usage views and users-to-usage deeplinks
The Usage page now keeps the view, global view tab, date range and entity filters in the URL, so links can be bookmarked and shared. Relative presets are stored as range=7d and keep rolling, custom ranges as UTC from/to. Invalid params are dropped, and a link to a view or user the viewer cannot access shows a toast and falls back to the default view. Internal Users rows get a "View usage" action that opens usage filtered to that user, and the per-user usage views link back to the user's record. A "Copy Share Link" button sits next to "Ask AI"
This commit is contained in:
parent
7244040908
commit
8cf03d6fd9
13 changed files with 777 additions and 118 deletions
|
|
@ -1140,7 +1140,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, type ComponentProps, 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";
|
||||
|
|
@ -9,6 +9,13 @@ import type { DailyData, KeyMetadata, KeyMetricWithMetadata, SpendMetrics } from
|
|||
import EntityUsage from "./EntityUsage";
|
||||
import { getGlobalTopKeys, getTopAPIKeys } from "./entityUsageAggregations";
|
||||
|
||||
type EntityUsageProps = ComponentProps<typeof EntityUsage>;
|
||||
|
||||
function StatefulEntityUsage(props: Omit<EntityUsageProps, "selectedEntities" | "onSelectedEntitiesChange">) {
|
||||
const [selectedEntities, setSelectedEntities] = useState<readonly string[]>([]);
|
||||
return <EntityUsage {...props} selectedEntities={selectedEntities} onSelectedEntitiesChange={setSelectedEntities} />;
|
||||
}
|
||||
|
||||
const emptySpendMetrics: SpendMetrics = {
|
||||
spend: 0,
|
||||
prompt_tokens: 0,
|
||||
|
|
@ -513,7 +520,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should render with tag entity type and display spend metrics", async () => {
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -531,7 +538,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should render with team entity type and call team API", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
|
|
@ -554,12 +561,12 @@ describe("EntityUsage", () => {
|
|||
teamsResult([{ team_id: "team-alpha" }, { team_id: "litellm-dashboard" }, { team_id: "team-beta" }]),
|
||||
);
|
||||
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
expect(await screen.findByText("team-user-spend:team-alpha|team-beta")).toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
mockUseTeams.mockReturnValue(teamsResult([]));
|
||||
render(<EntityUsage {...defaultProps} entityType="tag" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="tag" />);
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -567,7 +574,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should render with organization entity type and call organization API", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="organization" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="organization" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOrganizationDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -582,7 +589,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should render with customer entity type and call customer API", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="customer" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="customer" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCustomerDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -597,7 +604,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should render with agent entity type and call agent API", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="agent" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="agent" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAgentDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -612,7 +619,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should render with user entity type and call user API", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="user" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="user" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -627,7 +634,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should switch between tabs", async () => {
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -655,7 +662,7 @@ describe("EntityUsage", () => {
|
|||
...mockSpendData,
|
||||
metadata: { ...mockSpendData.metadata, api_key_limit: 100, total_api_keys: 3000 },
|
||||
});
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
|
|
@ -693,7 +700,7 @@ describe("EntityUsage", () => {
|
|||
];
|
||||
|
||||
it.each(NON_TEAM_PANELS)("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => {
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -719,7 +726,7 @@ describe("EntityUsage", () => {
|
|||
];
|
||||
|
||||
it.each(TEAM_PANELS)("shows only the %s panel for the team entity type", async (tabLabel, marker) => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
|
|
@ -750,7 +757,7 @@ describe("EntityUsage", () => {
|
|||
|
||||
mockTagDailyActivityCall.mockResolvedValue(emptyData);
|
||||
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -763,7 +770,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should display Model Activity tab for non-agent entity types", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="tag" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="tag" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -773,7 +780,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should display Request / Token Consumption tab for agent entity type", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="agent" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="agent" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAgentDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -783,7 +790,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should display Top Public Model Names title for non-agent entity types", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="tag" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="tag" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -793,7 +800,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("defaults Model Activity to public model names and toggles to litellm models", async () => {
|
||||
const { container } = render(<EntityUsage {...defaultProps} />);
|
||||
const { container } = render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -819,7 +826,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should display Top Agents title for agent entity type", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="agent" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="agent" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAgentDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -834,7 +841,7 @@ describe("EntityUsage", () => {
|
|||
{ label: "Tag 2", value: "tag-2" },
|
||||
];
|
||||
|
||||
render(<EntityUsage {...defaultProps} entityList={customEntityList} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityList={customEntityList} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -848,7 +855,7 @@ describe("EntityUsage", () => {
|
|||
it("should fallback to team_alias when entityList is provided but entity does not exist", async () => {
|
||||
const customEntityList = [{ label: "Tag 2", value: "tag-2" }];
|
||||
|
||||
render(<EntityUsage {...defaultProps} entityList={customEntityList} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityList={customEntityList} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -860,7 +867,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should fallback to team_alias when entityList is null", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityList={null} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityList={null} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -872,7 +879,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should still request the filter when the caller's tag scope is empty", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityList={[]} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityList={[]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -882,7 +889,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should not request the filter while the entity list is still unresolved", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityList={null} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityList={null} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -892,7 +899,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should display Agent Activity tab for team entity type", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
|
|
@ -902,7 +909,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should not display Agent Activity tab for non-team entity types", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="tag" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="tag" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -912,7 +919,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should display Top Agents Driving Spend card for team entity type", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
|
|
@ -922,7 +929,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should not display Top Agents Driving Spend card for non-team entity types", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="tag" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="tag" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -932,7 +939,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should fetch agent activity data when entity type is team", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAgentDailyActivityCall).toHaveBeenCalledWith(
|
||||
|
|
@ -946,7 +953,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should not fetch agent activity data for non-team entity types", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="tag" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="tag" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -956,7 +963,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should switch to Agent Activity tab for team entity type", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
|
|
@ -993,7 +1000,7 @@ describe("EntityUsage", () => {
|
|||
|
||||
mockTagDailyActivityCall.mockResolvedValue(spendDataWithoutAlias);
|
||||
|
||||
render(<EntityUsage {...defaultProps} entityList={null} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityList={null} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -1005,7 +1012,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("renders daily spend bars, per-entity bars, and the provider donut with cyan fills and a $ center total", async () => {
|
||||
const { container } = render(<EntityUsage {...defaultProps} />);
|
||||
const { container } = render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -1054,7 +1061,7 @@ describe("EntityUsage", () => {
|
|||
mockUserDailyActivityCall.mockResolvedValue(spendDataForUser);
|
||||
|
||||
// entityList is null to simulate a spender missing from the paginated user list
|
||||
render(<EntityUsage {...defaultProps} entityType="user" entityList={null} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="user" entityList={null} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -1067,7 +1074,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("renders the provider spend table logo from the bundled provider map", async () => {
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
const logo = await screen.findByAltText("openai logo");
|
||||
expect(logo).toHaveAttribute("src", expect.stringContaining("openai_small"));
|
||||
|
|
@ -1078,7 +1085,7 @@ describe("EntityUsage", () => {
|
|||
["organization", () => mockOrganizationDailyActivityCall, "Organization Spend Overview"],
|
||||
["agent", () => mockAgentDailyActivityCall, "Agent Spend Overview"],
|
||||
] as const)("fetches %s activity for an admin but not for an internal user", async (entityType, call, heading) => {
|
||||
render(<EntityUsage {...defaultProps} entityType={entityType} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType={entityType} />);
|
||||
await waitFor(() => {
|
||||
expect(call()).toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -1086,7 +1093,7 @@ describe("EntityUsage", () => {
|
|||
cleanup();
|
||||
call().mockClear();
|
||||
|
||||
render(<EntityUsage {...defaultProps} entityType={entityType} userRole="Internal User" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType={entityType} userRole="Internal User" />);
|
||||
expect(await screen.findByText(heading)).toBeInTheDocument();
|
||||
expect(call()).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -1099,7 +1106,9 @@ describe("EntityUsage", () => {
|
|||
["organization", () => mockOrganizationDailyActivityCall, true],
|
||||
["agent", () => mockAgentDailyActivityCall, false],
|
||||
] as const)("fetches %s activity for an org admin: %s", async (entityType, call, expected) => {
|
||||
render(<EntityUsage {...defaultProps} entityType={entityType} userRole="Internal User" isOrgAdmin={true} />);
|
||||
render(
|
||||
<StatefulEntityUsage {...defaultProps} entityType={entityType} userRole="Internal User" isOrgAdmin={true} />,
|
||||
);
|
||||
|
||||
if (expected) {
|
||||
await waitFor(() => {
|
||||
|
|
@ -1112,7 +1121,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("keeps the team breakdown but drops its agent sub-fetch for an internal user", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" userRole="Internal User" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" userRole="Internal User" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
|
|
@ -1125,7 +1134,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("keeps the tag breakdown for an internal user", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="tag" userRole="Internal User" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="tag" userRole="Internal User" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -1151,7 +1160,7 @@ describe("EntityUsage", () => {
|
|||
};
|
||||
mockTagDailyActivityCall.mockResolvedValue(spendDataUnknownProvider);
|
||||
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("zzz-internal").length).toBeGreaterThan(0);
|
||||
|
|
@ -1191,7 +1200,7 @@ describe("EntityUsage", () => {
|
|||
],
|
||||
});
|
||||
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("top-keys:sk-abc=30.75=alice@example.com")).toBeInTheDocument();
|
||||
|
|
@ -1201,7 +1210,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("uses the aggregated team endpoint and never drains paginated pages for teams", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
|
|
@ -1216,7 +1225,7 @@ describe("EntityUsage", () => {
|
|||
it("falls back to the paginated team endpoint when the aggregated call fails", async () => {
|
||||
mockTeamDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable"));
|
||||
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -1232,12 +1241,54 @@ describe("EntityUsage", () => {
|
|||
const userCombobox = (): HTMLElement => within(userDropdown()).getByRole("combobox");
|
||||
|
||||
const renderUserUsage = async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="user" entityList={null} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="user" entityList={null} />);
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
};
|
||||
|
||||
it("links the single picked user back to their Internal Users record", async () => {
|
||||
render(
|
||||
<EntityUsage
|
||||
{...defaultProps}
|
||||
entityType="user"
|
||||
entityList={null}
|
||||
selectedEntities={["user-001"]}
|
||||
onSelectedEntitiesChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("link", { name: "View user record" })).toHaveAttribute(
|
||||
"href",
|
||||
"/ui/users?user=user-001",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no user record link until a user is picked", async () => {
|
||||
await renderUserUsage();
|
||||
|
||||
expect(screen.queryByRole("link", { name: "View user record" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports the picked user to the owner of the selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectedEntitiesChange = vi.fn();
|
||||
render(
|
||||
<EntityUsage
|
||||
{...defaultProps}
|
||||
entityType="user"
|
||||
entityList={null}
|
||||
selectedEntities={[]}
|
||||
onSelectedEntitiesChange={onSelectedEntitiesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(userCombobox());
|
||||
await user.click(await screen.findByText("Alice (user-001)"));
|
||||
|
||||
expect(onSelectedEntitiesChange).toHaveBeenCalledWith(["user-001"]);
|
||||
});
|
||||
|
||||
it("offers a user filter even when the caller preloaded no user page", async () => {
|
||||
await renderUserUsage();
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||
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 UserRecordLink from "../UserRecordLink";
|
||||
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
|
||||
import { UsageExportHeader } from "@/components/EntityUsageExport";
|
||||
import { getApiKeyTruncation, getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||
|
|
@ -93,6 +94,8 @@ interface EntityUsageProps {
|
|||
premiumUser: boolean;
|
||||
dateValue: DateRangePickerValue;
|
||||
isOrgAdmin?: boolean;
|
||||
selectedEntities: readonly string[];
|
||||
onSelectedEntitiesChange: (ids: readonly string[]) => void;
|
||||
}
|
||||
|
||||
const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
|
||||
|
|
@ -123,9 +126,10 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
userRole,
|
||||
dateValue,
|
||||
isOrgAdmin = false,
|
||||
selectedEntities: selectedTags,
|
||||
onSelectedEntitiesChange: setSelectedTags,
|
||||
}) => {
|
||||
const { teams } = useTeams();
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [modelViewType, setModelViewType] = useState<ModelViewType>("groups");
|
||||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
|
||||
|
|
@ -289,7 +293,10 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
const entityFilterSlots: Partial<Record<EntityType, ReactNode>> = {
|
||||
team: <TeamMultiSelect value={selectedTags} onChange={setSelectedTags} />,
|
||||
user: (
|
||||
<UserDropdown value={selectedTags[0] ?? null} onChange={(userId) => setSelectedTags(userId ? [userId] : [])} />
|
||||
<div className="flex items-center gap-3">
|
||||
<UserDropdown value={selectedTags[0] ?? null} onChange={(userId) => setSelectedTags(userId ? [userId] : [])} />
|
||||
{selectedTags.length === 1 && <UserRecordLink userId={selectedTags[0]} />}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
const filterSlot = entityFilterSlots[entityType];
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
|
|||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { UrlUpdateEvent } from "nuqs/adapters/testing";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { Organization } from "@/components/networking";
|
||||
import * as networking from "@/components/networking";
|
||||
import UsagePage from "./UsagePageView";
|
||||
|
|
@ -48,9 +50,27 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("./EntityUsage/EntityUsage", () => ({
|
||||
default: ({ entityType, entityList }: { entityType: string; entityList: unknown }) => (
|
||||
<div data-testid="entity-usage" data-entity-type={entityType} data-entity-list={JSON.stringify(entityList ?? null)}>
|
||||
default: ({
|
||||
entityType,
|
||||
entityList,
|
||||
selectedEntities,
|
||||
onSelectedEntitiesChange,
|
||||
}: {
|
||||
entityType: string;
|
||||
entityList: unknown;
|
||||
selectedEntities: readonly string[];
|
||||
onSelectedEntitiesChange: (ids: readonly string[]) => void;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="entity-usage"
|
||||
data-entity-type={entityType}
|
||||
data-entity-list={JSON.stringify(entityList ?? null)}
|
||||
data-selected={JSON.stringify(selectedEntities)}
|
||||
>
|
||||
Entity Usage
|
||||
<button type="button" onClick={() => onSelectedEntitiesChange(["picked-1", "picked-2"])}>
|
||||
pick entities
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
EntityList: [],
|
||||
|
|
@ -64,7 +84,8 @@ vi.mock("./EndpointUsage/EndpointUsage", () => ({
|
|||
default: () => <div>Endpoint Usage</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
|
||||
vi.mock("./UsageViewSelect/UsageViewSelect", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./UsageViewSelect/UsageViewSelect")>();
|
||||
const React = await import("react");
|
||||
const UsageViewSelect = ({ value, onChange, canViewTagUsage = false }: any) => {
|
||||
const tagOption = canViewTagUsage ? React.createElement("option", { value: "tag" }, "Tag Usage") : null;
|
||||
|
|
@ -87,10 +108,11 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
|
|||
);
|
||||
};
|
||||
UsageViewSelect.displayName = "UsageViewSelect";
|
||||
return { UsageViewSelect };
|
||||
return { ...actual, UsageViewSelect };
|
||||
});
|
||||
|
||||
vi.mock("@/components/shared/advanced_date_picker", async () => {
|
||||
vi.mock("@/components/shared/advanced_date_picker", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/components/shared/advanced_date_picker")>();
|
||||
const React = await import("react");
|
||||
// The button is how a test drives a range change; the real picker's own UI is
|
||||
// not what any test here is asserting on.
|
||||
|
|
@ -110,7 +132,7 @@ vi.mock("@/components/shared/advanced_date_picker", async () => {
|
|||
),
|
||||
);
|
||||
AdvancedDatePicker.displayName = "AdvancedDatePicker";
|
||||
return { default: AdvancedDatePicker };
|
||||
return { ...actual, default: AdvancedDatePicker };
|
||||
});
|
||||
|
||||
vi.mock("@/components/user_agent_activity", () => ({
|
||||
|
|
@ -147,6 +169,10 @@ vi.mock("@/app/(dashboard)/hooks/useIsOrgAdmin", () => ({
|
|||
default: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||
useOrganizations: vi.fn(() => ({ data: [], isPending: false })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
|
||||
useCurrentUser: vi.fn(),
|
||||
}));
|
||||
|
|
@ -1365,4 +1391,142 @@ describe("UsagePage", () => {
|
|||
expect(screen.getByText("Endpoint Activity")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe("URL state", () => {
|
||||
const renderAt = (searchParams: string) => {
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
|
||||
const view = renderWithProviders(<UsagePage {...defaultProps} />, {
|
||||
searchParams,
|
||||
onUrlUpdate,
|
||||
resetUrlUpdateQueueOnMount: false,
|
||||
});
|
||||
const lastQuery = (): string | undefined => onUrlUpdate.mock.lastCall?.[0].queryString;
|
||||
return { ...view, onUrlUpdate, lastQuery };
|
||||
};
|
||||
|
||||
it("preselects the user filter from ?user= and links back to that user's record", async () => {
|
||||
renderAt("?user=user-001");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
"user-001",
|
||||
);
|
||||
});
|
||||
expect(screen.getByRole("link", { name: "View user record" })).toHaveAttribute("href", "/ui/users?user=user-001");
|
||||
});
|
||||
|
||||
it("shows no user record link while the global view is unfiltered", async () => {
|
||||
renderAt("");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
null,
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole("link", { name: "View user record" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the linked entity view with its selection and writes new picks back", async () => {
|
||||
const { lastQuery } = renderAt("?view=team&team=team-1");
|
||||
|
||||
const entityUsage = await screen.findByTestId("entity-usage");
|
||||
expect(entityUsage).toHaveAttribute("data-entity-type", "team");
|
||||
expect(entityUsage).toHaveAttribute("data-selected", JSON.stringify(["team-1"]));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "pick entities" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastQuery()).toBe("?view=team&team=picked-1&team=picked-2");
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the chosen view and drops the previous view's filters", async () => {
|
||||
const { lastQuery } = renderAt("?user=user-001&tab=keys&range=30d");
|
||||
|
||||
fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "customer" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastQuery()).toBe("?range=30d&view=customer");
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the inner tab from the URL and writes tab changes", async () => {
|
||||
const { lastQuery } = renderAt("?tab=models");
|
||||
|
||||
expect(await screen.findByRole("tab", { name: "Model Activity" })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Key Activity" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastQuery()).toBe("?tab=keys");
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches the custom range in the URL and writes a newly picked one", async () => {
|
||||
const { lastQuery } = renderAt("?from=2025-03-01T00:00:00.000Z&to=2025-03-05T00:00:00.000Z");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
new Date("2025-03-01T00:00:00.000Z"),
|
||||
new Date("2025-03-05T00:00:00.000Z"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("pick-a-different-range"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastQuery()).toBe("?from=2024-01-01T00:00:00.000Z&to=2024-01-08T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
it("tells a viewer they lack access and sends them to the default view", async () => {
|
||||
const warning = vi.spyOn(toast, "warning");
|
||||
mockUseAuthorized.mockReturnValue(nonAdminSession);
|
||||
|
||||
const { lastQuery } = renderAt("?view=organization&org=org-1&range=30d");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastQuery()).toBe("?range=30d");
|
||||
});
|
||||
expect(warning).toHaveBeenCalledWith("You don't have access to that usage view");
|
||||
expect(screen.queryByTestId("entity-usage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never fetches another user's usage for a non-admin who opens their link", async () => {
|
||||
const warning = vi.spyOn(toast, "warning");
|
||||
mockUseAuthorized.mockReturnValue(nonAdminSession);
|
||||
|
||||
const { lastQuery } = renderAt("?user=user-001");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastQuery()).toBe("");
|
||||
});
|
||||
expect(warning).toHaveBeenCalledWith("You don't have access to that usage view");
|
||||
expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"user-001",
|
||||
);
|
||||
});
|
||||
|
||||
it("copies the current page URL as the share link", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
||||
renderAt("?tab=models");
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Copy Share Link" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalledWith(window.location.href);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
* Works at 1m+ spend logs, by querying an aggregate table instead.
|
||||
*/
|
||||
|
||||
import { ChevronDown, ChevronRight, Download, Info, Sparkles, X } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Download, Info, Link2, Sparkles, X } from "lucide-react";
|
||||
import { useQueryStates } from "nuqs";
|
||||
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
|
|
@ -23,14 +24,17 @@ import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
|
||||
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
|
||||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { hasCapability } from "@/utils/capabilities";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { copyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
|
||||
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
|
||||
import CloudZeroExportModal from "@/components/cloudzero_export_modal";
|
||||
import UserDropdown from "@/components/common_components/UserDropdown";
|
||||
import EntityUsageExportModal from "@/components/EntityUsageExport";
|
||||
import { getApiKeyTruncation, getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||
import type { EntityType } from "@/components/EntityUsageExport/types";
|
||||
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import {
|
||||
|
|
@ -66,7 +70,20 @@ import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView";
|
|||
import TopKeyView, { type TopKeyItem } from "@/components/UsagePage/components/EntityUsage/TopKeyView";
|
||||
import { getGlobalTopKeys } from "./EntityUsage/entityUsageAggregations";
|
||||
import UsageAIChatPanel from "./UsageAIChatPanel";
|
||||
import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
|
||||
import { allowedUsageOptions, UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
|
||||
import UserRecordLink from "./UserRecordLink";
|
||||
import {
|
||||
cleanUsageUrl,
|
||||
dateRangeFromParams,
|
||||
dateRangePatch,
|
||||
entitySelectionPatch,
|
||||
selectedEntitiesFromParams,
|
||||
usageTabFromParams,
|
||||
usageTabPatch,
|
||||
usageUrlParsers,
|
||||
usageViewFromParams,
|
||||
usageViewPatch,
|
||||
} from "../usageUrlState";
|
||||
|
||||
interface UsagePageProps {
|
||||
teams: Team[];
|
||||
|
|
@ -94,11 +111,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const initialFromDate = useMemo(() => new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), []);
|
||||
const initialToDate = useMemo(() => new Date(), []);
|
||||
|
||||
// Single date state that directly triggers data fetching
|
||||
const [dateValue, setDateValue] = useState<DateRangePickerValue>({
|
||||
from: initialFromDate,
|
||||
to: initialToDate,
|
||||
});
|
||||
const [urlParams, setUrlParams] = useQueryStates(usageUrlParsers);
|
||||
const { range, from, to } = urlParams;
|
||||
const dateValue = useMemo<DateRangePickerValue>(
|
||||
() => dateRangeFromParams({ range, from, to }) ?? { from: initialFromDate, to: initialToDate },
|
||||
[range, from, to, initialFromDate, initialToDate],
|
||||
);
|
||||
|
||||
const [fetchedTags, setFetchedTags] = useState<FetchedForRange<EntityList[]> | null>(null);
|
||||
// No [] default: an unresolved query must stay undefined so the customer
|
||||
|
|
@ -112,14 +130,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin);
|
||||
const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage");
|
||||
|
||||
// For admins: null means global view (all users), a string means filter by that user
|
||||
// For non-admins: always set to their own user ID
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(isAdmin ? null : userID || null);
|
||||
const [modelViewType, setModelViewType] = useState<ModelViewType>("groups");
|
||||
const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false);
|
||||
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
|
||||
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
|
||||
const [selectedUsageView, setUsageView] = useState<UsageOption>("global");
|
||||
const selectedUsageView = usageViewFromParams(urlParams);
|
||||
const selectedTab = usageTabFromParams(urlParams);
|
||||
// Org-admin membership is read from the server, so unlike the other usage
|
||||
// views this one can be revoked while the page is open. Derive the view in
|
||||
// render rather than storing it, so the fallback lands on the same paint and
|
||||
|
|
@ -131,15 +147,27 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
|
||||
const [showTokenBreakdown, setShowTokenBreakdown] = useState(false);
|
||||
// Sync selectedUserId when auth state settles (isAdmin/userID may be null on initial render)
|
||||
useEffect(() => {
|
||||
if (!isAdmin && userID) {
|
||||
setSelectedUserId(userID);
|
||||
}
|
||||
}, [isAdmin, userID]);
|
||||
|
||||
// For non-admins or "my-usage" view, always pass their own user_id
|
||||
const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : selectedUserId;
|
||||
const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : urlParams.user;
|
||||
|
||||
const { isPending: organizationsPending } = useOrganizations();
|
||||
const allowedViews = useMemo(
|
||||
() => allowedUsageOptions(userRole, canViewTagUsage, isOrgAdmin),
|
||||
[userRole, canViewTagUsage, isOrgAdmin],
|
||||
);
|
||||
const accessSettled = userRole !== null && !organizationsPending;
|
||||
useEffect(() => {
|
||||
if (!accessSettled) return;
|
||||
const cleanup = cleanUsageUrl(urlParams, { allowedViews, isAdmin });
|
||||
if (!cleanup) return;
|
||||
if (cleanup.deniedAccess) toast.warning("You don't have access to that usage view");
|
||||
void setUrlParams(cleanup.patch);
|
||||
}, [accessSettled, urlParams, allowedViews, isAdmin, setUrlParams]);
|
||||
|
||||
const entitySelection = (entityType: EntityType) => ({
|
||||
selectedEntities: selectedEntitiesFromParams(urlParams, entityType),
|
||||
onSelectedEntitiesChange: (ids: readonly string[]) => void setUrlParams(entitySelectionPatch(entityType, ids)),
|
||||
});
|
||||
|
||||
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
|
||||
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
|
||||
|
|
@ -273,13 +301,15 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
}, [aggregatedFailed, paginatedResult.loading, paginatedResult.data.results.length]);
|
||||
|
||||
// Super responsive date change handler
|
||||
const handleDateChange = useCallback((newValue: DateRangePickerValue) => {
|
||||
// Instant visual feedback
|
||||
setIsDateChanging(true);
|
||||
const handleDateChange = useCallback(
|
||||
(newValue: DateRangePickerValue) => {
|
||||
// Instant visual feedback
|
||||
setIsDateChanging(true);
|
||||
|
||||
// Update date immediately for UI responsiveness
|
||||
setDateValue(newValue);
|
||||
}, []);
|
||||
void setUrlParams(dateRangePatch(newValue));
|
||||
},
|
||||
[setUrlParams],
|
||||
);
|
||||
|
||||
// Derived states from userSpendData
|
||||
const totalSpend = userSpendData.metadata?.total_spend || 0;
|
||||
|
|
@ -461,7 +491,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<div className="flex items-end justify-between gap-6 mb-4 w-full">
|
||||
<UsageViewSelect
|
||||
value={usageView}
|
||||
onChange={(value) => setUsageView(value)}
|
||||
onChange={(value) => void setUrlParams(usageViewPatch(value))}
|
||||
userRole={userRole}
|
||||
canViewTagUsage={canViewTagUsage}
|
||||
isOrgAdmin={isOrgAdmin}
|
||||
|
|
@ -481,10 +511,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
{isAdmin && usageView === "global" && (
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-sm text-foreground">Filter by user</p>
|
||||
<UserDropdown value={selectedUserId} onChange={setSelectedUserId} />
|
||||
<div className="flex items-center gap-3">
|
||||
<UserDropdown value={urlParams.user} onChange={(user) => void setUrlParams({ user })} />
|
||||
{urlParams.user && <UserRecordLink userId={urlParams.user} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultValue="cost">
|
||||
<Tabs value={selectedTab} onValueChange={(tab: string) => void setUrlParams(usageTabPatch(tab))}>
|
||||
<div className="flex justify-between items-center">
|
||||
<TabsList className="mt-1">
|
||||
<TabsTrigger value="cost" className="flex-none px-3">
|
||||
|
|
@ -504,6 +537,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => void copyToClipboard(window.location.href, "Link copied")}>
|
||||
<Link2 />
|
||||
Copy Share Link
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setIsAiChatOpen(true)}>
|
||||
<Sparkles />
|
||||
Ask AI
|
||||
|
|
@ -897,6 +934,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="organization"
|
||||
{...entitySelection("organization")}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
isOrgAdmin={isOrgAdmin}
|
||||
|
|
@ -916,6 +954,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="team"
|
||||
{...entitySelection("team")}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={
|
||||
|
|
@ -934,6 +973,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="customer"
|
||||
{...entitySelection("customer")}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={
|
||||
|
|
@ -972,6 +1012,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="tag"
|
||||
{...entitySelection("tag")}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={allTags}
|
||||
|
|
@ -984,6 +1025,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="agent"
|
||||
{...entitySelection("agent")}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={
|
||||
|
|
@ -998,6 +1040,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="user"
|
||||
{...entitySelection("user")}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={null}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,27 @@ const OPTIONS: OptionConfig[] = [
|
|||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
const visibleOptions = (userRole: string | null, canViewTagUsage: boolean, isOrgAdmin: boolean): OptionConfig[] => {
|
||||
const isAdmin = all_admin_roles.includes(userRole ?? "");
|
||||
return OPTIONS.filter((option) => {
|
||||
if (option.capability) {
|
||||
return hasCapability(userRole, option.capability, isOrgAdmin);
|
||||
}
|
||||
if (option.value === "tag" && canViewTagUsage) {
|
||||
return true;
|
||||
}
|
||||
return !(option.adminOnly && !isAdmin);
|
||||
});
|
||||
};
|
||||
|
||||
export const USAGE_OPTIONS: readonly UsageOption[] = OPTIONS.map((option) => option.value);
|
||||
|
||||
export const allowedUsageOptions = (
|
||||
userRole: string | null,
|
||||
canViewTagUsage: boolean,
|
||||
isOrgAdmin: boolean,
|
||||
): readonly UsageOption[] => visibleOptions(userRole, canViewTagUsage, isOrgAdmin).map((option) => option.value);
|
||||
|
||||
export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
|
|
@ -116,18 +137,7 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
|||
}) => {
|
||||
const isAdmin = all_admin_roles.includes(userRole ?? "");
|
||||
const getFilteredOptions = () => {
|
||||
return OPTIONS.filter((option) => {
|
||||
if (option.capability) {
|
||||
return hasCapability(userRole, option.capability, isOrgAdmin);
|
||||
}
|
||||
if (option.value === "tag" && canViewTagUsage) {
|
||||
return true;
|
||||
}
|
||||
if (option.adminOnly && !isAdmin) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map((option) => {
|
||||
return visibleOptions(userRole, canViewTagUsage, isOrgAdmin).map((option) => {
|
||||
let label = option.label;
|
||||
let desc = option.description;
|
||||
if (option.showForAdmin && option.showForNonAdmin) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { SquareArrowOutUpRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { userRecordHref } from "../usageUrlState";
|
||||
|
||||
export default function UserRecordLink({ userId }: { userId: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={userRecordHref(userId)}
|
||||
className="inline-flex shrink-0 items-center gap-1 text-sm text-primary underline underline-offset-2"
|
||||
>
|
||||
View user record
|
||||
<SquareArrowOutUpRight className="size-3.5" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import moment from "moment";
|
||||
import { createLoader, createSerializer } from "nuqs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { UsageOption } from "./components/UsageViewSelect/UsageViewSelect";
|
||||
import { USAGE_OPTIONS } from "./components/UsageViewSelect/UsageViewSelect";
|
||||
import {
|
||||
cleanUsageUrl,
|
||||
dateRangeFromParams,
|
||||
dateRangePatch,
|
||||
entitySelectionPatch,
|
||||
selectedEntitiesFromParams,
|
||||
usageHrefForUser,
|
||||
usageTabFromParams,
|
||||
usageTabPatch,
|
||||
usageUrlParsers,
|
||||
usageViewFromParams,
|
||||
usageViewPatch,
|
||||
type UsageUrlParams,
|
||||
type UsageUrlPatch,
|
||||
} from "./usageUrlState";
|
||||
|
||||
const load = createLoader(usageUrlParsers);
|
||||
const serialize = createSerializer(usageUrlParsers);
|
||||
|
||||
const applyPatch = (query: string, patch: UsageUrlPatch): string => serialize(query, patch);
|
||||
|
||||
const adminAccess = { allowedViews: USAGE_OPTIONS, isAdmin: true };
|
||||
const internalUserAccess = {
|
||||
allowedViews: ["global", "tag"] as readonly UsageOption[],
|
||||
isAdmin: false,
|
||||
};
|
||||
|
||||
const clean = (query: string, access = adminAccess): { query: string; deniedAccess: boolean } | null => {
|
||||
const cleanup = cleanUsageUrl(load(query), access);
|
||||
return cleanup && { query: applyPatch(query, cleanup.patch), deniedAccess: cleanup.deniedAccess };
|
||||
};
|
||||
|
||||
describe("usage view and tab", () => {
|
||||
it("reads a known view and tab from the URL", () => {
|
||||
const params = load("?view=team&tab=models");
|
||||
|
||||
expect(usageViewFromParams(params)).toBe("team");
|
||||
expect(usageTabFromParams(params)).toBe("models");
|
||||
});
|
||||
|
||||
it("falls back to the global view and cost tab when the URL names neither", () => {
|
||||
const params = load("?view=bogus&tab=bogus");
|
||||
|
||||
expect(usageViewFromParams(params)).toBe("global");
|
||||
expect(usageTabFromParams(params)).toBe("cost");
|
||||
});
|
||||
|
||||
it("switching views drops the previous view's filters and tab but keeps the date range", () => {
|
||||
expect(applyPatch("?user=u1&tab=keys&range=30d", usageViewPatch("team"))).toBe("?range=30d&view=team");
|
||||
expect(applyPatch("?view=team&team=t1", usageViewPatch("global"))).toBe("");
|
||||
});
|
||||
|
||||
it("keeps the default tab out of the URL", () => {
|
||||
expect(applyPatch("?tab=models", usageTabPatch("cost"))).toBe("");
|
||||
expect(applyPatch("", usageTabPatch("mcp"))).toBe("?tab=mcp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("entity selection", () => {
|
||||
it("stores teams as repeated keys so ids with commas survive", () => {
|
||||
const query = applyPatch("?view=tag", entitySelectionPatch("tag", ["Credential: a,b", "prod"]));
|
||||
|
||||
expect(query).toBe("?view=tag&tag=Credential:+a,b&tag=prod");
|
||||
expect(selectedEntitiesFromParams(load(query), "tag")).toEqual(["Credential: a,b", "prod"]);
|
||||
});
|
||||
|
||||
it("maps the organization view to the org key", () => {
|
||||
const query = applyPatch("?view=organization", entitySelectionPatch("organization", ["o1"]));
|
||||
|
||||
expect(query).toBe("?view=organization&org=o1");
|
||||
expect(selectedEntitiesFromParams(load(query), "organization")).toEqual(["o1"]);
|
||||
});
|
||||
|
||||
it("stores the user view's selection in the single user key", () => {
|
||||
const query = applyPatch("?view=user", entitySelectionPatch("user", ["u1"]));
|
||||
|
||||
expect(query).toBe("?view=user&user=u1");
|
||||
expect(selectedEntitiesFromParams(load(query), "user")).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("removes the key once the selection is cleared", () => {
|
||||
expect(applyPatch("?view=team&team=t1", entitySelectionPatch("team", []))).toBe("?view=team");
|
||||
expect(applyPatch("?view=user&user=u1", entitySelectionPatch("user", []))).toBe("?view=user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("date range", () => {
|
||||
it("stores a preset as a rolling range key", () => {
|
||||
const lastThirtyDays = { from: moment().subtract(30, "days").startOf("day").toDate(), to: new Date() };
|
||||
|
||||
expect(applyPatch("?from=a&to=b", dateRangePatch(lastThirtyDays))).toBe("?range=30d");
|
||||
});
|
||||
|
||||
it("re-resolves a rolling range against the current day", () => {
|
||||
const range = dateRangeFromParams(load("?range=7d"));
|
||||
|
||||
expect(moment(range?.from).isSame(moment().subtract(7, "days"), "day")).toBe(true);
|
||||
expect(moment(range?.to).isSame(moment(), "day")).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips a custom range as the same UTC instants", () => {
|
||||
const custom = { from: new Date("2026-01-05T08:00:00.000Z"), to: new Date("2026-01-09T20:30:00.000Z") };
|
||||
const query = applyPatch("?range=7d", dateRangePatch(custom));
|
||||
|
||||
expect(query).toBe("?from=2026-01-05T08:00:00.000Z&to=2026-01-09T20:30:00.000Z");
|
||||
expect(dateRangeFromParams(load(query))).toEqual(custom);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unknown preset", "?range=90d"],
|
||||
["a malformed date", "?from=yesterday&to=2026-01-09T00:00:00.000Z"],
|
||||
["a range that ends before it starts", "?from=2026-01-09T00:00:00.000Z&to=2026-01-05T00:00:00.000Z"],
|
||||
["only one end of a custom range", "?from=2026-01-05T00:00:00.000Z"],
|
||||
])("ignores %s", (_label, query) => {
|
||||
expect(dateRangeFromParams(load(query))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanUsageUrl", () => {
|
||||
it("leaves a valid URL alone", () => {
|
||||
expect(clean("?view=team&team=t1&team=t2&range=30d")).toBeNull();
|
||||
expect(clean("?user=u1&tab=keys&from=2026-01-05T00:00:00.000Z&to=2026-01-09T00:00:00.000Z")).toBeNull();
|
||||
expect(clean("?view=tag&tag=prod", internalUserAccess)).toBeNull();
|
||||
});
|
||||
|
||||
it("silently drops params that name nothing real", () => {
|
||||
expect(clean("?view=bogus&tab=bogus&range=90d")).toEqual({ query: "", deniedAccess: false });
|
||||
expect(clean("?from=yesterday&to=today&team=t1")).toEqual({ query: "", deniedAccess: false });
|
||||
});
|
||||
|
||||
it("drops a tab on a view that has no tabs", () => {
|
||||
expect(clean("?view=team&tab=models")).toEqual({ query: "?view=team", deniedAccess: false });
|
||||
});
|
||||
|
||||
it("drops filters that belong to a different view", () => {
|
||||
expect(clean("?view=team&team=t1&org=o1&user=u1")).toEqual({ query: "?view=team&team=t1", deniedAccess: false });
|
||||
expect(clean("?view=my-usage&user=u1")).toEqual({ query: "?view=my-usage", deniedAccess: false });
|
||||
});
|
||||
|
||||
it("prefers the preset when a URL carries both a preset and a custom range", () => {
|
||||
expect(clean("?range=7d&from=2026-01-05T00:00:00.000Z&to=2026-01-09T00:00:00.000Z")).toEqual({
|
||||
query: "?range=7d",
|
||||
deniedAccess: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a viewer to the default view, keeping the dates, when the linked view is not theirs", () => {
|
||||
expect(clean("?view=team&team=t1&range=30d", internalUserAccess)).toEqual({
|
||||
query: "?range=30d",
|
||||
deniedAccess: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("denies another user's usage to a non-admin", () => {
|
||||
expect(clean("?user=someone-else&tab=models", internalUserAccess)).toEqual({ query: "", deniedAccess: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-page links", () => {
|
||||
it("encodes the user id so it survives the round trip into the usage URL", () => {
|
||||
const href = usageHrefForUser("alice+ops@example.com");
|
||||
const params: UsageUrlParams = load(href.slice(href.indexOf("?")));
|
||||
|
||||
expect(params.user).toBe("alice+ops@example.com");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
import { parseAsNativeArrayOf, parseAsString, type inferParserType } from "nuqs";
|
||||
|
||||
import type { EntityType } from "@/components/EntityUsageExport/types";
|
||||
import { matchRelativeTimeOption, relativeTimeOptions } from "@/components/shared/advanced_date_picker";
|
||||
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { USAGE_OPTIONS, type UsageOption } from "./components/UsageViewSelect/UsageViewSelect";
|
||||
|
||||
export const DEFAULT_USAGE_VIEW: UsageOption = "global";
|
||||
export const USAGE_TABS = ["cost", "models", "keys", "mcp", "endpoints"] as const;
|
||||
export type UsageTab = (typeof USAGE_TABS)[number];
|
||||
export const DEFAULT_USAGE_TAB: UsageTab = "cost";
|
||||
|
||||
const entityIds = parseAsNativeArrayOf(parseAsString);
|
||||
|
||||
export const usageUrlParsers = {
|
||||
view: parseAsString,
|
||||
tab: parseAsString,
|
||||
range: parseAsString,
|
||||
from: parseAsString,
|
||||
to: parseAsString,
|
||||
user: parseAsString,
|
||||
team: entityIds,
|
||||
org: entityIds,
|
||||
customer: entityIds,
|
||||
tag: entityIds,
|
||||
agent: entityIds,
|
||||
};
|
||||
|
||||
export type UsageUrlParams = inferParserType<typeof usageUrlParsers>;
|
||||
export type UsageUrlPatch = { readonly [K in keyof UsageUrlParams]?: UsageUrlParams[K] | null };
|
||||
|
||||
type MultiEntityType = Exclude<EntityType, "user">;
|
||||
type EntityKey = "user" | (typeof MULTI_ENTITY_KEYS)[MultiEntityType];
|
||||
|
||||
const MULTI_ENTITY_KEYS = {
|
||||
organization: "org",
|
||||
team: "team",
|
||||
customer: "customer",
|
||||
tag: "tag",
|
||||
agent: "agent",
|
||||
} as const satisfies Record<MultiEntityType, keyof UsageUrlParams>;
|
||||
|
||||
const VIEW_ENTITY_KEYS: Readonly<Record<UsageOption, readonly EntityKey[]>> = {
|
||||
global: ["user"],
|
||||
"my-usage": [],
|
||||
organization: ["org"],
|
||||
team: ["team"],
|
||||
customer: ["customer"],
|
||||
tag: ["tag"],
|
||||
agent: ["agent"],
|
||||
user: ["user"],
|
||||
"user-agent-activity": [],
|
||||
};
|
||||
|
||||
const TABBED_VIEWS: readonly UsageOption[] = ["global", "my-usage"];
|
||||
|
||||
const RESET_TO_DEFAULT_VIEW: UsageUrlPatch = {
|
||||
view: null,
|
||||
tab: null,
|
||||
user: null,
|
||||
team: null,
|
||||
org: null,
|
||||
customer: null,
|
||||
tag: null,
|
||||
agent: null,
|
||||
};
|
||||
|
||||
const isUsageOption = (value: string | null): value is UsageOption => USAGE_OPTIONS.some((option) => option === value);
|
||||
|
||||
const isUsageTab = (value: string | null): value is UsageTab => USAGE_TABS.some((tab) => tab === value);
|
||||
|
||||
const hasEntityValue = (params: UsageUrlParams, key: EntityKey): boolean =>
|
||||
key === "user" ? params.user !== null : params[key].length > 0;
|
||||
|
||||
export const usageViewFromParams = (params: UsageUrlParams): UsageOption =>
|
||||
isUsageOption(params.view) ? params.view : DEFAULT_USAGE_VIEW;
|
||||
|
||||
export const usageTabFromParams = (params: UsageUrlParams): UsageTab =>
|
||||
isUsageTab(params.tab) ? params.tab : DEFAULT_USAGE_TAB;
|
||||
|
||||
export const usageViewPatch = (view: UsageOption): UsageUrlPatch => ({
|
||||
...RESET_TO_DEFAULT_VIEW,
|
||||
view: view === DEFAULT_USAGE_VIEW ? null : view,
|
||||
});
|
||||
|
||||
export const usageTabPatch = (tab: string): UsageUrlPatch => ({
|
||||
tab: isUsageTab(tab) && tab !== DEFAULT_USAGE_TAB ? tab : null,
|
||||
});
|
||||
|
||||
export const selectedEntitiesFromParams = (params: UsageUrlParams, entityType: EntityType): readonly string[] => {
|
||||
if (entityType === "user") return params.user === null ? [] : [params.user];
|
||||
return params[MULTI_ENTITY_KEYS[entityType]];
|
||||
};
|
||||
|
||||
export const entitySelectionPatch = (entityType: EntityType, ids: readonly string[]): UsageUrlPatch => {
|
||||
if (entityType === "user") return { user: ids[0] ?? null };
|
||||
return { [MULTI_ENTITY_KEYS[entityType]]: ids.length > 0 ? [...ids] : null };
|
||||
};
|
||||
|
||||
const parseIsoDate = (value: string): Date | undefined => {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
};
|
||||
|
||||
const customRangeFromParams = (from: string, to: string): DateRangePickerValue | undefined => {
|
||||
const fromDate = parseIsoDate(from);
|
||||
const toDate = parseIsoDate(to);
|
||||
if (!fromDate || !toDate || fromDate > toDate) return undefined;
|
||||
return { from: fromDate, to: toDate };
|
||||
};
|
||||
|
||||
type DateParams = Pick<UsageUrlParams, "range" | "from" | "to">;
|
||||
|
||||
export const dateRangeFromParams = (params: DateParams): DateRangePickerValue | undefined => {
|
||||
if (params.range !== null) {
|
||||
return relativeTimeOptions.find((option) => option.shortLabel === params.range)?.getValue();
|
||||
}
|
||||
if (params.from === null || params.to === null) return undefined;
|
||||
return customRangeFromParams(params.from, params.to);
|
||||
};
|
||||
|
||||
export const dateRangePatch = (value: DateRangePickerValue): UsageUrlPatch => {
|
||||
const preset = matchRelativeTimeOption(value);
|
||||
if (preset) return { range: preset.shortLabel, from: null, to: null };
|
||||
if (!value.from || !value.to) return { range: null, from: null, to: null };
|
||||
return { range: null, from: value.from.toISOString(), to: value.to.toISOString() };
|
||||
};
|
||||
|
||||
const invalidDatePatch = (params: DateParams): UsageUrlPatch => {
|
||||
const hasCustomRange = params.from !== null || params.to !== null;
|
||||
if (params.range !== null) {
|
||||
const rangeIsPreset = relativeTimeOptions.some((option) => option.shortLabel === params.range);
|
||||
return {
|
||||
...(rangeIsPreset ? {} : { range: null }),
|
||||
...(hasCustomRange ? { from: null, to: null } : {}),
|
||||
};
|
||||
}
|
||||
if (!hasCustomRange || dateRangeFromParams(params)) return {};
|
||||
return { from: null, to: null };
|
||||
};
|
||||
|
||||
export interface UsageUrlAccess {
|
||||
readonly allowedViews: readonly UsageOption[];
|
||||
readonly isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface UsageUrlCleanup {
|
||||
readonly patch: UsageUrlPatch;
|
||||
readonly deniedAccess: boolean;
|
||||
}
|
||||
|
||||
export const cleanUsageUrl = (params: UsageUrlParams, access: UsageUrlAccess): UsageUrlCleanup | null => {
|
||||
const view = usageViewFromParams(params);
|
||||
const deniedView = !access.allowedViews.includes(view);
|
||||
const deniedUserFilter = view === "global" && params.user !== null && !access.isAdmin;
|
||||
if (deniedView || deniedUserFilter) {
|
||||
return { patch: { ...RESET_TO_DEFAULT_VIEW, ...invalidDatePatch(params) }, deniedAccess: true };
|
||||
}
|
||||
|
||||
const viewKeys = VIEW_ENTITY_KEYS[view];
|
||||
const strayEntityKeys = (["user", "team", "org", "customer", "tag", "agent"] as const).filter(
|
||||
(key) => !viewKeys.includes(key) && hasEntityValue(params, key),
|
||||
);
|
||||
const invalidTab = params.tab !== null && (!TABBED_VIEWS.includes(view) || !isUsageTab(params.tab));
|
||||
const patch: UsageUrlPatch = {
|
||||
...(params.view !== null && !isUsageOption(params.view) ? { view: null } : {}),
|
||||
...(invalidTab ? { tab: null } : {}),
|
||||
...Object.fromEntries(strayEntityKeys.map((key) => [key, null])),
|
||||
...invalidDatePatch(params),
|
||||
};
|
||||
return Object.keys(patch).length > 0 ? { patch, deniedAccess: false } : null;
|
||||
};
|
||||
|
||||
export const usageHrefForUser = (userId: string): string => uiHref(`usage?${new URLSearchParams({ user: userId })}`);
|
||||
|
||||
export const userRecordHref = (userId: string): string => uiHref(`users?${new URLSearchParams({ user: userId })}`);
|
||||
|
|
@ -178,6 +178,18 @@ describe("UsersTable", () => {
|
|||
expect(onUserClick).toHaveBeenCalledWith("user-1", true);
|
||||
});
|
||||
|
||||
it("links the row menu to that user's usage", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await openRowMenu(user, "user-1");
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: "View usage" })).toHaveAttribute(
|
||||
"href",
|
||||
"/ui/usage?user=user-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("delegates delete and reset-password from the row menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteUser = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Copy, Info, KeyRound, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { ChartColumn, Copy, Info, KeyRound, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { UserInfo } from "@/components/networking";
|
||||
import { createSelectionColumn, DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { usageHrefForUser } from "@/app/(dashboard)/usage/_components/usageUrlState";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
const SSO_ID_HINT =
|
||||
|
|
@ -60,6 +62,10 @@ function UserRowActions({ user, onUserClick, onDeleteUser, onResetPassword }: Us
|
|||
<Copy />
|
||||
Copy user ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem render={<Link href={usageHrefForUser(user.user_id)} />} data-testid="user-action-view-usage">
|
||||
<ChartColumn />
|
||||
View usage
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDeleteUser(user)} data-testid="user-action-delete">
|
||||
<Trash2 />
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ interface AdvancedDatePickerProps {
|
|||
align?: "left" | "right";
|
||||
}
|
||||
|
||||
interface RelativeTimeOption {
|
||||
export interface RelativeTimeOption {
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
getValue: () => { from: Date; to: Date };
|
||||
}
|
||||
|
||||
const relativeTimeOptions: RelativeTimeOption[] = [
|
||||
export const relativeTimeOptions: readonly RelativeTimeOption[] = [
|
||||
{
|
||||
label: "Today",
|
||||
shortLabel: "today",
|
||||
|
|
@ -63,6 +63,15 @@ const relativeTimeOptions: RelativeTimeOption[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export function matchRelativeTimeOption(value: DateRangePickerValue): RelativeTimeOption | undefined {
|
||||
const { from, to } = value;
|
||||
if (!from || !to) return undefined;
|
||||
return relativeTimeOptions.find((option) => {
|
||||
const optionRange = option.getValue();
|
||||
return moment(from).isSame(optionRange.from, "day") && moment(to).isSame(optionRange.to, "day");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Date Range Picker with dropdown, relative times, and custom inputs
|
||||
*/
|
||||
|
|
@ -84,24 +93,10 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
|
|||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Function to check if current value matches a relative time option
|
||||
const getMatchingOption = useCallback((currentValue: DateRangePickerValue): string | null => {
|
||||
if (!currentValue.from || !currentValue.to) return null;
|
||||
|
||||
for (const option of relativeTimeOptions) {
|
||||
const optionRange = option.getValue();
|
||||
|
||||
// Compare dates with some tolerance (to account for time differences)
|
||||
const fromMatches = moment(currentValue.from).isSame(moment(optionRange.from), "day");
|
||||
const toMatches = moment(currentValue.to).isSame(moment(optionRange.to), "day");
|
||||
|
||||
if (fromMatches && toMatches) {
|
||||
return option.shortLabel;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
const getMatchingOption = useCallback(
|
||||
(currentValue: DateRangePickerValue): string | null => matchRelativeTimeOption(currentValue)?.shortLabel ?? null,
|
||||
[],
|
||||
);
|
||||
|
||||
// Update selected option when value changes
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -25,12 +25,18 @@ export const testQueryClient = new QueryClient({
|
|||
interface ProviderOptions {
|
||||
searchParams?: string | Record<string, string> | URLSearchParams;
|
||||
onUrlUpdate?: OnUrlUpdateFunction;
|
||||
resetUrlUpdateQueueOnMount?: boolean;
|
||||
}
|
||||
|
||||
export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions & ProviderOptions) => {
|
||||
const { searchParams, onUrlUpdate, ...renderOptions } = options ?? {};
|
||||
const { searchParams, onUrlUpdate, resetUrlUpdateQueueOnMount, ...renderOptions } = options ?? {};
|
||||
const Providers: React.FC<PropsWithChildren> = ({ children }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate} hasMemory>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={searchParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
resetUrlUpdateQueueOnMount={resetUrlUpdateQueueOnMount}
|
||||
hasMemory
|
||||
>
|
||||
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue