Skip to content

Commit bbd5134

Browse files
committed
improvement(usage): add a period-labelled chart to the workspace drill-down
The Workspaces drill-down showed two ranked lists and no chart, and stated its window nowhere — the period picker lives on the list behind it, so the carried-over window was invisible once you were inside. - Draw the summary's headline, delta, and trend chart at the top of the drill-down, narrowed to that workspace - Label its section with the selected period, which is now the only place the drill-down states its window - Carry workspaceId through the summary contract, route, use case, query key, and hook so the chart reads one workspace - Move the workspace narrowing onto buildUsageAnalyticsScope, so the chart, the headline, and both lists derive it from one definition instead of the breakdown query owning a second copy The comparison window takes the same narrowing, or the delta would measure one workspace against the whole organization. No allowance figure is shown, unlike the Overview: the limit is pooled across the organization and would read as that workspace's own cap.
1 parent cb28b11 commit bbd5134

11 files changed

Lines changed: 144 additions & 17 deletions

File tree

apps/sim/app/api/organizations/[id]/usage/summary/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const GET = defineInternalJsonRoute({
2626
errorPolicy: organizationUsageErrorPolicy,
2727
mapInput: ({ params, query }) => ({
2828
organizationId: params.id,
29+
workspaceId: query.workspaceId,
2930
preset: query.preset,
3031
startDate: query.startDate ? new Date(query.startDate) : undefined,
3132
endDate: query.endDate ? new Date(query.endDate) : undefined,

apps/sim/ee/organization-usage/components/usage-monitoring.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,18 @@ export function UsageMonitoring({
183183
limit: rowLimitFor('source'),
184184
...(workspace ? { workspaceId: workspace } : {}),
185185
})
186+
/**
187+
* The same headline and trend the Overview draws, narrowed to this workspace.
188+
*
189+
* A second summary rather than a figure derived from the lists below it: they carry
190+
* totals but no time series, and the shape of the period is the question the chart
191+
* answers. It is also the only place the drill-down states its window, which is why
192+
* its section is labelled with the period rather than with the word "Usage".
193+
*/
194+
const workspaceSummary = useOrganizationUsageSummary(organizationId, window, {
195+
enabled: isWorkspaceDetail,
196+
...(workspace ? { workspaceId: workspace } : {}),
197+
})
186198
// Already cached by Members and Billing, so the meter costs nothing extra and
187199
// cannot report a different allowance than they do.
188200
const billing = useOrganizationBilling(organizationId)
@@ -321,6 +333,23 @@ export function UsageMonitoring({
321333
: []
322334
}
323335
>
336+
{/*
337+
Labelled with the period, not "Usage": the picker lives on the list behind
338+
this view, so once you are in here the window is carried but invisible — and
339+
a total with no stated period is a number people read as all-time. The
340+
heading the chart already needs is where that belongs.
341+
342+
No allowance passed, unlike the Overview: the limit is pooled across the
343+
whole organization, and printing it under one workspace's figure would read
344+
as that workspace's own cap.
345+
*/}
346+
<SettingsSection label={periodLabel}>
347+
<UsageSummary
348+
summary={workspaceSummary.data}
349+
isLoading={workspaceSummary.isLoading}
350+
isError={workspaceSummary.isError}
351+
/>
352+
</SettingsSection>
324353
{/*
325354
Sources first, because in most workspaces the majority of usage is Chat
326355
rather than workflow runs — and a workflow list alone hid that behind a

apps/sim/hooks/queries/organization-usage.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,28 @@ export const ORGANIZATION_USAGE_EVENTS_STALE_TIME = 30 * 1000
2626

2727
const EVENTS_PAGE_SIZE = 50
2828

29+
interface UseSummaryOptions {
30+
/** The panel fetches the drill-down's chart only while that view is open. */
31+
enabled?: boolean
32+
/** Narrows to one workspace, for the Workspaces drill-down. */
33+
workspaceId?: string
34+
}
35+
2936
export function useOrganizationUsageSummary(
3037
organizationId: string | undefined,
31-
window: OrganizationUsageWindowKey
38+
window: OrganizationUsageWindowKey,
39+
options: UseSummaryOptions = {}
3240
) {
41+
const { workspaceId } = options
3342
return useQuery({
34-
queryKey: organizationUsageKeys.summary(organizationId ?? '', window),
43+
queryKey: organizationUsageKeys.summary(organizationId ?? '', window, workspaceId),
3544
queryFn: ({ signal }): Promise<OrganizationUsageSummary> =>
3645
requestJson(getOrganizationUsageSummaryContract, {
3746
params: { id: organizationId as string },
38-
query: { ...window },
47+
query: { ...window, ...(workspaceId ? { workspaceId } : {}) },
3948
signal,
4049
}),
41-
enabled: Boolean(organizationId),
50+
enabled: Boolean(organizationId) && (options.enabled ?? true),
4251
staleTime: ORGANIZATION_USAGE_SUMMARY_STALE_TIME,
4352
// Changing the period should dim the current figures rather than blank them.
4453
placeholderData: keepPreviousData,

apps/sim/hooks/queries/utils/organization-usage-keys.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,13 @@ export interface OrganizationUsageWindowKey {
2323

2424
export const organizationUsageKeys = {
2525
all: (organizationId: string) => ['organizations', 'detail', organizationId, 'usage'] as const,
26-
summary: (organizationId: string, window: OrganizationUsageWindowKey) =>
27-
[...organizationUsageKeys.all(organizationId), 'summary', window] as const,
26+
summary: (
27+
organizationId: string,
28+
window: OrganizationUsageWindowKey,
29+
/** Set only inside the Workspaces drill-down, whose chart reads one workspace. */
30+
workspaceId?: string
31+
) =>
32+
[...organizationUsageKeys.all(organizationId), 'summary', window, workspaceId ?? ''] as const,
2833
breakdowns: (organizationId: string, window: OrganizationUsageWindowKey) =>
2934
[...organizationUsageKeys.all(organizationId), 'breakdown', window] as const,
3035
breakdown: (

apps/sim/lib/api/contracts/organization-usage.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,24 @@ const organizationUsageWindowQuerySchema = z.object({
126126
.default('UTC'),
127127
})
128128

129-
export const organizationUsageSummaryQuerySchema = organizationUsageWindowQuerySchema
129+
/**
130+
* Narrows a read to one workspace, for the Workspaces drill-down.
131+
*
132+
* Declared once and spread into both query schemas: the drill-down draws its chart
133+
* from the summary and its lists from the breakdown, so a workspace filter either
134+
* surface could express alone is one the two could disagree about.
135+
*/
136+
const usageWorkspaceScopeShape = {
137+
workspaceId: workspaceIdSchema.optional(),
138+
} as const
139+
140+
export const organizationUsageSummaryQuerySchema =
141+
organizationUsageWindowQuerySchema.extend(usageWorkspaceScopeShape)
130142
export type OrganizationUsageSummaryQuery = z.input<typeof organizationUsageSummaryQuerySchema>
131143

132144
export const organizationUsageBreakdownQuerySchema = organizationUsageWindowQuerySchema.extend({
145+
...usageWorkspaceScopeShape,
133146
dimension: usageBreakdownDimensionSchema,
134-
/** Narrows the breakdown to one workspace, for the Workspaces drill-down. */
135-
workspaceId: workspaceIdSchema.optional(),
136147
limit: usageLimitSchema(50, 10),
137148
})
138149
export type OrganizationUsageBreakdownQuery = z.input<typeof organizationUsageBreakdownQuerySchema>

apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ export const getOrganizationUsageBreakdown = defineAuthorizedOrganizationUsageUs
7878
customEnd: input.endDate,
7979
timezone: input.timezone,
8080
})
81-
const scope = buildUsageAnalyticsScope(context.billingEntity, window)
82-
const raw = await readUsageBreakdown(scope, input.dimension, input.workspaceId)
81+
const scope = buildUsageAnalyticsScope(context.billingEntity, window, input.workspaceId)
82+
const raw = await readUsageBreakdown(scope, input.dimension)
8383

8484
/**
8585
* Re-key onto what the panel actually displays before ranking.

apps/sim/lib/billing/application/organization-usage/get-organization-usage-summary.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export interface OrganizationUsageSummaryInput {
2020
startDate?: Date
2121
endDate?: Date
2222
timezone: string
23+
/** Narrows to one workspace, for the Workspaces drill-down. */
24+
workspaceId?: string
2325
}
2426

2527
export interface OrganizationUsageSummaryResult {
@@ -47,7 +49,7 @@ export const getOrganizationUsageSummary = defineAuthorizedOrganizationUsageUseC
4749
timezone: input.timezone,
4850
})
4951
const bucket = resolveUsageBucket(window)
50-
const scope = buildUsageAnalyticsScope(context.billingEntity, window)
52+
const scope = buildUsageAnalyticsScope(context.billingEntity, window, input.workspaceId)
5153

5254
/**
5355
* The comparison window only exists when it is exactly derivable.
@@ -68,7 +70,11 @@ export const getOrganizationUsageSummary = defineAuthorizedOrganizationUsageUseC
6870
readUsageTotals(scope),
6971
readUsageTimeSeries(scope, bucket, input.timezone),
7072
comparison
71-
? readUsageTotals(buildUsageAnalyticsScope(context.billingEntity, comparison))
73+
? readUsageTotals(
74+
// Same narrowing as the current window, or the delta would compare one
75+
// workspace against the whole organization.
76+
buildUsageAnalyticsScope(context.billingEntity, comparison, input.workspaceId)
77+
)
7278
: Promise.resolve(null),
7379
])
7480

apps/sim/lib/billing/application/organization-usage/organization-usage-use-cases.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,30 @@ describe('organization usage authorization', () => {
125125
expect(JSON.stringify(totalsScope)).toContain(ORG)
126126
expect(JSON.stringify(seriesScope)).toBe(JSON.stringify(totalsScope))
127127
})
128+
129+
it('narrows the drill-down’s chart and its comparison window to the same workspace', async () => {
130+
/*
131+
A reporting period, so the delta's read actually happens: `resolvePreviousPeriod`
132+
returns null for a stripe period, and against the default subscription above this
133+
test would assert the narrowing of a query that was never issued.
134+
*/
135+
mocks.getOrganizationSubscription.mockResolvedValue({
136+
plan: 'enterprise',
137+
metadata: { reportingPeriodAnchorDate: '2026-01-01', reportingPeriodInterval: 'month' },
138+
})
139+
140+
await getOrganizationUsageSummary.execute({
141+
principal: session,
142+
input: { ...input, workspaceId: 'ws-1' },
143+
})
144+
145+
// The current window and the previous one, both narrowed. Narrowing only the
146+
// current window measures one workspace against the whole organization and
147+
// renders the difference as that workspace's own trend.
148+
expect(mocks.readUsageTotals).toHaveBeenCalledTimes(2)
149+
for (const [scope] of mocks.readUsageTotals.mock.calls) {
150+
expect(JSON.stringify(scope)).toContain('ws-1')
151+
}
152+
expect(JSON.stringify(mocks.readUsageTimeSeries.mock.calls[0][0])).toContain('ws-1')
153+
})
128154
})

apps/sim/lib/billing/core/usage-analytics-queries.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,10 @@ function breakdownColumn(dimension: UsageBreakdownDimension) {
120120
export async function readUsageBreakdown(
121121
scope: SQL[],
122122
dimension: UsageBreakdownDimension,
123-
/** Narrows to one workspace, for the Workspaces drill-down. */
124-
workspaceId: string | undefined,
125123
executor: DbClient = dbReplica
126124
): Promise<UsageBreakdownRow[]> {
127125
const column = breakdownColumn(dimension)
128126
const conditions = [...scope]
129-
if (workspaceId) conditions.push(eq(usageLog.workspaceId, workspaceId))
130127
/**
131128
* `description` holds a model name only for the model categories; a tool or fixed
132129
* row would otherwise appear as a phantom "model". The two model dimensions split

apps/sim/lib/billing/core/usage-analytics.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,33 @@ describe('buildUsageAnalyticsScope', () => {
6868
expect(shape).toContain('usageLog.billingEntityType')
6969
expect(shape).toContain('usageLog.billingEntityId')
7070
})
71+
72+
it('narrows to a workspace in every window shape', () => {
73+
// Each branch returns its own array, so a narrowing added to only one of them is a
74+
// drill-down that quietly reports the whole organization under the other two.
75+
const windows = [
76+
{ kind: 'period', period: period({ source: 'stripe' }) },
77+
{
78+
kind: 'period',
79+
period: period({ source: 'reporting', anchorDate: '2026-08-01', interval: 'month' }),
80+
},
81+
{
82+
kind: 'range',
83+
from: new Date('2026-08-01T00:00:00.000Z'),
84+
to: new Date('2026-08-08T00:00:00.000Z'),
85+
},
86+
] as const
87+
88+
for (const window of windows) {
89+
expect(JSON.stringify(buildUsageAnalyticsScope(ENTITY, window, 'ws-1'))).toContain(
90+
'usageLog.workspaceId'
91+
)
92+
}
93+
})
94+
95+
it('leaves the scope organization-wide when no workspace is given', () => {
96+
expect(scopeShape({ kind: 'period', period: period() })).not.toContain('usageLog.workspaceId')
97+
})
7198
})
7299

73100
describe('usageWindowLedgerFilter', () => {

0 commit comments

Comments
 (0)