Skip to content

Commit 55986a3

Browse files
committed
Merge remote-tracking branch 'origin/staging' into fix/connector-lease-transactions-bounded
2 parents 0a7dd0c + 0c321f7 commit 55986a3

42 files changed

Lines changed: 2827 additions & 761 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: 75 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,51 @@
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+
cn,
11+
DashboardMetric,
12+
formatChartLatency,
13+
} from '@sim/emcn'
514
import type { OrganizationActivitySummary } from '@/lib/api/contracts/organization-activity'
615
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
16+
import {
17+
USAGE_CHAT_COLOR,
18+
USAGE_OTHER_COLOR,
19+
USAGE_PALETTE_CLASS,
20+
} from '@/ee/organization-usage/constants'
21+
import { useLegendHighlight } from '@/ee/organization-usage/hooks/use-legend-highlight'
722
import { useOrganizationActivitySummary } from '@/hooks/queries/organization-activity'
823
import type { OrganizationUsageWindowKey } from '@/hooks/queries/utils/organization-usage-keys'
924

25+
const CHART_HEIGHT = 180
26+
27+
/**
28+
* Outcome layers, bottom-up. Failed is the status red and sits on the stack where a
29+
* spike reads at a glance; Other (cancelled, paused, unfinished) stays neutral, in a
30+
* gray whose lightness keeps it apart from the red for color-vision deficiency.
31+
*/
32+
const OUTCOMES = [
33+
{ id: 'completed', label: 'Completed', color: 'var(--brand-blue)' },
34+
{ id: 'failed', label: 'Failed', color: 'var(--text-error)' },
35+
{ id: 'other', label: 'Other', color: USAGE_OTHER_COLOR },
36+
] as const
37+
38+
const OUTCOME_LEGEND: ChartLegendItem[] = [...OUTCOMES]
39+
const OUTCOME_IDS = OUTCOMES.map((outcome) => outcome.id)
40+
41+
type ActivityPoint = OrganizationActivitySummary['series'][number]
42+
43+
const OUTCOME_VALUE: Record<(typeof OUTCOMES)[number]['id'], (point: ActivityPoint) => number> = {
44+
completed: (point) => point.completed,
45+
failed: (point) => point.failed,
46+
other: (point) => Math.max(0, point.workflowRuns - point.completed - point.failed),
47+
}
48+
1049
interface ActivitySummaryProps {
1150
summary?: OrganizationActivitySummary
1251
loading?: boolean
@@ -24,30 +63,26 @@ export function formatFailureRate(rate: number | null): string {
2463
}
2564

2665
export function ActivitySummary({ summary, loading, error, onRetry }: ActivitySummaryProps) {
27-
const workflowSeries = useMemo(
66+
const highlight = useLegendHighlight(OUTCOME_IDS)
67+
68+
const outcomeSeries = useMemo<BarChartSeries[]>(
2869
() =>
29-
summary?.series.map((point) => ({
30-
timestamp: point.timestamp,
31-
value: point.workflowRuns,
32-
})) ?? [],
70+
OUTCOMES.map((outcome) => ({
71+
...outcome,
72+
data: (summary?.series ?? []).map((point) => ({
73+
timestamp: point.timestamp,
74+
value: OUTCOME_VALUE[outcome.id](point),
75+
})),
76+
})),
3377
[summary?.series]
3478
)
79+
3580
const chatSeries = useMemo(
3681
() =>
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-
})) ?? [],
82+
summary?.series.map((point) => ({ timestamp: point.timestamp, value: point.chatRuns })) ?? [],
4983
[summary?.series]
5084
)
85+
5186
const totals = summary?.totals
5287
const metrics = [
5388
{
@@ -81,18 +116,10 @@ export function ActivitySummary({ summary, loading, error, onRetry }: ActivitySu
81116
description: 'Completed and failed workflows with a recorded duration.',
82117
},
83118
]
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-
]
93119
const chartState = { loading, error: error ? "Couldn't load activity." : undefined, onRetry }
120+
94121
return (
95-
<div className='flex flex-col gap-5'>
122+
<div className={cn('flex flex-col gap-5', USAGE_PALETTE_CLASS)}>
96123
<div className='grid grid-cols-[repeat(auto-fit,minmax(min(120px,100%),1fr))] gap-4'>
97124
{metrics.map((metric) => (
98125
<DashboardMetric
@@ -104,41 +131,32 @@ export function ActivitySummary({ summary, loading, error, onRetry }: ActivitySu
104131
))}
105132
</div>
106133
<div className='grid grid-cols-[repeat(auto-fit,minmax(min(280px,100%),1fr))] gap-6'>
107-
<ChartFrame title='Workflow runs' height={160} {...chartState}>
134+
<div className='flex min-w-0 flex-col gap-2'>
135+
<ChartFrame
136+
title='Workflow runs'
137+
description='Other includes cancelled, paused, and unfinished runs.'
138+
height={CHART_HEIGHT}
139+
{...chartState}
140+
>
141+
<BarChart
142+
label=''
143+
xAxisFormat='date'
144+
height={CHART_HEIGHT}
145+
series={outcomeSeries}
146+
highlightedSeriesId={highlight.highlightedId}
147+
/>
148+
</ChartFrame>
149+
<ChartLegend layout='row' items={OUTCOME_LEGEND} {...highlight.legendProps} />
150+
</div>
151+
<ChartFrame title='Chat runs' height={CHART_HEIGHT} {...chartState}>
108152
<BarChart
109-
xAxisFormat='date'
110-
data={workflowSeries}
111153
label=''
112-
color='var(--indicator-seat-filled)'
113-
height={160}
114-
/>
115-
</ChartFrame>
116-
<ChartFrame title='Chat runs' height={160} {...chartState}>
117-
<BarChart
118154
xAxisFormat='date'
155+
height={CHART_HEIGHT}
119156
data={chatSeries}
120-
label=''
121-
color='var(--indicator-seat-filled)'
122-
height={160}
157+
color={USAGE_CHAT_COLOR}
123158
/>
124159
</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>
142160
</div>
143161
</div>
144162
)

‎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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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('shows an unchanged period as neutral, not as a decrease', () => {
38+
const markup = renderToStaticMarkup(
39+
<UsageCredits
40+
overview={{ ...overview, totals: { credits: 100 }, previousTotals: { credits: 100 } }}
41+
isLoading={false}
42+
isError={false}
43+
/>
44+
)
45+
expect(markup).toContain('No change compared with the previous period')
46+
expect(markup).not.toContain('↓')
47+
})
48+
49+
it('omits the allowance outside the organization period', () => {
50+
const markup = renderToStaticMarkup(
51+
<UsageCredits
52+
overview={{ ...overview, limitCredits: null }}
53+
isLoading={false}
54+
isError={false}
55+
/>
56+
)
57+
expect(markup).not.toContain('Over limit')
58+
expect(markup).not.toContain('role="meter"')
59+
})
60+
})

0 commit comments

Comments
 (0)