mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-18 04:44:37 +00:00
feat(ui): shareable URL state for usage views and users-to-usage deeplinks (#43425)
* 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" * fix(ui): clean usage links for sessions without the organizations query The organizations query only runs for premium sessions, and a disabled query stays pending forever, so the URL cleanup never ran for other sessions. Gate on isLoading instead, and give the access denied toast an id so a re-run effect shows it once * fix(ui): type-check usage entity filters and address review findings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep team usage available to internal users Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(ui): format users table usage action Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): sync date picker preset with the current usage range Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): restore dashboard AGENTS.md from main and drop unused usage state Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): share custom usage date ranges as calendar days Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep per-user agent usage page across rerenders Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
40efc6e07e
commit
8efb99b4ac
28 changed files with 1236 additions and 192 deletions
|
|
@ -231,9 +231,6 @@
|
|||
"max-lines": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"react/no-unescaped-entities": {
|
||||
"count": 2
|
||||
}
|
||||
|
|
@ -1127,9 +1124,6 @@
|
|||
"src/app/(dashboard)/usage/_components/components/UsagePageView.tsx": {
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/users/_components/BulkEditUsers.tsx": {
|
||||
|
|
@ -1775,11 +1769,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/molecules/cost_optimization_feedback_banner.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/navbar.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -15,6 +15,13 @@ import type {
|
|||
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,
|
||||
|
|
@ -110,7 +117,7 @@ vi.mock("./TopModelView", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("./TeamUserSpendCard", () => ({
|
||||
default: ({ teamIds }: { teamIds: string[] }) => <div>{`team-user-spend:${teamIds.join("|")}`}</div>,
|
||||
default: ({ teamIds }: { teamIds: readonly string[] }) => <div>{`team-user-spend:${teamIds.join("|")}`}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/EntityUsageExport/EntityUsageExportModal", () => ({
|
||||
|
|
@ -549,7 +556,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();
|
||||
|
|
@ -568,7 +575,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(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -591,12 +598,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();
|
||||
});
|
||||
|
|
@ -604,7 +611,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();
|
||||
|
|
@ -619,7 +626,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();
|
||||
|
|
@ -634,7 +641,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();
|
||||
|
|
@ -649,7 +656,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();
|
||||
|
|
@ -664,7 +671,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("should switch between tabs", async () => {
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -688,7 +695,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("loads key pages separately from the aggregate using the current entity scope", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -743,7 +750,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();
|
||||
|
|
@ -769,7 +776,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(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -800,7 +807,7 @@ describe("EntityUsage", () => {
|
|||
|
||||
mockTagDailyActivityCall.mockResolvedValue(emptyData);
|
||||
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -814,7 +821,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();
|
||||
|
|
@ -824,7 +831,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();
|
||||
|
|
@ -834,7 +841,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();
|
||||
|
|
@ -844,7 +851,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("defaults Model Activity to public model names and toggles to litellm models", async () => {
|
||||
const { container } = render(<EntityUsage {...defaultProps} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -870,7 +877,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();
|
||||
|
|
@ -885,7 +892,7 @@ describe("EntityUsage", () => {
|
|||
{ label: "Tag 2", value: "tag-2" },
|
||||
];
|
||||
|
||||
render(<EntityUsage {...defaultProps} entityList={customEntityList} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityList={customEntityList} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -899,7 +906,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();
|
||||
|
|
@ -911,7 +918,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();
|
||||
|
|
@ -923,7 +930,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();
|
||||
|
|
@ -933,7 +940,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();
|
||||
|
|
@ -943,7 +950,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(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -953,7 +960,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();
|
||||
|
|
@ -963,7 +970,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(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -973,7 +980,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();
|
||||
|
|
@ -983,7 +990,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(expect.objectContaining({ accessToken: "test-token" }));
|
||||
|
|
@ -991,7 +998,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("offers per-model top keys in Model Activity but not in the agent breakdown", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -1016,7 +1023,7 @@ describe("EntityUsage", () => {
|
|||
resolveSpend = resolve;
|
||||
}),
|
||||
);
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -1037,7 +1044,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();
|
||||
|
|
@ -1047,7 +1054,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(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -1084,7 +1091,7 @@ describe("EntityUsage", () => {
|
|||
|
||||
mockTagDailyActivityCall.mockResolvedValue(spendDataWithoutAlias);
|
||||
|
||||
render(<EntityUsage {...defaultProps} entityList={null} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityList={null} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -1096,7 +1103,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("renders the stacked daily spend chart, the per-entity table, and the provider share bar with a $ total", async () => {
|
||||
const { container } = render(<EntityUsage {...defaultProps} />);
|
||||
const { container } = render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -1140,7 +1147,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();
|
||||
|
|
@ -1153,7 +1160,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"));
|
||||
|
|
@ -1164,7 +1171,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();
|
||||
});
|
||||
|
|
@ -1172,7 +1179,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();
|
||||
});
|
||||
|
|
@ -1185,7 +1192,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(() => {
|
||||
|
|
@ -1198,7 +1207,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(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -1211,7 +1220,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();
|
||||
|
|
@ -1237,7 +1246,7 @@ describe("EntityUsage", () => {
|
|||
};
|
||||
mockTagDailyActivityCall.mockResolvedValue(spendDataUnknownProvider);
|
||||
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("zzz-internal").length).toBeGreaterThan(0);
|
||||
|
|
@ -1277,7 +1286,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();
|
||||
|
|
@ -1287,7 +1296,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("uses the aggregated team endpoint and makes a single bounded request", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDailyActivityAggregatedCall).toHaveBeenCalledWith("team", expect.anything());
|
||||
|
|
@ -1300,7 +1309,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("does not scope the agent breakdown by the selected team ids", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -1322,7 +1331,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("does not refetch agent activity when the team selection changes", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAgentDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
@ -1345,7 +1354,7 @@ describe("EntityUsage", () => {
|
|||
it("surfaces a failure alert when the aggregated call fails instead of retrying other routes", async () => {
|
||||
mockTeamDailyActivityCall.mockRejectedValue(new Error("aggregated unavailable"));
|
||||
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/Fetching spend data failed/).length).toBeGreaterThan(0);
|
||||
|
|
@ -1358,12 +1367,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();
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { cn } from "@/lib/cva.config";
|
|||
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 type { EntityType } from "@/components/EntityUsageExport/types";
|
||||
|
|
@ -122,6 +123,8 @@ interface EntityUsageProps {
|
|||
premiumUser: boolean;
|
||||
dateValue: DateRangePickerValue;
|
||||
isOrgAdmin?: boolean;
|
||||
selectedEntities: readonly string[];
|
||||
onSelectedEntitiesChange: (ids: readonly string[]) => void;
|
||||
}
|
||||
|
||||
const ENTITY_CAPABILITIES: Partial<Record<EntityType, Capability>> = {
|
||||
|
|
@ -137,10 +140,11 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
userRole,
|
||||
dateValue,
|
||||
isOrgAdmin = false,
|
||||
selectedEntities: selectedTags,
|
||||
onSelectedEntitiesChange: setSelectedTags,
|
||||
}) => {
|
||||
const { teams } = useTeams();
|
||||
const teamList = useMemo(() => teams ?? [], [teams]);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [modelViewType, setModelViewType] = useState<ModelViewType>("groups");
|
||||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
|
||||
|
|
@ -339,7 +343,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];
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ interface TeamUserSpendCardProps {
|
|||
accessToken: string | null;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
teamIds: string[];
|
||||
teamIds: readonly string[];
|
||||
}
|
||||
|
||||
const QUIET_HEADER = { headerClassName: "font-normal" };
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
|
|||
import { act, fireEvent, 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 { UrlUpdateEvent } from "nuqs/adapters/testing";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { processActivityData } from "@/components/activity_metrics";
|
||||
import type { Organization } from "@/components/networking";
|
||||
import type { ModelActivityData } from "@/components/UsagePage/types";
|
||||
|
|
@ -16,6 +18,8 @@ import { STACKED_USAGE_PALETTE } from "@/components/shared/charts";
|
|||
import { OTHER_COLOR } from "./overview/overviewData";
|
||||
import UsagePage from "./UsagePageView";
|
||||
|
||||
const mockEntityUsageMounted = vi.hoisted(() => vi.fn());
|
||||
|
||||
// The Overview tab. The Key Activity tab stays mounted and carries its own spend-derived totals,
|
||||
// so assertions about the overview's request tiles are scoped here rather than to the whole page.
|
||||
const overview = () => within(screen.getByRole("tabpanel", { name: "Overview" }));
|
||||
|
|
@ -86,14 +90,38 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
|
|||
default: () => <div>Top Keys</div>,
|
||||
}));
|
||||
|
||||
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)}>
|
||||
Entity Usage
|
||||
</div>
|
||||
),
|
||||
EntityList: [],
|
||||
}));
|
||||
vi.mock("./EntityUsage/EntityUsage", async () => {
|
||||
const React = await import("react");
|
||||
const EntityUsage = ({
|
||||
entityType,
|
||||
entityList,
|
||||
selectedEntities,
|
||||
onSelectedEntitiesChange,
|
||||
}: {
|
||||
entityType: string;
|
||||
entityList: unknown;
|
||||
selectedEntities: readonly string[];
|
||||
onSelectedEntitiesChange: (ids: readonly string[]) => void;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
mockEntityUsageMounted(entityType);
|
||||
}, [entityType]);
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
return { default: EntityUsage, EntityList: [] };
|
||||
});
|
||||
|
||||
vi.mock("./EntityUsage/SpendByProvider", () => ({
|
||||
default: () => <div>Spend By Provider</div>,
|
||||
|
|
@ -103,7 +131,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,
|
||||
|
|
@ -135,14 +164,19 @@ 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.
|
||||
const AdvancedDatePicker = ({ onValueChange }: { onValueChange?: (value: unknown) => void }) =>
|
||||
const AdvancedDatePicker = ({
|
||||
onValueChange,
|
||||
}: {
|
||||
onValueChange?: (value: unknown, presetShortLabel: string | null) => void;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "advanced-date-picker" },
|
||||
|
|
@ -152,17 +186,33 @@ vi.mock("@/components/shared/advanced_date_picker", async () => {
|
|||
{
|
||||
"data-testid": "pick-a-different-range",
|
||||
onClick: () =>
|
||||
onValueChange?.({ from: new Date("2024-01-01T00:00:00Z"), to: new Date("2024-01-08T00:00:00Z") }),
|
||||
onValueChange?.(
|
||||
{ from: new Date(2024, 0, 1, 0, 0, 0, 0), to: new Date(2024, 0, 8, 23, 59, 59, 999) },
|
||||
null,
|
||||
),
|
||||
},
|
||||
"pick",
|
||||
),
|
||||
);
|
||||
AdvancedDatePicker.displayName = "AdvancedDatePicker";
|
||||
return { default: AdvancedDatePicker };
|
||||
return { ...actual, default: AdvancedDatePicker };
|
||||
});
|
||||
|
||||
vi.mock("@/components/user_agent_activity", () => ({
|
||||
default: () => <div>User Agent Activity</div>,
|
||||
default: ({
|
||||
selectedTags,
|
||||
onSelectedTagsChange,
|
||||
}: {
|
||||
selectedTags?: readonly string[];
|
||||
onSelectedTagsChange?: (tags: readonly string[]) => void;
|
||||
}) => (
|
||||
<div data-testid="user-agent-activity" data-selected-tags={JSON.stringify(selectedTags ?? [])}>
|
||||
User Agent Activity
|
||||
<button type="button" onClick={() => onSelectedTagsChange?.(["User-Agent: Firefox/2.0"])}>
|
||||
Select agent tag
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/cloudzero_export_modal", () => ({
|
||||
|
|
@ -195,6 +245,10 @@ vi.mock("@/app/(dashboard)/hooks/useIsOrgAdmin", () => ({
|
|||
default: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||
useOrganizations: vi.fn(() => ({ data: undefined, isPending: true, isLoading: false })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
|
||||
useCurrentUser: vi.fn(),
|
||||
}));
|
||||
|
|
@ -367,8 +421,10 @@ describe("UsagePage", () => {
|
|||
userRole: "Internal User",
|
||||
userRoleLabel: "Internal User",
|
||||
isViewOnly: false,
|
||||
loginMethod: null,
|
||||
premiumUser: true,
|
||||
disabledPersonalKeyCreation: false,
|
||||
passwordResetRequired: false,
|
||||
showSSOBanner: false,
|
||||
};
|
||||
|
||||
|
|
@ -486,6 +542,7 @@ describe("UsagePage", () => {
|
|||
mockDailyActivityAggregatedCall.mockImplementation((entity: string, request: unknown) =>
|
||||
entity === "user" ? mockUserDailyActivityAggregatedCall(request) : Promise.resolve({ results: [], metadata: {} }),
|
||||
);
|
||||
mockEntityUsageMounted.mockClear();
|
||||
mockTagListCall.mockClear();
|
||||
mockGatewayDailyActivityCall.mockClear();
|
||||
mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
|
||||
|
|
@ -1026,6 +1083,7 @@ describe("UsagePage", () => {
|
|||
act(() => {
|
||||
fireEvent.change(usageSelect, { target: { value: usageView } });
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Entity Usage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -1518,4 +1576,204 @@ 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");
|
||||
|
||||
const expectedRequest = {
|
||||
accessToken: "test-token",
|
||||
endTime: expect.any(Date),
|
||||
entityIds: ["user-001"],
|
||||
startTime: expect.any(Date),
|
||||
};
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(expect.objectContaining(expectedRequest));
|
||||
});
|
||||
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("");
|
||||
|
||||
const expectedRequest = {
|
||||
accessToken: "test-token",
|
||||
endTime: expect.any(Date),
|
||||
entityIds: null,
|
||||
startTime: expect.any(Date),
|
||||
};
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(expect.objectContaining(expectedRequest));
|
||||
});
|
||||
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.each([
|
||||
["users", "User Activity"],
|
||||
["errors", "Errors"],
|
||||
])("restores the %s tab from the URL", async (tab, label) => {
|
||||
renderAt(`?tab=${tab}`);
|
||||
|
||||
expect(await screen.findByRole("tab", { name: label })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("renders User Agent Activity from the URL and writes selected tags back", async () => {
|
||||
const { lastQuery } = renderAt("?view=user-agent-activity&tag=User-Agent%3A+Chrome%2F1.0");
|
||||
const activity = await screen.findByTestId("user-agent-activity");
|
||||
|
||||
expect(activity).toHaveAttribute("data-selected-tags", JSON.stringify(["User-Agent: Chrome/1.0"]));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Select agent tag" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastQuery()).toContain("tag=User-Agent");
|
||||
});
|
||||
expect(await screen.findByTestId("user-agent-activity")).toHaveAttribute(
|
||||
"data-selected-tags",
|
||||
JSON.stringify(["User-Agent: Firefox/2.0"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches the custom range in the URL and writes a newly picked one", async () => {
|
||||
const { lastQuery } = renderAt("?from=2025-03-01&to=2025-03-05");
|
||||
|
||||
const expectedRequest = {
|
||||
accessToken: "test-token",
|
||||
endTime: new Date(2025, 2, 5, 23, 59, 59, 999),
|
||||
entityIds: null,
|
||||
startTime: new Date(2025, 2, 1, 0, 0, 0, 0),
|
||||
};
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(expect.objectContaining(expectedRequest));
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("pick-a-different-range"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastQuery()).toBe("?from=2024-01-01&to=2024-01-08");
|
||||
});
|
||||
});
|
||||
|
||||
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", {
|
||||
id: "usage-url-access-denied",
|
||||
});
|
||||
expect(screen.queryByTestId("entity-usage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render a disallowed customer view for an internal user", async () => {
|
||||
mockUseAuthorized.mockReturnValue(nonAdminSession);
|
||||
|
||||
renderAt("?view=customer&customer=c-1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockEntityUsageMounted).not.toHaveBeenCalledWith("customer");
|
||||
expect(screen.queryByTestId("entity-usage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not select the admin-only Errors tab for an internal user", async () => {
|
||||
mockUseAuthorized.mockReturnValue(nonAdminSession);
|
||||
|
||||
const { lastQuery } = renderAt("?tab=errors");
|
||||
|
||||
expect(await screen.findByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
|
||||
await waitFor(() => {
|
||||
expect(lastQuery()).toBe("");
|
||||
});
|
||||
expect(screen.queryByRole("tab", { name: "Errors" })).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", {
|
||||
id: "usage-url-access-denied",
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the share link on an entity view", async () => {
|
||||
renderAt("?view=team");
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Copy Share Link" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
* Works at 1m+ spend logs, by querying an aggregate table instead.
|
||||
*/
|
||||
|
||||
import { Download, Search, Sparkles, X } from "lucide-react";
|
||||
import { Download, Link2, Search, 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";
|
||||
|
||||
|
|
@ -21,12 +22,16 @@ 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 { copyToClipboard } 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 type { EntityType } from "@/components/EntityUsageExport/types";
|
||||
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
|
||||
import TopUsersView from "@/components/UsagePage/components/TopUsersView";
|
||||
import UserActivityPanel from "@/components/UsagePage/components/UserActivityPanel";
|
||||
|
|
@ -67,7 +72,21 @@ import ModelViewToggle, { ModelViewType } from "./ModelViewToggle";
|
|||
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, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
|
||||
import UserRecordLink from "./UserRecordLink";
|
||||
import {
|
||||
cleanUsageUrl,
|
||||
dateRangeFromParams,
|
||||
dateRangePatch,
|
||||
entitySelectionPatch,
|
||||
selectedEntitiesFromParams,
|
||||
DEFAULT_USAGE_VIEW,
|
||||
usageTabFromParams,
|
||||
usageTabPatch,
|
||||
usageUrlParsers,
|
||||
usageViewFromParams,
|
||||
usageViewPatch,
|
||||
} from "../usageUrlState";
|
||||
import { overviewTotals, rollUpBreakdown } from "./overview/overviewData";
|
||||
import TopAgents from "./overview/TopAgents";
|
||||
import type { AgentRow } from "./overview/agentCatalog";
|
||||
|
|
@ -89,11 +108,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
|
||||
|
|
@ -104,36 +124,41 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const isAdmin = all_admin_roles.includes(userRole || "");
|
||||
const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || "");
|
||||
const isOrgAdmin = useIsOrgAdmin();
|
||||
const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin);
|
||||
const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage");
|
||||
const allowedViews = useMemo(
|
||||
() => allowedUsageOptions(userRole, canViewTagUsage, isOrgAdmin),
|
||||
[userRole, canViewTagUsage, isOrgAdmin],
|
||||
);
|
||||
|
||||
// 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 [modelQuery, setModelQuery] = useState("");
|
||||
const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false);
|
||||
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
|
||||
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
|
||||
const [selectedUsageView, setUsageView] = useState<UsageOption>("global");
|
||||
// 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
|
||||
// the selector never holds a value it no longer offers.
|
||||
const usageView: UsageOption =
|
||||
selectedUsageView === "organization" && !canViewOrganizationUsage ? "global" : selectedUsageView;
|
||||
const selectedUsageView = usageViewFromParams(urlParams);
|
||||
const selectedTab = usageTabFromParams(urlParams, isAdmin);
|
||||
const usageView = allowedViews.includes(selectedUsageView) ? selectedUsageView : DEFAULT_USAGE_VIEW;
|
||||
|
||||
const [showCredentialBanner, setShowCredentialBanner] = useState(true);
|
||||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
// 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 { isLoading: organizationsLoading } = useOrganizations();
|
||||
const accessSettled = userRole !== null && !organizationsLoading;
|
||||
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", { id: "usage-url-access-denied" });
|
||||
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]);
|
||||
|
|
@ -211,11 +236,15 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
deps: [accessToken, startTime, endTime, showTopAgents],
|
||||
});
|
||||
const tagDaily = useMemo(() => toDailyData(tagDailyRaw), [tagDailyRaw]);
|
||||
const [agentActivityTags, setAgentActivityTags] = useState<readonly string[] | undefined>(undefined);
|
||||
const openAgentActivity = useCallback((agent: AgentRow) => {
|
||||
setAgentActivityTags(agent.tags);
|
||||
setUsageView("user-agent-activity");
|
||||
}, []);
|
||||
const openAgentActivity = useCallback(
|
||||
(agent: AgentRow) => {
|
||||
void setUrlParams({
|
||||
...usageViewPatch("user-agent-activity"),
|
||||
...entitySelectionPatch("tag", agent.tags),
|
||||
});
|
||||
},
|
||||
[setUrlParams],
|
||||
);
|
||||
|
||||
// Gateway request counts (SGR). Admin-only: the source table is
|
||||
// deployment-wide, so a non-admin must not see it.
|
||||
|
|
@ -277,9 +306,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
[userSpendData],
|
||||
);
|
||||
|
||||
const handleDateChange = useCallback((newValue: DateRangePickerValue) => {
|
||||
setDateValue(newValue);
|
||||
}, []);
|
||||
const handleDateChange = useCallback(
|
||||
(newValue: DateRangePickerValue, presetShortLabel: string | null) =>
|
||||
void setUrlParams(dateRangePatch(newValue, presetShortLabel)),
|
||||
[setUrlParams],
|
||||
);
|
||||
|
||||
const totals = useMemo(
|
||||
() => overviewTotals(userSpendData.metadata, gatewayActivity),
|
||||
|
|
@ -360,18 +391,27 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<header className="mb-3 flex w-full flex-wrap items-center justify-between gap-3 border-b pb-3">
|
||||
<UsageViewSelect
|
||||
value={usageView}
|
||||
onChange={(value) => setUsageView(value)}
|
||||
onChange={(value) => void setUrlParams(usageViewPatch(value))}
|
||||
userRole={userRole}
|
||||
canViewTagUsage={canViewTagUsage}
|
||||
isOrgAdmin={isOrgAdmin}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isAdmin && usageView === "global" && (
|
||||
<div className="w-64">
|
||||
<UserDropdown value={selectedUserId} onChange={setSelectedUserId} />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-64">
|
||||
<UserDropdown value={urlParams.user} onChange={(user) => void setUrlParams({ user })} />
|
||||
</div>
|
||||
{urlParams.user && <UserRecordLink userId={urlParams.user} />}
|
||||
</div>
|
||||
)}
|
||||
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
|
||||
<div className="flex items-center gap-2">
|
||||
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} presetShortLabel={urlParams.range} />
|
||||
<Button variant="outline" onClick={() => void copyToClipboard(window.location.href, "Link copied")}>
|
||||
<Link2 />
|
||||
Copy Share Link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{aggregatedFailed && (
|
||||
|
|
@ -387,7 +427,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
{/* Your Usage / Global Usage Panel */}
|
||||
{(usageView === "global" || usageView === "my-usage") && (
|
||||
<>
|
||||
<Tabs defaultValue="cost" className="gap-3">
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onValueChange={(tab: string) => void setUrlParams(usageTabPatch(tab))}
|
||||
className="gap-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="cost" className="flex-none px-3">
|
||||
|
|
@ -555,10 +599,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
)}
|
||||
{/* Organization Usage Panel */}
|
||||
|
||||
{usageView === "organization" && canViewOrganizationUsage && (
|
||||
{usageView === "organization" && (
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="organization"
|
||||
{...entitySelection("organization")}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
isOrgAdmin={isOrgAdmin}
|
||||
|
|
@ -578,6 +623,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="team"
|
||||
{...entitySelection("team")}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={
|
||||
|
|
@ -596,6 +642,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="customer"
|
||||
{...entitySelection("customer")}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={
|
||||
|
|
@ -634,6 +681,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="tag"
|
||||
{...entitySelection("tag")}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={allTags}
|
||||
|
|
@ -646,6 +694,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="agent"
|
||||
{...entitySelection("agent")}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={
|
||||
|
|
@ -660,6 +709,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="user"
|
||||
{...entitySelection("user")}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
entityList={null}
|
||||
|
|
@ -670,12 +720,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
{/* User Agent Activity Panel */}
|
||||
{usageView === "user-agent-activity" && (
|
||||
<UserAgentActivity
|
||||
// Remount when opened from a different agent so its tags become the starting filter.
|
||||
key={agentActivityTags?.join("|") ?? "all"}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
dateValue={dateValue}
|
||||
initialTags={agentActivityTags}
|
||||
selectedTags={selectedEntitiesFromParams(urlParams, "tag")}
|
||||
onSelectedTagsChange={(tags) => void setUrlParams(entitySelectionPatch("tag", tags))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,267 @@
|
|||
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 withTimezone = <T>(timezone: string, callback: () => T): T => {
|
||||
const originalTimezone = process.env.TZ;
|
||||
process.env.TZ = timezone;
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
if (originalTimezone === undefined) {
|
||||
delete process.env.TZ;
|
||||
} else {
|
||||
process.env.TZ = originalTimezone;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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("round-trips the User Agent Activity view", () => {
|
||||
const query = applyPatch("", usageViewPatch("user-agent-activity"));
|
||||
|
||||
expect(query).toBe("?view=user-agent-activity");
|
||||
expect(usageViewFromParams(load(query))).toBe("user-agent-activity");
|
||||
});
|
||||
|
||||
it.each(["users", "errors"] as const)("round-trips the %s tab", (tab) => {
|
||||
const query = applyPatch("", usageTabPatch(tab));
|
||||
|
||||
expect(query).toBe(`?tab=${tab}`);
|
||||
expect(usageTabFromParams(load(query))).toBe(tab);
|
||||
});
|
||||
|
||||
it("falls back from the admin-only Errors tab for an internal user", () => {
|
||||
expect(usageTabFromParams(load("?tab=errors"), false)).toBe("cost");
|
||||
expect(clean("?tab=errors", internalUserAccess)).toEqual({ query: "", deniedAccess: false });
|
||||
});
|
||||
|
||||
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("keeps User Agent Activity tag filters in the URL", () => {
|
||||
const query = applyPatch("?view=user-agent-activity", entitySelectionPatch("tag", ["User-Agent: Chrome/1.0"]));
|
||||
|
||||
expect(query).toBe("?view=user-agent-activity&tag=User-Agent:+Chrome/1.0");
|
||||
expect(clean(query)).toBeNull();
|
||||
expect(selectedEntitiesFromParams(load(query), "tag")).toEqual(["User-Agent: Chrome/1.0"]);
|
||||
});
|
||||
|
||||
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, "30d"))).toBe("?range=30d");
|
||||
});
|
||||
|
||||
it("stores a custom range matching today as explicit dates", () => {
|
||||
const today = moment();
|
||||
const value = { from: today.clone().startOf("day").toDate(), to: today.clone().endOf("day").toDate() };
|
||||
|
||||
expect(dateRangePatch(value, null)).toEqual({
|
||||
range: null,
|
||||
from: today.format("YYYY-MM-DD"),
|
||||
to: today.format("YYYY-MM-DD"),
|
||||
});
|
||||
});
|
||||
|
||||
it("stores an unknown preset label as explicit dates", () => {
|
||||
const today = moment();
|
||||
const value = { from: today.clone().startOf("day").toDate(), to: today.clone().endOf("day").toDate() };
|
||||
|
||||
expect(dateRangePatch(value, "not-a-preset")).toEqual({
|
||||
range: null,
|
||||
from: today.format("YYYY-MM-DD"),
|
||||
to: today.format("YYYY-MM-DD"),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the custom calendar day when opened in a different timezone", () => {
|
||||
const query = withTimezone("Australia/Sydney", () => {
|
||||
const value = {
|
||||
from: new Date(2026, 0, 5, 0, 0, 0, 0),
|
||||
to: new Date(2026, 0, 5, 23, 59, 59, 999),
|
||||
};
|
||||
|
||||
expect(value.from.getTimezoneOffset()).toBe(-660);
|
||||
expect(moment(value.from).format("YYYY-MM-DD")).toBe("2026-01-05");
|
||||
expect(moment(value.to).format("YYYY-MM-DD")).toBe("2026-01-05");
|
||||
|
||||
const serialized = applyPatch("", dateRangePatch(value, null));
|
||||
expect(serialized).toBe("?from=2026-01-05&to=2026-01-05");
|
||||
return serialized;
|
||||
});
|
||||
|
||||
withTimezone("America/New_York", () => {
|
||||
expect(new Date(2026, 0, 5).getTimezoneOffset()).toBe(300);
|
||||
const range = dateRangeFromParams(load(query));
|
||||
expect(range).toBeDefined();
|
||||
if (!range) return;
|
||||
|
||||
expect(moment(range.from).format("YYYY-MM-DD")).toBe("2026-01-05");
|
||||
expect(moment(range.to).format("YYYY-MM-DD")).toBe("2026-01-05");
|
||||
});
|
||||
});
|
||||
|
||||
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 whole local calendar days", () => {
|
||||
const custom = { from: new Date(2026, 0, 5, 8, 0, 0, 0), to: new Date(2026, 0, 9, 20, 30, 0, 0) };
|
||||
const query = applyPatch("?range=7d", dateRangePatch(custom, null));
|
||||
|
||||
expect(query).toBe("?from=2026-01-05&to=2026-01-09");
|
||||
expect(dateRangeFromParams(load(query))).toEqual({
|
||||
from: new Date(2026, 0, 5, 0, 0, 0, 0),
|
||||
to: new Date(2026, 0, 9, 23, 59, 59, 999),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unknown preset", "?range=90d"],
|
||||
["an ISO timestamp", "?from=2026-01-04T13:00:00.000Z&to=2026-01-05"],
|
||||
["an impossible date", "?from=2026-02-30&to=2026-03-01"],
|
||||
["a malformed date", "?from=yesterday&to=2026-01-09"],
|
||||
["a range that ends before it starts", "?from=2026-01-09&to=2026-01-05"],
|
||||
["only one end of a custom range", "?from=2026-01-05"],
|
||||
])("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-05&to=2026-01-09")).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-05&to=2026-01-09")).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,180 @@
|
|||
import moment from "moment";
|
||||
import { parseAsNativeArrayOf, parseAsString, type inferParserType } from "nuqs";
|
||||
|
||||
import type { EntityType } from "@/components/EntityUsageExport/types";
|
||||
import { 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", "users", "mcp", "endpoints", "errors"] 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": ["tag"],
|
||||
};
|
||||
|
||||
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, isAdmin = true): UsageTab =>
|
||||
isUsageTab(params.tab) && (params.tab !== "errors" || isAdmin) ? 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 customRangeFromParams = (from: string, to: string): DateRangePickerValue | undefined => {
|
||||
const fromDate = moment(from, "YYYY-MM-DD", true);
|
||||
const toDate = moment(to, "YYYY-MM-DD", true);
|
||||
if (!fromDate.isValid() || !toDate.isValid() || fromDate.isAfter(toDate, "day")) return undefined;
|
||||
return { from: fromDate.startOf("day").toDate(), to: toDate.endOf("day").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, presetShortLabel: string | null): UsageUrlPatch => {
|
||||
if (presetShortLabel !== null && relativeTimeOptions.some((option) => option.shortLabel === presetShortLabel)) {
|
||||
return { range: presetShortLabel, from: null, to: null };
|
||||
}
|
||||
if (!value.from || !value.to) return { range: null, from: null, to: null };
|
||||
return {
|
||||
range: null,
|
||||
from: moment(value.from).format("YYYY-MM-DD"),
|
||||
to: moment(value.to).format("YYYY-MM-DD"),
|
||||
};
|
||||
};
|
||||
|
||||
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) || (!access.isAdmin && params.tab === "errors"));
|
||||
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 })}`);
|
||||
|
|
@ -17,7 +17,7 @@ import {
|
|||
import OnboardingModal, { InvitationLink } from "@/components/onboarding_link";
|
||||
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { isAdminRole, isProxyAdminRole } from "@/utils/roles";
|
||||
import { all_admin_roles, isAdminRole, isProxyAdminRole } from "@/utils/roles";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
|
|
@ -298,6 +298,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
isLoading={userListQuery.isLoading || userListQuery.isPlaceholderData}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
teams={teams}
|
||||
canViewUserUsage={all_admin_roles.includes(userRole ?? "")}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
pagination={pagination}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ interface HarnessOverrides {
|
|||
rowCount?: number;
|
||||
isLoading?: boolean;
|
||||
selectionEnabled?: boolean;
|
||||
canViewUserUsage?: boolean;
|
||||
onUserClick?: (userId: string, openInEditMode?: boolean) => void;
|
||||
onDeleteUser?: (user: UserInfo) => void;
|
||||
onResetPassword?: (userId: string) => void;
|
||||
|
|
@ -51,6 +52,7 @@ function Harness({
|
|||
rowCount = 1,
|
||||
isLoading = false,
|
||||
selectionEnabled = false,
|
||||
canViewUserUsage = true,
|
||||
onUserClick = vi.fn(),
|
||||
onDeleteUser = vi.fn(),
|
||||
onResetPassword = vi.fn(),
|
||||
|
|
@ -74,6 +76,7 @@ function Harness({
|
|||
isLoading={isLoading}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
teams={[]}
|
||||
canViewUserUsage={canViewUserUsage}
|
||||
sorting={sorting}
|
||||
onSortingChange={(updater) => {
|
||||
setSorting(updater);
|
||||
|
|
@ -178,6 +181,27 @@ describe("UsersTable", () => {
|
|||
expect(onUserClick).toHaveBeenCalledWith("user-1", true);
|
||||
});
|
||||
|
||||
it("links the row menu to that user's usage when allowed", 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("hides the usage link when the viewer cannot view usage", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness canViewUserUsage={false} />);
|
||||
|
||||
await openRowMenu(user, "user-1");
|
||||
|
||||
expect(screen.queryByRole("menuitem", { name: "View usage" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("delegates delete and reset-password from the row menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteUser = vi.fn();
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ interface UsersTableProps {
|
|||
isLoading: boolean;
|
||||
possibleUIRoles: Record<string, Record<string, string>> | null;
|
||||
teams: UsersTableTeamOption[] | null;
|
||||
canViewUserUsage: boolean;
|
||||
sorting: SortingState;
|
||||
onSortingChange: OnChangeFn<SortingState>;
|
||||
pagination: PaginationState;
|
||||
|
|
@ -74,6 +75,7 @@ export function UsersTable({
|
|||
isLoading,
|
||||
possibleUIRoles,
|
||||
teams,
|
||||
canViewUserUsage,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
pagination,
|
||||
|
|
@ -95,12 +97,13 @@ export function UsersTable({
|
|||
const columnDeps = {
|
||||
possibleUIRoles,
|
||||
includeSelection: selectionEnabled,
|
||||
canViewUserUsage,
|
||||
onUserClick,
|
||||
onDeleteUser,
|
||||
onResetPassword,
|
||||
};
|
||||
return getUsersTableColumns(columnDeps);
|
||||
}, [possibleUIRoles, selectionEnabled, onUserClick, onDeleteUser, onResetPassword]);
|
||||
}, [possibleUIRoles, selectionEnabled, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword]);
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
@ -29,12 +31,13 @@ function isScimInactive(user: UserInfo): boolean {
|
|||
|
||||
interface UserRowActionsProps {
|
||||
user: UserInfo;
|
||||
canViewUserUsage: boolean;
|
||||
onUserClick: (userId: string, openInEditMode?: boolean) => void;
|
||||
onDeleteUser: (user: UserInfo) => void;
|
||||
onResetPassword: (userId: string) => void;
|
||||
}
|
||||
|
||||
function UserRowActions({ user, onUserClick, onDeleteUser, onResetPassword }: UserRowActionsProps) {
|
||||
function UserRowActions({ user, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword }: UserRowActionsProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
|
|
@ -60,6 +63,15 @@ function UserRowActions({ user, onUserClick, onDeleteUser, onResetPassword }: Us
|
|||
<Copy />
|
||||
Copy user ID
|
||||
</DropdownMenuItem>
|
||||
{canViewUserUsage && (
|
||||
<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 />
|
||||
|
|
@ -73,6 +85,7 @@ function UserRowActions({ user, onUserClick, onDeleteUser, onResetPassword }: Us
|
|||
export interface UsersTableColumnsDeps {
|
||||
possibleUIRoles: Record<string, Record<string, string>> | null;
|
||||
includeSelection: boolean;
|
||||
canViewUserUsage: boolean;
|
||||
onUserClick: (userId: string, openInEditMode?: boolean) => void;
|
||||
onDeleteUser: (user: UserInfo) => void;
|
||||
onResetPassword: (userId: string) => void;
|
||||
|
|
@ -81,6 +94,7 @@ export interface UsersTableColumnsDeps {
|
|||
export const getUsersTableColumns = ({
|
||||
possibleUIRoles,
|
||||
includeSelection,
|
||||
canViewUserUsage,
|
||||
onUserClick,
|
||||
onDeleteUser,
|
||||
onResetPassword,
|
||||
|
|
@ -257,6 +271,7 @@ export const getUsersTableColumns = ({
|
|||
<div className="flex justify-end">
|
||||
<UserRowActions
|
||||
user={row.original}
|
||||
canViewUserUsage={canViewUserUsage}
|
||||
onUserClick={onUserClick}
|
||||
onDeleteUser={onDeleteUser}
|
||||
onResetPassword={onResetPassword}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { DateRangePickerValue } from "@/components/shared/date_picker_types
|
|||
|
||||
interface ExportSummaryProps {
|
||||
dateRange: DateRangePickerValue;
|
||||
selectedFilters: string[];
|
||||
selectedFilters: readonly string[];
|
||||
}
|
||||
|
||||
const ExportSummary: React.FC<ExportSummaryProps> = ({ dateRange, selectedFilters }) => {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ interface UsageExportHeaderProps {
|
|||
showFilters?: boolean;
|
||||
filterLabel?: string;
|
||||
filterPlaceholder?: string;
|
||||
selectedFilters?: string[];
|
||||
selectedFilters?: readonly string[];
|
||||
onFiltersChange?: (filters: string[]) => void;
|
||||
filterOptions?: Array<{ label: string; value: string }>;
|
||||
filterSlot?: React.ReactNode;
|
||||
|
|
@ -81,7 +81,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
multiple
|
||||
disabled={isFilterDisabled}
|
||||
items={optionValues}
|
||||
value={selectedFilters}
|
||||
value={[...selectedFilters]}
|
||||
onValueChange={(next: string[]) => onFiltersChange?.(next)}
|
||||
>
|
||||
<ComboboxChips render={<div ref={anchor} />} className="w-full">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export interface EntityUsageExportModalProps {
|
|||
entityType: EntityType;
|
||||
onExport: (exportType: ExportType, format: ExportFormat) => Promise<Blob>;
|
||||
dateRange: DateRangePickerValue;
|
||||
selectedFilters?: string[];
|
||||
selectedFilters?: readonly string[];
|
||||
customTitle?: string;
|
||||
teams?: Team[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { SearchSelectOption } from "@/components/shared/SearchSelect";
|
|||
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
||||
interface TeamMultiSelectProps {
|
||||
value?: string[];
|
||||
value?: readonly string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
disabled?: boolean;
|
||||
organizationId?: string | null;
|
||||
|
|
|
|||
|
|
@ -1465,7 +1465,7 @@ export const teamSpendByUserCall = async (
|
|||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
teamIds: string[],
|
||||
teamIds: readonly string[],
|
||||
): Promise<TeamUserSpendResponse> =>
|
||||
apiClient.get<TeamUserSpendResponse>(`/team/spend/by_user`, {
|
||||
accessToken,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ interface PerUserAnalyticsResponse {
|
|||
|
||||
interface PerUserUsageProps {
|
||||
accessToken: string | null;
|
||||
selectedTags: string[];
|
||||
selectedTags: readonly string[];
|
||||
formatAbbreviatedNumber: (value: number, decimalPlaces?: number) => string;
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ const PerUserUsage: React.FC<PerUserUsageProps> = ({ accessToken, selectedTags,
|
|||
});
|
||||
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 50 });
|
||||
const [pagedTags, setPagedTags] = useState(selectedTags);
|
||||
const [pagedTags, setPagedTags] = useState<readonly string[]>(selectedTags);
|
||||
|
||||
if (pagedTags !== selectedTags) {
|
||||
setPagedTags(selectedTags);
|
||||
|
|
@ -57,7 +57,7 @@ const PerUserUsage: React.FC<PerUserUsageProps> = ({ accessToken, selectedTags,
|
|||
accessToken,
|
||||
pagination.pageIndex + 1,
|
||||
pagination.pageSize,
|
||||
pagedTags.length > 0 ? pagedTags : undefined,
|
||||
pagedTags.length > 0 ? [...pagedTags] : undefined,
|
||||
)
|
||||
.then((response) => {
|
||||
if (stale) return;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { usePaginatedCombobox } from "./usePaginatedCombobox";
|
|||
|
||||
interface PaginatedMultiSelectProps {
|
||||
options: SearchSelectOption[];
|
||||
value?: string[];
|
||||
value?: readonly string[];
|
||||
onValueChange: (value: string[]) => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
onLoadMore: () => void;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import moment from "moment";
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
|
||||
import AdvancedDatePicker from "./advanced_date_picker";
|
||||
|
||||
|
|
@ -166,6 +167,82 @@ describe("AdvancedDatePicker", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("passes the clicked preset label when applying", async () => {
|
||||
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
|
||||
|
||||
openDropdown(container);
|
||||
fireEvent.click(screen.getByText("Today"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), "today");
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), "today");
|
||||
});
|
||||
});
|
||||
|
||||
it("passes a null preset label when typed dates match today", async () => {
|
||||
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
|
||||
const today = moment().format("YYYY-MM-DD");
|
||||
|
||||
openDropdown(container);
|
||||
fireEvent.change(screen.getByDisplayValue("2025-01-01"), { target: { value: today } });
|
||||
fireEvent.change(screen.getByDisplayValue("2025-01-31"), { target: { value: today } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), null);
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), null);
|
||||
});
|
||||
});
|
||||
|
||||
it("resets the preset label when the parent switches to a custom range", async () => {
|
||||
const { container, rerender } = render(
|
||||
<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />,
|
||||
);
|
||||
|
||||
openDropdown(container);
|
||||
fireEvent.click(screen.getByText("Today"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), "today");
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), "today");
|
||||
});
|
||||
|
||||
mockOnValueChange.mockClear();
|
||||
const customValue = {
|
||||
from: new Date("2025-02-01T00:00:00.000Z"),
|
||||
to: new Date("2025-02-10T23:59:59.999Z"),
|
||||
};
|
||||
rerender(<AdvancedDatePicker value={customValue} onValueChange={mockOnValueChange} presetShortLabel={null} />);
|
||||
|
||||
openDropdown(container);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), null);
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), null);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the parent preset label when applying an unchanged range", async () => {
|
||||
const value = {
|
||||
from: moment().subtract(30, "days").startOf("day").toDate(),
|
||||
to: moment().endOf("day").toDate(),
|
||||
};
|
||||
const { container } = render(
|
||||
<AdvancedDatePicker value={value} onValueChange={mockOnValueChange} presetShortLabel="30d" />,
|
||||
);
|
||||
|
||||
openDropdown(container);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), "30d");
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), "30d");
|
||||
});
|
||||
});
|
||||
|
||||
it("should select relative time option", () => {
|
||||
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,20 +7,21 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
|
|||
|
||||
interface AdvancedDatePickerProps {
|
||||
value: DateRangePickerValue;
|
||||
onValueChange: (value: DateRangePickerValue) => void;
|
||||
onValueChange: (value: DateRangePickerValue, presetShortLabel: string | null) => void;
|
||||
presetShortLabel?: string | null;
|
||||
label?: string;
|
||||
className?: string;
|
||||
showTimeRange?: boolean;
|
||||
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,12 +64,22 @@ 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
|
||||
*/
|
||||
const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
|
||||
value,
|
||||
onValueChange,
|
||||
presetShortLabel = null,
|
||||
label = "Select Time Range",
|
||||
className,
|
||||
showTimeRange = true,
|
||||
|
|
@ -77,6 +88,7 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
|
|||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [tempValue, setTempValue] = useState<DateRangePickerValue>(value);
|
||||
const [selectedOption, setSelectedOption] = useState<string | null>(null);
|
||||
const [pickedPreset, setPickedPreset] = useState<string | null>(null);
|
||||
|
||||
// Custom date inputs only - removed time inputs
|
||||
const [startDate, setStartDate] = useState("");
|
||||
|
|
@ -84,24 +96,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(() => {
|
||||
|
|
@ -140,7 +138,8 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
|
|||
setEndDate(moment(value.to).format("YYYY-MM-DD"));
|
||||
}
|
||||
setTempValue(value);
|
||||
}, [value]);
|
||||
setPickedPreset(presetShortLabel ?? null);
|
||||
}, [value, presetShortLabel]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
|
|
@ -206,6 +205,7 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
|
|||
// Update local state to reflect the selection (don't apply immediately)
|
||||
setTempValue(newValue);
|
||||
setSelectedOption(option.shortLabel);
|
||||
setPickedPreset(option.shortLabel);
|
||||
|
||||
// Update the form inputs to reflect the selection
|
||||
setStartDate(moment(from).format("YYYY-MM-DD"));
|
||||
|
|
@ -243,13 +243,13 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
|
|||
const handleApply = () => {
|
||||
if (tempValue.from && tempValue.to && validation.isValid) {
|
||||
// First call with immediate value for UI responsiveness
|
||||
onValueChange(tempValue);
|
||||
onValueChange(tempValue, pickedPreset);
|
||||
|
||||
// Then do the same background adjustment logic as the original component
|
||||
requestIdleCallback(
|
||||
() => {
|
||||
const adjustedValue = adjustDateRange(tempValue);
|
||||
onValueChange(adjustedValue);
|
||||
onValueChange(adjustedValue, pickedPreset);
|
||||
},
|
||||
{ timeout: 100 },
|
||||
);
|
||||
|
|
@ -273,6 +273,7 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
|
|||
// Reset selected option
|
||||
const matchingOption = getMatchingOption(value);
|
||||
setSelectedOption(matchingOption);
|
||||
setPickedPreset(presetShortLabel ?? null);
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
|
@ -367,7 +368,10 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
|
|||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setPickedPreset(null);
|
||||
setStartDate(e.target.value);
|
||||
}}
|
||||
className={`w-65 px-3 py-2 text-sm border rounded-md cursor-pointer hover:border-ring focus:border-info focus:ring-1 focus:ring-ring ${
|
||||
!validation.isValid
|
||||
? "border-destructive/30 focus:border-destructive focus:ring-red-200"
|
||||
|
|
@ -382,7 +386,10 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
|
|||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setPickedPreset(null);
|
||||
setEndDate(e.target.value);
|
||||
}}
|
||||
className={`w-65 px-3 py-2 text-sm border rounded-md cursor-pointer hover:border-ring focus:border-info focus:ring-1 focus:ring-ring ${
|
||||
!validation.isValid
|
||||
? "border-destructive/30 focus:border-destructive focus:ring-red-200"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { STACKED_USAGE_PALETTE } from "@/components/shared/charts";
|
||||
import UserAgentActivity from "./user_agent_activity";
|
||||
|
|
@ -11,11 +12,7 @@ vi.mock("./networking", () => ({
|
|||
tagWauCall: vi.fn(),
|
||||
tagMauCall: vi.fn(),
|
||||
tagDistinctCall: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock PerUserUsage component
|
||||
vi.mock("./per_user_usage", () => ({
|
||||
default: () => <div>Per User Usage</div>,
|
||||
perUserAnalyticsCall: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("UserAgentActivity", () => {
|
||||
|
|
@ -24,6 +21,7 @@ describe("UserAgentActivity", () => {
|
|||
const mockTagWauCall = vi.mocked(networking.tagWauCall);
|
||||
const mockTagMauCall = vi.mocked(networking.tagMauCall);
|
||||
const mockTagDistinctCall = vi.mocked(networking.tagDistinctCall);
|
||||
const mockPerUserAnalyticsCall = vi.mocked(networking.perUserAnalyticsCall);
|
||||
|
||||
const mockDistinctTagsData = {
|
||||
results: [{ tag: "User-Agent: Chrome/1.0" }, { tag: "User-Agent: Firefox/2.0" }, { tag: "User-Agent: Safari/3.0" }],
|
||||
|
|
@ -102,12 +100,20 @@ describe("UserAgentActivity", () => {
|
|||
mockTagWauCall.mockClear();
|
||||
mockTagMauCall.mockClear();
|
||||
mockTagDistinctCall.mockClear();
|
||||
mockPerUserAnalyticsCall.mockClear();
|
||||
|
||||
mockTagDistinctCall.mockResolvedValue(mockDistinctTagsData);
|
||||
mockUserAgentSummaryCall.mockResolvedValue(mockSummaryData);
|
||||
mockTagDauCall.mockResolvedValue(mockDauData);
|
||||
mockTagWauCall.mockResolvedValue(mockWauData);
|
||||
mockTagMauCall.mockResolvedValue(mockMauData);
|
||||
mockPerUserAnalyticsCall.mockResolvedValue({
|
||||
results: [],
|
||||
total_count: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total_pages: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render summary cards with user agent data", async () => {
|
||||
|
|
@ -185,6 +191,70 @@ describe("UserAgentActivity", () => {
|
|||
expect(prompts).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses and reports a controlled tag selection", async () => {
|
||||
const onSelectedTagsChange = vi.fn();
|
||||
const selectedTags = ["User-Agent: Chrome/1.0"];
|
||||
|
||||
render(
|
||||
<UserAgentActivity {...defaultProps} selectedTags={selectedTags} onSelectedTagsChange={onSelectedTagsChange} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDauCall).toHaveBeenCalledWith("test-token", expect.any(Date), undefined, selectedTags);
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Clear user agent filter" }));
|
||||
|
||||
expect(onSelectedTagsChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("keeps per-user pagination when controlled tags stay unchanged on parent rerender", async () => {
|
||||
const user = userEvent.setup();
|
||||
const selectedTags = ["User-Agent: Chrome/1.0"];
|
||||
const onSelectedTagsChange = vi.fn();
|
||||
const totalUsers = 120;
|
||||
const pageOfUsers = (page: number, pageSize: number) => {
|
||||
const start = (page - 1) * pageSize;
|
||||
const count = Math.max(0, Math.min(pageSize, totalUsers - start));
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
user_id: `user-${start + index + 1}`,
|
||||
user_email: null,
|
||||
user_agent: "Chrome/1.0",
|
||||
successful_requests: 5,
|
||||
failed_requests: 0,
|
||||
total_requests: 5,
|
||||
total_tokens: 100,
|
||||
spend: 1,
|
||||
}));
|
||||
};
|
||||
mockPerUserAnalyticsCall.mockImplementation(async (_token, page = 1, pageSize = 50) => ({
|
||||
results: pageOfUsers(page, pageSize),
|
||||
total_count: totalUsers,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
total_pages: Math.ceil(totalUsers / pageSize),
|
||||
}));
|
||||
const props = { ...defaultProps, selectedTags, onSelectedTagsChange };
|
||||
const { rerender } = render(<UserAgentActivity {...props} />);
|
||||
|
||||
await waitFor(() => expect(mockTagDistinctCall).toHaveBeenCalled());
|
||||
await waitFor(() => expect(mockUserAgentSummaryCall).toHaveBeenCalled());
|
||||
await waitFor(() => expect(mockTagDauCall).toHaveBeenCalled());
|
||||
await waitFor(() => expect(mockTagWauCall).toHaveBeenCalled());
|
||||
await waitFor(() => expect(mockTagMauCall).toHaveBeenCalled());
|
||||
await user.click(screen.getByRole("radio", { name: "Per user" }));
|
||||
expect(await screen.findByText("user-1")).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId("pagination-next"));
|
||||
expect(await screen.findByText("user-51")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 51-100 of 120");
|
||||
|
||||
const callsBeforeRerender = mockPerUserAnalyticsCall.mock.calls.length;
|
||||
rerender(<UserAgentActivity {...props} />);
|
||||
|
||||
expect(mockPerUserAnalyticsCall).toHaveBeenCalledTimes(callsBeforeRerender);
|
||||
expect(screen.getByText("user-51")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 51-100 of 120");
|
||||
});
|
||||
|
||||
// Walks up from the panel's heading to the nearest ancestor that owns a chart, so the
|
||||
// assertions do not depend on how many wrappers the tab library puts around a panel.
|
||||
const chartForTitle = (title: string): HTMLElement => {
|
||||
|
|
|
|||
|
|
@ -123,6 +123,8 @@ interface UserAgentActivityProps {
|
|||
onDateChange?: (value: DateRangePickerValue) => void; // Optional - not used anymore
|
||||
/** Tags to pre-select, e.g. an agent's user-agent tags when opened from the Overview's Top agents. */
|
||||
initialTags?: readonly string[];
|
||||
selectedTags?: readonly string[];
|
||||
onSelectedTagsChange?: (tags: readonly string[]) => void;
|
||||
}
|
||||
|
||||
const UserAgentActivity: React.FC<UserAgentActivityProps> = ({
|
||||
|
|
@ -131,6 +133,8 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({
|
|||
dateValue,
|
||||
onDateChange,
|
||||
initialTags,
|
||||
selectedTags: selectedTagsProp,
|
||||
onSelectedTagsChange,
|
||||
}) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
// Maximum number of categories to show in charts to prevent color palette overflow
|
||||
|
|
@ -146,7 +150,15 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({
|
|||
|
||||
// Tag filtering state
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>(() => [...(initialTags ?? [])]);
|
||||
const [localSelectedTags, setLocalSelectedTags] = useState<string[]>(() => [...(initialTags ?? [])]);
|
||||
const selectedTags = selectedTagsProp ?? localSelectedTags;
|
||||
const updateSelectedTags = (next: string[]) => {
|
||||
if (onSelectedTagsChange) {
|
||||
onSelectedTagsChange(next);
|
||||
return;
|
||||
}
|
||||
setLocalSelectedTags(next);
|
||||
};
|
||||
const [tagsLoading, setTagsLoading] = useState(false);
|
||||
|
||||
// Separate loading states for each endpoint
|
||||
|
|
@ -184,7 +196,7 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({
|
|||
accessToken,
|
||||
today,
|
||||
userAgentFilter || undefined,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
selectedTags.length > 0 ? [...selectedTags] : undefined,
|
||||
);
|
||||
setDauData(data);
|
||||
} catch (error) {
|
||||
|
|
@ -203,7 +215,7 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({
|
|||
accessToken,
|
||||
today,
|
||||
userAgentFilter || undefined,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
selectedTags.length > 0 ? [...selectedTags] : undefined,
|
||||
);
|
||||
setWauData(data);
|
||||
} catch (error) {
|
||||
|
|
@ -222,7 +234,7 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({
|
|||
accessToken,
|
||||
today,
|
||||
userAgentFilter || undefined,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
selectedTags.length > 0 ? [...selectedTags] : undefined,
|
||||
);
|
||||
setMauData(data);
|
||||
} catch (error) {
|
||||
|
|
@ -241,7 +253,7 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({
|
|||
accessToken,
|
||||
dateValue.from,
|
||||
dateValue.to,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
selectedTags.length > 0 ? [...selectedTags] : undefined,
|
||||
);
|
||||
setSummaryData(summary);
|
||||
} catch (error) {
|
||||
|
|
@ -455,12 +467,7 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({
|
|||
action={
|
||||
<div className="flex w-full items-center gap-2 sm:w-96">
|
||||
<label className="shrink-0 text-xs text-muted-foreground">Filter by User Agents</label>
|
||||
<Combobox
|
||||
multiple
|
||||
items={availableTags}
|
||||
value={selectedTags}
|
||||
onValueChange={(next: string[]) => setSelectedTags(next)}
|
||||
>
|
||||
<Combobox multiple items={availableTags} value={[...selectedTags]} onValueChange={updateSelectedTags}>
|
||||
<ComboboxChips render={<div ref={anchor} />} className="min-w-0 flex-1" aria-busy={tagsLoading}>
|
||||
<ComboboxValue>
|
||||
{(selected: string[]) =>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ describe("toast", () => {
|
|||
expect(sonner.success).toHaveBeenCalledWith("saved", { description: "Model x", duration: 1500 });
|
||||
});
|
||||
|
||||
it("passes an id through so repeated toasts collapse into one", () => {
|
||||
toast.warning("no access", { id: "denied" });
|
||||
expect(sonner.warning).toHaveBeenCalledWith("no access", expect.objectContaining({ id: "denied" }));
|
||||
});
|
||||
|
||||
it("dismiss clears every toast", () => {
|
||||
toast.dismiss();
|
||||
expect(sonner.dismiss).toHaveBeenCalledWith();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export type ToastKind = "success" | "info" | "warning" | "error";
|
|||
export type ToastOptions = {
|
||||
readonly description?: ReactNode;
|
||||
readonly durationMs?: number;
|
||||
readonly id?: string;
|
||||
};
|
||||
|
||||
type ErrorFacts = {
|
||||
|
|
@ -127,6 +128,7 @@ const titleFor = ({ status, proxyType }: ErrorFacts): string => {
|
|||
|
||||
const show = (kind: ToastKind, message: ReactNode, options?: ToastOptions): void => {
|
||||
sonner[kind](message, {
|
||||
id: options?.id,
|
||||
description: options?.description,
|
||||
duration: options?.durationMs ?? DEFAULT_DURATION_MS[kind],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,12 +26,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}>
|
||||
<HotkeysProvider>{children}</HotkeysProvider>
|
||||
</QueryClientProvider>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue