diff --git a/apps/sim/app/api/organizations/[id]/usage/summary/route.ts b/apps/sim/app/api/organizations/[id]/usage/overview/route.ts similarity index 62% rename from apps/sim/app/api/organizations/[id]/usage/summary/route.ts rename to apps/sim/app/api/organizations/[id]/usage/overview/route.ts index 4d1111e1962..8294ead619c 100644 --- a/apps/sim/app/api/organizations/[id]/usage/summary/route.ts +++ b/apps/sim/app/api/organizations/[id]/usage/overview/route.ts @@ -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', @@ -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, }) diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/usage/route.test.ts b/apps/sim/app/api/v2/organizations/[organizationId]/usage/route.test.ts index aa72574f436..0336f0878f4 100644 --- a/apps/sim/app/api/v2/organizations/[organizationId]/usage/route.test.ts +++ b/apps/sim/app/api/v2/organizations/[organizationId]/usage/route.test.ts @@ -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 })) @@ -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 }) + ) }) }) diff --git a/apps/sim/ee/organization-usage/components/activity-summary.tsx b/apps/sim/ee/organization-usage/components/activity-summary.tsx index 47d42aceed1..75376f8e62d 100644 --- a/apps/sim/ee/organization-usage/components/activity-summary.tsx +++ b/apps/sim/ee/organization-usage/components/activity-summary.tsx @@ -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 @@ -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( () => - 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 = [ { @@ -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 ( -
+
{metrics.map((metric) => (
- +
+ + + + +
+ - - - - - - - - -
) diff --git a/apps/sim/ee/organization-usage/components/usage-consumers.tsx b/apps/sim/ee/organization-usage/components/usage-consumers.tsx index 8a508eaf2c9..aef6f01de4e 100644 --- a/apps/sim/ee/organization-usage/components/usage-consumers.tsx +++ b/apps/sim/ee/organization-usage/components/usage-consumers.tsx @@ -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 { @@ -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 @@ -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, @@ -148,14 +151,15 @@ function UsageConsumerRow({ onSelect && 'transition-colors hover-hover:bg-[var(--surface-active)]' )} > - {ProviderIcon && } + {leading ?? + (ProviderIcon && )} {row.label}