Skip to content

Commit 0078d27

Browse files
committed
improvement(insights): serve org usage from settled segments and redesign the overview
1 parent a4a903c commit 0078d27

42 files changed

Lines changed: 2689 additions & 757 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,19 @@
1-
import { getOrganizationUsageSummaryContract } from '@/lib/api/contracts/organization-usage'
1+
import { getOrganizationUsageOverviewContract } from '@/lib/api/contracts/organization-usage'
22
import {
33
defineInternalJsonRoute,
44
internalRateLimits,
55
internalSessionAuth,
66
} from '@/lib/api/server/routes'
7-
import { getOrganizationUsageSummary } from '@/lib/billing/application/organization-usage/get-organization-usage-summary'
7+
import { getOrganizationUsageOverview } from '@/lib/billing/application/organization-usage/get-organization-usage-overview'
88
import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations'
99
import { organizationUsageErrorPolicy } from '@/app/api/organizations/[id]/usage/error-policy'
1010

1111
export const dynamic = 'force-dynamic'
1212

13-
/**
14-
* Everything above the fold in one round trip. Kept separate from the breakdown
15-
* route because every read here is index-covered, and folding in a dimension that
16-
* heap-scans would put that cost on first paint.
17-
*/
1813
export const GET = defineInternalJsonRoute({
19-
contract: getOrganizationUsageSummaryContract,
14+
contract: getOrganizationUsageOverviewContract,
2015
auth: internalSessionAuth,
21-
operation: organizationUsageOperations.readSummary,
16+
operation: organizationUsageOperations.readOverview,
2217
rateLimit: internalRateLimits.none({
2318
reason:
2419
'Authenticated org-admin settings read, gated on enterprise entitlement and billing authority',
@@ -32,6 +27,6 @@ export const GET = defineInternalJsonRoute({
3227
endDate: query.endDate ? new Date(query.endDate) : undefined,
3328
timezone: query.timezone,
3429
}),
35-
useCase: getOrganizationUsageSummary,
30+
useCase: getOrganizationUsageOverview,
3631
present: (result) => result,
3732
})

‎apps/sim/app/api/v2/organizations/[organizationId]/usage/route.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ vi.mock('@/lib/billing/organizations/member-limits', () => ({
5757
vi.mock('@/lib/billing/core/usage-analytics-queries', () => ({
5858
readUsageTotals: mocks.totals,
5959
readUsageTimeSeries: mocks.series,
60-
readUsageBreakdown: mocks.breakdown,
60+
readUsageGroups: mocks.breakdown,
6161
readUsageEntityNames: vi.fn().mockResolvedValue(new Map()),
6262
}))
6363
vi.mock('@/lib/billing/core/usage-log', () => ({ getBillingEntityUsageLogs: mocks.logs }))
@@ -431,7 +431,9 @@ describe('organization usage API authorization and bounds', () => {
431431
)
432432
const response = await breakdown(request('usage/breakdown?dimension=member'), usageContext)
433433
expect(response.status).toBe(413)
434-
expect(mocks.breakdown).toHaveBeenCalledWith(expect.any(Array), 'member', undefined, 10_000)
434+
expect(mocks.breakdown).toHaveBeenCalledWith(
435+
expect.objectContaining({ dimension: 'member', maxRows: 10_000 })
436+
)
435437
})
436438
})
437439

‎apps/sim/ee/organization-usage/components/activity-summary.tsx‎

Lines changed: 68 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,45 @@
11
'use client'
22

33
import { useMemo } from 'react'
4-
import { BarChart, ChartFrame, DashboardMetric, DonutChart, formatChartLatency } from '@sim/emcn'
4+
import {
5+
BarChart,
6+
type BarChartSeries,
7+
ChartFrame,
8+
ChartLegend,
9+
type ChartLegendItem,
10+
DashboardMetric,
11+
formatChartLatency,
12+
} from '@sim/emcn'
513
import type { OrganizationActivitySummary } from '@/lib/api/contracts/organization-activity'
614
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
15+
import { USAGE_CHAT_COLOR } from '@/ee/organization-usage/constants'
16+
import { useLegendHighlight } from '@/ee/organization-usage/hooks/use-legend-highlight'
717
import { useOrganizationActivitySummary } from '@/hooks/queries/organization-activity'
818
import type { OrganizationUsageWindowKey } from '@/hooks/queries/utils/organization-usage-keys'
919

20+
const CHART_HEIGHT = 180
21+
22+
/**
23+
* Outcome layers, bottom-up. Failed is the status red and sits on the stack where a
24+
* spike reads at a glance; Other (cancelled, paused, unfinished) stays neutral.
25+
*/
26+
const OUTCOMES = [
27+
{ id: 'completed', label: 'Completed', color: 'var(--brand-blue)' },
28+
{ id: 'failed', label: 'Failed', color: 'var(--text-error)' },
29+
{ id: 'other', label: 'Other', color: 'var(--text-muted)' },
30+
] as const
31+
32+
const OUTCOME_LEGEND: ChartLegendItem[] = [...OUTCOMES]
33+
const OUTCOME_IDS = OUTCOMES.map((outcome) => outcome.id)
34+
35+
type ActivityPoint = OrganizationActivitySummary['series'][number]
36+
37+
const OUTCOME_VALUE: Record<(typeof OUTCOMES)[number]['id'], (point: ActivityPoint) => number> = {
38+
completed: (point) => point.completed,
39+
failed: (point) => point.failed,
40+
other: (point) => Math.max(0, point.workflowRuns - point.completed - point.failed),
41+
}
42+
1043
interface ActivitySummaryProps {
1144
summary?: OrganizationActivitySummary
1245
loading?: boolean
@@ -24,30 +57,26 @@ export function formatFailureRate(rate: number | null): string {
2457
}
2558

2659
export function ActivitySummary({ summary, loading, error, onRetry }: ActivitySummaryProps) {
27-
const workflowSeries = useMemo(
60+
const highlight = useLegendHighlight(OUTCOME_IDS)
61+
62+
const outcomeSeries = useMemo<BarChartSeries[]>(
2863
() =>
29-
summary?.series.map((point) => ({
30-
timestamp: point.timestamp,
31-
value: point.workflowRuns,
32-
})) ?? [],
64+
OUTCOMES.map((outcome) => ({
65+
...outcome,
66+
data: (summary?.series ?? []).map((point) => ({
67+
timestamp: point.timestamp,
68+
value: OUTCOME_VALUE[outcome.id](point),
69+
})),
70+
})),
3371
[summary?.series]
3472
)
73+
3574
const chatSeries = useMemo(
3675
() =>
37-
summary?.series.map((point) => ({
38-
timestamp: point.timestamp,
39-
value: point.chatRuns,
40-
})) ?? [],
41-
[summary?.series]
42-
)
43-
const failureSeries = useMemo(
44-
() =>
45-
summary?.series.map((point) => ({
46-
timestamp: point.timestamp,
47-
value: point.failed,
48-
})) ?? [],
76+
summary?.series.map((point) => ({ timestamp: point.timestamp, value: point.chatRuns })) ?? [],
4977
[summary?.series]
5078
)
79+
5180
const totals = summary?.totals
5281
const metrics = [
5382
{
@@ -81,16 +110,8 @@ export function ActivitySummary({ summary, loading, error, onRetry }: ActivitySu
81110
description: 'Completed and failed workflows with a recorded duration.',
82111
},
83112
]
84-
const outcomes = [
85-
{ label: 'Completed', value: totals?.completed ?? 0, color: 'var(--indicator-seat-filled)' },
86-
{ label: 'Failed', value: totals?.failed ?? 0, color: 'var(--text-error)' },
87-
{
88-
label: 'Other',
89-
value: totals ? totals.workflowRuns - totals.completed - totals.failed : 0,
90-
color: 'var(--text-muted)',
91-
},
92-
]
93113
const chartState = { loading, error: error ? "Couldn't load activity." : undefined, onRetry }
114+
94115
return (
95116
<div className='flex flex-col gap-5'>
96117
<div className='grid grid-cols-[repeat(auto-fit,minmax(min(120px,100%),1fr))] gap-4'>
@@ -104,41 +125,32 @@ export function ActivitySummary({ summary, loading, error, onRetry }: ActivitySu
104125
))}
105126
</div>
106127
<div className='grid grid-cols-[repeat(auto-fit,minmax(min(280px,100%),1fr))] gap-6'>
107-
<ChartFrame title='Workflow runs' height={160} {...chartState}>
128+
<div className='flex min-w-0 flex-col gap-2'>
129+
<ChartFrame
130+
title='Workflow runs'
131+
description='Other includes cancelled, paused, and unfinished runs.'
132+
height={CHART_HEIGHT}
133+
{...chartState}
134+
>
135+
<BarChart
136+
label=''
137+
xAxisFormat='date'
138+
height={CHART_HEIGHT}
139+
series={outcomeSeries}
140+
highlightedSeriesId={highlight.highlightedId}
141+
/>
142+
</ChartFrame>
143+
<ChartLegend layout='row' items={OUTCOME_LEGEND} {...highlight.legendProps} />
144+
</div>
145+
<ChartFrame title='Chat runs' height={CHART_HEIGHT} {...chartState}>
108146
<BarChart
109-
xAxisFormat='date'
110-
data={workflowSeries}
111147
label=''
112-
color='var(--indicator-seat-filled)'
113-
height={160}
114-
/>
115-
</ChartFrame>
116-
<ChartFrame title='Chat runs' height={160} {...chartState}>
117-
<BarChart
118148
xAxisFormat='date'
149+
height={CHART_HEIGHT}
119150
data={chatSeries}
120-
label=''
121-
color='var(--indicator-seat-filled)'
122-
height={160}
151+
color={USAGE_CHAT_COLOR}
123152
/>
124153
</ChartFrame>
125-
<ChartFrame title='Failed runs' height={160} {...chartState}>
126-
<BarChart
127-
xAxisFormat='date'
128-
data={failureSeries}
129-
label=''
130-
color='var(--text-error)'
131-
height={160}
132-
/>
133-
</ChartFrame>
134-
<ChartFrame
135-
title='Workflow outcomes'
136-
description='Other includes cancelled, paused, and unfinished runs.'
137-
height={160}
138-
{...chartState}
139-
>
140-
<DonutChart segments={outcomes} label='Workflow outcomes' />
141-
</ChartFrame>
142154
</div>
143155
</div>
144156
)

‎apps/sim/ee/organization-usage/components/usage-consumers.tsx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import type { ComponentType } from 'react'
3+
import type { ComponentType, ReactNode } from 'react'
44
import { cn, disclosureChevronClass, formatChartCompactNumber } from '@sim/emcn'
55
import { ArrowRight, ChevronDown } from '@sim/emcn/icons'
66
import {
@@ -89,6 +89,8 @@ export const USAGE_PROVIDER_ICON_IDS = Object.keys(PROVIDER_ICONS)
8989

9090
interface UsageConsumerRowProps {
9191
row: OrganizationUsageBreakdownRow
92+
/** Replaces the provider mark, e.g. with a member's avatar. */
93+
leading?: ReactNode
9294
/** BYOK rows carry no cost, so tokens are the only usage they can show. */
9395
showTokensOnly: boolean
9496
onSelect?: (row: OrganizationUsageBreakdownRow) => void
@@ -126,6 +128,7 @@ export const USAGE_ROW_CLASSES = 'flex w-full items-center gap-2.5 rounded-lg p-
126128
*/
127129
function UsageConsumerRow({
128130
row,
131+
leading,
129132
showTokensOnly,
130133
onSelect,
131134
actions,
@@ -148,14 +151,15 @@ function UsageConsumerRow({
148151
onSelect && 'transition-colors hover-hover:bg-[var(--surface-active)]'
149152
)}
150153
>
151-
{ProviderIcon && <ProviderIcon className='size-[14px] shrink-0 text-[var(--text-icon)]' />}
154+
{leading ??
155+
(ProviderIcon && <ProviderIcon className='size-[14px] shrink-0 text-[var(--text-icon)]' />)}
152156
<span className='min-w-0 flex-1 truncate text-[var(--text-body)] text-sm'>{row.label}</span>
153157
<div
154158
className='h-[4px] w-[64px] shrink-0 overflow-hidden rounded-full bg-[var(--border)]'
155159
aria-hidden='true'
156160
>
157161
<div
158-
className='h-full rounded-full bg-[var(--indicator-seat-filled)]'
162+
className='h-full rounded-full bg-[var(--brand-blue)]'
159163
style={{ width: `${Math.max(2, Math.round(row.share * 100))}%` }}
160164
/>
161165
</div>
@@ -185,6 +189,8 @@ interface UsageConsumersProps {
185189
onSelectRow?: (row: OrganizationUsageBreakdownRow) => void
186190
/** Set on Members, where a row can open the shared manage-credits modal. */
187191
rowActions?: (row: OrganizationUsageBreakdownRow) => RowAction[]
192+
/** Leading visual per row, in place of the provider mark. */
193+
renderLeading?: (row: OrganizationUsageBreakdownRow) => ReactNode
188194
/**
189195
* Opens the truncated tail. Omitted when the list is already showing everything the
190196
* API will return, which is the one case where the `Other` row has nothing to open.
@@ -200,6 +206,7 @@ export function UsageConsumers({
200206
isPlaceholderData,
201207
onSelectRow,
202208
rowActions,
209+
renderLeading,
203210
onExpandOther,
204211
}: UsageConsumersProps) {
205212
if (isError) {
@@ -238,6 +245,7 @@ export function UsageConsumers({
238245
<UsageConsumerRow
239246
key={`${dimension}-${row.id}`}
240247
row={row}
248+
leading={renderLeading?.(row)}
241249
showTokensOnly={showTokensOnly}
242250
{...(onExpandOther && trailingSlot ? { reservedTrailing: trailingSlot } : {})}
243251
{...(onSelectRow && row.id ? { onSelect: onSelectRow } : {})}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/** @vitest-environment node */
2+
import { renderToStaticMarkup } from 'react-dom/server'
3+
import { describe, expect, it } from 'vitest'
4+
import type { OrganizationUsageOverview } from '@/lib/api/contracts/organization-usage'
5+
import { UsageCredits } from '@/ee/organization-usage/components/usage-credits'
6+
7+
const overview: OrganizationUsageOverview = {
8+
window: { start: '2026-01-01', end: '2026-01-08', source: 'range' },
9+
bucket: 'day',
10+
totals: { credits: 200 },
11+
previousTotals: { credits: 100 },
12+
limitCredits: 150,
13+
series: [],
14+
members: {
15+
dimension: 'member',
16+
rows: [],
17+
other: { credits: 0, events: 0, rowCount: 0, tokens: 0 },
18+
totalCredits: 200,
19+
},
20+
}
21+
22+
describe('UsageCredits', () => {
23+
it('hides stale usage badges when a refresh fails', () => {
24+
const render = (isError: boolean) =>
25+
renderToStaticMarkup(<UsageCredits overview={overview} isLoading={false} isError={isError} />)
26+
const ok = render(false)
27+
expect(ok).toContain('Over limit')
28+
expect(ok).toContain('compared with the previous period')
29+
expect(ok).toContain('133% of 150')
30+
const failed = render(true)
31+
expect(failed).not.toContain('Over limit')
32+
expect(failed).not.toContain('compared with the previous period')
33+
expect(failed).not.toContain('of 150')
34+
expect(failed).toContain('load credits.')
35+
})
36+
37+
it('omits the allowance outside the organization period', () => {
38+
const markup = renderToStaticMarkup(
39+
<UsageCredits
40+
overview={{ ...overview, limitCredits: null }}
41+
isLoading={false}
42+
isError={false}
43+
/>
44+
)
45+
expect(markup).not.toContain('Over limit')
46+
expect(markup).not.toContain('role="meter"')
47+
})
48+
})

0 commit comments

Comments
 (0)