From 2f980b74127220d045b62955005aa7af35ae5177 Mon Sep 17 00:00:00 2001 From: Arthur Vervaet Date: Fri, 7 Aug 2026 17:36:46 +0200 Subject: [PATCH 01/13] [front] feat: wire Members/Groups tabs of the usage filter panel to real, period-scoped data Members now come from the same period-ranked consumption data the Attribution table uses (useConsumptionTop) instead of the full, period-unaware member roster. Groups are no longer mock: a new relevant-groups endpoint resolves, for that same set of period-active users, which groups they belonged to during the period window (not "now"), via a new GroupResource.listGroupsForUserModelIdsInWindow. --- .../w/[wId]/analytics/consumption/index.ts | 2 + .../consumption/relevant-groups.test.ts | 111 ++++++++++++++ .../analytics/consumption/relevant-groups.ts | 51 +++++++ .../workspace/AnalyticsConsumptionPage.tsx | 7 +- .../workspace/analytics/UsageFilterPanel.tsx | 84 +++++++++-- .../workspace/analytics/usageFilter.ts | 4 + .../analytics/usageFilterMockData.ts | 27 +--- .../UsageFilterMemberGroupsControls.tsx | 19 +-- front/hooks/useConsumptionRelevantGroups.ts | 46 ++++++ .../analytics/consumption/relevant_groups.ts | 86 +++++++++++ front/lib/resources/group_resource.test.ts | 137 ++++++++++++++++++ front/lib/resources/group_resource.ts | 65 +++++++++ 12 files changed, 584 insertions(+), 55 deletions(-) create mode 100644 front-api/routes/w/[wId]/analytics/consumption/relevant-groups.test.ts create mode 100644 front-api/routes/w/[wId]/analytics/consumption/relevant-groups.ts create mode 100644 front/hooks/useConsumptionRelevantGroups.ts create mode 100644 front/lib/api/analytics/consumption/relevant_groups.ts diff --git a/front-api/routes/w/[wId]/analytics/consumption/index.ts b/front-api/routes/w/[wId]/analytics/consumption/index.ts index 0a3e6f58ecce..d23ecd7cb43e 100644 --- a/front-api/routes/w/[wId]/analytics/consumption/index.ts +++ b/front-api/routes/w/[wId]/analytics/consumption/index.ts @@ -1,5 +1,6 @@ import { workspaceApp } from "@front-api/middlewares/ctx"; import overview from "./overview"; +import relevantGroups from "./relevant-groups"; import timeseries from "./timeseries"; import topAgents from "./top-agents"; import topModels from "./top-models"; @@ -11,6 +12,7 @@ import topUsers from "./top-users"; const app = workspaceApp(); app.route("/overview", overview); +app.route("/relevant-groups", relevantGroups); app.route("/timeseries", timeseries); app.route("/top-agents", topAgents); app.route("/top-models", topModels); diff --git a/front-api/routes/w/[wId]/analytics/consumption/relevant-groups.test.ts b/front-api/routes/w/[wId]/analytics/consumption/relevant-groups.test.ts new file mode 100644 index 000000000000..580b405bcb70 --- /dev/null +++ b/front-api/routes/w/[wId]/analytics/consumption/relevant-groups.test.ts @@ -0,0 +1,111 @@ +import { fetchConsumptionRelevantGroups } from "@app/lib/api/analytics/consumption/relevant_groups"; +import { createPrivateApiMockRequest } from "@app/tests/utils/generic_private_api_tests"; +import type { MembershipRoleType } from "@app/types/memberships"; +import { Err, Ok } from "@app/types/shared/result"; +import { honoApp } from "@front-api/app"; +import { describe, expect, it, vi } from "vitest"; + +// devModeConstants reads localStorage at module load. jsdom does not always +// have localStorage initialized when mock factories evaluate, which crashes +// any test whose mocked lib transitively imports AuthContext. Stub it here. +vi.mock("@app/components/dev/devModeConstants", () => ({ + DEV_MODE_STORAGE_KEY: "dust_dev_mode", + DEV_MODE_ACTIVE: false, +})); + +vi.mock( + import("@app/lib/api/analytics/consumption/relevant_groups"), + async (orig) => { + const mod = await orig(); + return { + ...mod, + fetchConsumptionRelevantGroups: vi.fn(), + }; + } +); + +const RELEVANT_GROUPS = { + groups: [ + { id: "g1", name: "Engineering", memberIds: ["u1", "u2"] }, + { id: "g2", name: "Sales", memberIds: ["u3"] }, + ], +}; + +async function setupTest({ role = "admin" as MembershipRoleType } = {}) { + return createPrivateApiMockRequest({ role }); +} + +function getRelevantGroupsRequest( + wId: string, + query: Record = {} +) { + const qs = new URLSearchParams(query).toString(); + return honoApp.request( + `/api/w/${wId}/analytics/consumption/relevant-groups${qs ? `?${qs}` : ""}` + ); +} + +describe("GET /api/w/:wId/analytics/consumption/relevant-groups", () => { + it("returns 403 for non-manager users", async () => { + const { workspace } = await setupTest({ role: "user" }); + + const response = await getRelevantGroupsRequest(workspace.sId); + + expect(response.status).toBe(403); + expect(vi.mocked(fetchConsumptionRelevantGroups)).not.toHaveBeenCalled(); + }); + + it("returns the relevant groups for managers, defaulting to the current cycle", async () => { + vi.mocked(fetchConsumptionRelevantGroups).mockResolvedValue( + new Ok(RELEVANT_GROUPS) + ); + const { workspace } = await setupTest({ role: "admin" }); + + const response = await getRelevantGroupsRequest(workspace.sId); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(RELEVANT_GROUPS); + expect(vi.mocked(fetchConsumptionRelevantGroups)).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + period: expect.objectContaining({}), + limit: 10, + }) + ); + }); + + it("forwards a days period and a custom limit", async () => { + vi.mocked(fetchConsumptionRelevantGroups).mockResolvedValue( + new Ok(RELEVANT_GROUPS) + ); + const { workspace } = await setupTest(); + + const response = await getRelevantGroupsRequest(workspace.sId, { + period: "days", + days: "7", + limit: "50", + }); + + expect(response.status).toBe(200); + expect(vi.mocked(fetchConsumptionRelevantGroups)).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ limit: 50 }) + ); + }); + + it("returns 500 when the search fails", async () => { + vi.mocked(fetchConsumptionRelevantGroups).mockResolvedValue( + new Err( + Object.assign(new Error("boom"), { type: "query_error" as const }) + ) + ); + const { workspace } = await setupTest(); + + const response = await getRelevantGroupsRequest(workspace.sId); + + expect(response.status).toBe(500); + expect(await response.json()).toMatchObject({ + error: { type: "internal_server_error" }, + }); + }); +}); diff --git a/front-api/routes/w/[wId]/analytics/consumption/relevant-groups.ts b/front-api/routes/w/[wId]/analytics/consumption/relevant-groups.ts new file mode 100644 index 000000000000..408ca0c8bf16 --- /dev/null +++ b/front-api/routes/w/[wId]/analytics/consumption/relevant-groups.ts @@ -0,0 +1,51 @@ +import { resolveConsumptionPeriod } from "@app/lib/api/analytics/consumption/period"; +import type { GetConsumptionRelevantGroupsResponse } from "@app/lib/api/analytics/consumption/relevant_groups"; +import { fetchConsumptionRelevantGroups } from "@app/lib/api/analytics/consumption/relevant_groups"; +import { + ConsumptionTopQuerySchema, + toConsumptionPeriodInput, +} from "@app/lib/api/analytics/consumption/schema"; +import { workspaceApp } from "@front-api/middlewares/ctx"; +import { ensureIsManager } from "@front-api/middlewares/ensure_role"; +import { apiError } from "@front-api/middlewares/utils"; +import { validate } from "@front-api/middlewares/validator"; + +export type { GetConsumptionRelevantGroupsResponse }; + +// Mounted at /api/w/:wId/analytics/consumption/relevant-groups. +const app = workspaceApp(); + +/** @ignoreswagger */ +app.get( + "/", + ensureIsManager(), + validate("query", ConsumptionTopQuerySchema), + async (ctx) => { + const auth = ctx.get("auth"); + const { limit, ...periodQuery } = ctx.req.valid("query"); + + const period = await resolveConsumptionPeriod( + auth, + toConsumptionPeriodInput(periodQuery) + ); + + const result = await fetchConsumptionRelevantGroups(auth, { + period, + limit, + }); + if (result.isErr()) { + return apiError(ctx, { + status_code: 500, + api_error: { + type: "internal_server_error", + message: `Failed to retrieve consumption-relevant groups: ${result.error.message}`, + }, + }); + } + + const body: GetConsumptionRelevantGroupsResponse = result.value; + return ctx.json(body); + } +); + +export default app; diff --git a/front/components/pages/workspace/AnalyticsConsumptionPage.tsx b/front/components/pages/workspace/AnalyticsConsumptionPage.tsx index 6db77aed2661..2c6df2188429 100644 --- a/front/components/pages/workspace/AnalyticsConsumptionPage.tsx +++ b/front/components/pages/workspace/AnalyticsConsumptionPage.tsx @@ -6,10 +6,7 @@ import { DEFAULT_CONSUMPTION_DIMENSION } from "@app/components/workspace/analyti import { UsageFilterPanel } from "@app/components/workspace/analytics/UsageFilterPanel"; import type { UsageFilter } from "@app/components/workspace/analytics/usageFilter"; import { toConsumptionScopeFilter } from "@app/components/workspace/analytics/usageFilter"; -import { - USAGE_FILTER_MOCK_GROUPS, - USAGE_FILTER_MOCK_OPTIONS, -} from "@app/components/workspace/analytics/usageFilterMockData"; +import { USAGE_FILTER_MOCK_OPTIONS } from "@app/components/workspace/analytics/usageFilterMockData"; import type { ConsumptionPeriodSelection } from "@app/lib/analytics/consumption_period"; import { DEFAULT_CONSUMPTION_PERIOD } from "@app/lib/analytics/consumption_period"; import { useFeatureFlags, useWorkspace } from "@app/lib/auth/AuthContext"; @@ -92,8 +89,8 @@ export function AnalyticsConsumptionPage() {
diff --git a/front/components/workspace/analytics/UsageFilterPanel.tsx b/front/components/workspace/analytics/UsageFilterPanel.tsx index e951d40b5596..f8c050d607ba 100644 --- a/front/components/workspace/analytics/UsageFilterPanel.tsx +++ b/front/components/workspace/analytics/UsageFilterPanel.tsx @@ -1,3 +1,4 @@ +import type { ConsumptionPeriodSelection } from "@app/components/workspace/analytics/consumption/consumptionPeriod"; import type { UsageFilter, UsageFilterAgentOption, @@ -13,6 +14,8 @@ import type { UsageModelTier, } from "@app/components/workspace/analytics/usageFilter"; import { + addUsageFilterGroup, + removeUsageFilterGroup, USAGE_FILTER_CATEGORIES, USAGE_FILTER_CATEGORY_LABEL, USAGE_FILTER_SCOPES, @@ -26,7 +29,8 @@ import { UsageFilterModelComplexityControls } from "@app/components/workspace/an import { UsageFilterOptionCheckboxList } from "@app/components/workspace/analytics/usageFilterPanel/UsageFilterOptionCheckboxList"; import { UsageFilterSelectionSummary } from "@app/components/workspace/analytics/usageFilterPanel/UsageFilterSelectionSummary"; import { useUsageFilter } from "@app/components/workspace/analytics/useUsageFilter"; -import { useSearchMembers } from "@app/lib/swr/memberships"; +import { useConsumptionRelevantGroups } from "@app/hooks/useConsumptionRelevantGroups"; +import { useConsumptionTop } from "@app/hooks/useConsumptionTop"; import type { LightWorkspaceType } from "@app/types/user"; import { BarChart05, @@ -41,6 +45,11 @@ import { useMemo, useState } from "react"; interface UsageFilterPanelProps { owner: LightWorkspaceType; + period: ConsumptionPeriodSelection; + // Agents/models/tools/skills/sources are still mock data (see + // usageFilterMockData.ts — sources are fake connectors standing in for a + // real db call); members and groups are fetched live below, scoped to + // `period` (useConsumptionTop, useConsumptionRelevantGroups). categoryOptions: { agent: UsageFilterAgentOption[]; model: UsageFilterModelOption[]; @@ -48,15 +57,14 @@ interface UsageFilterPanelProps { skill: UsageFilterSkillOption[]; source: UsageFilterSourceOption[]; }; - groups: UsageFilterGroup[]; filter: UsageFilter; onFilterChange: (next: UsageFilter) => void; } export function UsageFilterPanel({ owner, + period, categoryOptions, - groups, filter, onFilterChange, }: UsageFilterPanelProps) { @@ -81,24 +89,43 @@ export function UsageFilterPanel({ USAGE_MODEL_TIERS[0] ); const [searchText, setSearchText] = useState(""); + // Only used for the "member" category: narrows the displayed members down + // to those belonging to at least one of these groups. Groups only narrow + // the picker — the user still checks individual members to add them to the + // filter. Lifted here (rather than owned by UsageFilterMemberGroupsControls) + // because filteredEntities below needs it too. + const [selectedGroups, setSelectedGroups] = useState([]); - const { members } = useSearchMembers({ + const isMemberCategoryActive = isOpen && activeCategory === "member"; + + const { rows: topUserRows } = useConsumptionTop({ + workspaceId: owner.sId, + dimension: "user", + period, + // Wider than the Attribution table's own top-N: the picker needs broader + // coverage of the period's active population than a ranking display does. + limit: 100, + disabled: !isMemberCategoryActive, + }); + + const { groups } = useConsumptionRelevantGroups({ workspaceId: owner.sId, - searchTerm: activeCategory === "member" ? searchText : "", - pageIndex: 0, - pageSize: 100, - disabled: !isOpen || activeCategory !== "member", + period, + disabled: !isMemberCategoryActive, }); + // Search is applied client-side below (the top-users ranking has no + // server-side search), so a member outside the top 100 by credits over the + // period will not be searchable here. const memberOptions = useMemo( () => - members.map((m) => ({ - id: m.sId, - name: m.fullName, + topUserRows.map((row) => ({ + id: row.id, + name: row.name, kind: "member", - image: m.image, + image: row.pictureUrl, })), - [members] + [topUserRows] ); const resolvedCategoryOptions = useMemo<{ @@ -114,6 +141,10 @@ export function UsageFilterPanel({ const activeOptions = resolvedCategoryOptions[activeCategory]; const filteredOptions = useMemo(() => { const search = searchText.trim().toLowerCase(); + const selectedGroupMemberIds = + activeCategory === "member" && selectedGroups.length > 0 + ? new Set(selectedGroups.flatMap((group) => group.memberIds)) + : null; return activeOptions.filter((option) => { if (option.kind === "agent" && option.scope !== activeScope) { return false; @@ -121,12 +152,22 @@ export function UsageFilterPanel({ if (option.kind === "model" && option.tier !== activeTier) { return false; } + if (selectedGroupMemberIds && !selectedGroupMemberIds.has(option.id)) { + return false; + } if (search && !option.name.toLowerCase().includes(search)) { return false; } return true; }); - }, [activeOptions, searchText, activeScope, activeTier]); + }, [ + activeOptions, + searchText, + activeScope, + activeTier, + activeCategory, + selectedGroups, + ]); const selectedIdsForActiveCategory = useMemo( () => @@ -173,6 +214,14 @@ export function UsageFilterPanel({ setIsOpen(false); }; + const handleAddGroup = (group: UsageFilterGroup) => { + setSelectedGroups((current) => addUsageFilterGroup(current, group)); + }; + + const handleRemoveGroup = (id: string) => { + setSelectedGroups((current) => removeUsageFilterGroup(current, id)); + }; + const activeCategorySelectionCount = draftFilter[activeCategory]?.length ?? 0; return ( @@ -219,7 +268,12 @@ export function UsageFilterPanel({ placeholder={`Search ${USAGE_FILTER_CATEGORY_LABEL[activeCategory].toLowerCase()}`} /> {activeCategory === "member" && ( - + )} {activeCategory === "model" && ( = diff --git a/front/components/workspace/analytics/usageFilterMockData.ts b/front/components/workspace/analytics/usageFilterMockData.ts index 9249fea5c7d9..cc7d8fb34fdc 100644 --- a/front/components/workspace/analytics/usageFilterMockData.ts +++ b/front/components/workspace/analytics/usageFilterMockData.ts @@ -1,6 +1,5 @@ import type { UsageFilterAgentOption, - UsageFilterGroup, UsageFilterModelOption, UsageFilterSkillOption, UsageFilterSourceOption, @@ -28,30 +27,10 @@ const MOCK_MODEL_LAB: Record = { "DeepSeek V4": "deepseek", }; -const MOCK_GROUP_NAMES = [ - "Engineering", - "Sales", - "Product", - "Design", - "Support", - "Finance", - "Legal", - "HR", - "Marketing", - "Leadership", - "Customer Success", - "Operations", -]; - -export const USAGE_FILTER_MOCK_GROUPS: UsageFilterGroup[] = - MOCK_GROUP_NAMES.map((name, index) => ({ - id: `group_${index + 1}`, - name, - })); - // Placeholder data for categories not yet wired to a real backend endpoint. -// Members are fetched live in UsageFilterPanel via useSearchMembers. Lists -// are long enough to exercise scrolling in the preview. +// Members and groups are fetched live in UsageFilterPanel (useConsumptionTop, +// useConsumptionRelevantGroups). Lists are long enough to exercise scrolling +// in the preview. const MOCK_ENTITY_NAMES = { agent: [ "SupportBot", diff --git a/front/components/workspace/analytics/usageFilterPanel/UsageFilterMemberGroupsControls.tsx b/front/components/workspace/analytics/usageFilterPanel/UsageFilterMemberGroupsControls.tsx index 43888e08419d..a33dae2002be 100644 --- a/front/components/workspace/analytics/usageFilterPanel/UsageFilterMemberGroupsControls.tsx +++ b/front/components/workspace/analytics/usageFilterPanel/UsageFilterMemberGroupsControls.tsx @@ -1,8 +1,4 @@ import type { UsageFilterGroup } from "@app/components/workspace/analytics/usageFilter"; -import { - addUsageFilterGroup, - removeUsageFilterGroup, -} from "@app/components/workspace/analytics/usageFilter"; import { Button, Chip, @@ -15,27 +11,28 @@ import { useState } from "react"; interface UsageFilterMemberGroupsControlsProps { groups: UsageFilterGroup[]; + selectedGroups: UsageFilterGroup[]; + onAddGroup: (group: UsageFilterGroup) => void; + onRemoveGroup: (id: string) => void; } export function UsageFilterMemberGroupsControls({ groups, + selectedGroups, + onAddGroup, + onRemoveGroup, }: UsageFilterMemberGroupsControlsProps) { const [isAddGroupOpen, setIsAddGroupOpen] = useState(false); - const [selectedGroups, setSelectedGroups] = useState([]); const availableGroups = groups.filter( (group) => !selectedGroups.some((selected) => selected.id === group.id) ); const handleAddGroup = (group: UsageFilterGroup) => { - setSelectedGroups((current) => addUsageFilterGroup(current, group)); + onAddGroup(group); setIsAddGroupOpen(false); }; - const handleRemoveGroup = (id: string) => { - setSelectedGroups((current) => removeUsageFilterGroup(current, id)); - }; - return ( <> handleRemoveGroup(group.id)} + onRemove={() => onRemoveGroup(group.id)} /> ))}
diff --git a/front/hooks/useConsumptionRelevantGroups.ts b/front/hooks/useConsumptionRelevantGroups.ts new file mode 100644 index 000000000000..87c8f489a38a --- /dev/null +++ b/front/hooks/useConsumptionRelevantGroups.ts @@ -0,0 +1,46 @@ +import type { ConsumptionPeriodSelection } from "@app/components/workspace/analytics/consumption/consumptionPeriod"; +import { consumptionQueryString } from "@app/components/workspace/analytics/consumption/consumptionPeriod"; +// Type-only: importing a value from this module would pull the Elasticsearch +// client into the browser bundle (see the note in `series.ts`). +import type { GetConsumptionRelevantGroupsResponse } from "@app/lib/api/analytics/consumption/relevant_groups"; +import { emptyArray, useFetcher, useSWRWithDefaults } from "@app/lib/swr/swr"; +import type { Fetcher } from "swr"; + +export type ConsumptionRelevantGroupRow = { + id: string; + name: string; + memberIds: string[]; +}; + +// Broader than the Attribution table's own top-N (25): the picker needs wider +// coverage of the period's active population than a ranking display does. +const RELEVANT_GROUPS_LIMIT = 100; + +export function useConsumptionRelevantGroups({ + workspaceId, + period, + disabled, +}: { + workspaceId: string; + period: ConsumptionPeriodSelection; + disabled?: boolean; +}) { + const { fetcher } = useFetcher(); + const relevantGroupsFetcher: Fetcher = + fetcher; + + const params = new URLSearchParams(consumptionQueryString(period)); + params.set("limit", String(RELEVANT_GROUPS_LIMIT)); + + const { data, error } = useSWRWithDefaults( + `/api/w/${workspaceId}/analytics/consumption/relevant-groups?${params.toString()}`, + relevantGroupsFetcher, + { disabled } + ); + + return { + groups: data?.groups ?? emptyArray(), + isRelevantGroupsLoading: !error && !data && !disabled, + isRelevantGroupsError: error, + }; +} diff --git a/front/lib/api/analytics/consumption/relevant_groups.ts b/front/lib/api/analytics/consumption/relevant_groups.ts new file mode 100644 index 000000000000..32d1e284f1a6 --- /dev/null +++ b/front/lib/api/analytics/consumption/relevant_groups.ts @@ -0,0 +1,86 @@ +import type { ConsumptionPeriod } from "@app/lib/api/analytics/consumption/period"; +import { fetchConsumptionTopGroups } from "@app/lib/api/analytics/consumption/top"; +import type { ElasticsearchError } from "@app/lib/api/elasticsearch"; +import type { Authenticator } from "@app/lib/auth"; +import { GroupResource } from "@app/lib/resources/group_resource"; +import { UserResource } from "@app/lib/resources/user_resource"; +import type { Result } from "@app/types/shared/result"; +import { Ok } from "@app/types/shared/result"; + +/** + * Groups relevant to a consumption period: the groups whose members actually + * consumed credits during that window, resolved as of the window itself (not + * "now") so a historical period reflects who was in the group at the time. + * + * There is no `group` field on the consumption index — group membership is a + * Postgres concept — so this starts from the same real, period-ranked user + * population `top-users` already surfaces, then resolves their group + * membership over the period in Postgres. + */ + +export type ConsumptionRelevantGroup = { + id: string; + name: string; + // Member sIds this group and the period's active users have in common, so + // the frontend can narrow a member list to a group without another call. + memberIds: string[]; +}; + +export type ConsumptionRelevantGroups = { + groups: ConsumptionRelevantGroup[]; +}; + +export type GetConsumptionRelevantGroupsResponse = ConsumptionRelevantGroups; + +export async function fetchConsumptionRelevantGroups( + auth: Authenticator, + { period, limit }: { period: ConsumptionPeriod; limit: number } +): Promise> { + const topUsersResult = await fetchConsumptionTopGroups(auth, { + dimension: "user", + unit: "message", + period, + limit, + }); + if (topUsersResult.isErr()) { + return topUsersResult; + } + + const userSids = topUsersResult.value.groups.map((group) => group.key); + if (userSids.length === 0) { + return new Ok({ groups: [] }); + } + + const users = await UserResource.fetchByIds(userSids); + const groupsByUserModelId = + await GroupResource.listGroupsForUserModelIdsInWindow({ + workspace: auth.getNonNullableWorkspace(), + userModelIds: users.map((user) => user.id), + window: { + start: new Date(period.startDate), + end: new Date(period.endDate), + }, + }); + + const groupById = new Map(); + for (const user of users) { + for (const group of groupsByUserModelId.get(user.id) ?? []) { + const existing = groupById.get(group.sId); + if (existing) { + existing.memberIds.push(user.sId); + } else { + groupById.set(group.sId, { + id: group.sId, + name: group.name, + memberIds: [user.sId], + }); + } + } + } + + const groups = [...groupById.values()].sort((a, b) => + a.name.localeCompare(b.name) + ); + + return new Ok({ groups }); +} diff --git a/front/lib/resources/group_resource.test.ts b/front/lib/resources/group_resource.test.ts index d18336171985..3f73c82865e7 100644 --- a/front/lib/resources/group_resource.test.ts +++ b/front/lib/resources/group_resource.test.ts @@ -214,6 +214,143 @@ describe("GroupResource", () => { }); }); + describe("listGroupsForUserModelIdsInWindow", () => { + const JANUARY = new Date("2026-01-15T00:00:00Z"); + const FEBRUARY = new Date("2026-02-15T00:00:00Z"); + const MARCH = new Date("2026-03-15T00:00:00Z"); + const WINDOW = { start: FEBRUARY, end: MARCH }; + + // Group memberships are historized by startAt/endAt, but no resource method + // lets a caller choose those values, so the window is backdated directly. + async function setGroupMembershipWindow( + group: GroupResource, + member: UserResource, + { startAt, endAt }: { startAt: Date; endAt: Date | null } + ) { + await GroupMembershipModel.update( + { startAt, endAt }, + { + where: { + workspaceId: workspace.id, + groupId: group.id, + userId: member.id, + }, + } + ); + } + + it("includes a membership that started before the window and is still ongoing", async () => { + const member = await UserFactory.basic(); + await MembershipFactory.associate(workspace, member, { role: "user" }); + const sales = await GroupResource.makeNew({ + name: "Sales", + workspaceId: workspace.id, + kind: "regular_auto", + }); + await sales.dangerouslyAddMembers(authenticator, { + users: [member.toJSON()], + }); + await setGroupMembershipWindow(sales, member, { + startAt: JANUARY, + endAt: null, + }); + + const result = await GroupResource.listGroupsForUserModelIdsInWindow({ + workspace, + userModelIds: [member.id], + window: WINDOW, + }); + + expect(result.get(member.id)?.map((g) => g.name)).toEqual(["Sales"]); + }); + + it("includes a membership that ended inside the window", async () => { + const member = await UserFactory.basic(); + await MembershipFactory.associate(workspace, member, { role: "user" }); + const sales = await GroupResource.makeNew({ + name: "Sales", + workspaceId: workspace.id, + kind: "regular_auto", + }); + await sales.dangerouslyAddMembers(authenticator, { + users: [member.toJSON()], + }); + // Left the group right at the window's start — still overlaps it. + await setGroupMembershipWindow(sales, member, { + startAt: JANUARY, + endAt: FEBRUARY, + }); + + const result = await GroupResource.listGroupsForUserModelIdsInWindow({ + workspace, + userModelIds: [member.id], + window: WINDOW, + }); + + expect(result.get(member.id)?.map((g) => g.name)).toEqual(["Sales"]); + }); + + it("excludes a membership that ended before the window started", async () => { + const member = await UserFactory.basic(); + await MembershipFactory.associate(workspace, member, { role: "user" }); + const sales = await GroupResource.makeNew({ + name: "Sales", + workspaceId: workspace.id, + kind: "regular_auto", + }); + await sales.dangerouslyAddMembers(authenticator, { + users: [member.toJSON()], + }); + await setGroupMembershipWindow(sales, member, { + startAt: JANUARY, + endAt: new Date("2026-02-01T00:00:00Z"), + }); + + const result = await GroupResource.listGroupsForUserModelIdsInWindow({ + workspace, + userModelIds: [member.id], + window: WINDOW, + }); + + expect(result.has(member.id)).toBe(false); + }); + + it("excludes a membership that had not started yet by the window's end", async () => { + const member = await UserFactory.basic(); + await MembershipFactory.associate(workspace, member, { role: "user" }); + const sales = await GroupResource.makeNew({ + name: "Sales", + workspaceId: workspace.id, + kind: "regular_auto", + }); + await sales.dangerouslyAddMembers(authenticator, { + users: [member.toJSON()], + }); + await setGroupMembershipWindow(sales, member, { + startAt: new Date("2026-04-01T00:00:00Z"), + endAt: null, + }); + + const result = await GroupResource.listGroupsForUserModelIdsInWindow({ + workspace, + userModelIds: [member.id], + window: WINDOW, + }); + + expect(result.has(member.id)).toBe(false); + }); + + it("returns an empty map when no user ids are given", async () => { + const result = await GroupResource.listGroupsForUserModelIdsInWindow({ + workspace, + userModelIds: [], + window: WINDOW, + }); + + expect(result.size).toBe(0); + }); + }); + describe("listUserGroupsInWorkspace with `at`", () => { const JANUARY = new Date("2026-01-15T00:00:00Z"); const FEBRUARY = new Date("2026-02-15T00:00:00Z"); diff --git a/front/lib/resources/group_resource.ts b/front/lib/resources/group_resource.ts index e8afa4d36aa4..15c976dc5218 100644 --- a/front/lib/resources/group_resource.ts +++ b/front/lib/resources/group_resource.ts @@ -1329,6 +1329,71 @@ export class GroupResource extends BaseResource { return result; } + /** + * For each user, the groups whose membership overlapped `window` at all + * (started before the window ended, and either never ended or ended after + * the window started) — not just membership as of `now`. Used to resolve + * "who was in which group" for a historical period, e.g. consumption + * analytics filters scoped to a past billing cycle. Single batched query + * (membership rows, then the groups they reference), no N+1. + */ + static async listGroupsForUserModelIdsInWindow({ + workspace, + userModelIds, + groupKinds = ["regular_auto", "provisioned"], + window, + }: { + workspace: LightWorkspaceType; + userModelIds: ModelId[]; + groupKinds?: Exclude[]; + window: { start: Date; end: Date }; + }): Promise> { + const result = new Map(); + if (userModelIds.length === 0) { + return result; + } + + const memberships = await GroupMembershipModel.findAll({ + where: { + workspaceId: workspace.id, + userId: userModelIds, + status: "active", + startAt: { [Op.lte]: window.end }, + [Op.or]: [{ endAt: null }, { endAt: { [Op.gte]: window.start } }], + }, + }); + if (memberships.length === 0) { + return result; + } + + const groupModelIds = [...new Set(memberships.map((m) => m.groupId))]; + const groups = await GroupModel.findAll({ + where: { + id: groupModelIds, + workspaceId: workspace.id, + kind: groupKinds, + }, + }); + const groupById = new Map( + groups.map((group) => [group.id, new this(GroupModel, group.get())]) + ); + + for (const m of memberships) { + const group = groupById.get(m.groupId); + if (!group) { + continue; + } + const existing = result.get(m.userId); + if (existing) { + existing.push(group); + } else { + result.set(m.userId, [group]); + } + } + + return result; + } + /** * For each user, the subset of `groupModelIds` they are an active member of. * Users with no matching membership are absent from the map. Restricting the From 67d1e1fb483d377ad4b1ccb4923c6f7b4a669066 Mon Sep 17 00:00:00 2001 From: Arthur Vervaet Date: Fri, 7 Aug 2026 17:57:49 +0200 Subject: [PATCH 02/13] [front] refactor: rename usage filter "member" category to "user" Matches the naming convention already used by the consumption scope dimensions and consumptionDimensions.ts: the category key is "user" (same as the ES dimension it filters on), "Members" stays the display label. Avoids a per-category translation layer as more categories get wired to real data. --- .../workspace/analytics/UsageFilterPanel.tsx | 16 +++++++------- .../workspace/analytics/usageFilter.ts | 22 ++++++++++--------- .../UsageFilterOptionIcon.tsx | 2 +- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/front/components/workspace/analytics/UsageFilterPanel.tsx b/front/components/workspace/analytics/UsageFilterPanel.tsx index f8c050d607ba..751d4de239bf 100644 --- a/front/components/workspace/analytics/UsageFilterPanel.tsx +++ b/front/components/workspace/analytics/UsageFilterPanel.tsx @@ -4,13 +4,13 @@ import type { UsageFilterAgentOption, UsageFilterCategory, UsageFilterGroup, - UsageFilterMemberOption, UsageFilterModelOption, UsageFilterOptionForCategory, UsageFilterScope, UsageFilterSkillOption, UsageFilterSourceOption, UsageFilterToolOption, + UsageFilterUserOption, UsageModelTier, } from "@app/components/workspace/analytics/usageFilter"; import { @@ -89,14 +89,14 @@ export function UsageFilterPanel({ USAGE_MODEL_TIERS[0] ); const [searchText, setSearchText] = useState(""); - // Only used for the "member" category: narrows the displayed members down + // Only used for the "user" category: narrows the displayed members down // to those belonging to at least one of these groups. Groups only narrow // the picker — the user still checks individual members to add them to the // filter. Lifted here (rather than owned by UsageFilterMemberGroupsControls) // because filteredEntities below needs it too. const [selectedGroups, setSelectedGroups] = useState([]); - const isMemberCategoryActive = isOpen && activeCategory === "member"; + const isMemberCategoryActive = isOpen && activeCategory === "user"; const { rows: topUserRows } = useConsumptionTop({ workspaceId: owner.sId, @@ -117,12 +117,12 @@ export function UsageFilterPanel({ // Search is applied client-side below (the top-users ranking has no // server-side search), so a member outside the top 100 by credits over the // period will not be searchable here. - const memberOptions = useMemo( + const memberOptions = useMemo( () => topUserRows.map((row) => ({ id: row.id, name: row.name, - kind: "member", + kind: "user", image: row.pictureUrl, })), [topUserRows] @@ -133,7 +133,7 @@ export function UsageFilterPanel({ }>( () => ({ ...categoryOptions, - member: memberOptions, + user: memberOptions, }), [categoryOptions, memberOptions] ); @@ -142,7 +142,7 @@ export function UsageFilterPanel({ const filteredOptions = useMemo(() => { const search = searchText.trim().toLowerCase(); const selectedGroupMemberIds = - activeCategory === "member" && selectedGroups.length > 0 + activeCategory === "user" && selectedGroups.length > 0 ? new Set(selectedGroups.flatMap((group) => group.memberIds)) : null; return activeOptions.filter((option) => { @@ -267,7 +267,7 @@ export function UsageFilterPanel({ onChange={setSearchText} placeholder={`Search ${USAGE_FILTER_CATEGORY_LABEL[activeCategory].toLowerCase()}`} /> - {activeCategory === "member" && ( + {activeCategory === "user" && ( = { agent: "Agents", - member: "Members", + user: "Members", model: "Models", tool: "Tools", skill: "Skills", @@ -53,8 +55,8 @@ export interface UsageFilterAgentOption extends UsageFilterOptionBase { scope: UsageFilterScope; } -export interface UsageFilterMemberOption extends UsageFilterOptionBase { - kind: "member"; +export interface UsageFilterUserOption extends UsageFilterOptionBase { + kind: "user"; image: string | null; } @@ -79,7 +81,7 @@ export interface UsageFilterSkillOption extends UsageFilterOptionBase { export type UsageFilterOption = | UsageFilterAgentOption - | UsageFilterMemberOption + | UsageFilterUserOption | UsageFilterSourceOption | UsageFilterModelOption | UsageFilterToolOption @@ -88,7 +90,7 @@ export type UsageFilterOption = export interface UsageFilterGroup { id: string; name: string; - // Member sIds, used to narrow the "member" category's checklist down to a + // Member sIds, used to narrow the "user" category's checklist down to a // selected group. Not itself part of `UsageFilter` — groups only narrow the // picker, the user still checks individual members to filter by. memberIds: string[]; @@ -160,11 +162,11 @@ export function removeUsageFilterGroup( return groups.filter((g) => g.id !== id); } -// Only "member" is wired to a real consumption scope dimension ("user") so -// far; the other categories stay mock data and are not sent as query filters. +// Only "user" is wired to a real consumption scope dimension so far; the +// other categories stay mock data and are not sent as query filters. export function toConsumptionScopeFilter( filter: UsageFilter ): ConsumptionScopeFilter { - const memberIds = filter.member?.map((entity) => entity.id); - return memberIds && memberIds.length > 0 ? { users: memberIds } : {}; + const userIds = filter.user?.map((entity) => entity.id); + return userIds && userIds.length > 0 ? { users: userIds } : {}; } diff --git a/front/components/workspace/analytics/usageFilterPanel/UsageFilterOptionIcon.tsx b/front/components/workspace/analytics/usageFilterPanel/UsageFilterOptionIcon.tsx index 89ff26b2ff58..b238584bf5a6 100644 --- a/front/components/workspace/analytics/usageFilterPanel/UsageFilterOptionIcon.tsx +++ b/front/components/workspace/analytics/usageFilterPanel/UsageFilterOptionIcon.tsx @@ -13,7 +13,7 @@ export function UsageFilterOptionIcon({ option }: UsageFilterOptionIconProps) { const { isDark } = useTheme(); switch (option.kind) { - case "member": + case "user": return ( Date: Mon, 10 Aug 2026 09:53:20 +0200 Subject: [PATCH 03/13] [front] fix: correct consumption_period import path --- front/components/workspace/analytics/UsageFilterPanel.tsx | 2 +- front/hooks/useConsumptionRelevantGroups.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/front/components/workspace/analytics/UsageFilterPanel.tsx b/front/components/workspace/analytics/UsageFilterPanel.tsx index 751d4de239bf..539bafa1b638 100644 --- a/front/components/workspace/analytics/UsageFilterPanel.tsx +++ b/front/components/workspace/analytics/UsageFilterPanel.tsx @@ -1,4 +1,3 @@ -import type { ConsumptionPeriodSelection } from "@app/components/workspace/analytics/consumption/consumptionPeriod"; import type { UsageFilter, UsageFilterAgentOption, @@ -31,6 +30,7 @@ import { UsageFilterSelectionSummary } from "@app/components/workspace/analytics import { useUsageFilter } from "@app/components/workspace/analytics/useUsageFilter"; import { useConsumptionRelevantGroups } from "@app/hooks/useConsumptionRelevantGroups"; import { useConsumptionTop } from "@app/hooks/useConsumptionTop"; +import type { ConsumptionPeriodSelection } from "@app/lib/analytics/consumption_period"; import type { LightWorkspaceType } from "@app/types/user"; import { BarChart05, diff --git a/front/hooks/useConsumptionRelevantGroups.ts b/front/hooks/useConsumptionRelevantGroups.ts index 87c8f489a38a..c4347f45847b 100644 --- a/front/hooks/useConsumptionRelevantGroups.ts +++ b/front/hooks/useConsumptionRelevantGroups.ts @@ -1,5 +1,5 @@ -import type { ConsumptionPeriodSelection } from "@app/components/workspace/analytics/consumption/consumptionPeriod"; -import { consumptionQueryString } from "@app/components/workspace/analytics/consumption/consumptionPeriod"; +import type { ConsumptionPeriodSelection } from "@app/lib/analytics/consumption_period"; +import { consumptionQueryString } from "@app/lib/analytics/consumption_period"; // Type-only: importing a value from this module would pull the Elasticsearch // client into the browser bundle (see the note in `series.ts`). import type { GetConsumptionRelevantGroupsResponse } from "@app/lib/api/analytics/consumption/relevant_groups"; From 87b650d194d4cb3437f468fcec634287a1f86efb Mon Sep 17 00:00:00 2001 From: Arthur Vervaet Date: Mon, 10 Aug 2026 10:32:55 +0200 Subject: [PATCH 04/13] using elastic group ids indexing instead of rown db search --- front/hooks/useConsumptionRelevantGroups.ts | 4 +- .../consumption/relevant_groups.test.ts | 124 ++++++++++++++++ .../analytics/consumption/relevant_groups.ts | 127 +++++++++------- front/lib/resources/group_resource.test.ts | 137 ------------------ front/lib/resources/group_resource.ts | 65 --------- 5 files changed, 204 insertions(+), 253 deletions(-) create mode 100644 front/lib/api/analytics/consumption/relevant_groups.test.ts diff --git a/front/hooks/useConsumptionRelevantGroups.ts b/front/hooks/useConsumptionRelevantGroups.ts index c4347f45847b..8361b7a01920 100644 --- a/front/hooks/useConsumptionRelevantGroups.ts +++ b/front/hooks/useConsumptionRelevantGroups.ts @@ -12,8 +12,8 @@ export type ConsumptionRelevantGroupRow = { memberIds: string[]; }; -// Broader than the Attribution table's own top-N (25): the picker needs wider -// coverage of the period's active population than a ranking display does. +// Caps the number of distinct groups returned (the aggregation itself already +// covers every group active in the period, unlike a top-N ranking). const RELEVANT_GROUPS_LIMIT = 100; export function useConsumptionRelevantGroups({ diff --git a/front/lib/api/analytics/consumption/relevant_groups.test.ts b/front/lib/api/analytics/consumption/relevant_groups.test.ts new file mode 100644 index 000000000000..f4462477e324 --- /dev/null +++ b/front/lib/api/analytics/consumption/relevant_groups.test.ts @@ -0,0 +1,124 @@ +import type { ConsumptionPeriod } from "@app/lib/api/analytics/consumption/period"; +import { fetchConsumptionRelevantGroups } from "@app/lib/api/analytics/consumption/relevant_groups"; +import { searchConsumptionAnalytics } from "@app/lib/api/elasticsearch"; +import { Authenticator } from "@app/lib/auth"; +import { makeSId } from "@app/lib/resources/string_ids"; +import { GroupFactory } from "@app/tests/utils/GroupFactory"; +import { WorkspaceFactory } from "@app/tests/utils/WorkspaceFactory"; +import { Ok } from "@app/types/shared/result"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock(import("@app/lib/api/elasticsearch"), async (orig) => { + const mod = await orig(); + return { ...mod, searchConsumptionAnalytics: vi.fn() }; +}); + +const PERIOD: ConsumptionPeriod = { + startDate: "2026-07-01T00:00:00.000Z", + endDate: "2026-08-01T00:00:00.000Z", +}; + +function mockGroupBuckets(buckets: unknown[]) { + vi.mocked(searchConsumptionAnalytics).mockResolvedValue( + new Ok({ aggregations: { by_group: { buckets } } }) as Awaited< + ReturnType + > + ); +} + +async function setup() { + const workspace = await WorkspaceFactory.basic(); + const auth = await Authenticator.internalAdminForWorkspace(workspace.sId); + return { auth, workspace }; +} + +describe("fetchConsumptionRelevantGroups", () => { + afterEach(() => { + vi.mocked(searchConsumptionAnalytics).mockReset(); + }); + + it("resolves group names from Postgres and carries member sIds off the aggregation, sorted by name", async () => { + const { auth, workspace } = await setup(); + const sales = await GroupFactory.regularManual(workspace, "Sales"); + const engineering = await GroupFactory.regularManual( + workspace, + "Engineering" + ); + mockGroupBuckets([ + { key: sales.sId, members: { buckets: [{ key: "u1" }, { key: "u2" }] } }, + { key: engineering.sId, members: { buckets: [{ key: "u3" }] } }, + ]); + + const result = await fetchConsumptionRelevantGroups(auth, { + period: PERIOD, + limit: 10, + }); + + expect(result.isOk()).toBe(true); + if (!result.isOk()) { + return; + } + expect(result.value.groups).toEqual([ + { id: engineering.sId, name: "Engineering", memberIds: ["u3"] }, + { id: sales.sId, name: "Sales", memberIds: ["u1", "u2"] }, + ]); + }); + + it("silently drops a group sId that no longer resolves in Postgres (hard-deleted group)", async () => { + const { auth, workspace } = await setup(); + const deletedGroupSId = makeSId("group", { + id: 999_999_999, + workspaceId: workspace.id, + }); + mockGroupBuckets([ + { key: deletedGroupSId, members: { buckets: [{ key: "u1" }] } }, + ]); + + const result = await fetchConsumptionRelevantGroups(auth, { + period: PERIOD, + limit: 10, + }); + + expect(result.isOk()).toBe(true); + if (!result.isOk()) { + return; + } + expect(result.value.groups).toEqual([]); + }); + + it("returns an empty list when no document carries a group", async () => { + const { auth } = await setup(); + mockGroupBuckets([]); + + const result = await fetchConsumptionRelevantGroups(auth, { + period: PERIOD, + limit: 10, + }); + + expect(result.isOk()).toBe(true); + if (!result.isOk()) { + return; + } + expect(result.value.groups).toEqual([]); + }); + + it("aggregates directly on user.group_ids, scoped to the workspace and period", async () => { + const { auth } = await setup(); + mockGroupBuckets([]); + + await fetchConsumptionRelevantGroups(auth, { period: PERIOD, limit: 25 }); + + const [query, options] = vi.mocked(searchConsumptionAnalytics).mock + .calls[0]; + expect(query.bool?.filter).toContainEqual({ + range: { completed_at: { gte: PERIOD.startDate, lt: PERIOD.endDate } }, + }); + expect(options?.aggregations?.by_group?.terms).toMatchObject({ + field: "user.group_ids", + size: 25, + }); + expect(options?.aggregations?.by_group?.aggs?.members?.terms).toMatchObject( + { field: "user.id" } + ); + }); +}); diff --git a/front/lib/api/analytics/consumption/relevant_groups.ts b/front/lib/api/analytics/consumption/relevant_groups.ts index 32d1e284f1a6..612a9f7258bf 100644 --- a/front/lib/api/analytics/consumption/relevant_groups.ts +++ b/front/lib/api/analytics/consumption/relevant_groups.ts @@ -1,28 +1,36 @@ import type { ConsumptionPeriod } from "@app/lib/api/analytics/consumption/period"; -import { fetchConsumptionTopGroups } from "@app/lib/api/analytics/consumption/top"; +import { buildConsumptionScopeQuery } from "@app/lib/api/analytics/consumption/scope"; import type { ElasticsearchError } from "@app/lib/api/elasticsearch"; +import { + bucketsToArray, + searchConsumptionAnalytics, +} from "@app/lib/api/elasticsearch"; import type { Authenticator } from "@app/lib/auth"; import { GroupResource } from "@app/lib/resources/group_resource"; -import { UserResource } from "@app/lib/resources/user_resource"; +import { getResourceIdFromSId } from "@app/lib/resources/string_ids"; import type { Result } from "@app/types/shared/result"; import { Ok } from "@app/types/shared/result"; +import { removeNulls } from "@app/types/shared/utils/general"; +import type { estypes } from "@elastic/elasticsearch"; /** - * Groups relevant to a consumption period: the groups whose members actually - * consumed credits during that window, resolved as of the window itself (not - * "now") so a historical period reflects who was in the group at the time. + * Each analytics document stores the group sIds the triggering user + * belonged to when the message completed. * - * There is no `group` field on the consumption index — group membership is a - * Postgres concept — so this starts from the same real, period-ranked user - * population `top-users` already surfaces, then resolves their group - * membership over the period in Postgres. + * Group names still live in Postgres. Groups are hard-deleted, so a group + * sId aggregated out of a historical document may no longer resolve. Such + * ids are silently dropped. */ +const USER_GROUP_IDS_FIELD = "user.group_ids"; +const USER_ID_FIELD = "user.id"; + +const MEMBER_IDS_PER_GROUP_LIMIT = 1000; + export type ConsumptionRelevantGroup = { id: string; name: string; - // Member sIds this group and the period's active users have in common, so - // the frontend can narrow a member list to a group without another call. + // Member sIds this group and the period's active users have in common memberIds: string[]; }; @@ -32,55 +40,76 @@ export type ConsumptionRelevantGroups = { export type GetConsumptionRelevantGroupsResponse = ConsumptionRelevantGroups; +type MemberBucket = { key: string }; + +type GroupBucket = { + key: string; + members?: estypes.AggregationsMultiBucketAggregateBase; +}; + +type RelevantGroupsAggs = { + by_group?: estypes.AggregationsMultiBucketAggregateBase; +}; + export async function fetchConsumptionRelevantGroups( auth: Authenticator, { period, limit }: { period: ConsumptionPeriod; limit: number } ): Promise> { - const topUsersResult = await fetchConsumptionTopGroups(auth, { - dimension: "user", - unit: "message", - period, - limit, + const query = buildConsumptionScopeQuery({ + auth, + startDate: period.startDate, + endDate: period.endDate, }); - if (topUsersResult.isErr()) { - return topUsersResult; + + const result = await searchConsumptionAnalytics( + query, + { + aggregations: { + by_group: { + terms: { field: USER_GROUP_IDS_FIELD, size: limit }, + aggs: { + members: { + terms: { field: USER_ID_FIELD, size: MEMBER_IDS_PER_GROUP_LIMIT }, + }, + }, + }, + }, + size: 0, + } + ); + + if (result.isErr()) { + return result; } - const userSids = topUsersResult.value.groups.map((group) => group.key); - if (userSids.length === 0) { + const buckets = bucketsToArray( + result.value.aggregations?.by_group?.buckets + ); + if (buckets.length === 0) { return new Ok({ groups: [] }); } - const users = await UserResource.fetchByIds(userSids); - const groupsByUserModelId = - await GroupResource.listGroupsForUserModelIdsInWindow({ - workspace: auth.getNonNullableWorkspace(), - userModelIds: users.map((user) => user.id), - window: { - start: new Date(period.startDate), - end: new Date(period.endDate), - }, - }); - - const groupById = new Map(); - for (const user of users) { - for (const group of groupsByUserModelId.get(user.id) ?? []) { - const existing = groupById.get(group.sId); - if (existing) { - existing.memberIds.push(user.sId); - } else { - groupById.set(group.sId, { - id: group.sId, - name: group.name, - memberIds: [user.sId], - }); - } - } - } + const memberIdsByGroupSId = new Map( + buckets.map((bucket) => [ + String(bucket.key), + bucketsToArray(bucket.members?.buckets).map((member) => + String(member.key) + ), + ]) + ); - const groups = [...groupById.values()].sort((a, b) => - a.name.localeCompare(b.name) + const groupModelIds = removeNulls( + [...memberIdsByGroupSId.keys()].map((sId) => getResourceIdFromSId(sId)) ); + const groups = await GroupResource.fetchByModelIds(auth, groupModelIds); + + const relevantGroups = groups + .map((group) => ({ + id: group.sId, + name: group.name, + memberIds: memberIdsByGroupSId.get(group.sId) ?? [], + })) + .sort((a, b) => a.name.localeCompare(b.name)); - return new Ok({ groups }); + return new Ok({ groups: relevantGroups }); } diff --git a/front/lib/resources/group_resource.test.ts b/front/lib/resources/group_resource.test.ts index 3f73c82865e7..d18336171985 100644 --- a/front/lib/resources/group_resource.test.ts +++ b/front/lib/resources/group_resource.test.ts @@ -214,143 +214,6 @@ describe("GroupResource", () => { }); }); - describe("listGroupsForUserModelIdsInWindow", () => { - const JANUARY = new Date("2026-01-15T00:00:00Z"); - const FEBRUARY = new Date("2026-02-15T00:00:00Z"); - const MARCH = new Date("2026-03-15T00:00:00Z"); - const WINDOW = { start: FEBRUARY, end: MARCH }; - - // Group memberships are historized by startAt/endAt, but no resource method - // lets a caller choose those values, so the window is backdated directly. - async function setGroupMembershipWindow( - group: GroupResource, - member: UserResource, - { startAt, endAt }: { startAt: Date; endAt: Date | null } - ) { - await GroupMembershipModel.update( - { startAt, endAt }, - { - where: { - workspaceId: workspace.id, - groupId: group.id, - userId: member.id, - }, - } - ); - } - - it("includes a membership that started before the window and is still ongoing", async () => { - const member = await UserFactory.basic(); - await MembershipFactory.associate(workspace, member, { role: "user" }); - const sales = await GroupResource.makeNew({ - name: "Sales", - workspaceId: workspace.id, - kind: "regular_auto", - }); - await sales.dangerouslyAddMembers(authenticator, { - users: [member.toJSON()], - }); - await setGroupMembershipWindow(sales, member, { - startAt: JANUARY, - endAt: null, - }); - - const result = await GroupResource.listGroupsForUserModelIdsInWindow({ - workspace, - userModelIds: [member.id], - window: WINDOW, - }); - - expect(result.get(member.id)?.map((g) => g.name)).toEqual(["Sales"]); - }); - - it("includes a membership that ended inside the window", async () => { - const member = await UserFactory.basic(); - await MembershipFactory.associate(workspace, member, { role: "user" }); - const sales = await GroupResource.makeNew({ - name: "Sales", - workspaceId: workspace.id, - kind: "regular_auto", - }); - await sales.dangerouslyAddMembers(authenticator, { - users: [member.toJSON()], - }); - // Left the group right at the window's start — still overlaps it. - await setGroupMembershipWindow(sales, member, { - startAt: JANUARY, - endAt: FEBRUARY, - }); - - const result = await GroupResource.listGroupsForUserModelIdsInWindow({ - workspace, - userModelIds: [member.id], - window: WINDOW, - }); - - expect(result.get(member.id)?.map((g) => g.name)).toEqual(["Sales"]); - }); - - it("excludes a membership that ended before the window started", async () => { - const member = await UserFactory.basic(); - await MembershipFactory.associate(workspace, member, { role: "user" }); - const sales = await GroupResource.makeNew({ - name: "Sales", - workspaceId: workspace.id, - kind: "regular_auto", - }); - await sales.dangerouslyAddMembers(authenticator, { - users: [member.toJSON()], - }); - await setGroupMembershipWindow(sales, member, { - startAt: JANUARY, - endAt: new Date("2026-02-01T00:00:00Z"), - }); - - const result = await GroupResource.listGroupsForUserModelIdsInWindow({ - workspace, - userModelIds: [member.id], - window: WINDOW, - }); - - expect(result.has(member.id)).toBe(false); - }); - - it("excludes a membership that had not started yet by the window's end", async () => { - const member = await UserFactory.basic(); - await MembershipFactory.associate(workspace, member, { role: "user" }); - const sales = await GroupResource.makeNew({ - name: "Sales", - workspaceId: workspace.id, - kind: "regular_auto", - }); - await sales.dangerouslyAddMembers(authenticator, { - users: [member.toJSON()], - }); - await setGroupMembershipWindow(sales, member, { - startAt: new Date("2026-04-01T00:00:00Z"), - endAt: null, - }); - - const result = await GroupResource.listGroupsForUserModelIdsInWindow({ - workspace, - userModelIds: [member.id], - window: WINDOW, - }); - - expect(result.has(member.id)).toBe(false); - }); - - it("returns an empty map when no user ids are given", async () => { - const result = await GroupResource.listGroupsForUserModelIdsInWindow({ - workspace, - userModelIds: [], - window: WINDOW, - }); - - expect(result.size).toBe(0); - }); - }); - describe("listUserGroupsInWorkspace with `at`", () => { const JANUARY = new Date("2026-01-15T00:00:00Z"); const FEBRUARY = new Date("2026-02-15T00:00:00Z"); diff --git a/front/lib/resources/group_resource.ts b/front/lib/resources/group_resource.ts index 15c976dc5218..e8afa4d36aa4 100644 --- a/front/lib/resources/group_resource.ts +++ b/front/lib/resources/group_resource.ts @@ -1329,71 +1329,6 @@ export class GroupResource extends BaseResource { return result; } - /** - * For each user, the groups whose membership overlapped `window` at all - * (started before the window ended, and either never ended or ended after - * the window started) — not just membership as of `now`. Used to resolve - * "who was in which group" for a historical period, e.g. consumption - * analytics filters scoped to a past billing cycle. Single batched query - * (membership rows, then the groups they reference), no N+1. - */ - static async listGroupsForUserModelIdsInWindow({ - workspace, - userModelIds, - groupKinds = ["regular_auto", "provisioned"], - window, - }: { - workspace: LightWorkspaceType; - userModelIds: ModelId[]; - groupKinds?: Exclude[]; - window: { start: Date; end: Date }; - }): Promise> { - const result = new Map(); - if (userModelIds.length === 0) { - return result; - } - - const memberships = await GroupMembershipModel.findAll({ - where: { - workspaceId: workspace.id, - userId: userModelIds, - status: "active", - startAt: { [Op.lte]: window.end }, - [Op.or]: [{ endAt: null }, { endAt: { [Op.gte]: window.start } }], - }, - }); - if (memberships.length === 0) { - return result; - } - - const groupModelIds = [...new Set(memberships.map((m) => m.groupId))]; - const groups = await GroupModel.findAll({ - where: { - id: groupModelIds, - workspaceId: workspace.id, - kind: groupKinds, - }, - }); - const groupById = new Map( - groups.map((group) => [group.id, new this(GroupModel, group.get())]) - ); - - for (const m of memberships) { - const group = groupById.get(m.groupId); - if (!group) { - continue; - } - const existing = result.get(m.userId); - if (existing) { - existing.push(group); - } else { - result.set(m.userId, [group]); - } - } - - return result; - } - /** * For each user, the subset of `groupModelIds` they are an active member of. * Users with no matching membership are absent from the map. Restricting the From 4efc6298581fc46022d98e415be30219c4ad641c Mon Sep 17 00:00:00 2001 From: Arthur Vervaet Date: Mon, 10 Aug 2026 11:47:15 +0200 Subject: [PATCH 05/13] rename back to members --- .../workspace/analytics/UsageFilterPanel.tsx | 16 +++++++------- .../workspace/analytics/usageFilter.ts | 22 +++++++++---------- .../UsageFilterOptionIcon.tsx | 2 +- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/front/components/workspace/analytics/UsageFilterPanel.tsx b/front/components/workspace/analytics/UsageFilterPanel.tsx index 539bafa1b638..a21def321754 100644 --- a/front/components/workspace/analytics/UsageFilterPanel.tsx +++ b/front/components/workspace/analytics/UsageFilterPanel.tsx @@ -3,13 +3,13 @@ import type { UsageFilterAgentOption, UsageFilterCategory, UsageFilterGroup, + UsageFilterMemberOption, UsageFilterModelOption, UsageFilterOptionForCategory, UsageFilterScope, UsageFilterSkillOption, UsageFilterSourceOption, UsageFilterToolOption, - UsageFilterUserOption, UsageModelTier, } from "@app/components/workspace/analytics/usageFilter"; import { @@ -89,14 +89,14 @@ export function UsageFilterPanel({ USAGE_MODEL_TIERS[0] ); const [searchText, setSearchText] = useState(""); - // Only used for the "user" category: narrows the displayed members down + // Only used for the "member" category: narrows the displayed members down // to those belonging to at least one of these groups. Groups only narrow // the picker — the user still checks individual members to add them to the // filter. Lifted here (rather than owned by UsageFilterMemberGroupsControls) // because filteredEntities below needs it too. const [selectedGroups, setSelectedGroups] = useState([]); - const isMemberCategoryActive = isOpen && activeCategory === "user"; + const isMemberCategoryActive = isOpen && activeCategory === "member"; const { rows: topUserRows } = useConsumptionTop({ workspaceId: owner.sId, @@ -117,12 +117,12 @@ export function UsageFilterPanel({ // Search is applied client-side below (the top-users ranking has no // server-side search), so a member outside the top 100 by credits over the // period will not be searchable here. - const memberOptions = useMemo( + const memberOptions = useMemo( () => topUserRows.map((row) => ({ id: row.id, name: row.name, - kind: "user", + kind: "member", image: row.pictureUrl, })), [topUserRows] @@ -133,7 +133,7 @@ export function UsageFilterPanel({ }>( () => ({ ...categoryOptions, - user: memberOptions, + member: memberOptions, }), [categoryOptions, memberOptions] ); @@ -142,7 +142,7 @@ export function UsageFilterPanel({ const filteredOptions = useMemo(() => { const search = searchText.trim().toLowerCase(); const selectedGroupMemberIds = - activeCategory === "user" && selectedGroups.length > 0 + activeCategory === "member" && selectedGroups.length > 0 ? new Set(selectedGroups.flatMap((group) => group.memberIds)) : null; return activeOptions.filter((option) => { @@ -267,7 +267,7 @@ export function UsageFilterPanel({ onChange={setSearchText} placeholder={`Search ${USAGE_FILTER_CATEGORY_LABEL[activeCategory].toLowerCase()}`} /> - {activeCategory === "user" && ( + {activeCategory === "member" && ( = { agent: "Agents", - user: "Members", + member: "Members", model: "Models", tool: "Tools", skill: "Skills", @@ -55,8 +53,8 @@ export interface UsageFilterAgentOption extends UsageFilterOptionBase { scope: UsageFilterScope; } -export interface UsageFilterUserOption extends UsageFilterOptionBase { - kind: "user"; +export interface UsageFilterMemberOption extends UsageFilterOptionBase { + kind: "member"; image: string | null; } @@ -81,7 +79,7 @@ export interface UsageFilterSkillOption extends UsageFilterOptionBase { export type UsageFilterOption = | UsageFilterAgentOption - | UsageFilterUserOption + | UsageFilterMemberOption | UsageFilterSourceOption | UsageFilterModelOption | UsageFilterToolOption @@ -90,7 +88,7 @@ export type UsageFilterOption = export interface UsageFilterGroup { id: string; name: string; - // Member sIds, used to narrow the "user" category's checklist down to a + // Member sIds, used to narrow the "member" category's checklist down to a // selected group. Not itself part of `UsageFilter` — groups only narrow the // picker, the user still checks individual members to filter by. memberIds: string[]; @@ -162,11 +160,11 @@ export function removeUsageFilterGroup( return groups.filter((g) => g.id !== id); } -// Only "user" is wired to a real consumption scope dimension so far; the -// other categories stay mock data and are not sent as query filters. +// Only "member" is wired to a real consumption scope dimension ("users") so +// far; the other categories stay mock data and are not sent as query filters. export function toConsumptionScopeFilter( filter: UsageFilter ): ConsumptionScopeFilter { - const userIds = filter.user?.map((entity) => entity.id); - return userIds && userIds.length > 0 ? { users: userIds } : {}; + const memberIds = filter.member?.map((entity) => entity.id); + return memberIds && memberIds.length > 0 ? { users: memberIds } : {}; } diff --git a/front/components/workspace/analytics/usageFilterPanel/UsageFilterOptionIcon.tsx b/front/components/workspace/analytics/usageFilterPanel/UsageFilterOptionIcon.tsx index b238584bf5a6..89ff26b2ff58 100644 --- a/front/components/workspace/analytics/usageFilterPanel/UsageFilterOptionIcon.tsx +++ b/front/components/workspace/analytics/usageFilterPanel/UsageFilterOptionIcon.tsx @@ -13,7 +13,7 @@ export function UsageFilterOptionIcon({ option }: UsageFilterOptionIconProps) { const { isDark } = useTheme(); switch (option.kind) { - case "user": + case "member": return ( Date: Mon, 10 Aug 2026 12:14:55 +0200 Subject: [PATCH 06/13] renaming relevant groups group with activity --- ...s.test.ts => groups-with-activity.test.ts} | 44 +++++++++--------- ...vant-groups.ts => groups-with-activity.ts} | 14 +++--- .../w/[wId]/analytics/consumption/index.ts | 4 +- .../workspace/analytics/UsageFilterPanel.tsx | 6 +-- .../analytics/usageFilterMockData.ts | 2 +- ...ts => useConsumptionGroupsWithActivity.ts} | 24 +++++----- ...s.test.ts => groups_with_activity.test.ts} | 15 ++++--- ...vant_groups.ts => groups_with_activity.ts} | 45 ++++++++++--------- 8 files changed, 79 insertions(+), 75 deletions(-) rename front-api/routes/w/[wId]/analytics/consumption/{relevant-groups.test.ts => groups-with-activity.test.ts} (60%) rename front-api/routes/w/[wId]/analytics/consumption/{relevant-groups.ts => groups-with-activity.ts} (64%) rename front/hooks/{useConsumptionRelevantGroups.ts => useConsumptionGroupsWithActivity.ts} (54%) rename front/lib/api/analytics/consumption/{relevant_groups.test.ts => groups_with_activity.test.ts} (88%) rename front/lib/api/analytics/consumption/{relevant_groups.ts => groups_with_activity.ts} (76%) diff --git a/front-api/routes/w/[wId]/analytics/consumption/relevant-groups.test.ts b/front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.test.ts similarity index 60% rename from front-api/routes/w/[wId]/analytics/consumption/relevant-groups.test.ts rename to front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.test.ts index 580b405bcb70..f762c8c14a8d 100644 --- a/front-api/routes/w/[wId]/analytics/consumption/relevant-groups.test.ts +++ b/front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.test.ts @@ -1,4 +1,4 @@ -import { fetchConsumptionRelevantGroups } from "@app/lib/api/analytics/consumption/relevant_groups"; +import { fetchConsumptionGroupsWithActivity } from "@app/lib/api/analytics/consumption/groups_with_activity"; import { createPrivateApiMockRequest } from "@app/tests/utils/generic_private_api_tests"; import type { MembershipRoleType } from "@app/types/memberships"; import { Err, Ok } from "@app/types/shared/result"; @@ -14,17 +14,17 @@ vi.mock("@app/components/dev/devModeConstants", () => ({ })); vi.mock( - import("@app/lib/api/analytics/consumption/relevant_groups"), + import("@app/lib/api/analytics/consumption/groups_with_activity"), async (orig) => { const mod = await orig(); return { ...mod, - fetchConsumptionRelevantGroups: vi.fn(), + fetchConsumptionGroupsWithActivity: vi.fn(), }; } ); -const RELEVANT_GROUPS = { +const GROUPS_WITH_ACTIVITY = { groups: [ { id: "g1", name: "Engineering", memberIds: ["u1", "u2"] }, { id: "g2", name: "Sales", memberIds: ["u3"] }, @@ -35,37 +35,39 @@ async function setupTest({ role = "admin" as MembershipRoleType } = {}) { return createPrivateApiMockRequest({ role }); } -function getRelevantGroupsRequest( +function getGroupsWithActivityRequest( wId: string, query: Record = {} ) { const qs = new URLSearchParams(query).toString(); return honoApp.request( - `/api/w/${wId}/analytics/consumption/relevant-groups${qs ? `?${qs}` : ""}` + `/api/w/${wId}/analytics/consumption/groups-with-activity${qs ? `?${qs}` : ""}` ); } -describe("GET /api/w/:wId/analytics/consumption/relevant-groups", () => { +describe("GET /api/w/:wId/analytics/consumption/groups-with-activity", () => { it("returns 403 for non-manager users", async () => { const { workspace } = await setupTest({ role: "user" }); - const response = await getRelevantGroupsRequest(workspace.sId); + const response = await getGroupsWithActivityRequest(workspace.sId); expect(response.status).toBe(403); - expect(vi.mocked(fetchConsumptionRelevantGroups)).not.toHaveBeenCalled(); + expect( + vi.mocked(fetchConsumptionGroupsWithActivity) + ).not.toHaveBeenCalled(); }); - it("returns the relevant groups for managers, defaulting to the current cycle", async () => { - vi.mocked(fetchConsumptionRelevantGroups).mockResolvedValue( - new Ok(RELEVANT_GROUPS) + it("returns the groups with activity for managers, defaulting to the current cycle", async () => { + vi.mocked(fetchConsumptionGroupsWithActivity).mockResolvedValue( + new Ok(GROUPS_WITH_ACTIVITY) ); const { workspace } = await setupTest({ role: "admin" }); - const response = await getRelevantGroupsRequest(workspace.sId); + const response = await getGroupsWithActivityRequest(workspace.sId); expect(response.status).toBe(200); - expect(await response.json()).toEqual(RELEVANT_GROUPS); - expect(vi.mocked(fetchConsumptionRelevantGroups)).toHaveBeenCalledWith( + expect(await response.json()).toEqual(GROUPS_WITH_ACTIVITY); + expect(vi.mocked(fetchConsumptionGroupsWithActivity)).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ period: expect.objectContaining({}), @@ -75,33 +77,33 @@ describe("GET /api/w/:wId/analytics/consumption/relevant-groups", () => { }); it("forwards a days period and a custom limit", async () => { - vi.mocked(fetchConsumptionRelevantGroups).mockResolvedValue( - new Ok(RELEVANT_GROUPS) + vi.mocked(fetchConsumptionGroupsWithActivity).mockResolvedValue( + new Ok(GROUPS_WITH_ACTIVITY) ); const { workspace } = await setupTest(); - const response = await getRelevantGroupsRequest(workspace.sId, { + const response = await getGroupsWithActivityRequest(workspace.sId, { period: "days", days: "7", limit: "50", }); expect(response.status).toBe(200); - expect(vi.mocked(fetchConsumptionRelevantGroups)).toHaveBeenCalledWith( + expect(vi.mocked(fetchConsumptionGroupsWithActivity)).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ limit: 50 }) ); }); it("returns 500 when the search fails", async () => { - vi.mocked(fetchConsumptionRelevantGroups).mockResolvedValue( + vi.mocked(fetchConsumptionGroupsWithActivity).mockResolvedValue( new Err( Object.assign(new Error("boom"), { type: "query_error" as const }) ) ); const { workspace } = await setupTest(); - const response = await getRelevantGroupsRequest(workspace.sId); + const response = await getGroupsWithActivityRequest(workspace.sId); expect(response.status).toBe(500); expect(await response.json()).toMatchObject({ diff --git a/front-api/routes/w/[wId]/analytics/consumption/relevant-groups.ts b/front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.ts similarity index 64% rename from front-api/routes/w/[wId]/analytics/consumption/relevant-groups.ts rename to front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.ts index 408ca0c8bf16..9fade526e0c2 100644 --- a/front-api/routes/w/[wId]/analytics/consumption/relevant-groups.ts +++ b/front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.ts @@ -1,6 +1,6 @@ +import type { GetConsumptionGroupsWithActivityResponse } from "@app/lib/api/analytics/consumption/groups_with_activity"; +import { fetchConsumptionGroupsWithActivity } from "@app/lib/api/analytics/consumption/groups_with_activity"; import { resolveConsumptionPeriod } from "@app/lib/api/analytics/consumption/period"; -import type { GetConsumptionRelevantGroupsResponse } from "@app/lib/api/analytics/consumption/relevant_groups"; -import { fetchConsumptionRelevantGroups } from "@app/lib/api/analytics/consumption/relevant_groups"; import { ConsumptionTopQuerySchema, toConsumptionPeriodInput, @@ -10,9 +10,9 @@ import { ensureIsManager } from "@front-api/middlewares/ensure_role"; import { apiError } from "@front-api/middlewares/utils"; import { validate } from "@front-api/middlewares/validator"; -export type { GetConsumptionRelevantGroupsResponse }; +export type { GetConsumptionGroupsWithActivityResponse }; -// Mounted at /api/w/:wId/analytics/consumption/relevant-groups. +// Mounted at /api/w/:wId/analytics/consumption/groups-with-activity. const app = workspaceApp(); /** @ignoreswagger */ @@ -29,7 +29,7 @@ app.get( toConsumptionPeriodInput(periodQuery) ); - const result = await fetchConsumptionRelevantGroups(auth, { + const result = await fetchConsumptionGroupsWithActivity(auth, { period, limit, }); @@ -38,12 +38,12 @@ app.get( status_code: 500, api_error: { type: "internal_server_error", - message: `Failed to retrieve consumption-relevant groups: ${result.error.message}`, + message: `Failed to retrieve consumption groups with activity: ${result.error.message}`, }, }); } - const body: GetConsumptionRelevantGroupsResponse = result.value; + const body: GetConsumptionGroupsWithActivityResponse = result.value; return ctx.json(body); } ); diff --git a/front-api/routes/w/[wId]/analytics/consumption/index.ts b/front-api/routes/w/[wId]/analytics/consumption/index.ts index d23ecd7cb43e..f3fecd97af45 100644 --- a/front-api/routes/w/[wId]/analytics/consumption/index.ts +++ b/front-api/routes/w/[wId]/analytics/consumption/index.ts @@ -1,6 +1,6 @@ import { workspaceApp } from "@front-api/middlewares/ctx"; +import groupsWithActivity from "./groups-with-activity"; import overview from "./overview"; -import relevantGroups from "./relevant-groups"; import timeseries from "./timeseries"; import topAgents from "./top-agents"; import topModels from "./top-models"; @@ -11,8 +11,8 @@ import topUsers from "./top-users"; const app = workspaceApp(); +app.route("/groups-with-activity", groupsWithActivity); app.route("/overview", overview); -app.route("/relevant-groups", relevantGroups); app.route("/timeseries", timeseries); app.route("/top-agents", topAgents); app.route("/top-models", topModels); diff --git a/front/components/workspace/analytics/UsageFilterPanel.tsx b/front/components/workspace/analytics/UsageFilterPanel.tsx index a21def321754..d98c2b181934 100644 --- a/front/components/workspace/analytics/UsageFilterPanel.tsx +++ b/front/components/workspace/analytics/UsageFilterPanel.tsx @@ -28,7 +28,7 @@ import { UsageFilterModelComplexityControls } from "@app/components/workspace/an import { UsageFilterOptionCheckboxList } from "@app/components/workspace/analytics/usageFilterPanel/UsageFilterOptionCheckboxList"; import { UsageFilterSelectionSummary } from "@app/components/workspace/analytics/usageFilterPanel/UsageFilterSelectionSummary"; import { useUsageFilter } from "@app/components/workspace/analytics/useUsageFilter"; -import { useConsumptionRelevantGroups } from "@app/hooks/useConsumptionRelevantGroups"; +import { useConsumptionGroupsWithActivity } from "@app/hooks/useConsumptionGroupsWithActivity"; import { useConsumptionTop } from "@app/hooks/useConsumptionTop"; import type { ConsumptionPeriodSelection } from "@app/lib/analytics/consumption_period"; import type { LightWorkspaceType } from "@app/types/user"; @@ -49,7 +49,7 @@ interface UsageFilterPanelProps { // Agents/models/tools/skills/sources are still mock data (see // usageFilterMockData.ts — sources are fake connectors standing in for a // real db call); members and groups are fetched live below, scoped to - // `period` (useConsumptionTop, useConsumptionRelevantGroups). + // `period` (useConsumptionTop, useConsumptionGroupsWithActivity). categoryOptions: { agent: UsageFilterAgentOption[]; model: UsageFilterModelOption[]; @@ -108,7 +108,7 @@ export function UsageFilterPanel({ disabled: !isMemberCategoryActive, }); - const { groups } = useConsumptionRelevantGroups({ + const { groups } = useConsumptionGroupsWithActivity({ workspaceId: owner.sId, period, disabled: !isMemberCategoryActive, diff --git a/front/components/workspace/analytics/usageFilterMockData.ts b/front/components/workspace/analytics/usageFilterMockData.ts index cc7d8fb34fdc..a118f420ed94 100644 --- a/front/components/workspace/analytics/usageFilterMockData.ts +++ b/front/components/workspace/analytics/usageFilterMockData.ts @@ -29,7 +29,7 @@ const MOCK_MODEL_LAB: Record = { // Placeholder data for categories not yet wired to a real backend endpoint. // Members and groups are fetched live in UsageFilterPanel (useConsumptionTop, -// useConsumptionRelevantGroups). Lists are long enough to exercise scrolling +// useConsumptionGroupsWithActivity). Lists are long enough to exercise scrolling // in the preview. const MOCK_ENTITY_NAMES = { agent: [ diff --git a/front/hooks/useConsumptionRelevantGroups.ts b/front/hooks/useConsumptionGroupsWithActivity.ts similarity index 54% rename from front/hooks/useConsumptionRelevantGroups.ts rename to front/hooks/useConsumptionGroupsWithActivity.ts index 8361b7a01920..4773e2c86c86 100644 --- a/front/hooks/useConsumptionRelevantGroups.ts +++ b/front/hooks/useConsumptionGroupsWithActivity.ts @@ -1,12 +1,10 @@ import type { ConsumptionPeriodSelection } from "@app/lib/analytics/consumption_period"; import { consumptionQueryString } from "@app/lib/analytics/consumption_period"; -// Type-only: importing a value from this module would pull the Elasticsearch -// client into the browser bundle (see the note in `series.ts`). -import type { GetConsumptionRelevantGroupsResponse } from "@app/lib/api/analytics/consumption/relevant_groups"; +import type { GetConsumptionGroupsWithActivityResponse } from "@app/lib/api/analytics/consumption/groups_with_activity"; import { emptyArray, useFetcher, useSWRWithDefaults } from "@app/lib/swr/swr"; import type { Fetcher } from "swr"; -export type ConsumptionRelevantGroupRow = { +export type ConsumptionGroupWithActivityRow = { id: string; name: string; memberIds: string[]; @@ -14,9 +12,9 @@ export type ConsumptionRelevantGroupRow = { // Caps the number of distinct groups returned (the aggregation itself already // covers every group active in the period, unlike a top-N ranking). -const RELEVANT_GROUPS_LIMIT = 100; +const GROUPS_WITH_ACTIVITY_LIMIT = 100; -export function useConsumptionRelevantGroups({ +export function useConsumptionGroupsWithActivity({ workspaceId, period, disabled, @@ -26,21 +24,21 @@ export function useConsumptionRelevantGroups({ disabled?: boolean; }) { const { fetcher } = useFetcher(); - const relevantGroupsFetcher: Fetcher = + const groupsWithActivityFetcher: Fetcher = fetcher; const params = new URLSearchParams(consumptionQueryString(period)); - params.set("limit", String(RELEVANT_GROUPS_LIMIT)); + params.set("limit", String(GROUPS_WITH_ACTIVITY_LIMIT)); const { data, error } = useSWRWithDefaults( - `/api/w/${workspaceId}/analytics/consumption/relevant-groups?${params.toString()}`, - relevantGroupsFetcher, + `/api/w/${workspaceId}/analytics/consumption/groups-with-activity?${params.toString()}`, + groupsWithActivityFetcher, { disabled } ); return { - groups: data?.groups ?? emptyArray(), - isRelevantGroupsLoading: !error && !data && !disabled, - isRelevantGroupsError: error, + groups: data?.groups ?? emptyArray(), + isGroupsWithActivityLoading: !error && !data && !disabled, + isGroupsWithActivityError: error, }; } diff --git a/front/lib/api/analytics/consumption/relevant_groups.test.ts b/front/lib/api/analytics/consumption/groups_with_activity.test.ts similarity index 88% rename from front/lib/api/analytics/consumption/relevant_groups.test.ts rename to front/lib/api/analytics/consumption/groups_with_activity.test.ts index f4462477e324..7df85804ef01 100644 --- a/front/lib/api/analytics/consumption/relevant_groups.test.ts +++ b/front/lib/api/analytics/consumption/groups_with_activity.test.ts @@ -1,5 +1,5 @@ +import { fetchConsumptionGroupsWithActivity } from "@app/lib/api/analytics/consumption/groups_with_activity"; import type { ConsumptionPeriod } from "@app/lib/api/analytics/consumption/period"; -import { fetchConsumptionRelevantGroups } from "@app/lib/api/analytics/consumption/relevant_groups"; import { searchConsumptionAnalytics } from "@app/lib/api/elasticsearch"; import { Authenticator } from "@app/lib/auth"; import { makeSId } from "@app/lib/resources/string_ids"; @@ -32,7 +32,7 @@ async function setup() { return { auth, workspace }; } -describe("fetchConsumptionRelevantGroups", () => { +describe("fetchConsumptionGroupsWithActivity", () => { afterEach(() => { vi.mocked(searchConsumptionAnalytics).mockReset(); }); @@ -49,7 +49,7 @@ describe("fetchConsumptionRelevantGroups", () => { { key: engineering.sId, members: { buckets: [{ key: "u3" }] } }, ]); - const result = await fetchConsumptionRelevantGroups(auth, { + const result = await fetchConsumptionGroupsWithActivity(auth, { period: PERIOD, limit: 10, }); @@ -74,7 +74,7 @@ describe("fetchConsumptionRelevantGroups", () => { { key: deletedGroupSId, members: { buckets: [{ key: "u1" }] } }, ]); - const result = await fetchConsumptionRelevantGroups(auth, { + const result = await fetchConsumptionGroupsWithActivity(auth, { period: PERIOD, limit: 10, }); @@ -90,7 +90,7 @@ describe("fetchConsumptionRelevantGroups", () => { const { auth } = await setup(); mockGroupBuckets([]); - const result = await fetchConsumptionRelevantGroups(auth, { + const result = await fetchConsumptionGroupsWithActivity(auth, { period: PERIOD, limit: 10, }); @@ -106,7 +106,10 @@ describe("fetchConsumptionRelevantGroups", () => { const { auth } = await setup(); mockGroupBuckets([]); - await fetchConsumptionRelevantGroups(auth, { period: PERIOD, limit: 25 }); + await fetchConsumptionGroupsWithActivity(auth, { + period: PERIOD, + limit: 25, + }); const [query, options] = vi.mocked(searchConsumptionAnalytics).mock .calls[0]; diff --git a/front/lib/api/analytics/consumption/relevant_groups.ts b/front/lib/api/analytics/consumption/groups_with_activity.ts similarity index 76% rename from front/lib/api/analytics/consumption/relevant_groups.ts rename to front/lib/api/analytics/consumption/groups_with_activity.ts index 612a9f7258bf..cffcf65de2da 100644 --- a/front/lib/api/analytics/consumption/relevant_groups.ts +++ b/front/lib/api/analytics/consumption/groups_with_activity.ts @@ -27,18 +27,19 @@ const USER_ID_FIELD = "user.id"; const MEMBER_IDS_PER_GROUP_LIMIT = 1000; -export type ConsumptionRelevantGroup = { +export type ConsumptionGroupWithActivity = { id: string; name: string; // Member sIds this group and the period's active users have in common memberIds: string[]; }; -export type ConsumptionRelevantGroups = { - groups: ConsumptionRelevantGroup[]; +export type ConsumptionGroupsWithActivity = { + groups: ConsumptionGroupWithActivity[]; }; -export type GetConsumptionRelevantGroupsResponse = ConsumptionRelevantGroups; +export type GetConsumptionGroupsWithActivityResponse = + ConsumptionGroupsWithActivity; type MemberBucket = { key: string }; @@ -47,36 +48,36 @@ type GroupBucket = { members?: estypes.AggregationsMultiBucketAggregateBase; }; -type RelevantGroupsAggs = { +type GroupsWithActivityAggs = { by_group?: estypes.AggregationsMultiBucketAggregateBase; }; -export async function fetchConsumptionRelevantGroups( +export async function fetchConsumptionGroupsWithActivity( auth: Authenticator, { period, limit }: { period: ConsumptionPeriod; limit: number } -): Promise> { +): Promise> { const query = buildConsumptionScopeQuery({ auth, startDate: period.startDate, endDate: period.endDate, }); - const result = await searchConsumptionAnalytics( - query, - { - aggregations: { - by_group: { - terms: { field: USER_GROUP_IDS_FIELD, size: limit }, - aggs: { - members: { - terms: { field: USER_ID_FIELD, size: MEMBER_IDS_PER_GROUP_LIMIT }, - }, + const result = await searchConsumptionAnalytics< + never, + GroupsWithActivityAggs + >(query, { + aggregations: { + by_group: { + terms: { field: USER_GROUP_IDS_FIELD, size: limit }, + aggs: { + members: { + terms: { field: USER_ID_FIELD, size: MEMBER_IDS_PER_GROUP_LIMIT }, }, }, }, - size: 0, - } - ); + }, + size: 0, + }); if (result.isErr()) { return result; @@ -103,7 +104,7 @@ export async function fetchConsumptionRelevantGroups( ); const groups = await GroupResource.fetchByModelIds(auth, groupModelIds); - const relevantGroups = groups + const groupsWithActivity = groups .map((group) => ({ id: group.sId, name: group.name, @@ -111,5 +112,5 @@ export async function fetchConsumptionRelevantGroups( })) .sort((a, b) => a.name.localeCompare(b.name)); - return new Ok({ groups: relevantGroups }); + return new Ok({ groups: groupsWithActivity }); } From 0be6288b9344314aa9a41d0fa194759c91c31089 Mon Sep 17 00:00:00 2001 From: Arthur Vervaet Date: Mon, 10 Aug 2026 14:38:19 +0200 Subject: [PATCH 07/13] move logi close to components --- .../workspace/analytics/UsageFilterPanel.tsx | 8 ++++---- .../workspace/analytics/usageFilter.ts | 17 ----------------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/front/components/workspace/analytics/UsageFilterPanel.tsx b/front/components/workspace/analytics/UsageFilterPanel.tsx index d98c2b181934..8245dadd8007 100644 --- a/front/components/workspace/analytics/UsageFilterPanel.tsx +++ b/front/components/workspace/analytics/UsageFilterPanel.tsx @@ -13,8 +13,6 @@ import type { UsageModelTier, } from "@app/components/workspace/analytics/usageFilter"; import { - addUsageFilterGroup, - removeUsageFilterGroup, USAGE_FILTER_CATEGORIES, USAGE_FILTER_CATEGORY_LABEL, USAGE_FILTER_SCOPES, @@ -215,11 +213,13 @@ export function UsageFilterPanel({ }; const handleAddGroup = (group: UsageFilterGroup) => { - setSelectedGroups((current) => addUsageFilterGroup(current, group)); + setSelectedGroups((current) => + current.some((g) => g.id === group.id) ? current : [...current, group] + ); }; const handleRemoveGroup = (id: string) => { - setSelectedGroups((current) => removeUsageFilterGroup(current, id)); + setSelectedGroups((current) => current.filter((g) => g.id !== id)); }; const activeCategorySelectionCount = draftFilter[activeCategory]?.length ?? 0; diff --git a/front/components/workspace/analytics/usageFilter.ts b/front/components/workspace/analytics/usageFilter.ts index d47f35ca5156..de574c66077b 100644 --- a/front/components/workspace/analytics/usageFilter.ts +++ b/front/components/workspace/analytics/usageFilter.ts @@ -143,23 +143,6 @@ export function selectAllUsageFilterOptions( return { ...filter, [category]: [...current, ...additions] }; } -export function addUsageFilterGroup( - groups: UsageFilterGroup[], - group: UsageFilterGroup -): UsageFilterGroup[] { - if (groups.some((g) => g.id === group.id)) { - return groups; - } - return [...groups, group]; -} - -export function removeUsageFilterGroup( - groups: UsageFilterGroup[], - id: string -): UsageFilterGroup[] { - return groups.filter((g) => g.id !== id); -} - // Only "member" is wired to a real consumption scope dimension ("users") so // far; the other categories stay mock data and are not sent as query filters. export function toConsumptionScopeFilter( From 20037838b718de0e2f55c576fb34007018083937 Mon Sep 17 00:00:00 2001 From: Arthur Vervaet Date: Mon, 10 Aug 2026 14:58:59 +0200 Subject: [PATCH 08/13] listing all groups and members not only the active ones --- .../consumption/groups-with-activity.test.ts | 113 ---------------- .../consumption/groups-with-activity.ts | 51 ------- .../w/[wId]/analytics/consumption/index.ts | 2 - front-api/routes/w/[wId]/groups/index.test.ts | 71 ++++++++++ front-api/routes/w/[wId]/groups/index.ts | 10 +- .../workspace/AnalyticsConsumptionPage.tsx | 1 - .../workspace/analytics/UsageFilterPanel.tsx | 67 +++++---- .../analytics/usageFilterMockData.ts | 5 +- .../hooks/useConsumptionGroupsWithActivity.ts | 44 ------ .../consumption/groups_with_activity.test.ts | 127 ------------------ .../consumption/groups_with_activity.ts | 116 ---------------- front/lib/resources/group_resource.ts | 31 +++++ front/lib/swr/groups.ts | 9 +- front/types/groups.ts | 4 + 14 files changed, 166 insertions(+), 485 deletions(-) delete mode 100644 front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.test.ts delete mode 100644 front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.ts create mode 100644 front-api/routes/w/[wId]/groups/index.test.ts delete mode 100644 front/hooks/useConsumptionGroupsWithActivity.ts delete mode 100644 front/lib/api/analytics/consumption/groups_with_activity.test.ts delete mode 100644 front/lib/api/analytics/consumption/groups_with_activity.ts diff --git a/front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.test.ts b/front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.test.ts deleted file mode 100644 index f762c8c14a8d..000000000000 --- a/front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { fetchConsumptionGroupsWithActivity } from "@app/lib/api/analytics/consumption/groups_with_activity"; -import { createPrivateApiMockRequest } from "@app/tests/utils/generic_private_api_tests"; -import type { MembershipRoleType } from "@app/types/memberships"; -import { Err, Ok } from "@app/types/shared/result"; -import { honoApp } from "@front-api/app"; -import { describe, expect, it, vi } from "vitest"; - -// devModeConstants reads localStorage at module load. jsdom does not always -// have localStorage initialized when mock factories evaluate, which crashes -// any test whose mocked lib transitively imports AuthContext. Stub it here. -vi.mock("@app/components/dev/devModeConstants", () => ({ - DEV_MODE_STORAGE_KEY: "dust_dev_mode", - DEV_MODE_ACTIVE: false, -})); - -vi.mock( - import("@app/lib/api/analytics/consumption/groups_with_activity"), - async (orig) => { - const mod = await orig(); - return { - ...mod, - fetchConsumptionGroupsWithActivity: vi.fn(), - }; - } -); - -const GROUPS_WITH_ACTIVITY = { - groups: [ - { id: "g1", name: "Engineering", memberIds: ["u1", "u2"] }, - { id: "g2", name: "Sales", memberIds: ["u3"] }, - ], -}; - -async function setupTest({ role = "admin" as MembershipRoleType } = {}) { - return createPrivateApiMockRequest({ role }); -} - -function getGroupsWithActivityRequest( - wId: string, - query: Record = {} -) { - const qs = new URLSearchParams(query).toString(); - return honoApp.request( - `/api/w/${wId}/analytics/consumption/groups-with-activity${qs ? `?${qs}` : ""}` - ); -} - -describe("GET /api/w/:wId/analytics/consumption/groups-with-activity", () => { - it("returns 403 for non-manager users", async () => { - const { workspace } = await setupTest({ role: "user" }); - - const response = await getGroupsWithActivityRequest(workspace.sId); - - expect(response.status).toBe(403); - expect( - vi.mocked(fetchConsumptionGroupsWithActivity) - ).not.toHaveBeenCalled(); - }); - - it("returns the groups with activity for managers, defaulting to the current cycle", async () => { - vi.mocked(fetchConsumptionGroupsWithActivity).mockResolvedValue( - new Ok(GROUPS_WITH_ACTIVITY) - ); - const { workspace } = await setupTest({ role: "admin" }); - - const response = await getGroupsWithActivityRequest(workspace.sId); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual(GROUPS_WITH_ACTIVITY); - expect(vi.mocked(fetchConsumptionGroupsWithActivity)).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - period: expect.objectContaining({}), - limit: 10, - }) - ); - }); - - it("forwards a days period and a custom limit", async () => { - vi.mocked(fetchConsumptionGroupsWithActivity).mockResolvedValue( - new Ok(GROUPS_WITH_ACTIVITY) - ); - const { workspace } = await setupTest(); - - const response = await getGroupsWithActivityRequest(workspace.sId, { - period: "days", - days: "7", - limit: "50", - }); - - expect(response.status).toBe(200); - expect(vi.mocked(fetchConsumptionGroupsWithActivity)).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ limit: 50 }) - ); - }); - - it("returns 500 when the search fails", async () => { - vi.mocked(fetchConsumptionGroupsWithActivity).mockResolvedValue( - new Err( - Object.assign(new Error("boom"), { type: "query_error" as const }) - ) - ); - const { workspace } = await setupTest(); - - const response = await getGroupsWithActivityRequest(workspace.sId); - - expect(response.status).toBe(500); - expect(await response.json()).toMatchObject({ - error: { type: "internal_server_error" }, - }); - }); -}); diff --git a/front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.ts b/front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.ts deleted file mode 100644 index 9fade526e0c2..000000000000 --- a/front-api/routes/w/[wId]/analytics/consumption/groups-with-activity.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { GetConsumptionGroupsWithActivityResponse } from "@app/lib/api/analytics/consumption/groups_with_activity"; -import { fetchConsumptionGroupsWithActivity } from "@app/lib/api/analytics/consumption/groups_with_activity"; -import { resolveConsumptionPeriod } from "@app/lib/api/analytics/consumption/period"; -import { - ConsumptionTopQuerySchema, - toConsumptionPeriodInput, -} from "@app/lib/api/analytics/consumption/schema"; -import { workspaceApp } from "@front-api/middlewares/ctx"; -import { ensureIsManager } from "@front-api/middlewares/ensure_role"; -import { apiError } from "@front-api/middlewares/utils"; -import { validate } from "@front-api/middlewares/validator"; - -export type { GetConsumptionGroupsWithActivityResponse }; - -// Mounted at /api/w/:wId/analytics/consumption/groups-with-activity. -const app = workspaceApp(); - -/** @ignoreswagger */ -app.get( - "/", - ensureIsManager(), - validate("query", ConsumptionTopQuerySchema), - async (ctx) => { - const auth = ctx.get("auth"); - const { limit, ...periodQuery } = ctx.req.valid("query"); - - const period = await resolveConsumptionPeriod( - auth, - toConsumptionPeriodInput(periodQuery) - ); - - const result = await fetchConsumptionGroupsWithActivity(auth, { - period, - limit, - }); - if (result.isErr()) { - return apiError(ctx, { - status_code: 500, - api_error: { - type: "internal_server_error", - message: `Failed to retrieve consumption groups with activity: ${result.error.message}`, - }, - }); - } - - const body: GetConsumptionGroupsWithActivityResponse = result.value; - return ctx.json(body); - } -); - -export default app; diff --git a/front-api/routes/w/[wId]/analytics/consumption/index.ts b/front-api/routes/w/[wId]/analytics/consumption/index.ts index f3fecd97af45..0a3e6f58ecce 100644 --- a/front-api/routes/w/[wId]/analytics/consumption/index.ts +++ b/front-api/routes/w/[wId]/analytics/consumption/index.ts @@ -1,5 +1,4 @@ import { workspaceApp } from "@front-api/middlewares/ctx"; -import groupsWithActivity from "./groups-with-activity"; import overview from "./overview"; import timeseries from "./timeseries"; import topAgents from "./top-agents"; @@ -11,7 +10,6 @@ import topUsers from "./top-users"; const app = workspaceApp(); -app.route("/groups-with-activity", groupsWithActivity); app.route("/overview", overview); app.route("/timeseries", timeseries); app.route("/top-agents", topAgents); diff --git a/front-api/routes/w/[wId]/groups/index.test.ts b/front-api/routes/w/[wId]/groups/index.test.ts new file mode 100644 index 000000000000..bf248ad60126 --- /dev/null +++ b/front-api/routes/w/[wId]/groups/index.test.ts @@ -0,0 +1,71 @@ +import { Authenticator } from "@app/lib/auth"; +import { GroupFactory } from "@app/tests/utils/GroupFactory"; +import { createPrivateApiMockRequest } from "@app/tests/utils/generic_private_api_tests"; +import { MembershipFactory } from "@app/tests/utils/MembershipFactory"; +import { UserFactory } from "@app/tests/utils/UserFactory"; +import { honoApp } from "@front-api/app"; +import { describe, expect, it } from "vitest"; + +function getGroupsRequest(wId: string, query: Record = {}) { + const qs = new URLSearchParams(query).toString(); + return honoApp.request(`/api/w/${wId}/groups${qs ? `?${qs}` : ""}`); +} + +describe("GET /api/w/:wId/groups", () => { + it("returns memberCount but no memberIds by default", async () => { + const { workspace } = await createPrivateApiMockRequest({ + role: "admin", + }); + const adminAuth = await Authenticator.internalAdminForWorkspace( + workspace.sId + ); + const alice = await UserFactory.basic(); + await MembershipFactory.associate(workspace, alice, { role: "user" }); + const sales = await GroupFactory.regularManual(workspace, "Sales"); + await GroupFactory.withMembers(adminAuth, sales, [alice]); + + const response = await getGroupsRequest(workspace.sId, { + kind: "regular_manual", + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.groups).toEqual([ + expect.objectContaining({ + sId: sales.sId, + name: "Sales", + memberCount: 1, + }), + ]); + expect(body.groups[0].memberIds).toBeUndefined(); + }); + + it("returns memberIds when withMembers=true is requested", async () => { + const { workspace } = await createPrivateApiMockRequest({ + role: "admin", + }); + const adminAuth = await Authenticator.internalAdminForWorkspace( + workspace.sId + ); + const alice = await UserFactory.basic(); + await MembershipFactory.associate(workspace, alice, { role: "user" }); + const sales = await GroupFactory.regularManual(workspace, "Sales"); + await GroupFactory.withMembers(adminAuth, sales, [alice]); + + const response = await getGroupsRequest(workspace.sId, { + kind: "regular_manual", + withMembers: "true", + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.groups).toEqual([ + expect.objectContaining({ + sId: sales.sId, + name: "Sales", + memberCount: 1, + memberIds: [alice.sId], + }), + ]); + }); +}); diff --git a/front-api/routes/w/[wId]/groups/index.ts b/front-api/routes/w/[wId]/groups/index.ts index 93bc6fb830a8..b60519df1484 100644 --- a/front-api/routes/w/[wId]/groups/index.ts +++ b/front-api/routes/w/[wId]/groups/index.ts @@ -23,6 +23,9 @@ export type GetGroupsResponseBody = { const GetGroupsQuerySchema = z.object({ kind: z.union([GroupKindCodec, z.array(GroupKindCodec)]).optional(), spaceId: z.string().optional(), + // When "true", each group also carries its member sIds (one extra batched + // query) instead of just memberCount. + withMembers: z.enum(["true", "false"]).optional(), }); // Mounted at /api/w/:wId/groups. @@ -34,7 +37,7 @@ app.get( validate("query", GetGroupsQuerySchema), async (ctx): HandlerResult => { const auth = ctx.get("auth"); - const { kind, spaceId } = ctx.req.valid("query"); + const { kind, spaceId, withMembers } = ctx.req.valid("query"); const groupKinds: GroupKind[] = kind ? Array.isArray(kind) @@ -47,7 +50,10 @@ app.get( : await GroupResource.listAllWorkspaceGroups(auth, { groupKinds }); return ctx.json({ - groups: await GroupResource.toJSONWithMemberCounts(auth, groups), + groups: + withMembers === "true" + ? await GroupResource.toJSONWithMembers(auth, groups) + : await GroupResource.toJSONWithMemberCounts(auth, groups), }); } ); diff --git a/front/components/pages/workspace/AnalyticsConsumptionPage.tsx b/front/components/pages/workspace/AnalyticsConsumptionPage.tsx index 2c6df2188429..89eae1b01354 100644 --- a/front/components/pages/workspace/AnalyticsConsumptionPage.tsx +++ b/front/components/pages/workspace/AnalyticsConsumptionPage.tsx @@ -89,7 +89,6 @@ export function AnalyticsConsumptionPage() {
( () => - topUserRows.map((row) => ({ - id: row.id, - name: row.name, + searchedMembers.map((member) => ({ + id: member.sId, + name: member.fullName, kind: "member", - image: row.pictureUrl, + image: member.image, + })), + [searchedMembers] + ); + + const groups = useMemo( + () => + workspaceGroups.map((group) => ({ + id: group.sId, + name: group.name, + memberIds: group.memberIds ?? [], })), - [topUserRows] + [workspaceGroups] ); const resolvedCategoryOptions = useMemo<{ @@ -153,7 +163,14 @@ export function UsageFilterPanel({ if (selectedGroupMemberIds && !selectedGroupMemberIds.has(option.id)) { return false; } - if (search && !option.name.toLowerCase().includes(search)) { + // The member category is already searched server-side by + // useSearchMembers; re-filtering client-side here would just drop + // results while the debounced search catches up. + if ( + activeCategory !== "member" && + search && + !option.name.toLowerCase().includes(search) + ) { return false; } return true; diff --git a/front/components/workspace/analytics/usageFilterMockData.ts b/front/components/workspace/analytics/usageFilterMockData.ts index a118f420ed94..d4d6f82939b7 100644 --- a/front/components/workspace/analytics/usageFilterMockData.ts +++ b/front/components/workspace/analytics/usageFilterMockData.ts @@ -28,9 +28,8 @@ const MOCK_MODEL_LAB: Record = { }; // Placeholder data for categories not yet wired to a real backend endpoint. -// Members and groups are fetched live in UsageFilterPanel (useConsumptionTop, -// useConsumptionGroupsWithActivity). Lists are long enough to exercise scrolling -// in the preview. +// Members and groups are fetched live in UsageFilterPanel (useSearchMembers, +// useGroups). Lists are long enough to exercise scrolling in the preview. const MOCK_ENTITY_NAMES = { agent: [ "SupportBot", diff --git a/front/hooks/useConsumptionGroupsWithActivity.ts b/front/hooks/useConsumptionGroupsWithActivity.ts deleted file mode 100644 index 4773e2c86c86..000000000000 --- a/front/hooks/useConsumptionGroupsWithActivity.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { ConsumptionPeriodSelection } from "@app/lib/analytics/consumption_period"; -import { consumptionQueryString } from "@app/lib/analytics/consumption_period"; -import type { GetConsumptionGroupsWithActivityResponse } from "@app/lib/api/analytics/consumption/groups_with_activity"; -import { emptyArray, useFetcher, useSWRWithDefaults } from "@app/lib/swr/swr"; -import type { Fetcher } from "swr"; - -export type ConsumptionGroupWithActivityRow = { - id: string; - name: string; - memberIds: string[]; -}; - -// Caps the number of distinct groups returned (the aggregation itself already -// covers every group active in the period, unlike a top-N ranking). -const GROUPS_WITH_ACTIVITY_LIMIT = 100; - -export function useConsumptionGroupsWithActivity({ - workspaceId, - period, - disabled, -}: { - workspaceId: string; - period: ConsumptionPeriodSelection; - disabled?: boolean; -}) { - const { fetcher } = useFetcher(); - const groupsWithActivityFetcher: Fetcher = - fetcher; - - const params = new URLSearchParams(consumptionQueryString(period)); - params.set("limit", String(GROUPS_WITH_ACTIVITY_LIMIT)); - - const { data, error } = useSWRWithDefaults( - `/api/w/${workspaceId}/analytics/consumption/groups-with-activity?${params.toString()}`, - groupsWithActivityFetcher, - { disabled } - ); - - return { - groups: data?.groups ?? emptyArray(), - isGroupsWithActivityLoading: !error && !data && !disabled, - isGroupsWithActivityError: error, - }; -} diff --git a/front/lib/api/analytics/consumption/groups_with_activity.test.ts b/front/lib/api/analytics/consumption/groups_with_activity.test.ts deleted file mode 100644 index 7df85804ef01..000000000000 --- a/front/lib/api/analytics/consumption/groups_with_activity.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { fetchConsumptionGroupsWithActivity } from "@app/lib/api/analytics/consumption/groups_with_activity"; -import type { ConsumptionPeriod } from "@app/lib/api/analytics/consumption/period"; -import { searchConsumptionAnalytics } from "@app/lib/api/elasticsearch"; -import { Authenticator } from "@app/lib/auth"; -import { makeSId } from "@app/lib/resources/string_ids"; -import { GroupFactory } from "@app/tests/utils/GroupFactory"; -import { WorkspaceFactory } from "@app/tests/utils/WorkspaceFactory"; -import { Ok } from "@app/types/shared/result"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -vi.mock(import("@app/lib/api/elasticsearch"), async (orig) => { - const mod = await orig(); - return { ...mod, searchConsumptionAnalytics: vi.fn() }; -}); - -const PERIOD: ConsumptionPeriod = { - startDate: "2026-07-01T00:00:00.000Z", - endDate: "2026-08-01T00:00:00.000Z", -}; - -function mockGroupBuckets(buckets: unknown[]) { - vi.mocked(searchConsumptionAnalytics).mockResolvedValue( - new Ok({ aggregations: { by_group: { buckets } } }) as Awaited< - ReturnType - > - ); -} - -async function setup() { - const workspace = await WorkspaceFactory.basic(); - const auth = await Authenticator.internalAdminForWorkspace(workspace.sId); - return { auth, workspace }; -} - -describe("fetchConsumptionGroupsWithActivity", () => { - afterEach(() => { - vi.mocked(searchConsumptionAnalytics).mockReset(); - }); - - it("resolves group names from Postgres and carries member sIds off the aggregation, sorted by name", async () => { - const { auth, workspace } = await setup(); - const sales = await GroupFactory.regularManual(workspace, "Sales"); - const engineering = await GroupFactory.regularManual( - workspace, - "Engineering" - ); - mockGroupBuckets([ - { key: sales.sId, members: { buckets: [{ key: "u1" }, { key: "u2" }] } }, - { key: engineering.sId, members: { buckets: [{ key: "u3" }] } }, - ]); - - const result = await fetchConsumptionGroupsWithActivity(auth, { - period: PERIOD, - limit: 10, - }); - - expect(result.isOk()).toBe(true); - if (!result.isOk()) { - return; - } - expect(result.value.groups).toEqual([ - { id: engineering.sId, name: "Engineering", memberIds: ["u3"] }, - { id: sales.sId, name: "Sales", memberIds: ["u1", "u2"] }, - ]); - }); - - it("silently drops a group sId that no longer resolves in Postgres (hard-deleted group)", async () => { - const { auth, workspace } = await setup(); - const deletedGroupSId = makeSId("group", { - id: 999_999_999, - workspaceId: workspace.id, - }); - mockGroupBuckets([ - { key: deletedGroupSId, members: { buckets: [{ key: "u1" }] } }, - ]); - - const result = await fetchConsumptionGroupsWithActivity(auth, { - period: PERIOD, - limit: 10, - }); - - expect(result.isOk()).toBe(true); - if (!result.isOk()) { - return; - } - expect(result.value.groups).toEqual([]); - }); - - it("returns an empty list when no document carries a group", async () => { - const { auth } = await setup(); - mockGroupBuckets([]); - - const result = await fetchConsumptionGroupsWithActivity(auth, { - period: PERIOD, - limit: 10, - }); - - expect(result.isOk()).toBe(true); - if (!result.isOk()) { - return; - } - expect(result.value.groups).toEqual([]); - }); - - it("aggregates directly on user.group_ids, scoped to the workspace and period", async () => { - const { auth } = await setup(); - mockGroupBuckets([]); - - await fetchConsumptionGroupsWithActivity(auth, { - period: PERIOD, - limit: 25, - }); - - const [query, options] = vi.mocked(searchConsumptionAnalytics).mock - .calls[0]; - expect(query.bool?.filter).toContainEqual({ - range: { completed_at: { gte: PERIOD.startDate, lt: PERIOD.endDate } }, - }); - expect(options?.aggregations?.by_group?.terms).toMatchObject({ - field: "user.group_ids", - size: 25, - }); - expect(options?.aggregations?.by_group?.aggs?.members?.terms).toMatchObject( - { field: "user.id" } - ); - }); -}); diff --git a/front/lib/api/analytics/consumption/groups_with_activity.ts b/front/lib/api/analytics/consumption/groups_with_activity.ts deleted file mode 100644 index cffcf65de2da..000000000000 --- a/front/lib/api/analytics/consumption/groups_with_activity.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { ConsumptionPeriod } from "@app/lib/api/analytics/consumption/period"; -import { buildConsumptionScopeQuery } from "@app/lib/api/analytics/consumption/scope"; -import type { ElasticsearchError } from "@app/lib/api/elasticsearch"; -import { - bucketsToArray, - searchConsumptionAnalytics, -} from "@app/lib/api/elasticsearch"; -import type { Authenticator } from "@app/lib/auth"; -import { GroupResource } from "@app/lib/resources/group_resource"; -import { getResourceIdFromSId } from "@app/lib/resources/string_ids"; -import type { Result } from "@app/types/shared/result"; -import { Ok } from "@app/types/shared/result"; -import { removeNulls } from "@app/types/shared/utils/general"; -import type { estypes } from "@elastic/elasticsearch"; - -/** - * Each analytics document stores the group sIds the triggering user - * belonged to when the message completed. - * - * Group names still live in Postgres. Groups are hard-deleted, so a group - * sId aggregated out of a historical document may no longer resolve. Such - * ids are silently dropped. - */ - -const USER_GROUP_IDS_FIELD = "user.group_ids"; -const USER_ID_FIELD = "user.id"; - -const MEMBER_IDS_PER_GROUP_LIMIT = 1000; - -export type ConsumptionGroupWithActivity = { - id: string; - name: string; - // Member sIds this group and the period's active users have in common - memberIds: string[]; -}; - -export type ConsumptionGroupsWithActivity = { - groups: ConsumptionGroupWithActivity[]; -}; - -export type GetConsumptionGroupsWithActivityResponse = - ConsumptionGroupsWithActivity; - -type MemberBucket = { key: string }; - -type GroupBucket = { - key: string; - members?: estypes.AggregationsMultiBucketAggregateBase; -}; - -type GroupsWithActivityAggs = { - by_group?: estypes.AggregationsMultiBucketAggregateBase; -}; - -export async function fetchConsumptionGroupsWithActivity( - auth: Authenticator, - { period, limit }: { period: ConsumptionPeriod; limit: number } -): Promise> { - const query = buildConsumptionScopeQuery({ - auth, - startDate: period.startDate, - endDate: period.endDate, - }); - - const result = await searchConsumptionAnalytics< - never, - GroupsWithActivityAggs - >(query, { - aggregations: { - by_group: { - terms: { field: USER_GROUP_IDS_FIELD, size: limit }, - aggs: { - members: { - terms: { field: USER_ID_FIELD, size: MEMBER_IDS_PER_GROUP_LIMIT }, - }, - }, - }, - }, - size: 0, - }); - - if (result.isErr()) { - return result; - } - - const buckets = bucketsToArray( - result.value.aggregations?.by_group?.buckets - ); - if (buckets.length === 0) { - return new Ok({ groups: [] }); - } - - const memberIdsByGroupSId = new Map( - buckets.map((bucket) => [ - String(bucket.key), - bucketsToArray(bucket.members?.buckets).map((member) => - String(member.key) - ), - ]) - ); - - const groupModelIds = removeNulls( - [...memberIdsByGroupSId.keys()].map((sId) => getResourceIdFromSId(sId)) - ); - const groups = await GroupResource.fetchByModelIds(auth, groupModelIds); - - const groupsWithActivity = groups - .map((group) => ({ - id: group.sId, - name: group.name, - memberIds: memberIdsByGroupSId.get(group.sId) ?? [], - })) - .sort((a, b) => a.name.localeCompare(b.name)); - - return new Ok({ groups: groupsWithActivity }); -} diff --git a/front/lib/resources/group_resource.ts b/front/lib/resources/group_resource.ts index e8afa4d36aa4..48a09334ba51 100644 --- a/front/lib/resources/group_resource.ts +++ b/front/lib/resources/group_resource.ts @@ -2959,4 +2959,35 @@ export class GroupResource extends BaseResource { memberCount: memberCounts.get(group.id) ?? 0, })); } + + /** + * Batched counterpart of `toJSONWithMemberCount` that also carries each + * group's active member sIds, resolved in two queries total regardless of + * group count. Does not resolve the global group's implicit membership + * (there are no explicit GroupMembershipModel rows for it) — callers that + * need it should filter it out of `groups` beforehand. + */ + static async toJSONWithMembers( + auth: Authenticator, + groups: GroupResource[] + ): Promise<(GroupType & { memberIds: string[] })[]> { + const membershipsByGroup = + await GroupResource.getActiveMembershipsForGroups(auth, groups); + const userModelIds = [...new Set(Object.values(membershipsByGroup).flat())]; + const users = await UserResource.fetchByModelIds(userModelIds); + const sIdByModelId = new Map(users.map((user) => [user.id, user.sId])); + + return groups.map((group) => { + const memberIds = removeNulls( + (membershipsByGroup[group.id] ?? []).map((userModelId) => + sIdByModelId.get(userModelId) + ) + ); + return { + ...group.toJSON(), + memberCount: memberIds.length, + memberIds, + }; + }); + } } diff --git a/front/lib/swr/groups.ts b/front/lib/swr/groups.ts index d14aa43155e7..24e18dcfc003 100644 --- a/front/lib/swr/groups.ts +++ b/front/lib/swr/groups.ts @@ -24,11 +24,15 @@ export function useGroups({ owner, kinds, spaceId, + withMembers, disabled, }: { owner: LightWorkspaceType; kinds?: readonly GroupKind[]; spaceId?: string; + // Also resolves each group's member sIds (one extra batched query + // server-side) instead of just its memberCount. + withMembers?: boolean; disabled?: boolean; }) { const { fetcher } = useFetcher(); @@ -40,9 +44,12 @@ export function useGroups({ if (spaceId) { params.append("spaceId", spaceId); } + if (withMembers) { + params.append("withMembers", "true"); + } const queryString = params.toString(); return `/api/w/${owner.sId}/groups${queryString ? `?${queryString}` : ""}`; - }, [owner.sId, kinds, spaceId]); + }, [owner.sId, kinds, spaceId, withMembers]); const groupsFetcher: Fetcher = fetcher; diff --git a/front/types/groups.ts b/front/types/groups.ts index a0574305eaa9..3ff36458b076 100644 --- a/front/types/groups.ts +++ b/front/types/groups.ts @@ -100,6 +100,10 @@ export type GroupType = { // Per-group usage spend limit (excluding seat allowance), applied per member. // null means the group carries no cap (falls back to the workspace default). poolCapAwuCredits: number | null; + // Member sIds, only populated when explicitly requested (e.g. GET + // /groups?withMembers=true) — omitted otherwise to avoid paying for it on + // every group listing. + memberIds?: string[]; }; export const GroupKindCodec = z.enum([ From 1f8a5c61c511205ae198dbcaa7e31ee931b3cfa1 Mon Sep 17 00:00:00 2001 From: Arthur Vervaet Date: Mon, 10 Aug 2026 15:21:38 +0200 Subject: [PATCH 09/13] paginating lists --- .../workspace/analytics/UsageFilterPanel.tsx | 19 ++++++++++++++----- .../workspace/analytics/usageFilter.ts | 3 --- front/lib/resources/group_resource.ts | 4 +--- front/types/groups.ts | 4 +--- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/front/components/workspace/analytics/UsageFilterPanel.tsx b/front/components/workspace/analytics/UsageFilterPanel.tsx index 027c599e7093..097055c0d0eb 100644 --- a/front/components/workspace/analytics/UsageFilterPanel.tsx +++ b/front/components/workspace/analytics/UsageFilterPanel.tsx @@ -41,9 +41,11 @@ import { } from "@dust-tt/sparkle"; import { useMemo, useState } from "react"; -// Matches the picker in AnalyticsFilterDropdown, the sibling analytics filter -// that also lists workspace members via useSearchMembers. -const MEMBER_PICKER_PAGE_SIZE = 100; +// Caps how many options are fetched or shown per category picker, whether +// they come from a live paginated fetch (members) or a static list +// (agents/models/tools/skills/sources). Matches the member picker size used +// by the sibling AnalyticsFilterDropdown's useSearchMembers call. +const FILTER_PICKER_PAGE_SIZE = 100; interface UsageFilterPanelProps { owner: LightWorkspaceType; @@ -104,7 +106,7 @@ export function UsageFilterPanel({ workspaceId: owner.sId, searchTerm: searchText, pageIndex: 0, - pageSize: MEMBER_PICKER_PAGE_SIZE, + pageSize: FILTER_PICKER_PAGE_SIZE, disabled: !isMemberCategoryActive, }); @@ -153,7 +155,7 @@ export function UsageFilterPanel({ activeCategory === "member" && selectedGroups.length > 0 ? new Set(selectedGroups.flatMap((group) => group.memberIds)) : null; - return activeOptions.filter((option) => { + const matchingOptions = activeOptions.filter((option) => { if (option.kind === "agent" && option.scope !== activeScope) { return false; } @@ -175,6 +177,13 @@ export function UsageFilterPanel({ } return true; }); + // The member category is already capped server-side via the + // useSearchMembers pageSize; cap the other, statically-loaded + // categories here so every picker shows at most the same number of + // options. + return activeCategory === "member" + ? matchingOptions + : matchingOptions.slice(0, FILTER_PICKER_PAGE_SIZE); }, [ activeOptions, searchText, diff --git a/front/components/workspace/analytics/usageFilter.ts b/front/components/workspace/analytics/usageFilter.ts index de574c66077b..6c14146f30d8 100644 --- a/front/components/workspace/analytics/usageFilter.ts +++ b/front/components/workspace/analytics/usageFilter.ts @@ -88,9 +88,6 @@ export type UsageFilterOption = export interface UsageFilterGroup { id: string; name: string; - // Member sIds, used to narrow the "member" category's checklist down to a - // selected group. Not itself part of `UsageFilter` — groups only narrow the - // picker, the user still checks individual members to filter by. memberIds: string[]; } diff --git a/front/lib/resources/group_resource.ts b/front/lib/resources/group_resource.ts index 48a09334ba51..66768fd1fc01 100644 --- a/front/lib/resources/group_resource.ts +++ b/front/lib/resources/group_resource.ts @@ -2963,9 +2963,7 @@ export class GroupResource extends BaseResource { /** * Batched counterpart of `toJSONWithMemberCount` that also carries each * group's active member sIds, resolved in two queries total regardless of - * group count. Does not resolve the global group's implicit membership - * (there are no explicit GroupMembershipModel rows for it) — callers that - * need it should filter it out of `groups` beforehand. + * group count. */ static async toJSONWithMembers( auth: Authenticator, diff --git a/front/types/groups.ts b/front/types/groups.ts index 3ff36458b076..834f45fa2d50 100644 --- a/front/types/groups.ts +++ b/front/types/groups.ts @@ -100,9 +100,7 @@ export type GroupType = { // Per-group usage spend limit (excluding seat allowance), applied per member. // null means the group carries no cap (falls back to the workspace default). poolCapAwuCredits: number | null; - // Member sIds, only populated when explicitly requested (e.g. GET - // /groups?withMembers=true) — omitted otherwise to avoid paying for it on - // every group listing. + // Member sIds, only populated when explicitly requested memberIds?: string[]; }; From 21a31f727fe5eacb1e8e514f9ed004bc59bb17a3 Mon Sep 17 00:00:00 2001 From: Arthur Vervaet Date: Mon, 10 Aug 2026 16:03:11 +0200 Subject: [PATCH 10/13] Adding infinite scroll to all filters --- .../workspace/analytics/UsageFilterPanel.tsx | 122 +++++++++++++----- .../UsageFilterOptionCheckboxList.tsx | 89 +++++++++---- front/lib/swr/memberships.ts | 3 +- 3 files changed, 156 insertions(+), 58 deletions(-) diff --git a/front/components/workspace/analytics/UsageFilterPanel.tsx b/front/components/workspace/analytics/UsageFilterPanel.tsx index 097055c0d0eb..1387c24c8ee7 100644 --- a/front/components/workspace/analytics/UsageFilterPanel.tsx +++ b/front/components/workspace/analytics/UsageFilterPanel.tsx @@ -39,12 +39,9 @@ import { PopoverTrigger, SearchInput, } from "@dust-tt/sparkle"; -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; -// Caps how many options are fetched or shown per category picker, whether -// they come from a live paginated fetch (members) or a static list -// (agents/models/tools/skills/sources). Matches the member picker size used -// by the sibling AnalyticsFilterDropdown's useSearchMembers call. +// Chunk size for the infinite scroll const FILTER_PICKER_PAGE_SIZE = 100; interface UsageFilterPanelProps { @@ -92,24 +89,77 @@ export function UsageFilterPanel({ ); const [searchText, setSearchText] = useState(""); // Only used for the "member" category: narrows the displayed members down - // to those belonging to at least one of these groups. Groups only narrow - // the picker — the user still checks individual members to add them to the - // filter. Lifted here (rather than owned by UsageFilterMemberGroupsControls) - // because filteredEntities below needs it too. + // to those belonging to at least one of these groups. const [selectedGroups, setSelectedGroups] = useState([]); const isMemberCategoryActive = isOpen && activeCategory === "member"; + // Every category picker supports scroll-to-load-more: + const [memberPageIndex, setMemberPageIndex] = useState(0); + const [accumulatedMemberOptions, setAccumulatedMemberOptions] = useState< + UsageFilterMemberOption[] + >([]); + const [visibleStaticCount, setVisibleStaticCount] = useState( + FILTER_PICKER_PAGE_SIZE + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset every category's load-more window when the active picker's filters change + useEffect(() => { + setMemberPageIndex(0); + setVisibleStaticCount(FILTER_PICKER_PAGE_SIZE); + }, [activeCategory, searchText, activeScope, activeTier]); + // Search is applied server-side by useSearchMembers, same as the sibling // AnalyticsFilterDropdown's member picker. - const { members: searchedMembers } = useSearchMembers({ + const { + members: searchedMembers, + totalMembersCount, + isMembersValidating, + } = useSearchMembers({ workspaceId: owner.sId, searchTerm: searchText, - pageIndex: 0, + pageIndex: memberPageIndex, pageSize: FILTER_PICKER_PAGE_SIZE, disabled: !isMemberCategoryActive, }); + useEffect(() => { + const page = searchedMembers.map((member) => ({ + id: member.sId, + name: member.fullName, + kind: "member" as const, + image: member.image, + })); + if (memberPageIndex === 0) { + setAccumulatedMemberOptions(page); + return; + } + if (page.length === 0) { + return; + } + setAccumulatedMemberOptions((prev) => { + const existingIds = new Set(prev.map((option) => option.id)); + const newOptions = page.filter((option) => !existingIds.has(option.id)); + return newOptions.length > 0 ? [...prev, ...newOptions] : prev; + }); + }, [searchedMembers, memberPageIndex]); + + // Whether more members exist server-side, independent of the client-side + // group filter below — scrolling must keep fetching even if the current + // group filter narrows the visible list to fewer than a full page. + const hasMoreMembers = accumulatedMemberOptions.length < totalMembersCount; + + const handleLoadMoreMembers = useCallback(() => { + if (isMembersValidating || !hasMoreMembers) { + return; + } + setMemberPageIndex((current) => current + 1); + }, [isMembersValidating, hasMoreMembers]); + + const handleLoadMoreStaticOptions = useCallback(() => { + setVisibleStaticCount((current) => current + FILTER_PICKER_PAGE_SIZE); + }, []); + const { groups: workspaceGroups } = useGroups({ owner, kinds: MANAGEABLE_GROUP_KINDS, @@ -117,17 +167,6 @@ export function UsageFilterPanel({ disabled: !isMemberCategoryActive, }); - const memberOptions = useMemo( - () => - searchedMembers.map((member) => ({ - id: member.sId, - name: member.fullName, - kind: "member", - image: member.image, - })), - [searchedMembers] - ); - const groups = useMemo( () => workspaceGroups.map((group) => ({ @@ -143,9 +182,9 @@ export function UsageFilterPanel({ }>( () => ({ ...categoryOptions, - member: memberOptions, + member: accumulatedMemberOptions, }), - [categoryOptions, memberOptions] + [categoryOptions, accumulatedMemberOptions] ); const activeOptions = resolvedCategoryOptions[activeCategory]; @@ -177,13 +216,7 @@ export function UsageFilterPanel({ } return true; }); - // The member category is already capped server-side via the - // useSearchMembers pageSize; cap the other, statically-loaded - // categories here so every picker shows at most the same number of - // options. - return activeCategory === "member" - ? matchingOptions - : matchingOptions.slice(0, FILTER_PICKER_PAGE_SIZE); + return matchingOptions; }, [ activeOptions, searchText, @@ -193,6 +226,20 @@ export function UsageFilterPanel({ selectedGroups, ]); + // Members are already paginated server-side into filteredOptions; the + // other categories reveal a growing window of the already-loaded + // filteredOptions as the user scrolls. + const displayedOptions = useMemo( + () => + activeCategory === "member" + ? filteredOptions + : filteredOptions.slice(0, visibleStaticCount), + [filteredOptions, activeCategory, visibleStaticCount] + ); + + const hasMoreStaticOptions = + activeCategory !== "member" && visibleStaticCount < filteredOptions.length; + const selectedIdsForActiveCategory = useMemo( () => new Set((draftFilter[activeCategory] ?? []).map((option) => option.id)), @@ -319,12 +366,23 @@ export function UsageFilterPanel({ toggleOption(activeCategory, option)} onSelectAll={() => selectAllFiltered(activeCategory, filteredOptions) } + hasMore={ + activeCategory === "member" + ? hasMoreMembers + : hasMoreStaticOptions + } + isLoadingMore={activeCategory === "member" && isMembersValidating} + onLoadMore={ + activeCategory === "member" + ? handleLoadMoreMembers + : handleLoadMoreStaticOptions + } />
; onToggleOption: (option: UsageFilterOption) => void; onSelectAll: () => void; + // Only set for categories backed by a paginated server fetch (members). + hasMore?: boolean; + isLoadingMore?: boolean; + onLoadMore?: () => void; } export function UsageFilterOptionCheckboxList({ @@ -27,7 +33,19 @@ export function UsageFilterOptionCheckboxList({ selectedIds, onToggleOption, onSelectAll, + hasMore = false, + isLoadingMore = false, + onLoadMore, }: UsageFilterOptionCheckboxListProps) { + // Tracked as state (not a ref) so InfiniteScroll re-renders once the node + // mounts and can attach its scroll listener directly to it — passing this + // as an explicit root is what the InfiniteScroll component recommends, + // since its IntersectionObserver-sentinel fallback doesn't reliably fire + // for a nested scroll container like this one. + const [scrollContainer, setScrollContainer] = useState( + null + ); + return ( <> } /> - +
{options.length > 0 ? ( - options.map((option) => { - const checked = selectedIds.has(option.id); - const checkboxId = `usage-filter-option-${category}-${option.id}`; - return ( -
- onToggleOption(option)} - /> - -
+ ); + })} + {onLoadMore && ( + + +
+ } + options={{ + root: scrollContainer, + rootMargin: "0px 0px 100px 0px", + }} + /> + )} + ) : (
No results
)} -
+ ); } diff --git a/front/lib/swr/memberships.ts b/front/lib/swr/memberships.ts index 91b2d84383fb..7140ce0fba0c 100644 --- a/front/lib/swr/memberships.ts +++ b/front/lib/swr/memberships.ts @@ -170,7 +170,7 @@ export function useSearchMembers< searchParams.set("role", role); } - const { data, error, mutate, mutateRegardlessOfQueryParams } = + const { data, error, isValidating, mutate, mutateRegardlessOfQueryParams } = useSWRWithDefaults( `/api/w/${workspaceId}/members/search?${searchParams.toString()}`, searchMembersFetcher, @@ -186,6 +186,7 @@ export function useSearchMembers< members: data?.members ?? emptyArray(), totalMembersCount: data?.total ?? 0, isLoading: !error && !data && !disabled, + isMembersValidating: isValidating, isError: !!error, mutate, mutateRegardlessOfQueryParams, From 34a17ecae30eb6e4e2b71a7b917ca0a9f1c1f2df Mon Sep 17 00:00:00 2001 From: Arthur Vervaet Date: Mon, 10 Aug 2026 16:10:06 +0200 Subject: [PATCH 11/13] fixing infinite scroll loading --- .../workspace/analytics/UsageFilterPanel.tsx | 28 +++++++++++++++---- .../UsageFilterOptionCheckboxList.tsx | 7 ++--- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/front/components/workspace/analytics/UsageFilterPanel.tsx b/front/components/workspace/analytics/UsageFilterPanel.tsx index 1387c24c8ee7..658ad4f0831f 100644 --- a/front/components/workspace/analytics/UsageFilterPanel.tsx +++ b/front/components/workspace/analytics/UsageFilterPanel.tsx @@ -103,11 +103,10 @@ export function UsageFilterPanel({ FILTER_PICKER_PAGE_SIZE ); - // biome-ignore lint/correctness/useExhaustiveDependencies: reset every category's load-more window when the active picker's filters change - useEffect(() => { + const resetFilterPickerPagination = useCallback(() => { setMemberPageIndex(0); setVisibleStaticCount(FILTER_PICKER_PAGE_SIZE); - }, [activeCategory, searchText, activeScope, activeTier]); + }, []); // Search is applied server-side by useSearchMembers, same as the sibling // AnalyticsFilterDropdown's member picker. @@ -268,12 +267,29 @@ export function UsageFilterPanel({ if (open) { setDraftFilter(filter); setSearchText(""); + resetFilterPickerPagination(); } }; const handleCategoryChange = (category: UsageFilterCategory) => { setActiveCategory(category); setSearchText(""); + resetFilterPickerPagination(); + }; + + const handleSearchTextChange = (text: string) => { + setSearchText(text); + resetFilterPickerPagination(); + }; + + const handleScopeChange = (scope: UsageFilterScope) => { + setActiveScope(scope); + resetFilterPickerPagination(); + }; + + const handleTierChange = (tier: UsageModelTier) => { + setActiveTier(tier); + resetFilterPickerPagination(); }; const handleCancel = () => { @@ -337,7 +353,7 @@ export function UsageFilterPanel({ {activeCategory === "member" && ( @@ -354,13 +370,13 @@ export function UsageFilterPanel({ selectedModelIds={selectedIdsForActiveCategory} onToggleModel={(model) => toggleOption("model", model)} activeTier={activeTier} - onTierChange={setActiveTier} + onTierChange={handleTierChange} /> )} {activeCategory === "agent" && ( )} ( null ); From 77c52b0f21195b1dc08d253b7696a9513dd9a7cf Mon Sep 17 00:00:00 2001 From: Arthur Vervaet Date: Mon, 10 Aug 2026 17:18:05 +0200 Subject: [PATCH 12/13] Applying review nits --- front-api/routes/w/[wId]/groups/index.ts | 2 +- .../workspace/analytics/UsageFilterPanel.tsx | 98 +++++++++++-------- front/hooks/useToggleSelectionList.ts | 20 ++++ front/lib/resources/group_resource.ts | 2 +- 4 files changed, 81 insertions(+), 41 deletions(-) create mode 100644 front/hooks/useToggleSelectionList.ts diff --git a/front-api/routes/w/[wId]/groups/index.ts b/front-api/routes/w/[wId]/groups/index.ts index b60519df1484..c90772d8c629 100644 --- a/front-api/routes/w/[wId]/groups/index.ts +++ b/front-api/routes/w/[wId]/groups/index.ts @@ -52,7 +52,7 @@ app.get( return ctx.json({ groups: withMembers === "true" - ? await GroupResource.toJSONWithMembers(auth, groups) + ? await GroupResource.fetchJSONWithMembers(auth, groups) : await GroupResource.toJSONWithMemberCounts(auth, groups), }); } diff --git a/front/components/workspace/analytics/UsageFilterPanel.tsx b/front/components/workspace/analytics/UsageFilterPanel.tsx index 658ad4f0831f..d0e0a070f154 100644 --- a/front/components/workspace/analytics/UsageFilterPanel.tsx +++ b/front/components/workspace/analytics/UsageFilterPanel.tsx @@ -26,9 +26,11 @@ import { UsageFilterModelComplexityControls } from "@app/components/workspace/an import { UsageFilterOptionCheckboxList } from "@app/components/workspace/analytics/usageFilterPanel/UsageFilterOptionCheckboxList"; import { UsageFilterSelectionSummary } from "@app/components/workspace/analytics/usageFilterPanel/UsageFilterSelectionSummary"; import { useUsageFilter } from "@app/components/workspace/analytics/useUsageFilter"; +import { useToggleSelectionList } from "@app/hooks/useToggleSelectionList"; import { useGroups } from "@app/lib/swr/groups"; import { useSearchMembers } from "@app/lib/swr/memberships"; import { MANAGEABLE_GROUP_KINDS } from "@app/types/groups"; +import { assertNever } from "@app/types/shared/utils/assert_never"; import type { LightWorkspaceType } from "@app/types/user"; import { BarChart05, @@ -44,6 +46,12 @@ import { useCallback, useEffect, useMemo, useState } from "react"; // Chunk size for the infinite scroll const FILTER_PICKER_PAGE_SIZE = 100; +interface UsageFilterPaginationState { + hasMore: boolean; + isLoadingMore: boolean; + onLoadMore: () => void; +} + interface UsageFilterPanelProps { owner: LightWorkspaceType; // Agents/models/tools/skills/sources are still mock data (see @@ -90,7 +98,7 @@ export function UsageFilterPanel({ const [searchText, setSearchText] = useState(""); // Only used for the "member" category: narrows the displayed members down // to those belonging to at least one of these groups. - const [selectedGroups, setSelectedGroups] = useState([]); + const selectedGroups = useToggleSelectionList(); const isMemberCategoryActive = isOpen && activeCategory === "member"; @@ -190,8 +198,8 @@ export function UsageFilterPanel({ const filteredOptions = useMemo(() => { const search = searchText.trim().toLowerCase(); const selectedGroupMemberIds = - activeCategory === "member" && selectedGroups.length > 0 - ? new Set(selectedGroups.flatMap((group) => group.memberIds)) + activeCategory === "member" && selectedGroups.items.length > 0 + ? new Set(selectedGroups.items.flatMap((group) => group.memberIds)) : null; const matchingOptions = activeOptions.filter((option) => { if (option.kind === "agent" && option.scope !== activeScope) { @@ -222,7 +230,7 @@ export function UsageFilterPanel({ activeScope, activeTier, activeCategory, - selectedGroups, + selectedGroups.items, ]); // Members are already paginated server-side into filteredOptions; the @@ -239,6 +247,36 @@ export function UsageFilterPanel({ const hasMoreStaticOptions = activeCategory !== "member" && visibleStaticCount < filteredOptions.length; + const activePagination = useMemo(() => { + switch (activeCategory) { + case "member": + return { + hasMore: hasMoreMembers, + isLoadingMore: isMembersValidating, + onLoadMore: handleLoadMoreMembers, + }; + case "agent": + case "model": + case "tool": + case "skill": + case "source": + return { + hasMore: hasMoreStaticOptions, + isLoadingMore: false, + onLoadMore: handleLoadMoreStaticOptions, + }; + default: + return assertNever(activeCategory); + } + }, [ + activeCategory, + hasMoreMembers, + isMembersValidating, + handleLoadMoreMembers, + hasMoreStaticOptions, + handleLoadMoreStaticOptions, + ]); + const selectedIdsForActiveCategory = useMemo( () => new Set((draftFilter[activeCategory] ?? []).map((option) => option.id)), @@ -282,15 +320,15 @@ export function UsageFilterPanel({ resetFilterPickerPagination(); }; - const handleScopeChange = (scope: UsageFilterScope) => { - setActiveScope(scope); - resetFilterPickerPagination(); - }; - - const handleTierChange = (tier: UsageModelTier) => { - setActiveTier(tier); - resetFilterPickerPagination(); - }; + // Category-specific "active option" controls (scope, tier, ...) all need + // to reset pagination on change; wrap their setters once instead of + // writing a dedicated handleXxxChange per category. + const withPaginationReset = + (setter: (value: T) => void) => + (value: T) => { + setter(value); + resetFilterPickerPagination(); + }; const handleCancel = () => { setIsOpen(false); @@ -301,16 +339,6 @@ export function UsageFilterPanel({ setIsOpen(false); }; - const handleAddGroup = (group: UsageFilterGroup) => { - setSelectedGroups((current) => - current.some((g) => g.id === group.id) ? current : [...current, group] - ); - }; - - const handleRemoveGroup = (id: string) => { - setSelectedGroups((current) => current.filter((g) => g.id !== id)); - }; - const activeCategorySelectionCount = draftFilter[activeCategory]?.length ?? 0; return ( @@ -359,9 +387,9 @@ export function UsageFilterPanel({ {activeCategory === "member" && ( )} {activeCategory === "model" && ( @@ -370,13 +398,13 @@ export function UsageFilterPanel({ selectedModelIds={selectedIdsForActiveCategory} onToggleModel={(model) => toggleOption("model", model)} activeTier={activeTier} - onTierChange={handleTierChange} + onTierChange={withPaginationReset(setActiveTier)} /> )} {activeCategory === "agent" && ( )} selectAllFiltered(activeCategory, filteredOptions) } - hasMore={ - activeCategory === "member" - ? hasMoreMembers - : hasMoreStaticOptions - } - isLoadingMore={activeCategory === "member" && isMembersValidating} - onLoadMore={ - activeCategory === "member" - ? handleLoadMoreMembers - : handleLoadMoreStaticOptions - } + hasMore={activePagination.hasMore} + isLoadingMore={activePagination.isLoadingMore} + onLoadMore={activePagination.onLoadMore} /> () { + const [items, setItems] = useState([]); + + const add = useCallback((item: T) => { + setItems((current) => + current.some((i) => i.id === item.id) ? current : [...current, item] + ); + }, []); + + const remove = useCallback((id: string) => { + setItems((current) => current.filter((i) => i.id !== id)); + }, []); + + return { items, add, remove, setItems }; +} diff --git a/front/lib/resources/group_resource.ts b/front/lib/resources/group_resource.ts index 66768fd1fc01..1366244ff682 100644 --- a/front/lib/resources/group_resource.ts +++ b/front/lib/resources/group_resource.ts @@ -2965,7 +2965,7 @@ export class GroupResource extends BaseResource { * group's active member sIds, resolved in two queries total regardless of * group count. */ - static async toJSONWithMembers( + static async fetchJSONWithMembers( auth: Authenticator, groups: GroupResource[] ): Promise<(GroupType & { memberIds: string[] })[]> { From 96fb4f4d4310d8246c8a8386b5b2fe049068b2eb Mon Sep 17 00:00:00 2001 From: Arthur Vervaet Date: Mon, 10 Aug 2026 17:32:47 +0200 Subject: [PATCH 13/13] review changes --- front/components/workspace/analytics/UsageFilterPanel.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/front/components/workspace/analytics/UsageFilterPanel.tsx b/front/components/workspace/analytics/UsageFilterPanel.tsx index d0e0a070f154..1d724eb063e1 100644 --- a/front/components/workspace/analytics/UsageFilterPanel.tsx +++ b/front/components/workspace/analytics/UsageFilterPanel.tsx @@ -305,6 +305,7 @@ export function UsageFilterPanel({ if (open) { setDraftFilter(filter); setSearchText(""); + selectedGroups.setItems([]); resetFilterPickerPagination(); } };