diff --git a/.speakeasy/out.openapi.yaml b/.speakeasy/out.openapi.yaml index d2291cc232..dcf2124672 100644 --- a/.speakeasy/out.openapi.yaml +++ b/.speakeasy/out.openapi.yaml @@ -34614,6 +34614,50 @@ components: description: Selector constraints. Null means unrestricted. required: - scope + RoleSummary: + type: object + properties: + cost_per_user: + type: number + description: Average cost per user + format: double + role_id: + type: string + description: Role identifier extracted from role URN + total_chats: + type: integer + description: Total chat sessions across all users + format: int64 + total_cost: + type: number + description: Total cost across all users with this role + format: double + total_input_tokens: + type: integer + description: Sum of input tokens across all users + format: int64 + total_output_tokens: + type: integer + description: Sum of output tokens across all users + format: int64 + total_tokens: + type: integer + description: Sum of all tokens across all users + format: int64 + user_count: + type: integer + description: Number of users with this role + format: int64 + description: Aggregated usage summary for a role + required: + - role_id + - user_count + - total_cost + - cost_per_user + - total_input_tokens + - total_output_tokens + - total_tokens + - total_chats ScopeDefinition: type: object properties: @@ -34942,6 +34986,13 @@ components: description: Cursor for pagination (user identifier from last item) filter: $ref: '#/components/schemas/SearchUsersFilter' + group_by: + type: string + description: Grouping dimension for results + default: employee + enum: + - employee + - role limit: type: integer description: Number of items to return (1-1000) @@ -34972,11 +35023,16 @@ components: next_cursor: type: string description: Cursor for next page + roles: + type: array + items: + $ref: '#/components/schemas/RoleSummary' + description: List of role usage summaries (populated when group_by=role) users: type: array items: $ref: '#/components/schemas/UserSummary' - description: List of user usage summaries + description: List of user usage summaries (populated when group_by=employee) description: Result of searching user usage summaries required: - users diff --git a/client/dashboard/src/components/observe/InsightsAgents.tsx b/client/dashboard/src/components/observe/InsightsAgents.tsx index eeca0f734e..511b709c0d 100644 --- a/client/dashboard/src/components/observe/InsightsAgents.tsx +++ b/client/dashboard/src/components/observe/InsightsAgents.tsx @@ -27,10 +27,11 @@ import type { GetObservabilityOverviewResult, ModelUsage, ProjectSummary, + RoleSummary, TimeSeriesBucket, UserSummary, } from "@gram/client/models/components"; -import { useGramContext, useMembers } from "@gram/client/react-query"; +import { useGramContext, useMembers, useRoles } from "@gram/client/react-query"; import { unwrapAsync } from "@gram/client/types/fp"; import { TimeRangePicker, @@ -175,6 +176,9 @@ export function InsightsAgentsContent() { const [valueMode, setValueMode] = useState("tokens"); const [expandedChart, setExpandedChart] = useState(null); const [clientFilter, setClientFilter] = useState("all"); + const [groupByDimension, setGroupByDimension] = useState<"employee" | "role">( + "employee", + ); const { from, to, timeRangeMs } = useMemo(() => { const range = customRange ?? getPresetRange(dateRange); @@ -191,10 +195,15 @@ export function InsightsAgentsContent() { }, [customRange, customRangeLabel, dateRange]); const { data: membersData, isLoading: membersLoading } = useMembers(); + const { data: rolesData } = useRoles(); const memberMap = useMemo( () => new Map((membersData?.members ?? []).map((m) => [m.id, m])), [membersData], ); + const roleNameById = useMemo( + () => new Map((rolesData?.roles ?? []).map((r) => [r.id, r.name])), + [rolesData], + ); const usersQuery = useQuery({ queryKey: [ @@ -240,7 +249,24 @@ export function InsightsAgentsContent() { throwOnError: false, }); + const roleUsageQuery = useQuery({ + queryKey: [ + "insights", + "agents", + "roleUsage", + from.toISOString(), + to.toISOString(), + ], + queryFn: () => fetchRoleUsage(client, from, to), + enabled: groupByDimension === "role", + throwOnError: false, + }); + const users = useMemo(() => usersQuery.data ?? [], [usersQuery.data]); + const roleUsage = useMemo( + () => roleUsageQuery.data ?? [], + [roleUsageQuery.data], + ); const projectMetrics = projectQuery.data ?? null; const timeSeries = overviewQuery.data?.timeSeries ?? []; @@ -595,8 +621,13 @@ export function InsightsAgentsContent() { c.label)} /> @@ -935,7 +966,10 @@ type SortField = | "cost" | "costPerSession" | "sessions" - | "share"; + | "share" + | "role" + | "userCount" + | "costPerUser"; function SortableHead({ field, @@ -970,16 +1004,27 @@ function SortableHead({ function EmployeeCostTable({ users, + roleUsage, + roleNameById, valueMode, clientFilter, + groupByDimension, + onGroupByChange, + roleUsageLoading, }: { users: EmployeeRow[]; + roleUsage: RoleSummary[]; + roleNameById: Map; valueMode: ValueMode; clientFilter: string; + groupByDimension: "employee" | "role"; + onGroupByChange: (dim: "employee" | "role") => void; + roleUsageLoading: boolean; }) { const PAGE_SIZE = 10; const [page, setPage] = useState(0); const isCost = valueMode === "cost"; + const isRoleView = groupByDimension === "role"; // Default sort follows the value mode; clicking a column overrides const [sortField, setSortField] = useState(null); @@ -995,6 +1040,13 @@ function EmployeeCostTable({ setPage(0); }; + // Reset sort + page when switching views + const handleGroupByChange = (dim: "employee" | "role") => { + setSortField(null); + setPage(0); + onGroupByChange(dim); + }; + const effectiveSortField = sortField ?? (isCost ? "cost" : "totalTokens"); const effectiveSortDir = sortField ? sortDirection : "desc"; @@ -1032,9 +1084,49 @@ function EmployeeCostTable({ }); }, [users, effectiveSortField, effectiveSortDir, isCost]); - const totalPages = Math.ceil(sortedUsers.length / PAGE_SIZE); + const totalRoleCost = useMemo( + () => roleUsage.reduce((sum, r) => sum + r.totalCost, 0), + [roleUsage], + ); + + const sortedRoles = useMemo(() => { + const getValue = (r: RoleSummary): number | string => { + switch (effectiveSortField) { + case "role": + return (roleNameById.get(r.roleId) ?? r.roleId).toLowerCase(); + case "userCount": + return r.userCount; + case "cost": + return r.totalCost; + case "costPerUser": + return r.costPerUser; + case "input": + return r.totalInputTokens; + case "output": + return r.totalOutputTokens; + case "totalTokens": + return r.totalTokens; + case "sessions": + return r.totalChats; + default: + return r.totalCost; + } + }; + return roleUsage.slice().sort((a, b) => { + const va = getValue(a); + const vb = getValue(b); + const cmp = + typeof va === "string" && typeof vb === "string" + ? va.localeCompare(vb) + : (va as number) - (vb as number); + return effectiveSortDir === "asc" ? cmp : -cmp; + }); + }, [roleUsage, roleNameById, effectiveSortField, effectiveSortDir]); + + const items = isRoleView ? sortedRoles : sortedUsers; + const totalPages = Math.ceil(items.length / PAGE_SIZE); const safePage = Math.min(page, Math.max(totalPages - 1, 0)); - const pageUsers = sortedUsers.slice( + const pageItems = items.slice( safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE, ); @@ -1044,190 +1136,347 @@ function EmployeeCostTable({

- {isCost ? "Cost" : "Usage"} by Employee + {isCost ? "Cost" : "Usage"} by {isRoleView ? "Role" : "Employee"}

- {clientFilter !== "all" && + {!isRoleView && + clientFilter !== "all" && `Filtered to ${formatPlatform(clientFilter)} · `} - {sortedUsers.length} employee - {sortedUsers.length !== 1 ? "s" : ""} + {items.length} {isRoleView ? "role" : "employee"} + {items.length !== 1 ? "s" : ""}

+
+ {(["employee", "role"] as const).map((option) => ( + + ))} +
- - - - - Employee - - - Input - - - Output - - - Total Tokens - - - Cost - - - $/Session - - - Sessions - - - Share - - - - - {pageUsers.length > 0 ? ( - pageUsers.map((user) => ( - - -
- - {user.photoUrl ? ( - - ) : null} - - {initials(user.displayName)} - - -
-

- {user.displayName} -

- {user.email ? ( -

- {user.email} -

- ) : null} - {clientFilter === "all" && user.clients.length > 0 && ( -

- {user.clients.join(", ")} -

- )} -
-
-
- - {formatTokens(user.totalInputTokens)} - - - {formatTokens(user.totalOutputTokens)} - - - - {formatTokens(user.totalTokens)} - - - - + + + ) : ( +
+ + + + Role + + + Users + + + Total Cost + + + Cost/User + + + Input Tokens + + + Output Tokens + + + Sessions + + + + + {(pageItems as RoleSummary[]).length > 0 ? ( + (pageItems as RoleSummary[]).map((role) => { + const roleName = + role.roleId === "unassigned" + ? "Unassigned" + : (roleNameById.get(role.roleId) ?? role.roleId); + const costShare = + totalRoleCost > 0 + ? (role.totalCost / totalRoleCost) * 100 + : 0; + return ( + + +
+ + {roleName} + + {role.roleId === "unassigned" && ( + + no role + + )} +
+
+ + {role.userCount.toLocaleString()} + + +
+ + {formatCost(role.totalCost)} + + + {costShare.toFixed(1)}% + +
+
+ + {formatCost(role.costPerUser)} + + + {formatTokens(role.totalInputTokens)} + + + {formatTokens(role.totalOutputTokens)} + + + {role.totalChats.toLocaleString()} + +
+ ); + }) + ) : ( + + - {formatCost(user.totalCost)} - - - - {formatCost(user.costPerSession)} - - - {user.totalChats.toLocaleString()} - - -
-
-
+ No role usage data found for this time range. + + + )} + +
+ ) + ) : ( + + + + + Employee + + + Input + + + Output + + + Total Tokens + + + Cost + + + $/Session + + + Sessions + + + Share + + + + + {(pageItems as EmployeeRow[]).length > 0 ? ( + (pageItems as EmployeeRow[]).map((user) => ( + + +
+ + {user.photoUrl ? ( + + ) : null} + + {initials(user.displayName)} + + +
+

+ {user.displayName} +

+ {user.email ? ( +

+ {user.email} +

+ ) : null} + {clientFilter === "all" && + user.clients.length > 0 && ( +

+ {user.clients.join(", ")} +

+ )} +
- - {(isCost ? user.costShare : user.tokenShare).toFixed(1)} - % +
+ + {formatTokens(user.totalInputTokens)} + + + {formatTokens(user.totalOutputTokens)} + + + + {formatTokens(user.totalTokens)} + + + + + {formatCost(user.totalCost)} - + + + {formatCost(user.costPerSession)} + + + {user.totalChats.toLocaleString()} + + +
+
+
+
+ + {(isCost ? user.costShare : user.tokenShare).toFixed( + 1, + )} + % + +
+ + + )) + ) : ( + + + No employee activity found for this time range. - )) - ) : ( - - - No employee activity found for this time range. - - - )} - -
+ )} + + + )} {totalPages > 1 && (

{safePage * PAGE_SIZE + 1}– - {Math.min((safePage + 1) * PAGE_SIZE, sortedUsers.length)} of{" "} - {sortedUsers.length} + {Math.min((safePage + 1) * PAGE_SIZE, items.length)} of{" "} + {items.length}