Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cb28b11
feat(lambda): add AWS Lambda integration with 50 operations (#7216)
waleedlatif1 Aug 28, 2026
9f594c9
improvement(tools): retire direct execution (#7207)
icecrasher321 Aug 28, 2026
6818425
fix(pricing): correct free-tier credits as one-time, not monthly (#7218)
TheodoreSpeaks Aug 28, 2026
b094b4d
improvement(usage): add a period-labelled chart to the workspace dril…
icecrasher321 Aug 28, 2026
60cb6f5
feat(observability): record Redis connection state on failed slot ope…
waleedlatif1 Aug 28, 2026
eadf30a
fix(enterprise): verify invited owner on acceptance (#7221)
icecrasher321 Aug 28, 2026
03a76b8
fix(workflow): stop requiring a human subject on actorless runs (#7220)
TheodoreSpeaks Aug 28, 2026
0f3d5e5
fix(canvas): show full Agent prompt tooltips (#6961)
BillLeoutsakosvl346 Aug 28, 2026
cc5affa
feat(integrations): add Dynamics 365 CRM (#6714)
BillLeoutsakosvl346 Aug 28, 2026
2bda859
fix(logs): prevent dashboard horizontal scrolling (#7223)
waleedlatif1 Aug 28, 2026
59b3f37
improvement(ui): consolidate fade-only overflow (#7225)
waleedlatif1 Aug 28, 2026
836b87f
fix(knowledge): classify rejected BYOK embedding keys (#7226)
waleedlatif1 Aug 28, 2026
34a5300
feat(secrets): reveal visible values to members (#7231)
icecrasher321 Aug 28, 2026
8a31757
fix(execution): treat an undetermined lease as a fallback, not a deni…
waleedlatif1 Aug 28, 2026
1ade0f4
fix(guardrails): route PII validation through app runtime (#7227)
TheodoreSpeaks Aug 28, 2026
acc4f1d
fix(executor): preserve actors for actorless tool calls (#7230)
TheodoreSpeaks Aug 28, 2026
d90e5ed
improvement(ui): soften overflow fade (#7234)
waleedlatif1 Aug 28, 2026
4bb46be
chore(skills): name directExecution as retired in the add-integration…
waleedlatif1 Aug 28, 2026
e9c343d
fix(ui): prevent competing sidebar tooltips (#7235)
waleedlatif1 Aug 28, 2026
6ca5b52
fix(execution): release the lease when abort races an undetermined ac…
waleedlatif1 Aug 28, 2026
44a3e44
fix(dataverse): harden OAuth connection preflight (#7238)
waleedlatif1 Aug 28, 2026
128e9d2
chore(docs): refresh introduction screenshot (#7239)
waleedlatif1 Aug 28, 2026
60776ba
feat(credential-groups): add seventeen OAuth providers (#7201)
TheodoreSpeaks Aug 28, 2026
e4c0a9f
docs(library): update best-ai-agent-builder-2026 (#7242)
icecrasher321 Aug 29, 2026
4aa1c04
fix(lambda): close the remaining contract validation gaps (#7241)
waleedlatif1 Aug 29, 2026
a38492b
fix(integrations): validate runtime values the type system only claim…
waleedlatif1 Aug 29, 2026
d673548
feat(library): Best AI Agents for Slack (#7245)
icecrasher321 Aug 29, 2026
3077553
fix(secrets): preserve caret when revealing values (#7246)
waleedlatif1 Aug 29, 2026
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
Prev Previous commit
Next Next commit
improvement(usage): add a period-labelled chart to the workspace dril…
…l-down (#7219)

* 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.

* fix(usage): stop a retained summary from crossing workspace scopes

The summary key now carries a workspaceId, and keepPreviousData retains
across any key change — so moving between two drill-downs drew one
workspace's headline, delta, and chart under the other's name until the
fetch landed.

- Narrow placeholderData to a period change only, matching the breakdown's
  existing scoped predicate, and share the one key-identity helper
- Order the summary key so window is the trailing segment, making the
  scope a plain prefix as it already is on the breakdown
- Give UsageSummary the isPlaceholderData signal UsageConsumers already
  takes, so retained figures dim instead of reading as fresh ones
  • Loading branch information
icecrasher321 authored Aug 28, 2026
commit b094b4d559f91214c4d37db9a92644de126fbea2
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