Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions apps/web/src/components/usage-analytics/FilterGeneratorPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import type { Granularity } from './types';
type FilterGeneratorPopoverProps = {
/** Query scope (to populate value suggestions). */
organizationId: string | null;
/** Multi-org aggregate scope (parent + children), when viewing all orgs. */
organizationIds: string[] | null;
dateRange: DateRange;
personalScope: PersonalScope;
/**
Expand Down Expand Up @@ -59,6 +61,7 @@ const DIMENSIONS_ORG: Dimension[] = ['feature', 'model', 'mode', 'user', 'provid

export function FilterGeneratorPopover({
organizationId,
organizationIds,
dateRange,
personalScope,
canFilterByUser,
Expand All @@ -76,14 +79,19 @@ export function FilterGeneratorPopover({

const dimensionOptions = canFilterByUser ? DIMENSIONS_ORG : DIMENSIONS_PERSONAL;

const scopeOrganizationIds =
organizationIds && organizationIds.length > 0 ? organizationIds : undefined;

const trpc = useTRPC();
const { data: breakdown, isLoading } = useQuery({
...trpc.usageAnalytics.getBreakdown.queryOptions({
startDate: dateRange.startDate,
endDate: dateRange.endDate,
granularity,
costSource,
organizationId: organizationId ?? undefined,
// In multi-org aggregate mode the server keys off `organizationIds`.
organizationId: scopeOrganizationIds ? undefined : (organizationId ?? undefined),
organizationIds: scopeOrganizationIds,
personalScope,
viewAs,
dimension,
Expand All @@ -101,8 +109,12 @@ export function FilterGeneratorPopover({
() => (dimension === 'user' ? suggestionKeys : []),
[dimension, suggestionKeys]
);
const resolutionOrgIds = useMemo(
() => scopeOrganizationIds ?? (organizationId ? [organizationId] : []),
[scopeOrganizationIds, organizationId]
);
const { data: userSuggestionResolution, isLoading: userSuggestionResolutionLoading } =
useResolveOrgUsers(organizationId, userSuggestionIds);
useResolveOrgUsers(resolutionOrgIds, userSuggestionIds);
const isResolvingUserSuggestions =
dimension === 'user' && userSuggestionIds.length > 0 && userSuggestionResolutionLoading;
const resolvedUsersById = useMemo(
Expand Down
217 changes: 149 additions & 68 deletions apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx

Large diffs are not rendered by default.

60 changes: 42 additions & 18 deletions apps/web/src/components/usage-analytics/UsageAnalyticsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '@/components/ui/select';
import { X } from 'lucide-react';
import { FilterGeneratorPopover } from './FilterGeneratorPopover';
import { ORG_SCOPE_ALL_ORGS, ORG_SCOPE_SELF } from './useUsageDashboardState';
import {
COST_SOURCE_LABELS,
DIMENSION_LABELS,
Expand Down Expand Up @@ -58,6 +59,8 @@ export type ViewAs = 'self' | 'org-wide';
type UsageAnalyticsSidebarProps = {
context: 'personal' | 'organization';
organizationId: string | null;
/** Multi-org aggregate scope (parent + children) for value-suggestion queries. */
organizationIds: string[] | null;
dateRange: DateRange;
personalScope: PersonalScope;

Expand All @@ -66,12 +69,23 @@ type UsageAnalyticsSidebarProps = {
onPersonalViewChange: (value: PersonalView) => void;
organizations: OrganizationSummary[];

// View-as toggle (org context, role-gated)
// Scope (org context, role-gated)
viewAs: ViewAs;
onViewAsChange: (value: ViewAs) => void;
/**
* Whether the caller's role lets them flip to the org-wide view. Drives the
* visibility of the "My Usage / Entire Organization" toggle only.
* Org-context scope selection: `'self'`, `'all-orgs'`, or an organization id.
*/
orgScope: string;
onOrgScopeChange: (value: string) => void;
/** The org whose usage page this is (the parent, when it has children). */
pageOrganizationId: string | null;
pageOrganizationName: string | null;
/** Direct child orgs, when the page org is a parent. */
childOrganizations: OrganizationSummary[];
/** True when the page org has at least one child org. */
isParentOrg: boolean;
/**
* Whether the caller's role lets them view org-wide usage. Drives the
* visibility of the org-context Scope selector.
*/
canViewAllOrgUsers: boolean;
/**
Expand All @@ -80,8 +94,6 @@ type UsageAnalyticsSidebarProps = {
* Becomes false when the caller is seeing only their own usage.
*/
isOrgWideView: boolean;
/** Name used in the "Entire {orgName}" label. Falls back to "Organization". */
effectiveOrganizationName: string | null;

// Period
period: PeriodOption;
Expand Down Expand Up @@ -115,16 +127,21 @@ type UsageAnalyticsSidebarProps = {
export function UsageAnalyticsSidebar({
context,
organizationId,
organizationIds,
dateRange,
personalScope,
personalView,
onPersonalViewChange,
organizations,
viewAs,
onViewAsChange,
orgScope,
onOrgScopeChange,
pageOrganizationId,
pageOrganizationName,
childOrganizations,
isParentOrg,
canViewAllOrgUsers,
isOrgWideView,
effectiveOrganizationName,
period,
onPeriodChange,
granularity,
Expand All @@ -146,12 +163,10 @@ export function UsageAnalyticsSidebar({
}: UsageAnalyticsSidebarProps) {
const isOrgContext = context === 'organization';
const showPersonalViewSelector = context === 'personal' && organizations.length > 0;
// Per plan: the view-as toggle is only rendered on the organization usage
// page and only when the caller has permission to see all org users.
const showViewAsSelector = isOrgContext && canViewAllOrgUsers;
const entireOrgLabel = effectiveOrganizationName
? `${effectiveOrganizationName}`
: 'Organization';
// The org-context Scope selector is only rendered on the organization usage
// page and only when the caller may view org-wide usage.
const showOrgScopeSelector = isOrgContext && canViewAllOrgUsers && !!pageOrganizationId;
const pageOrgLabel = pageOrganizationName ?? 'Organization';

const groupByOptions: (Dimension | 'none')[] = useMemo(() => {
const opts: (Dimension | 'none')[] = [
Expand Down Expand Up @@ -194,15 +209,23 @@ export function UsageAnalyticsSidebar({
</Section>
)}

{showViewAsSelector && (
{showOrgScopeSelector && pageOrganizationId && (
<Section title="Scope">
<Select value={viewAs} onValueChange={v => onViewAsChange(v as ViewAs)}>
<Select value={orgScope} onValueChange={onOrgScopeChange}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="self">My Usage</SelectItem>
<SelectItem value="org-wide">{entireOrgLabel}</SelectItem>
<SelectItem value={ORG_SCOPE_SELF}>My Usage</SelectItem>
{isParentOrg && (
<SelectItem value={ORG_SCOPE_ALL_ORGS}>All Organizations</SelectItem>
)}
<SelectItem value={pageOrganizationId}>{pageOrgLabel}</SelectItem>
{childOrganizations.map(child => (
<SelectItem key={child.organizationId} value={child.organizationId}>
{child.organizationName}
</SelectItem>
))}
</SelectContent>
</Select>
</Section>
Expand Down Expand Up @@ -304,6 +327,7 @@ export function UsageAnalyticsSidebar({
<div className="flex flex-col gap-2">
<FilterGeneratorPopover
organizationId={organizationId}
organizationIds={organizationIds}
dateRange={dateRange}
personalScope={personalScope}
viewAs={viewAs}
Expand Down
23 changes: 20 additions & 3 deletions apps/web/src/components/usage-analytics/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ export type ViewAs = 'self' | 'org-wide';

type CommonArgs = {
organizationId: string | null;
/**
* Multi-org aggregate ("All Organizations"). When set and non-empty, the
* server aggregates org-wide usage across these orgs and ignores
* `organizationId` / `viewAs`.
*/
organizationIds?: string[] | null;
dateRange: DateRange;
granularity: Granularity;
costSource: CostSource;
Expand Down Expand Up @@ -148,12 +154,17 @@ function pickFiltersInput(filters: UsageFilters) {
}

function commonFilters(args: CommonArgs) {
const organizationIds =
args.organizationIds && args.organizationIds.length > 0 ? args.organizationIds : undefined;
return {
startDate: args.dateRange.startDate,
endDate: args.dateRange.endDate,
granularity: args.granularity,
costSource: args.costSource,
organizationId: args.organizationId ?? undefined,
// In multi-org aggregate mode the server keys off `organizationIds`; don't
// also send a single `organizationId` (they are mutually exclusive).
organizationId: organizationIds ? undefined : (args.organizationId ?? undefined),
organizationIds,
personalScope: args.personalScope ?? 'personal-only',
viewAs: args.viewAs ?? 'self',
...pickFiltersInput(args.filters),
Expand Down Expand Up @@ -224,16 +235,22 @@ export function useUsageTable(
});
}

export function useResolveOrgUsers(organizationId: string | null, userIds: string[]) {
export function useResolveOrgUsers(organizationIds: string[], userIds: string[]) {
const trpc = useTRPC();
const dedupedIds = useMemo(() => Array.from(new Set(userIds)).sort(), [userIds]);
const dedupedOrgIds = useMemo(
() => Array.from(new Set(organizationIds)).sort(),
[organizationIds]
);
// Pass the real input only when we have a legitimate org scope and a
// non-empty id list. Using `skipToken` (instead of a placeholder UUID +
// `enabled: false`) guarantees the server never receives a sentinel id
// even if future call sites drop the `enabled` gate.
return useQuery(
trpc.usageAnalytics.resolveOrgUsers.queryOptions(
organizationId && dedupedIds.length > 0 ? { organizationId, userIds: dedupedIds } : skipToken
dedupedOrgIds.length > 0 && dedupedIds.length > 0
? { organizationIds: dedupedOrgIds, userIds: dedupedIds }
: skipToken
)
);
}
32 changes: 21 additions & 11 deletions apps/web/src/components/usage-analytics/useUsageDashboardState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,20 @@ export type DashboardState = {
filters: UsageFilters;
groupBy: Dimension | 'none';
personalView: string;
viewAs: 'self' | 'org-wide';
/**
* Org-context scope selection. One of:
* - `'self'` — the caller's own usage in the page org (default)
* - `'all-orgs'` — org-wide aggregate across a parent org + its children
* - any other string — an organization id, viewed org-wide
* Only meaningful in the organization context; ignored in personal context.
*/
orgScope: string;
usageView: OrganizationUsageView;
};

export const ORG_SCOPE_SELF = 'self';
export const ORG_SCOPE_ALL_ORGS = 'all-orgs';

const VALID_PERIODS: PeriodOption[] = ['today', 'yesterday', '7d', '30d', '1y'];
const VALID_GRANULARITIES: Granularity[] = ['hour', 'day', 'week', 'month'];
const VALID_DIMENSIONS: Dimension[] = ['feature', 'model', 'mode', 'user', 'provider', 'project'];
Expand Down Expand Up @@ -199,8 +209,7 @@ export function useUsageDashboardState(defaultState?: Partial<DashboardState>):
const personalView =
params.get('personalView') ?? defaultState?.personalView ?? 'personal-only';

const viewAsRaw = params.get('viewAs');
const viewAs = viewAsRaw === 'org-wide' ? 'org-wide' : (defaultState?.viewAs ?? 'self');
const orgScope = params.get('scope') ?? defaultState?.orgScope ?? ORG_SCOPE_SELF;
Comment thread
RSO marked this conversation as resolved.

const usageViewRaw = params.get('view');
const usageView = VALID_USAGE_VIEWS.includes(usageViewRaw as OrganizationUsageView)
Expand All @@ -217,7 +226,7 @@ export function useUsageDashboardState(defaultState?: Partial<DashboardState>):
filters,
groupBy,
personalView,
viewAs,
orgScope,
usageView,
};
});
Expand Down Expand Up @@ -257,8 +266,7 @@ export function useUsageDashboardState(defaultState?: Partial<DashboardState>):
? (groupByRaw as Dimension | 'none')
: state.groupBy;
const personalView = params.get('personalView') ?? state.personalView;
const viewAsRaw = params.get('viewAs');
const viewAs = viewAsRaw === 'org-wide' ? 'org-wide' : state.viewAs;
const orgScope = params.get('scope') ?? state.orgScope;
const usageViewRaw = params.get('view');
const usageView = VALID_USAGE_VIEWS.includes(usageViewRaw as OrganizationUsageView)
? (usageViewRaw as OrganizationUsageView)
Expand All @@ -273,7 +281,7 @@ export function useUsageDashboardState(defaultState?: Partial<DashboardState>):
filters,
groupBy,
personalView,
viewAs,
orgScope,
usageView,
});

Expand All @@ -293,7 +301,7 @@ export function useUsageDashboardState(defaultState?: Partial<DashboardState>):
state.chartMetric,
state.groupBy,
state.personalView,
state.viewAs,
state.orgScope,
state.usageView,
]);

Expand Down Expand Up @@ -329,11 +337,13 @@ export function useUsageDashboardState(defaultState?: Partial<DashboardState>):
params.delete('personalView');
}

if (state.viewAs === 'org-wide') {
params.set('viewAs', 'org-wide');
if (state.orgScope && state.orgScope !== ORG_SCOPE_SELF) {
params.set('scope', state.orgScope);
} else {
params.delete('viewAs');
params.delete('scope');
}
// Drop the legacy org-wide param once migrated to `scope` (see dashboard).
params.delete('viewAs');

if (state.usageView === 'overview') {
params.delete('view');
Expand Down
Loading