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
1 change: 1 addition & 0 deletions apps/sim/app/api/organizations/[id]/usage/summary/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const GET = defineInternalJsonRoute({
errorPolicy: organizationUsageErrorPolicy,
mapInput: ({ params, query }) => ({
organizationId: params.id,
workspaceId: query.workspaceId,
preset: query.preset,
startDate: query.startDate ? new Date(query.startDate) : undefined,
endDate: query.endDate ? new Date(query.endDate) : undefined,
Expand Down
31 changes: 31 additions & 0 deletions apps/sim/ee/organization-usage/components/usage-monitoring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,18 @@ export function UsageMonitoring({
limit: rowLimitFor('source'),
...(workspace ? { workspaceId: workspace } : {}),
})
/**
* The same headline and trend the Overview draws, narrowed to this workspace.
*
* A second summary rather than a figure derived from the lists below it: they carry
* totals but no time series, and the shape of the period is the question the chart
* answers. It is also the only place the drill-down states its window, which is why
* its section is labelled with the period rather than with the word "Usage".
*/
const workspaceSummary = useOrganizationUsageSummary(organizationId, window, {
enabled: isWorkspaceDetail,
...(workspace ? { workspaceId: workspace } : {}),
})
// Already cached by Members and Billing, so the meter costs nothing extra and
// cannot report a different allowance than they do.
const billing = useOrganizationBilling(organizationId)
Expand Down Expand Up @@ -321,6 +333,24 @@ export function UsageMonitoring({
: []
}
>
{/*
Labelled with the period, not "Usage": the picker lives on the list behind
this view, so once you are in here the window is carried but invisible — and
a total with no stated period is a number people read as all-time. The
heading the chart already needs is where that belongs.

No allowance passed, unlike the Overview: the limit is pooled across the
whole organization, and printing it under one workspace's figure would read
as that workspace's own cap.
*/}
<SettingsSection label={periodLabel}>
<UsageSummary
summary={workspaceSummary.data}
isLoading={workspaceSummary.isLoading}
isError={workspaceSummary.isError}
isPlaceholderData={workspaceSummary.isPlaceholderData}
/>
</SettingsSection>
{/*
Sources first, because in most workspaces the majority of usage is Chat
rather than workflow runs — and a workflow list alone hid that behind a
Expand Down Expand Up @@ -452,6 +482,7 @@ export function UsageMonitoring({
}
isLoading={summary.isLoading}
isError={summary.isError}
isPlaceholderData={summary.isPlaceholderData}
/>
</SettingsSection>
{/*
Expand Down
20 changes: 17 additions & 3 deletions apps/sim/ee/organization-usage/components/usage-summary.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { useMemo } from 'react'
import { Badge } from '@sim/emcn'
import { Badge, cn } from '@sim/emcn'
import { BarChart } from '@/components/charts'
import type { OrganizationUsageSummary } from '@/lib/api/contracts/organization-usage'
import { formatCreditsLabel } from '@/lib/billing/credits/conversion'
Expand All @@ -16,14 +16,26 @@ interface UsageSummaryProps {
limitCredits?: number | null
isLoading: boolean
isError: boolean
/**
* Dims the figures while a re-keyed fetch resolves, rather than blanking them — the
* same treatment `UsageConsumers` gives a retained list. Without it the headline and
* chart present the previous period's numbers as though they were the new period's.
*/
isPlaceholderData?: boolean
}

function percentDelta(current: number, previous: number): number | null {
if (previous <= 0) return null
return ((current - previous) / previous) * 100
}

export function UsageSummary({ summary, limitCredits, isLoading, isError }: UsageSummaryProps) {
export function UsageSummary({
summary,
limitCredits,
isLoading,
isError,
isPlaceholderData,
}: UsageSummaryProps) {
/*
Stabilized so `BarChart`'s `memo()` can actually pass. Built inline it was a new
array on every render of the panel — a date-picker toggle or an export click
Expand Down Expand Up @@ -52,7 +64,9 @@ export function UsageSummary({ summary, limitCredits, isLoading, isError }: Usag
const isOverLimit = hasLimit && used > limitCredits

return (
<div className='flex flex-col gap-3'>
<div
className={cn('flex flex-col gap-3', isPlaceholderData && 'opacity-50 transition-opacity')}
>
{/*
One line, and the allowance sits beside the figure rather than under it —
restating "4,958 credits used" below a "4,958 credits" headline said the same
Expand Down
59 changes: 44 additions & 15 deletions apps/sim/hooks/queries/organization-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,59 @@ export const ORGANIZATION_USAGE_EVENTS_STALE_TIME = 30 * 1000

const EVENTS_PAGE_SIZE = 50

/**
* A usage key with its trailing segment dropped — the identity of the question being
* asked, which is what `placeholderData` has to compare on.
*
* Both keys put the one segment their placeholder may legitimately cross last: the
* summary's window ("the same scope, a different period") and the breakdown's row limit
* ("the same list, more rows"). Everything a retained answer must never cross —
* organization, workspace, dimension — sits in the prefix.
*/
function usageKeyIdentity(key: readonly unknown[]): string {
return hashKey(key.slice(0, -1))
}

interface UseSummaryOptions {
/** The panel fetches the drill-down's chart only while that view is open. */
enabled?: boolean
/** Narrows to one workspace, for the Workspaces drill-down. */
workspaceId?: string
}

export function useOrganizationUsageSummary(
organizationId: string | undefined,
window: OrganizationUsageWindowKey
window: OrganizationUsageWindowKey,
options: UseSummaryOptions = {}
) {
const { workspaceId } = options
const queryKey = organizationUsageKeys.summary(organizationId ?? '', window, workspaceId)
return useQuery({
queryKey: organizationUsageKeys.summary(organizationId ?? '', window),
queryKey,
queryFn: ({ signal }): Promise<OrganizationUsageSummary> =>
requestJson(getOrganizationUsageSummaryContract, {
params: { id: organizationId as string },
query: { ...window },
query: { ...window, ...(workspaceId ? { workspaceId } : {}) },
signal,
}),
enabled: Boolean(organizationId),
enabled: Boolean(organizationId) && (options.enabled ?? true),
staleTime: ORGANIZATION_USAGE_SUMMARY_STALE_TIME,
// Changing the period should dim the current figures rather than blank them.
placeholderData: keepPreviousData,
/**
* Kept only across a period change — the same scope asked about a different window,
* where dimming the figures beats blanking them.
*
* Not `keepPreviousData`, which retains across *any* key change: once the key
* carries a workspace, moving between two drill-downs would draw one workspace's
* headline, delta, and chart under the other's name until the fetch landed. A
* figure attributed to the wrong workspace is worse than a brief skeleton, and
* unlike a ranked list it carries nothing that would look out of place.
*/
placeholderData: (previous, previousQuery) =>
previous &&
previousQuery &&
usageKeyIdentity(previousQuery.queryKey) === usageKeyIdentity(queryKey)
? previous
: undefined,
})
}

Expand All @@ -53,14 +90,6 @@ interface UseBreakdownOptions {
workspaceId?: string
}

/**
* A breakdown key with its trailing row limit removed — the identity of the list,
* which is what "the same list, more rows" has to compare on.
*/
function breakdownListIdentity(key: readonly unknown[]): string {
return hashKey(key.slice(0, -1))
}

export function useOrganizationUsageBreakdown(
organizationId: string | undefined,
window: OrganizationUsageWindowKey,
Expand Down Expand Up @@ -101,7 +130,7 @@ export function useOrganizationUsageBreakdown(
placeholderData: (previous, previousQuery) =>
previous &&
previousQuery &&
breakdownListIdentity(previousQuery.queryKey) === breakdownListIdentity(queryKey)
usageKeyIdentity(previousQuery.queryKey) === usageKeyIdentity(queryKey)
? previous
: undefined,
})
Expand Down
21 changes: 19 additions & 2 deletions apps/sim/hooks/queries/utils/organization-usage-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,25 @@ export interface OrganizationUsageWindowKey {

export const organizationUsageKeys = {
all: (organizationId: string) => ['organizations', 'detail', organizationId, 'usage'] as const,
summary: (organizationId: string, window: OrganizationUsageWindowKey) =>
[...organizationUsageKeys.all(organizationId), 'summary', window] as const,
summary: (
organizationId: string,
window: OrganizationUsageWindowKey,
/** Set only inside the Workspaces drill-down, whose chart reads one workspace. */
workspaceId?: string
) =>
[
...organizationUsageKeys.all(organizationId),
'summary',
workspaceId ?? '',
/*
Last deliberately, as on `breakdown`: it is the one segment the summary's
`placeholderData` may cross, so the scope's identity is a plain prefix rather
than an index-based filter that would silently drop `workspaceId` if a segment
were ever appended. Ordering it the other way is what let a retained summary
cross workspaces.
*/
window,
] as const,
breakdowns: (organizationId: string, window: OrganizationUsageWindowKey) =>
[...organizationUsageKeys.all(organizationId), 'breakdown', window] as const,
breakdown: (
Expand Down
17 changes: 14 additions & 3 deletions apps/sim/lib/api/contracts/organization-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,24 @@ const organizationUsageWindowQuerySchema = z.object({
.default('UTC'),
})

export const organizationUsageSummaryQuerySchema = organizationUsageWindowQuerySchema
/**
* Narrows a read to one workspace, for the Workspaces drill-down.
*
* Declared once and spread into both query schemas: the drill-down draws its chart
* from the summary and its lists from the breakdown, so a workspace filter either
* surface could express alone is one the two could disagree about.
*/
const usageWorkspaceScopeShape = {
workspaceId: workspaceIdSchema.optional(),
} as const

export const organizationUsageSummaryQuerySchema =
organizationUsageWindowQuerySchema.extend(usageWorkspaceScopeShape)
export type OrganizationUsageSummaryQuery = z.input<typeof organizationUsageSummaryQuerySchema>

export const organizationUsageBreakdownQuerySchema = organizationUsageWindowQuerySchema.extend({
...usageWorkspaceScopeShape,
dimension: usageBreakdownDimensionSchema,
/** Narrows the breakdown to one workspace, for the Workspaces drill-down. */
workspaceId: workspaceIdSchema.optional(),
limit: usageLimitSchema(50, 10),
})
export type OrganizationUsageBreakdownQuery = z.input<typeof organizationUsageBreakdownQuerySchema>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ export const getOrganizationUsageBreakdown = defineAuthorizedOrganizationUsageUs
customEnd: input.endDate,
timezone: input.timezone,
})
const scope = buildUsageAnalyticsScope(context.billingEntity, window)
const raw = await readUsageBreakdown(scope, input.dimension, input.workspaceId)
const scope = buildUsageAnalyticsScope(context.billingEntity, window, input.workspaceId)
const raw = await readUsageBreakdown(scope, input.dimension)

/**
* Re-key onto what the panel actually displays before ranking.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface OrganizationUsageSummaryInput {
startDate?: Date
endDate?: Date
timezone: string
/** Narrows to one workspace, for the Workspaces drill-down. */
workspaceId?: string
}

export interface OrganizationUsageSummaryResult {
Expand Down Expand Up @@ -47,7 +49,7 @@ export const getOrganizationUsageSummary = defineAuthorizedOrganizationUsageUseC
timezone: input.timezone,
})
const bucket = resolveUsageBucket(window)
const scope = buildUsageAnalyticsScope(context.billingEntity, window)
const scope = buildUsageAnalyticsScope(context.billingEntity, window, input.workspaceId)

/**
* The comparison window only exists when it is exactly derivable.
Expand All @@ -68,7 +70,11 @@ export const getOrganizationUsageSummary = defineAuthorizedOrganizationUsageUseC
readUsageTotals(scope),
readUsageTimeSeries(scope, bucket, input.timezone),
comparison
? readUsageTotals(buildUsageAnalyticsScope(context.billingEntity, comparison))
? readUsageTotals(
// Same narrowing as the current window, or the delta would compare one
// workspace against the whole organization.
buildUsageAnalyticsScope(context.billingEntity, comparison, input.workspaceId)
)
: Promise.resolve(null),
])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,30 @@ describe('organization usage authorization', () => {
expect(JSON.stringify(totalsScope)).toContain(ORG)
expect(JSON.stringify(seriesScope)).toBe(JSON.stringify(totalsScope))
})

it('narrows the drill-down’s chart and its comparison window to the same workspace', async () => {
/*
A reporting period, so the delta's read actually happens: `resolvePreviousPeriod`
returns null for a stripe period, and against the default subscription above this
test would assert the narrowing of a query that was never issued.
*/
mocks.getOrganizationSubscription.mockResolvedValue({
plan: 'enterprise',
metadata: { reportingPeriodAnchorDate: '2026-01-01', reportingPeriodInterval: 'month' },
})

await getOrganizationUsageSummary.execute({
principal: session,
input: { ...input, workspaceId: 'ws-1' },
})

// The current window and the previous one, both narrowed. Narrowing only the
// current window measures one workspace against the whole organization and
// renders the difference as that workspace's own trend.
expect(mocks.readUsageTotals).toHaveBeenCalledTimes(2)
for (const [scope] of mocks.readUsageTotals.mock.calls) {
expect(JSON.stringify(scope)).toContain('ws-1')
}
expect(JSON.stringify(mocks.readUsageTimeSeries.mock.calls[0][0])).toContain('ws-1')
})
})
3 changes: 0 additions & 3 deletions apps/sim/lib/billing/core/usage-analytics-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,10 @@ function breakdownColumn(dimension: UsageBreakdownDimension) {
export async function readUsageBreakdown(
scope: SQL[],
dimension: UsageBreakdownDimension,
/** Narrows to one workspace, for the Workspaces drill-down. */
workspaceId: string | undefined,
executor: DbClient = dbReplica
): Promise<UsageBreakdownRow[]> {
const column = breakdownColumn(dimension)
const conditions = [...scope]
if (workspaceId) conditions.push(eq(usageLog.workspaceId, workspaceId))
/**
* `description` holds a model name only for the model categories; a tool or fixed
* row would otherwise appear as a phantom "model". The two model dimensions split
Expand Down
27 changes: 27 additions & 0 deletions apps/sim/lib/billing/core/usage-analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,33 @@ describe('buildUsageAnalyticsScope', () => {
expect(shape).toContain('usageLog.billingEntityType')
expect(shape).toContain('usageLog.billingEntityId')
})

it('narrows to a workspace in every window shape', () => {
// Each branch returns its own array, so a narrowing added to only one of them is a
// drill-down that quietly reports the whole organization under the other two.
const windows = [
{ kind: 'period', period: period({ source: 'stripe' }) },
{
kind: 'period',
period: period({ source: 'reporting', anchorDate: '2026-08-01', interval: 'month' }),
},
{
kind: 'range',
from: new Date('2026-08-01T00:00:00.000Z'),
to: new Date('2026-08-08T00:00:00.000Z'),
},
] as const

for (const window of windows) {
expect(JSON.stringify(buildUsageAnalyticsScope(ENTITY, window, 'ws-1'))).toContain(
'usageLog.workspaceId'
)
}
})

it('leaves the scope organization-wide when no workspace is given', () => {
expect(scopeShape({ kind: 'period', period: period() })).not.toContain('usageLog.workspaceId')
})
})

describe('usageWindowLedgerFilter', () => {
Expand Down
Loading
Loading