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
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
import { getOrganizationUsageSummaryContract } from '@/lib/api/contracts/organization-usage'
import { getOrganizationUsageOverviewContract } from '@/lib/api/contracts/organization-usage'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { getOrganizationUsageSummary } from '@/lib/billing/application/organization-usage/get-organization-usage-summary'
import { getOrganizationUsageOverview } from '@/lib/billing/application/organization-usage/get-organization-usage-overview'
import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations'
import { organizationUsageErrorPolicy } from '@/app/api/organizations/[id]/usage/error-policy'

export const dynamic = 'force-dynamic'

/**
* Everything above the fold in one round trip. Kept separate from the breakdown
* route because every read here is index-covered, and folding in a dimension that
* heap-scans would put that cost on first paint.
*/
export const GET = defineInternalJsonRoute({
contract: getOrganizationUsageSummaryContract,
contract: getOrganizationUsageOverviewContract,
auth: internalSessionAuth,
operation: organizationUsageOperations.readSummary,
operation: organizationUsageOperations.readOverview,
rateLimit: internalRateLimits.none({
reason:
'Authenticated org-admin settings read, gated on enterprise entitlement and billing authority',
Expand All @@ -32,6 +27,6 @@ export const GET = defineInternalJsonRoute({
endDate: query.endDate ? new Date(query.endDate) : undefined,
timezone: query.timezone,
}),
useCase: getOrganizationUsageSummary,
useCase: getOrganizationUsageOverview,
present: (result) => result,
})
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ vi.mock('@/lib/billing/organizations/member-limits', () => ({
vi.mock('@/lib/billing/core/usage-analytics-queries', () => ({
readUsageTotals: mocks.totals,
readUsageTimeSeries: mocks.series,
readUsageBreakdown: mocks.breakdown,
readUsageGroups: mocks.breakdown,
readUsageEntityNames: vi.fn().mockResolvedValue(new Map()),
}))
vi.mock('@/lib/billing/core/usage-log', () => ({ getBillingEntityUsageLogs: mocks.logs }))
Expand Down Expand Up @@ -431,7 +431,9 @@ describe('organization usage API authorization and bounds', () => {
)
const response = await breakdown(request('usage/breakdown?dimension=member'), usageContext)
expect(response.status).toBe(413)
expect(mocks.breakdown).toHaveBeenCalledWith(expect.any(Array), 'member', undefined, 10_000)
expect(mocks.breakdown).toHaveBeenCalledWith(
expect.objectContaining({ dimension: 'member', maxRows: 10_000 })
)
})
})

Expand Down
132 changes: 75 additions & 57 deletions apps/sim/ee/organization-usage/components/activity-summary.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,51 @@
'use client'

import { useMemo } from 'react'
import { BarChart, ChartFrame, DashboardMetric, DonutChart, formatChartLatency } from '@sim/emcn'
import {
BarChart,
type BarChartSeries,
ChartFrame,
ChartLegend,
type ChartLegendItem,
cn,
DashboardMetric,
formatChartLatency,
} from '@sim/emcn'
import type { OrganizationActivitySummary } from '@/lib/api/contracts/organization-activity'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import {
USAGE_CHAT_COLOR,
USAGE_OTHER_COLOR,
USAGE_PALETTE_CLASS,
} from '@/ee/organization-usage/constants'
import { useLegendHighlight } from '@/ee/organization-usage/hooks/use-legend-highlight'
import { useOrganizationActivitySummary } from '@/hooks/queries/organization-activity'
import type { OrganizationUsageWindowKey } from '@/hooks/queries/utils/organization-usage-keys'

const CHART_HEIGHT = 180

/**
* Outcome layers, bottom-up. Failed is the status red and sits on the stack where a
* spike reads at a glance; Other (cancelled, paused, unfinished) stays neutral, in a
* gray whose lightness keeps it apart from the red for color-vision deficiency.
*/
const OUTCOMES = [
{ id: 'completed', label: 'Completed', color: 'var(--brand-blue)' },
{ id: 'failed', label: 'Failed', color: 'var(--text-error)' },
{ id: 'other', label: 'Other', color: USAGE_OTHER_COLOR },
] as const

const OUTCOME_LEGEND: ChartLegendItem[] = [...OUTCOMES]
const OUTCOME_IDS = OUTCOMES.map((outcome) => outcome.id)

type ActivityPoint = OrganizationActivitySummary['series'][number]

const OUTCOME_VALUE: Record<(typeof OUTCOMES)[number]['id'], (point: ActivityPoint) => number> = {
completed: (point) => point.completed,
failed: (point) => point.failed,
other: (point) => Math.max(0, point.workflowRuns - point.completed - point.failed),
}

interface ActivitySummaryProps {
summary?: OrganizationActivitySummary
loading?: boolean
Expand All @@ -24,30 +63,26 @@ export function formatFailureRate(rate: number | null): string {
}

export function ActivitySummary({ summary, loading, error, onRetry }: ActivitySummaryProps) {
const workflowSeries = useMemo(
const highlight = useLegendHighlight(OUTCOME_IDS)

const outcomeSeries = useMemo<BarChartSeries[]>(
() =>
summary?.series.map((point) => ({
timestamp: point.timestamp,
value: point.workflowRuns,
})) ?? [],
OUTCOMES.map((outcome) => ({
...outcome,
data: (summary?.series ?? []).map((point) => ({
timestamp: point.timestamp,
value: OUTCOME_VALUE[outcome.id](point),
})),
})),
[summary?.series]
)

const chatSeries = useMemo(
() =>
summary?.series.map((point) => ({
timestamp: point.timestamp,
value: point.chatRuns,
})) ?? [],
[summary?.series]
)
const failureSeries = useMemo(
() =>
summary?.series.map((point) => ({
timestamp: point.timestamp,
value: point.failed,
})) ?? [],
summary?.series.map((point) => ({ timestamp: point.timestamp, value: point.chatRuns })) ?? [],
[summary?.series]
)

const totals = summary?.totals
const metrics = [
{
Expand Down Expand Up @@ -81,18 +116,10 @@ export function ActivitySummary({ summary, loading, error, onRetry }: ActivitySu
description: 'Completed and failed workflows with a recorded duration.',
},
]
const outcomes = [
{ label: 'Completed', value: totals?.completed ?? 0, color: 'var(--indicator-seat-filled)' },
{ label: 'Failed', value: totals?.failed ?? 0, color: 'var(--text-error)' },
{
label: 'Other',
value: totals ? totals.workflowRuns - totals.completed - totals.failed : 0,
color: 'var(--text-muted)',
},
]
const chartState = { loading, error: error ? "Couldn't load activity." : undefined, onRetry }

return (
<div className='flex flex-col gap-5'>
<div className={cn('flex flex-col gap-5', USAGE_PALETTE_CLASS)}>
<div className='grid grid-cols-[repeat(auto-fit,minmax(min(120px,100%),1fr))] gap-4'>
{metrics.map((metric) => (
<DashboardMetric
Expand All @@ -104,41 +131,32 @@ export function ActivitySummary({ summary, loading, error, onRetry }: ActivitySu
))}
</div>
<div className='grid grid-cols-[repeat(auto-fit,minmax(min(280px,100%),1fr))] gap-6'>
<ChartFrame title='Workflow runs' height={160} {...chartState}>
<div className='flex min-w-0 flex-col gap-2'>
<ChartFrame
title='Workflow runs'
description='Other includes cancelled, paused, and unfinished runs.'
height={CHART_HEIGHT}
{...chartState}
>
<BarChart
label=''
xAxisFormat='date'
height={CHART_HEIGHT}
series={outcomeSeries}
highlightedSeriesId={highlight.highlightedId}
/>
</ChartFrame>
<ChartLegend layout='row' items={OUTCOME_LEGEND} {...highlight.legendProps} />
</div>
<ChartFrame title='Chat runs' height={CHART_HEIGHT} {...chartState}>
<BarChart
xAxisFormat='date'
data={workflowSeries}
label=''
color='var(--indicator-seat-filled)'
height={160}
/>
</ChartFrame>
<ChartFrame title='Chat runs' height={160} {...chartState}>
<BarChart
xAxisFormat='date'
height={CHART_HEIGHT}
data={chatSeries}
label=''
color='var(--indicator-seat-filled)'
height={160}
color={USAGE_CHAT_COLOR}
/>
</ChartFrame>
<ChartFrame title='Failed runs' height={160} {...chartState}>
<BarChart
xAxisFormat='date'
data={failureSeries}
label=''
color='var(--text-error)'
height={160}
/>
</ChartFrame>
<ChartFrame
title='Workflow outcomes'
description='Other includes cancelled, paused, and unfinished runs.'
height={160}
{...chartState}
>
<DonutChart segments={outcomes} label='Workflow outcomes' />
</ChartFrame>
</div>
</div>
)
Expand Down
14 changes: 11 additions & 3 deletions apps/sim/ee/organization-usage/components/usage-consumers.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

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

interface UsageConsumerRowProps {
row: OrganizationUsageBreakdownRow
/** Replaces the provider mark, e.g. with a member's avatar. */
leading?: ReactNode
/** BYOK rows carry no cost, so tokens are the only usage they can show. */
showTokensOnly: boolean
onSelect?: (row: OrganizationUsageBreakdownRow) => void
Expand Down Expand Up @@ -126,6 +128,7 @@ export const USAGE_ROW_CLASSES = 'flex w-full items-center gap-2.5 rounded-lg p-
*/
function UsageConsumerRow({
row,
leading,
showTokensOnly,
onSelect,
actions,
Expand All @@ -148,14 +151,15 @@ function UsageConsumerRow({
onSelect && 'transition-colors hover-hover:bg-[var(--surface-active)]'
)}
>
{ProviderIcon && <ProviderIcon className='size-[14px] shrink-0 text-[var(--text-icon)]' />}
{leading ??
(ProviderIcon && <ProviderIcon className='size-[14px] shrink-0 text-[var(--text-icon)]' />)}
<span className='min-w-0 flex-1 truncate text-[var(--text-body)] text-sm'>{row.label}</span>
<div
className='h-[4px] w-[64px] shrink-0 overflow-hidden rounded-full bg-[var(--border)]'
aria-hidden='true'
>
<div
className='h-full rounded-full bg-[var(--indicator-seat-filled)]'
className='h-full rounded-full bg-[var(--brand-blue)]'
style={{ width: `${Math.max(2, Math.round(row.share * 100))}%` }}
/>
</div>
Expand Down Expand Up @@ -185,6 +189,8 @@ interface UsageConsumersProps {
onSelectRow?: (row: OrganizationUsageBreakdownRow) => void
/** Set on Members, where a row can open the shared manage-credits modal. */
rowActions?: (row: OrganizationUsageBreakdownRow) => RowAction[]
/** Leading visual per row, in place of the provider mark. */
renderLeading?: (row: OrganizationUsageBreakdownRow) => ReactNode
/**
* Opens the truncated tail. Omitted when the list is already showing everything the
* API will return, which is the one case where the `Other` row has nothing to open.
Expand All @@ -200,6 +206,7 @@ export function UsageConsumers({
isPlaceholderData,
onSelectRow,
rowActions,
renderLeading,
onExpandOther,
}: UsageConsumersProps) {
if (isError) {
Expand Down Expand Up @@ -238,6 +245,7 @@ export function UsageConsumers({
<UsageConsumerRow
key={`${dimension}-${row.id}`}
row={row}
leading={renderLeading?.(row)}
showTokensOnly={showTokensOnly}
{...(onExpandOther && trailingSlot ? { reservedTrailing: trailingSlot } : {})}
{...(onSelectRow && row.id ? { onSelect: onSelectRow } : {})}
Expand Down
60 changes: 60 additions & 0 deletions apps/sim/ee/organization-usage/components/usage-credits.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/** @vitest-environment node */
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import type { OrganizationUsageOverview } from '@/lib/api/contracts/organization-usage'
import { UsageCredits } from '@/ee/organization-usage/components/usage-credits'

const overview: OrganizationUsageOverview = {
window: { start: '2026-01-01', end: '2026-01-08', source: 'range' },
bucket: 'day',
totals: { credits: 200 },
previousTotals: { credits: 100 },
limitCredits: 150,
series: [],
members: {
dimension: 'member',
rows: [],
other: { credits: 0, events: 0, rowCount: 0, tokens: 0 },
totalCredits: 200,
},
}

describe('UsageCredits', () => {
it('hides stale usage badges when a refresh fails', () => {
const render = (isError: boolean) =>
renderToStaticMarkup(<UsageCredits overview={overview} isLoading={false} isError={isError} />)
const ok = render(false)
expect(ok).toContain('Over limit')
expect(ok).toContain('compared with the previous period')
expect(ok).toContain('133% of 150')
const failed = render(true)
expect(failed).not.toContain('Over limit')
expect(failed).not.toContain('compared with the previous period')
expect(failed).not.toContain('of 150')
expect(failed).toContain('load credits.')
})

it('shows an unchanged period as neutral, not as a decrease', () => {
const markup = renderToStaticMarkup(
<UsageCredits
overview={{ ...overview, totals: { credits: 100 }, previousTotals: { credits: 100 } }}
isLoading={false}
isError={false}
/>
)
expect(markup).toContain('No change compared with the previous period')
expect(markup).not.toContain('↓')
})

it('omits the allowance outside the organization period', () => {
const markup = renderToStaticMarkup(
<UsageCredits
overview={{ ...overview, limitCredits: null }}
isLoading={false}
isError={false}
/>
)
expect(markup).not.toContain('Over limit')
expect(markup).not.toContain('role="meter"')
})
})
Loading
Loading