diff --git a/apps/docs/content/docs/en/platform/enterprise/index.mdx b/apps/docs/content/docs/en/platform/enterprise/index.mdx index fd3e46e4e1a..fa3f62af3b1 100644 --- a/apps/docs/content/docs/en/platform/enterprise/index.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/index.mdx @@ -53,6 +53,12 @@ Track configuration and security-relevant actions across your organization for c --- +## Usage Tracking + +See where your organization's credits go — by member, workspace, model, and platform feature. See the [usage tracking guide](/platform/enterprise/usage-tracking). + +--- + ## Data Retention Configure how long execution logs, soft-deleted resources, and Chat data are kept before permanent deletion. See the [data retention guide](/platform/enterprise/data-retention). diff --git a/apps/docs/content/docs/en/platform/enterprise/meta.json b/apps/docs/content/docs/en/platform/enterprise/meta.json index cdf420f1ff3..48e14db8d21 100644 --- a/apps/docs/content/docs/en/platform/enterprise/meta.json +++ b/apps/docs/content/docs/en/platform/enterprise/meta.json @@ -10,6 +10,7 @@ "custom-blocks", "whitelabeling", "audit-logs", + "usage-tracking", "data-retention", "data-drains", "forks" diff --git a/apps/docs/content/docs/en/platform/enterprise/usage-tracking.mdx b/apps/docs/content/docs/en/platform/enterprise/usage-tracking.mdx new file mode 100644 index 00000000000..d37b5c428c2 --- /dev/null +++ b/apps/docs/content/docs/en/platform/enterprise/usage-tracking.mdx @@ -0,0 +1,161 @@ +--- +title: Usage Tracking +description: See where your organization's credits go, by member, workspace, and model +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { FAQ } from '@/components/ui/faq' +import { Image } from '@/components/ui/image' + +Usage tracking shows how your organization consumes credits across every part of the platform — which members, which workspaces, which models, and which product features. Use it to monitor spend against your commitment, find what is driving it, and export the underlying events for chargeback. + +All figures are in **credits** (1 credit = $0.005). See [cost calculation](/platform/costs) for how credits are derived. + +--- + +## Viewing usage + +Go to **Settings → Organization → Usage tracking** in your workspace. + +Usage tracking Overview tab showing the period selector, credits used against the organization limit, a daily usage chart, and a Sources breakdown listing Sim Chat, Workflow, and Agent block + +The period selector applies to every tab: + +| Period | What it covers | +|--------|----------------| +| **Current period** | Your organization's current billing period | +| **Previous period** | The period immediately before it | +| **Last 7 days** / **Last 30 days** | A rolling window ending now | +| **Custom range** | Any range up to 92 days | + + + Daily bars are drawn in your browser's timezone, while a billing period begins at a fixed UTC instant. The first and last bar of a period can therefore be partial, and two admins in different timezones will see the same total split across slightly different days. + + +### Tabs + +| Tab | Answers | +|-----|---------| +| **Overview** | How much have we used, against what limit, and what kind of work was it | +| **Members** | Which people are driving usage | +| **Workspaces** | Which workspaces are driving usage — select one to drill in | +| **Models** | Which models we are paying for | +| **BYOK** | What ran on our own provider keys | + +Selecting a workspace opens its detail view, which splits that workspace's usage into **Sources** (what kind of work) and **Workflows** (the individual workflow runs). **Open logs** jumps to that workspace's execution logs. + +A workspace's detail view with a Sources section listing Sim Chat and Workflow, and a Workflows section ranking individual workflows by credits + +The two sections answer different questions, and the difference is the point: **Sources** adds up to the workspace's total, while **Workflows** covers only the workflow-run part of it. In the example above, Sources totals 4,435 credits but the workflows list only accounts for the 161 credits under Workflow — the other 4,274 came from Chat, which no workflow produced. + +--- + +## What each source means + +A **source** is the part of the platform that consumed the credits. Every charge belongs to exactly one source, so the Sources breakdown always adds up to your total. + +| Source | Platform features that bill to it | +|--------|-----------------------------------| +| **Workflow** | Every workflow run — model calls made on Sim's hosted keys, hosted-key tool calls (web search, scraping, and similar), and the per-run base charge | +| **Sim Chat** | The Chat panel, agent calls made through the API, and the email Inbox | +| **Agent block** | The Agent block running inside a workflow | +| **Knowledge Base** | Embedding documents on upload or connector sync, and semantic search queries | +| **Wand** | Inline AI generation in editors | +| **Enrichment** | Table column enrichment | +| **Voice input** | Voice sessions | +| **Voice output** | Spoken responses in deployed chats | +| **Sim Chat (MCP)** | Retired. Appears only for historical periods | + +### Nuances worth knowing + +These follow from how charges are recorded, and they explain most questions about why a number looks the way it does. + +**Workflow covers the whole run, not just the base charge.** A workflow row includes three things: the models it called on Sim's hosted keys, any hosted-key tool calls, and the per-run base charge. Model usage typically dominates, but the per-run charge is not negligible — across a high-volume organization it commonly accounts for around a fifth of workflow spend, because it applies to every run whether or not the workflow calls a model. + +**Agent block is billed separately from the workflow that contains it.** An Agent block runs inside a workflow but is metered on its own, so its credits appear under **Agent block** rather than **Workflow**. Nothing is counted twice — but it also means Workflow alone understates what your workflows cost. Read the two lines together. + +**Sim Chat covers three surfaces.** The Chat panel, headless agent calls through the API, and the Inbox all record as **Sim Chat** and cannot be separated from one another. If you need per-surface attribution today, separate them by workspace instead. + +**Only workflow runs carry workflow attribution.** Chat, Agent block, Wand, Knowledge Base, and voice usage are not produced by a workflow and have no workflow attached. That is the gap between a workspace's **Sources** and **Workflows** sections shown above. + +**Some usage has no workspace.** Agent calls made through the API may not name a workspace, particularly from headless or self-hosted callers. That usage is grouped under **No workspace** on the Workspaces tab. It is still counted in your total. + +**Models excludes non-model charges.** The Models tab covers model usage only, so it will read lower than your period total — the difference is per-run base charges, hosted-key tool charges, and fixed charges such as voice sessions. + + + Each tab is a different slice of the same charges, not an additional set of them. Members, Workspaces, and Sources each add up to your period total on their own; adding two tabs together double-counts. + + +--- + +## Bring your own keys (BYOK) + +When a workspace or organization supplies its own provider key, Sim does not charge for that model usage. Those calls are still recorded so you can see the volume. + +The **BYOK** tab groups this usage by provider and reports **tokens** rather than credits, because the credit cost is zero by definition. Tokens on your own keys are not included in the credit totals anywhere else in the panel. + + + Tool calls and Chat usage made on your own keys are not yet recorded. BYOK currently covers model usage in workflow runs. + + +--- + +## Exporting + +**Export** downloads the events behind the current period and filters as a CSV with columns `Date, Source, Description, Workflow, Credits`. Credits are exported as plain numbers so the column can be summed, and carry decimals — an individual event often costs a fraction of a credit. + +**All events** opens the full ledger — every credit-consuming event, newest first, with its own filters and export. + +Very large exports are capped. When that happens the download still succeeds and Sim tells you it was truncated; narrow the date range to capture everything. + +--- + + + +--- + +## Self-hosted setup + +Self-hosted deployments use environment variables instead of the billing/plan check. + +### Environment variables + +```bash +USAGE_MONITORING_ENABLED=true +NEXT_PUBLIC_USAGE_MONITORING_ENABLED=true +``` + +Once enabled, usage tracking is viewable in **Settings → Organization → Usage tracking** for organization owners and admins. diff --git a/apps/docs/public/static/enterprise/usage-tracking-overview.png b/apps/docs/public/static/enterprise/usage-tracking-overview.png new file mode 100644 index 00000000000..d55e13d0d23 Binary files /dev/null and b/apps/docs/public/static/enterprise/usage-tracking-overview.png differ diff --git a/apps/docs/public/static/enterprise/usage-tracking-workspace-detail.png b/apps/docs/public/static/enterprise/usage-tracking-workspace-detail.png new file mode 100644 index 00000000000..70003a33efb Binary files /dev/null and b/apps/docs/public/static/enterprise/usage-tracking-workspace-detail.png differ diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index 63cfb4994dd..d880aaf1dae 100644 --- a/apps/sim/app/api/emails/preview/route.ts +++ b/apps/sim/app/api/emails/preview/route.ts @@ -160,7 +160,7 @@ const emailTemplates = { scope: 'organization', currentUsage: 500, limit: 500, - ctaLink: 'https://sim.ai/organization/org_123/settings/billing', + ctaLink: 'https://sim.ai/workspace/ws_123/settings/billing', }), // Operational notification emails diff --git a/apps/sim/app/api/organizations/[id]/usage/breakdown/route.ts b/apps/sim/app/api/organizations/[id]/usage/breakdown/route.ts new file mode 100644 index 00000000000..7af435c43da --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/usage/breakdown/route.ts @@ -0,0 +1,39 @@ +import { getOrganizationUsageBreakdownContract } from '@/lib/api/contracts/organization-usage' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { getOrganizationUsageBreakdown } from '@/lib/billing/application/organization-usage/get-organization-usage-breakdown' +import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations' +import { organizationUsageErrorPolicy } from '@/app/api/organizations/[id]/usage/error-policy' + +export const dynamic = 'force-dynamic' + +/** + * One route for all five dimensions: they share a scope, a window, a row shape, and + * authorization, so five routes would be five copies of the same mapping. Separate + * from the summary because three of the five heap-scan the ledger. + */ +export const GET = defineInternalJsonRoute({ + contract: getOrganizationUsageBreakdownContract, + auth: internalSessionAuth, + operation: organizationUsageOperations.readBreakdown, + rateLimit: internalRateLimits.none({ + reason: + 'Authenticated org-admin settings read, gated on enterprise entitlement and billing authority', + }), + errorPolicy: organizationUsageErrorPolicy, + mapInput: ({ params, query }) => ({ + organizationId: params.id, + dimension: query.dimension, + workspaceId: query.workspaceId, + preset: query.preset, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + timezone: query.timezone, + limit: query.limit, + }), + useCase: getOrganizationUsageBreakdown, + present: (result) => result, +}) diff --git a/apps/sim/app/api/organizations/[id]/usage/error-policy.ts b/apps/sim/app/api/organizations/[id]/usage/error-policy.ts new file mode 100644 index 00000000000..862fc6dd554 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/usage/error-policy.ts @@ -0,0 +1,27 @@ +import { + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { + UsageWindowRangeInvertedError, + UsageWindowRangeTooLargeError, +} from '@/lib/billing/core/usage-analytics' + +/** + * The window resolver throws when a custom range exceeds its cap or ends before it +begins, both of which are + * caller-fixable input error rather than a fault. Without this it fell through to + * the orchestration policy's `unhandled` branch and every over-long range answered + * `500 Internal server error`, so the client could neither surface the real reason + * nor tell the two apart. + * + * Shared by all four usage routes so they cannot classify the same throw differently. + */ +export const organizationUsageErrorPolicy = extendInternalErrorPolicy( + internalOrchestrationErrorPolicy, + (error) => + error instanceof UsageWindowRangeTooLargeError || error instanceof UsageWindowRangeInvertedError + ? internalErrorResponse(400, { error: error.message }) + : null +) diff --git a/apps/sim/app/api/organizations/[id]/usage/events/route.ts b/apps/sim/app/api/organizations/[id]/usage/events/route.ts new file mode 100644 index 00000000000..59d5c28de45 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/usage/events/route.ts @@ -0,0 +1,39 @@ +import { listOrganizationUsageEventsContract } from '@/lib/api/contracts/organization-usage' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listOrganizationUsageEvents } from '@/lib/billing/application/organization-usage/list-organization-usage-events' +import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations' +import { organizationUsageErrorPolicy } from '@/app/api/organizations/[id]/usage/error-policy' + +export const dynamic = 'force-dynamic' + +/** + * The raw ledger, paged. Separate from the summary because it owns a cursor + * lifecycle and its own staleness — folding it in would re-run the headline + * aggregate on every scroll. + */ +export const GET = defineInternalJsonRoute({ + contract: listOrganizationUsageEventsContract, + auth: internalSessionAuth, + operation: organizationUsageOperations.listEvents, + rateLimit: internalRateLimits.none({ + reason: + 'Authenticated org-admin settings read, gated on enterprise entitlement and billing authority', + }), + errorPolicy: organizationUsageErrorPolicy, + mapInput: ({ params, query }) => ({ + organizationId: params.id, + preset: query.preset, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + timezone: query.timezone, + source: query.source, + limit: query.limit, + cursor: query.cursor, + }), + useCase: listOrganizationUsageEvents, + present: (result) => result, +}) diff --git a/apps/sim/app/api/organizations/[id]/usage/export/route.ts b/apps/sim/app/api/organizations/[id]/usage/export/route.ts new file mode 100644 index 00000000000..c4b77332e17 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/usage/export/route.ts @@ -0,0 +1,107 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { exportOrganizationUsageContract } from '@/lib/api/contracts/organization-usage' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { + exportOrganizationUsageEvents, + type OrganizationUsageExportRow, +} from '@/lib/billing/application/organization-usage/export-organization-usage-events' +import { + UsageWindowRangeInvertedError, + UsageWindowRangeTooLargeError, +} from '@/lib/billing/core/usage-analytics' +import { ForbiddenOperationError } from '@/lib/core/application' +import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('OrganizationUsageExportAPI') + +const CSV_HEADER = toCsvRow(['Date', 'Source', 'Description', 'Workflow', 'Credits']) + +/** + * A bare number, to four decimals, with trailing zeros trimmed. + * + * Not `formatCreditsLabel`: that renders `"0 credits"` for any charge under half a + * credit, so a real cost vanished from the export, and the `"N credits"` text made + * the column unsummable in a spreadsheet — which is most of what a CSV is for. + */ +function formatExportCredits(credits: number): string { + return String(Number(credits.toFixed(4))) +} + +/** `formatCsvValue` neutralizes formula injection — model and workflow names are user-controlled. */ +function toCsvLine(row: OrganizationUsageExportRow): string { + return toCsvRow([ + formatCsvValue(row.createdAt), + formatCsvValue(row.source), + formatCsvValue(row.description), + formatCsvValue(row.workflowName ?? ''), + formatCsvValue(formatExportCredits(row.credits)), + ]) +} + +/** + * A raw handler rather than a JSON builder: the body is `text/csv`, and the response + * carries `X-Export-Truncated` so the client can tell the user their range was capped + * rather than silently handing them a partial file. + */ +export const GET = withRouteHandler(async (request: NextRequest, context) => { + try { + const session = await getSession() + const sessionId = session?.session?.id + if (!session?.user?.id || !sessionId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(exportOrganizationUsageContract, request, context, { + validationErrorResponse: (error) => + NextResponse.json( + { error: getValidationErrorMessage(error, 'Invalid query parameters') }, + { status: 400 } + ), + }) + if (!parsed.success) return parsed.response + + const { query, params } = parsed.data + const result = await exportOrganizationUsageEvents.execute({ + principal: { kind: 'session', userId: session.user.id, sessionId }, + input: { + organizationId: params.id, + preset: query.preset, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + timezone: query.timezone, + source: query.source, + }, + }) + + const csv = [CSV_HEADER, ...result.rows.map(toCsvLine)].join('\n') + return new NextResponse(csv, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + // Every member's spend for one organization, behind session auth. Without + // this a browser or shared intermediary may serve it again after the + // viewer's access to that organization has been revoked. + 'Cache-Control': 'no-store', + 'Content-Disposition': `attachment; filename="organization-usage-${params.id}.csv"`, + ...(result.truncated ? { 'X-Export-Truncated': '1' } : {}), + }, + }) + } catch (error) { + if (error instanceof ForbiddenOperationError) { + return NextResponse.json({ error: error.message }, { status: 403 }) + } + // A range over the cap, or inverted, is the caller's input — the same + // classification the three JSON routes make through `organizationUsageErrorPolicy`. + if ( + error instanceof UsageWindowRangeTooLargeError || + error instanceof UsageWindowRangeInvertedError + ) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + logger.error('Failed to export organization usage', { error: getErrorMessage(error) }) + return NextResponse.json({ error: 'Failed to export usage' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/organizations/[id]/usage/summary/route.ts b/apps/sim/app/api/organizations/[id]/usage/summary/route.ts new file mode 100644 index 00000000000..ba7abb7372a --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/usage/summary/route.ts @@ -0,0 +1,36 @@ +import { getOrganizationUsageSummaryContract } 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 { 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, + auth: internalSessionAuth, + operation: organizationUsageOperations.readSummary, + rateLimit: internalRateLimits.none({ + reason: + 'Authenticated org-admin settings read, gated on enterprise entitlement and billing authority', + }), + errorPolicy: organizationUsageErrorPolicy, + mapInput: ({ params, query }) => ({ + organizationId: params.id, + preset: query.preset, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + timezone: query.timezone, + }), + useCase: getOrganizationUsageSummary, + present: (result) => result, +}) diff --git a/apps/sim/app/api/users/me/usage-logs/export/route.test.ts b/apps/sim/app/api/users/me/usage-logs/export/route.test.ts index 87854ca15c7..fd4f01a2588 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.test.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.test.ts @@ -7,6 +7,7 @@ import { apportionCredits } from '@/lib/billing/credits/conversion' const { mockGetUserUsageLogs, mockGetUsageCreditsByLogId } = vi.hoisted(() => ({ mockGetUserUsageLogs: vi.fn(), + /** Still mocked because the module exports it; the export route must never call it. */ mockGetUsageCreditsByLogId: vi.fn(), })) @@ -47,7 +48,6 @@ describe('GET /api/users/me/usage-logs/export', () => { summary: { totalCost: 0.5, bySource: { copilot: 0.5 } }, pagination: { hasMore: false }, }) - mockGetUsageCreditsByLogId.mockResolvedValue(apportionCredits([{ key: 'log-1', dollars: 0.5 }])) const response = await GET(createMockRequest('GET')) const csv = await response.text() @@ -108,10 +108,6 @@ describe('GET /api/users/me/usage-logs/export', () => { 'user-1', expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) ) - expect(mockGetUsageCreditsByLogId).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) - ) }) it('names the specific workflow for workflow-sourced rows', async () => { @@ -208,7 +204,9 @@ describe('GET /api/users/me/usage-logs/export', () => { expect(csv.split('\n')).toHaveLength(3) }) - it('apportions credits once over the whole filtered set, not per page', async () => { + it('apportions across pages so the printed rows reconcile to the printed total', async () => { + // 0.002 + 0.002 = 0.004 -> 0.8 credits -> 1 credit for the whole file. Rounding each + // row alone would print 0 and 0; the largest-remainder split prints 1 and 0. mockGetUserUsageLogs .mockResolvedValueOnce({ logs: [ @@ -224,16 +222,35 @@ describe('GET /api/users/me/usage-logs/export', () => { summary: { totalCost: 0, bySource: {} }, pagination: { hasMore: false }, }) - mockGetUsageCreditsByLogId.mockResolvedValue( - apportionCredits([ - { key: 'log-1', dollars: 0.002 }, - { key: 'log-2', dollars: 0.002 }, - ]) + + const csv = await (await GET(createMockRequest('GET'))).text() + const printed = csv + .split('\n') + .slice(1) + .map((line) => Number(line.split(',')[2])) + + expect(printed.reduce((sum, credits) => sum + credits, 0)).toBe( + Object.values( + apportionCredits([ + { key: 'log-1', dollars: 0.002 }, + { key: 'log-2', dollars: 0.002 }, + ]) + ).reduce((sum, credits) => sum + credits, 0) ) + }) + + it('never issues the whole-filter apportionment read, so the safety cap actually bounds it', async () => { + // The cap stops the paging loop; a second unbounded read beside it would make that + // cap meaningless, and with `period=all` it is a full lifetime scan. + mockGetUserUsageLogs.mockResolvedValueOnce({ + logs: [{ id: 'log-1', createdAt: '2026-07-01T00:00:00.000Z', source: 'copilot', cost: 0.5 }], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }) await GET(createMockRequest('GET')) - expect(mockGetUsageCreditsByLogId).toHaveBeenCalledTimes(1) + expect(mockGetUsageCreditsByLogId).not.toHaveBeenCalled() }) it('stops at exactly the safety cap without an extra wasted page fetch', async () => { diff --git a/apps/sim/app/api/users/me/usage-logs/export/route.ts b/apps/sim/app/api/users/me/usage-logs/export/route.ts index e9c20256455..97fc7e3bffd 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -3,7 +3,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { exportUsageLogsContract } from '@/lib/api/contracts/user' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { getUsageCreditsByLogId, getUserUsageLogs } from '@/lib/billing/core/usage-log' +import { getUserUsageLogs } from '@/lib/billing/core/usage-log' +import { apportionCredits } from '@/lib/billing/credits/conversion' import { BILLING_USAGE_LOG_SOURCE_LABELS, toBillingUsageLogSource, @@ -70,7 +71,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => { cursorCreatedAt = lastRow ? new Date(lastRow.createdAt) : undefined } - const creditsByLogId = await getUsageCreditsByLogId(auth.userId, filter) + /** + * Apportioned over the rows this file actually contains, not over a second + * unbounded read of the whole filter. + * + * `getUsageCreditsByLogId` exists for the paginated list, where a row must show the + * same value on every page and only the full set can guarantee that. An export has + * no pages: it already holds every row it will print. Re-reading the filter here + * made {@link EXPORT_SAFETY_CAP} illusory — the loop stopped at the cap and the very + * next statement loaded every matching row anyway, which with `period=all` is an + * unbounded lifetime scan. + * + * The values are unchanged in the ordinary case, because an untruncated export's row + * set *is* the whole filter. When it truncates they are strictly better: the printed + * rows now reconcile to the printed total instead of to one that includes rows the + * file does not contain. + */ + const creditsByLogId = apportionCredits(rows.map((log) => ({ key: log.id, dollars: log.cost }))) if (truncated) { logger.error('Usage log export hit the safety cap — investigate this account', { diff --git a/apps/sim/app/api/v1/admin/organizations/route.ts b/apps/sim/app/api/v1/admin/organizations/route.ts index ecc446207d3..497d0cad173 100644 --- a/apps/sim/app/api/v1/admin/organizations/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/route.ts @@ -19,6 +19,10 @@ * - ownerId: string - User ID of the organization owner (required) * * Response: AdminSingleResponse + * + * Creates the organization and its owner membership, and nothing else. Attaching + * or creating a workspace for it is deliberately not done here — see the note on + * `adminV1CreateOrganizationContract`. */ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' diff --git a/apps/sim/app/organization/[organizationId]/settings/[section]/page.tsx b/apps/sim/app/organization/[organizationId]/settings/[section]/page.tsx deleted file mode 100644 index ab2f2fce4ae..00000000000 --- a/apps/sim/app/organization/[organizationId]/settings/[section]/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import type { Metadata } from 'next' -import { notFound, redirect } from 'next/navigation' -import { - getOrganizationSettingsFeatures, - getSettingsSectionMeta, - isOrganizationSettingsSectionAvailable, - ORGANIZATION_SETTINGS_ITEMS, - ORGANIZATION_SETTINGS_PATH_ALIASES, - parseSettingsPathSection, -} from '@/components/settings/navigation' -import { OrganizationSettingsRenderer } from '@/components/settings/organization-settings-renderer' -import { SettingsUnavailable } from '@/components/settings/settings-unavailable' -import { getSession } from '@/lib/auth' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing' -import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' - -interface OrganizationSettingsSectionPageProps { - params: Promise<{ organizationId: string; section: string }> -} - -export async function generateMetadata({ - params, -}: OrganizationSettingsSectionPageProps): Promise { - const { section } = await params - const parsed = parseSettingsPathSection({ - path: section, - items: ORGANIZATION_SETTINGS_ITEMS, - defaultSection: null, - aliases: ORGANIZATION_SETTINGS_PATH_ALIASES, - }) - const meta = parsed ? getSettingsSectionMeta('organization', parsed) : null - return { title: meta ? `${meta.label} - Organization settings` : 'Organization settings' } -} - -export default async function OrganizationSettingsSectionPage({ - params, -}: OrganizationSettingsSectionPageProps) { - const session = await getSession() - if (!session?.user) redirect('/login') - - const { organizationId, section } = await params - const parsed = parseSettingsPathSection({ - path: section, - items: ORGANIZATION_SETTINGS_ITEMS, - defaultSection: null, - aliases: ORGANIZATION_SETTINGS_PATH_ALIASES, - }) - if (!parsed) notFound() - - const canOpen = await canOpenOrganizationSettingsSection(organizationId, session.user.id, parsed) - if (!canOpen) return - const hasEnterprisePlan = - parsed !== 'members' && - parsed !== 'billing' && - (await isOrganizationOnEnterprisePlan(organizationId)) - if ( - !isOrganizationSettingsSectionAvailable( - parsed, - getOrganizationSettingsFeatures(hasEnterprisePlan) - ) - ) { - return ( - - ) - } - - return -} diff --git a/apps/sim/app/organization/[organizationId]/settings/layout.tsx b/apps/sim/app/organization/[organizationId]/settings/layout.tsx deleted file mode 100644 index 12dce2f96e3..00000000000 --- a/apps/sim/app/organization/[organizationId]/settings/layout.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { redirect } from 'next/navigation' -import { SettingsUnavailable } from '@/components/settings/settings-unavailable' -import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell' -import { getSession } from '@/lib/auth' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing' -import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' - -interface OrganizationSettingsLayoutProps { - children: React.ReactNode - params: Promise<{ organizationId: string }> -} - -export default async function OrganizationSettingsLayout({ - children, - params, -}: OrganizationSettingsLayoutProps) { - const session = await getSession() - if (!session?.user) redirect('/login') - - const { organizationId } = await params - const access = await getOrganizationSettingsAccess(organizationId, session.user.id) - if (!access.isMember) return - const hasEnterprisePlan = access.isAdmin && (await isOrganizationOnEnterprisePlan(organizationId)) - - return ( - - {children} - - ) -} diff --git a/apps/sim/app/organization/[organizationId]/settings/page.tsx b/apps/sim/app/organization/[organizationId]/settings/page.tsx deleted file mode 100644 index c0d5c917a48..00000000000 --- a/apps/sim/app/organization/[organizationId]/settings/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { redirect } from 'next/navigation' -import { getOrganizationSettingsHref } from '@/components/settings/navigation' - -interface OrganizationSettingsPageProps { - params: Promise<{ organizationId: string }> -} - -export default async function OrganizationSettingsPage({ params }: OrganizationSettingsPageProps) { - const { organizationId } = await params - redirect(getOrganizationSettingsHref(organizationId, 'members')) -} diff --git a/apps/sim/app/organization/[organizationId]/settings/unavailable/page.tsx b/apps/sim/app/organization/[organizationId]/settings/unavailable/page.tsx deleted file mode 100644 index 9e4bd26f677..00000000000 --- a/apps/sim/app/organization/[organizationId]/settings/unavailable/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SettingsUnavailable } from '@/components/settings/settings-unavailable' - -export default function OrganizationSettingsUnavailablePage() { - return -} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/index.ts index 7f3469b912e..85d51971767 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/index.ts @@ -1,3 +1,2 @@ -export { LineChart, type LineChartMultiSeries, type LineChartPoint } from './line-chart' export { StatusBar, type StatusBarSegment } from './status-bar' export { type WorkflowExecutionItem, WorkflowsList } from './workflows-list' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/index.ts deleted file mode 100644 index be4131ebb5a..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LineChart, type LineChartMultiSeries, type LineChartPoint } from './line-chart' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx index 83a53d724c0..44b145c8b54 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx @@ -3,6 +3,7 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react' import { Loader } from '@sim/emcn' import { useParams } from 'next/navigation' +import { LineChart } from '@/components/charts' import { DashboardSegmentsContext, type SegmentSelectionMode, @@ -11,7 +12,7 @@ import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log- import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs' import { useWorkflows } from '@/hooks/queries/workflows' -import { LineChart, WorkflowsList } from './components' +import { WorkflowsList } from './components' interface WorkflowExecution { workflowId: string diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index 4736430b5b2..b45d859aee4 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -1,7 +1,8 @@ import React from 'react' import { Badge } from '@sim/emcn' -import { formatDuration, formatRelativeTime } from '@sim/utils/formatting' +import { formatRelativeTime } from '@sim/utils/formatting' import { format } from 'date-fns' +import { formatChartLatency } from '@/components/charts/chart-format' import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options' import { getBlock } from '@/blocks/registry' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' @@ -199,13 +200,16 @@ export function parseDuration(log: LogWithDuration): number | null { } /** - * Format latency value for display in dashboard UI + * Format latency value for display in dashboard UI. + * + * Delegates so the axis ticks and the surrounding table can never disagree about + * what a duration reads as. + * * @param ms - Latency in milliseconds (number) * @returns Formatted latency string */ export function formatLatency(ms: number): string { - if (!Number.isFinite(ms) || ms <= 0) return '—' - return formatDuration(ms, { precision: 2 }) ?? '—' + return formatChartLatency(ms) } export const formatDate = (dateString: string) => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx index 26e408c6af1..205c416042e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -42,6 +42,19 @@ vi.mock('@/components/settings/navigation', () => ({ getOrganizationSettingsFeatures: vi.fn(() => ({})), isOrganizationSettingsSectionAvailable: mockIsOrganizationSettingsSectionAvailable, resolveWorkspaceNavigation: mockResolveWorkspaceNavigation, + /** Mirrors the registry-derived map; a section absent here gets no organization gate. */ + UNIFIED_TO_ORGANIZATION_SECTION: { + organization: 'members', + billing: 'billing', + 'access-control': 'access-control', + 'audit-logs': 'audit-logs', + sso: 'sso', + sessions: 'sessions', + 'data-retention': 'data-retention', + 'data-drains': 'data-drains', + usage: 'usage', + whitelabeling: 'whitelabeling', + }, workspaceSectionUsesPermissionConfig: vi.fn((section: string) => ['secrets', 'api-keys', 'inbox', 'mcp', 'custom-tools'].includes(section) ), diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 0cf8a89a781..ca0c7426125 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -5,8 +5,8 @@ import { notFound, redirect } from 'next/navigation' import { getOrganizationSettingsFeatures, isOrganizationSettingsSectionAvailable, - type OrganizationSettingsSection, resolveWorkspaceNavigation, + UNIFIED_TO_ORGANIZATION_SECTION, type WorkspaceSettingsSection, workspaceSectionUsesPermissionConfig, } from '@/components/settings/navigation' @@ -48,18 +48,6 @@ const WORKSPACE_SECTION_MAP: Partial> = { - organization: 'members', - billing: 'billing', - 'access-control': 'access-control', - 'audit-logs': 'audit-logs', - sso: 'sso', - sessions: 'sessions', - 'data-retention': 'data-retention', - 'data-drains': 'data-drains', - whitelabeling: 'whitelabeling', -} - /** * Settings availability varies across workspaces, so a preserved section may * need to land on the destination workspace's universally available page. @@ -163,7 +151,7 @@ export default async function WorkspaceSettingsSectionPage({ } } - const organizationSection = ORGANIZATION_SECTION_MAP[parsed] + const organizationSection = UNIFIED_TO_ORGANIZATION_SECTION[parsed] if (organizationSection) { if (!isBillingEnabled && (parsed === 'billing' || parsed === 'organization')) { redirectToGeneralSettings(workspaceId) @@ -173,7 +161,12 @@ export default async function WorkspaceSettingsSectionPage({ redirectToGeneralSettings(workspaceId) } } else { - if (!hostContext.viewer.isHostOrganizationAdmin) { + /** + * The roster is the one organization section a plain member may open, read-only + * (`resolveOrganizationSectionAccess` returns `'view'` for it below). Everything + * else acts on the organization and stays admin-only. + */ + if (organizationSection !== 'members' && !hostContext.viewer.isHostOrganizationAdmin) { redirectToGeneralSettings(workspaceId) } /** diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index edb01ca9214..b565424b422 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -104,6 +104,9 @@ const DataRetentionSettings = dynamic(() => const DataDrainsSettings = dynamic(() => import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings) ) +const UsageMonitoring = dynamic(() => + import('@/ee/organization-usage/components/usage-monitoring').then((m) => m.UsageMonitoring) +) const Desktop = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/desktop/desktop').then((m) => m.Desktop) ) @@ -172,6 +175,9 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'audit-logs' && organizationId && ( )} + {effectiveSection === 'usage' && organizationId && ( + + )} {effectiveSection === 'apikeys' && } {isBillingEnabled && effectiveSection === 'billing' && ( 0 ? Math.min(segments, Math.round((used / scale) * segments)) : 0 + const allowedSegments = scale > 0 ? Math.round((total / scale) * segments) : 0 + + return ( + -
- {Array.from({ length: pillCount }).map((_, i) => { - const isFilled = i < usedSeats - const isOverage = i >= totalSeats - return ( -
- ) - })} -
+

{isOverLimit diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx index 94c191fb855..ffa5a03c2ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx @@ -94,7 +94,11 @@ describe('TeamManagement organization errors', () => { isLoading: false, }) - act(() => root.render()) + act(() => + root.render( + + ) + ) expect(container.textContent).toContain('Organization request failed') expect(container.textContent).not.toContain('no-organization-view') diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index b62f1e0b292..8efc23f7ba5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -35,13 +35,14 @@ const logger = createLogger('TeamManagement') interface TeamManagementProps { organizationId: string - billingHref?: string + /** + * Required: organization billing is reached only through a workspace, so the + * caller — which knows the workspace — is the only thing that can build it. + */ + billingHref: string } -export function TeamManagement({ - organizationId, - billingHref = `/organization/${organizationId}/settings/billing`, -}: TeamManagementProps) { +export function TeamManagement({ organizationId, billingHref }: TeamManagementProps) { const { data: session } = useSession() const { isInvitationsDisabled } = usePermissionConfig() const [memberQuery, setMemberQuery] = useSettingsSearch() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index b3c64e62c0d..f304c1e6574 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -31,6 +31,7 @@ describe('unified settings navigation', () => { { id: 'billing', label: 'Subscription', section: 'account' }, { id: 'teammates', label: 'Teammates', section: 'workspace' }, { id: 'organization', label: 'Members', section: 'organization' }, + { id: 'usage', label: 'Usage tracking', section: 'organization' }, { id: 'secrets', label: 'Secrets', section: 'workspace' }, { id: 'credential-groups', label: 'Credential groups', section: 'workspace' }, { id: 'custom-tools', label: 'Custom tools', section: 'workspace' }, @@ -82,6 +83,7 @@ describe('unified settings navigation', () => { ]) expect(idsForSection('organization')).toEqual([ 'organization', + 'usage', 'custom-blocks', 'forks', 'access-control', diff --git a/apps/sim/app/workspace/[workspaceId]/settings/usage/events/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/usage/events/layout.tsx new file mode 100644 index 00000000000..5886a0c78b5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/usage/events/layout.tsx @@ -0,0 +1,18 @@ +import { + SettingsHeaderProvider, + SettingsHeaderShell, +} from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' + +/** + * Usage events is a static route outside `[section]`, so it inherits none of + * `SettingsSectionLayout`'s chrome. Its body renders through `SettingsPanel`, which + * only registers header config into `SettingsHeaderProvider` — without this shell the + * page would have no header bar, title, or scroll region. + */ +export default function UsageEventsLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/usage/events/loading.tsx b/apps/sim/app/workspace/[workspaceId]/settings/usage/events/loading.tsx new file mode 100644 index 00000000000..aa745a1024e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/usage/events/loading.tsx @@ -0,0 +1,22 @@ +'use client' + +import { ArrowLeft } from '@sim/emcn/icons' +import { useParams, useRouter } from 'next/navigation' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' + +export default function UsageEventsLoading() { + const router = useRouter() + const { workspaceId } = useParams<{ workspaceId: string }>() + + return ( + router.push(`/workspace/${workspaceId}/settings/usage`), + }} + title='Usage events' + description='Every credit-consuming event behind your usage.' + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/usage/events/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/usage/events/page.tsx new file mode 100644 index 00000000000..36c0777ef07 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/usage/events/page.tsx @@ -0,0 +1,47 @@ +import { Suspense } from 'react' +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription' +import { isUsageMonitoringEnabled } from '@/lib/core/config/env-flags' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' +import UsageEventsLoading from '@/app/workspace/[workspaceId]/settings/usage/events/loading' +import { UsageEventsView } from '@/ee/organization-usage/components/usage-events-view' + +export const metadata: Metadata = { + title: 'Usage events', +} + +interface UsageEventsPageProps { + params: Promise<{ workspaceId: string }> +} + +/** + * This route sits outside `[section]`, so it inherits none of that page's gates and + * has to repeat them: a host organization, an org-admin viewer, and the enterprise + * entitlement. The API refuses regardless; this keeps a deep link from rendering a + * shell the viewer can never fill. + */ +export default async function UsageEventsPage({ params }: UsageEventsPageProps) { + const session = await getSession() + if (!session?.user) redirect('/login') + + const { workspaceId } = await params + const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id) + const organizationId = hostContext?.hostOrganizationId + if (!hostContext || !organizationId || !hostContext.viewer.isHostOrganizationAdmin) { + redirect(`/workspace/${workspaceId}/settings/general`) + } + if (!(await isOrganizationFeatureEntitled(organizationId, isUsageMonitoringEnabled))) { + redirect(`/workspace/${workspaceId}/settings/general`) + } + + return ( + }> + + + ) +} diff --git a/apps/sim/components/charts/bar-chart.tsx b/apps/sim/components/charts/bar-chart.tsx new file mode 100644 index 00000000000..8a40747c18b --- /dev/null +++ b/apps/sim/components/charts/bar-chart.tsx @@ -0,0 +1,373 @@ +'use client' + +import { memo, useId, useMemo, useState } from 'react' +import { cn } from '@sim/emcn' +import { + formatChartCompactNumber, + formatChartLatency, + formatChartTimestamp, +} from '@/components/charts/chart-format' +import { + CHART_DEFAULT_HEIGHT, + CHART_GRID_FRACTIONS, + CHART_PADDING, + CHART_TICK_FILL, + CHART_TICK_FONT_SIZE, + chartPlotBand, + formatTimeTick, + resolveSpanMs, + resolveTimeTickIndices, +} from '@/components/charts/chart-geometry' +import { + ChartTooltip, + ChartTooltipRow, + estimateTooltipWidth, + positionChartTooltip, +} from '@/components/charts/chart-tooltip' +import { + useChartWidth, + useIsDarkTheme, + useResolvedChartColors, +} from '@/components/charts/use-chart-theme' + +export interface BarChartPoint { + timestamp: string + value: number +} + +interface BarChartProps { + data: BarChartPoint[] + /** Pass `''` for the caller-owned-wrapper form, mirroring {@link LineChart}. */ + label: string + color: string + /** `''` | `'%'` | `'ms'` | `'latency'` | `'credits'` — drives tick and tooltip formatting. */ + unit?: string + height?: number + /** Bucket drawn at full opacity, e.g. the period in progress. */ + highlightIndex?: number +} + +/** + * Discrete time buckets as bars. + * + * The sibling of {@link LineChart}, and deliberately built from the same geometry, + * tooltip, and theme modules: a smoothed line implies a continuous signal between + * samples, which is wrong for a calendar bucket like a day's spend, but the two must + * still line up pixel-for-pixel when stacked in one card. + */ +function BarChartComponent({ + data, + label, + color, + unit, + height = CHART_DEFAULT_HEIGHT, + highlightIndex, +}: BarChartProps) { + /* + `useId`, not `useRef(generateShortId())`: a ref initializer is evaluated on + every render and all but the first result thrown away, and React already has + a hook whose whole job is a stable unique id. + */ + const uniqueId = useId().replace(/:/g, '') + const [containerRef, containerWidth] = useChartWidth() + const width = containerWidth ?? 0 + const padding = CHART_PADDING + const chartWidth = width - padding.left - padding.right + const chartHeight = height - padding.top - padding.bottom + const { yMin, yMax } = chartPlotBand(height) + const isDark = useIsDarkTheme() + const [hoverIndex, setHoverIndex] = useState(null) + const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null) + + const colorTokens = useMemo(() => ({ base: color }), [color]) + const resolvedColors = useResolvedChartColors(colorTokens) + const resolvedColor = resolvedColors.base || color + + const hasExternalWrapper = !label + + /** + * The track is read against its own background, so its opacity is per-theme + * rather than one shared value. `--border` is the platform's neutral track + * token — the same one the proportional row meters use — but it resolves to + * `#444` on dark and `#d8d8d8` on light, and a strength that reads as a column + * on near-black is a half-percent delta on white. Hover keeps the same ratio. + */ + const trackOpacity = isDark ? 0.12 : 0.3 + const trackHoverOpacity = isDark ? 0.22 : 0.5 + + const maxValue = useMemo(() => { + const peak = Math.max(...data.map((d) => d.value), 0) + return peak <= 0 ? 1 : peak * 1.1 + }, [data]) + + /** Slot geometry: every bucket owns an equal slice, with the bar centred in it. */ + const slot = data.length > 0 ? Math.max(1, chartWidth) / data.length : 0 + const barWidth = Math.max(1, Math.min(24, slot * 0.7)) + + const bars = useMemo( + () => + data.map((point, index) => { + const x = padding.left + slot * index + (slot - barWidth) / 2 + const rawY = padding.top + chartHeight - (point.value / maxValue) * chartHeight + const y = Math.max(yMin, Math.min(yMax, rawY)) + return { + x, + y, + /* + * A zero bucket draws nothing. The clamp above keeps a *drawn* bar off the + * axis rule, but applied to zero it floored the bar at the 3px band and + * every empty day rendered as a small amount of usage — the densified zeros + * this chart exists to show honestly. Only the track represents an empty + * bucket. + */ + height: point.value > 0 ? Math.max(0, height - padding.bottom - y) : 0, + point, + } + }), + [data, slot, barWidth, maxValue, chartHeight, height, padding.left, padding.top, yMin, yMax] + ) + + const formatValue = (value?: number) => { + if (typeof value !== 'number' || !Number.isFinite(value)) return '—' + const suffix = (unit ?? '').toLowerCase() + if (suffix.includes('%')) return `${value.toFixed(1)}%` + if (suffix === 'latency') return formatChartLatency(value) + if (suffix.includes('ms')) return `${Math.round(value)}ms` + if (suffix === 'credits') return formatChartCompactNumber(value) + return `${Math.round(value)}${unit ?? ''}` + } + + if (containerWidth === null) { + return ( +

+ ) + } + + if (data.length === 0) { + return ( + // Keeps the measurement ref: dropping it here left the observer watching a + // detached node, so a resize while empty was never seen and the next non-empty + // render laid out at the stale width. +
+

No data

+
+ ) + } + + const spanMs = resolveSpanMs(data) + const tickIndices = resolveTimeTickIndices(data.length, Math.max(1, chartWidth)) + + return ( +
+ {!hasExternalWrapper && ( +
+

{label}

+
+ )} +
+ { + if (bars.length === 0 || slot <= 0) return + const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect() + const x = e.clientX - rect.left + // Bars own a slot, so the hovered bucket is which slot the cursor is in — + // not the nearest sample, which is how a line chart resolves it. + const index = Math.max( + 0, + Math.min(data.length - 1, Math.floor((x - padding.left) / slot)) + ) + setHoverIndex(index) + setHoverPos({ x, y: e.clientY - rect.top }) + }} + onMouseLeave={() => { + setHoverIndex(null) + setHoverPos(null) + }} + > + + + + + + + + + + {CHART_GRID_FRACTIONS.map((fraction) => ( + + ))} + + {/* + A full-height track keeps an empty bucket visible and gives every slot + the same hover target, so a run of zero days reads as zero rather than + as missing data. + + Drawn outside the blend group below: the bars want `screen` on dark so + the gradient stays luminous, but a track composited that way is only + legible against a dark background, and on white it disappears. + */} + + {bars.map((bar, index) => ( + + ))} + + + + {bars.map( + (bar, index) => + bar.height > 0 && ( + + ) + )} + + + {tickIndices.map((index) => { + const timestamp = data[index]?.timestamp + if (!timestamp) return null + const date = new Date(timestamp) + return ( + + {Number.isNaN(date.getTime()) ? '' : formatTimeTick(date, spanMs)} + + ) + })} + + + {/* Same formatter the tooltip uses, or the axis and the hover disagree + about what the numbers mean on any non-`credits` unit. */} + {formatValue(maxValue)} + + + 0 + + + + + + {hoverIndex !== null && + bars[hoverIndex] && + (() => { + const bar = bars[hoverIndex] + const value = formatValue(bar.point.value) + const { left, top } = positionChartTooltip({ + anchorX: hoverPos?.x ?? bar.x, + anchorY: hoverPos?.y ?? bar.y, + width, + height, + tooltipMaxWidth: estimateTooltipWidth(value.length), + }) + return ( + + + + ) + })()} +
+
+ ) +} + +export const BarChart = memo(BarChartComponent) diff --git a/apps/sim/components/charts/chart-format.ts b/apps/sim/components/charts/chart-format.ts new file mode 100644 index 00000000000..c5b7151814b --- /dev/null +++ b/apps/sim/components/charts/chart-format.ts @@ -0,0 +1,31 @@ +import { formatDuration } from '@sim/utils/formatting' +import { format } from 'date-fns' + +/** + * Value and tick formatting shared by the chart family. + * + * These live here rather than in the logs feature's `utils.ts` because that module + * imports the block registry, and a chart that reached for it would drag the whole + * executable registry into every consumer's bundle. + */ + +/** Duration for an axis tick or tooltip. `—` for a missing or non-positive value. */ +export function formatChartLatency(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return '—' + return formatDuration(ms, { precision: 2 }) ?? '—' +} + +/** The tooltip's header line: `MAR 4 3:05 PM`. Empty for an unparseable timestamp. */ +export function formatChartTimestamp(timestamp?: string): string { + if (!timestamp) return '' + const date = new Date(timestamp) + if (Number.isNaN(date.getTime())) return '' + return `${format(date, 'MMM d').toUpperCase()} ${format(date, 'h:mm a')}` +} + +/** Compact axis magnitude — `1.2k`, `3.4m`. */ +export function formatChartCompactNumber(value: number): string { + return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }) + .format(value) + .toLowerCase() +} diff --git a/apps/sim/components/charts/chart-geometry.test.ts b/apps/sim/components/charts/chart-geometry.test.ts new file mode 100644 index 00000000000..82c2694abbe --- /dev/null +++ b/apps/sim/components/charts/chart-geometry.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + CHART_PADDING, + chartPlotBand, + formatTimeTick, + resolveSpanMs, + resolveTimeTickIndices, +} from '@/components/charts/chart-geometry' + +describe('resolveTimeTickIndices', () => { + it('budgets roughly one tick per 64px, clamped to 3..8', () => { + expect(resolveTimeTickIndices(100, 100).length).toBe(3) + expect(resolveTimeTickIndices(100, 320).length).toBe(5) + expect(resolveTimeTickIndices(100, 4000).length).toBe(8) + }) + + it('dedupes the collisions rounding produces on a short series', () => { + // 2 points across a wide chart wants 8 ticks but only has indices 0 and 1. + const indices = resolveTimeTickIndices(2, 4000) + expect(indices).toEqual([...new Set(indices)]) + expect(indices.every((index) => index >= 0 && index < 2)).toBe(true) + }) + + it('always spans the full series, first index to last', () => { + const indices = resolveTimeTickIndices(50, 640) + expect(indices[0]).toBe(0) + expect(indices[indices.length - 1]).toBe(49) + }) +}) + +describe('formatTimeTick', () => { + const date = new Date('2026-03-04T15:05:00.000Z') + + it('shows clock time within a day and a half', () => { + expect(formatTimeTick(date, 36 * 60 * 60 * 1000)).toMatch(/^\d{2}:\d{2}$/) + }) + + it('shows a calendar day within a quarter', () => { + expect(formatTimeTick(date, 40 * 24 * 60 * 60 * 1000)).toMatch(/^[A-Z][a-z]{2} \d{1,2}$/) + }) + + it('shows month and year beyond a quarter', () => { + expect(formatTimeTick(date, 400 * 24 * 60 * 60 * 1000)).toMatch(/^[A-Z][a-z]{2} \d{4}$/) + }) +}) + +describe('resolveSpanMs', () => { + it('measures first to last', () => { + expect( + resolveSpanMs([ + { timestamp: '2026-03-04T00:00:00.000Z' }, + { timestamp: '2026-03-05T00:00:00.000Z' }, + ]) + ).toBe(24 * 60 * 60 * 1000) + }) + + it('returns 0 for a degenerate or unparseable series rather than NaN', () => { + expect(resolveSpanMs([])).toBe(0) + expect(resolveSpanMs([{ timestamp: '2026-03-04T00:00:00.000Z' }])).toBe(0) + expect(resolveSpanMs([{ timestamp: 'nope' }, { timestamp: 'also nope' }])).toBe(0) + }) +}) + +describe('chartPlotBand', () => { + it('insets the band so strokes clear the axis rules', () => { + // The line and bar charts both clamp to this band, which is what keeps them + // aligned when stacked in the same card. + expect(chartPlotBand(166)).toEqual({ + yMin: CHART_PADDING.top + 3, + yMax: CHART_PADDING.top + (166 - CHART_PADDING.top - CHART_PADDING.bottom) - 3, + }) + }) + + it('tracks a caller-supplied height', () => { + expect(chartPlotBand(240).yMax).toBeGreaterThan(chartPlotBand(166).yMax) + }) +}) diff --git a/apps/sim/components/charts/chart-geometry.ts b/apps/sim/components/charts/chart-geometry.ts new file mode 100644 index 00000000000..e85a010d251 --- /dev/null +++ b/apps/sim/components/charts/chart-geometry.ts @@ -0,0 +1,67 @@ +/** + * Geometry shared by every chart in the family. + * + * Extracted so a sibling chart cannot drift: bar and line charts read the same + * padding, the same clamps, the same gridlines, and resolve their x ticks the same + * way, so two charts stacked in one card line up on the pixel. + * + * Pure — no React, no DOM — so a server module can read the constants. + */ + +export const CHART_PADDING = { top: 16, right: 28, bottom: 26, left: 26 } as const + +/** Matches the loader placeholders callers size themselves against. */ +export const CHART_DEFAULT_HEIGHT = 166 + +/** Below this the axis labels collide, so the chart scrolls rather than compresses. */ +export const CHART_MIN_WIDTH = 280 + +export const CHART_TICK_FILL = 'var(--text-tertiary)' +export const CHART_TICK_FONT_SIZE = '9' +export const CHART_GRID_FRACTIONS = [0.25, 0.5, 0.75] as const + +/** Vertical clamp for plotted geometry, keeping strokes off the axis rules. */ +export function chartPlotBand(height: number): { yMin: number; yMax: number } { + const chartHeight = height - CHART_PADDING.top - CHART_PADDING.bottom + return { yMin: CHART_PADDING.top + 3, yMax: CHART_PADDING.top + chartHeight - 3 } +} + +/** + * Evenly spaced point indices to label, budgeting ~64px per tick and deduping the + * collisions that rounding produces on short series. + */ +export function resolveTimeTickIndices(pointCount: number, usableWidth: number): number[] { + const approxLabelWidth = 64 + const desired = Math.min(8, Math.max(3, Math.floor(usableWidth / approxLabelWidth))) + const seen = new Set() + return Array.from({ length: desired }, (_, i) => + Math.round((i * (pointCount - 1)) / Math.max(1, desired - 1)) + ).filter((index) => { + if (seen.has(index)) return false + seen.add(index) + return true + }) +} + +/** + * Tick label whose precision follows the window: clock time within a day and a half, + * calendar day within a quarter, month beyond that. + */ +export function formatTimeTick(date: Date, spanMs: number): string { + if (spanMs <= 36 * 60 * 60 * 1000) { + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) + } + if (spanMs <= 90 * 24 * 60 * 60 * 1000) { + return date.toLocaleString('en-US', { month: 'short', day: 'numeric' }) + } + return date.toLocaleString('en-US', { month: 'short', year: 'numeric' }) +} + +/** Milliseconds between the first and last timestamp, or 0 for a degenerate series. */ +export function resolveSpanMs(points: ReadonlyArray<{ timestamp: string }>): number { + if (points.length < 2) return 0 + const first = new Date(points[0].timestamp).getTime() + const last = new Date(points[points.length - 1].timestamp).getTime() + if (Number.isNaN(first) || Number.isNaN(last)) return 0 + return Math.abs(last - first) +} diff --git a/apps/sim/components/charts/chart-tooltip.tsx b/apps/sim/components/charts/chart-tooltip.tsx new file mode 100644 index 00000000000..6a49032394a --- /dev/null +++ b/apps/sim/components/charts/chart-tooltip.tsx @@ -0,0 +1,89 @@ +'use client' + +import type { ReactNode } from 'react' +import { CHART_PADDING } from '@/components/charts/chart-geometry' + +/** + * The chart family's hover surface. Defined once so a sibling chart cannot ship a + * tooltip that looks almost the same — this class string was previously duplicated + * between the line chart and the status bar. + */ +export const CHART_TOOLTIP_CLASSES = + 'pointer-events-none absolute rounded-lg border border-[var(--border-1)] bg-[var(--surface-1)] px-2 py-1.5 text-xs shadow-lg' + +interface PositionChartTooltipArgs { + anchorX: number + anchorY: number + width: number + height: number + tooltipMaxWidth: number +} + +/** + * Places the tooltip beside the cursor, preferring the right and flipping left when + * it would overflow, then clamping into the plot band so it never escapes the card. + */ +export function positionChartTooltip({ + anchorX, + anchorY, + width, + height, + tooltipMaxWidth, +}: PositionChartTooltipArgs): { left: number; top: number } { + const margin = 10 + const rightEdge = width - CHART_PADDING.right + const preferRight = anchorX + margin + tooltipMaxWidth <= rightEdge + const left = preferRight + ? Math.max(CHART_PADDING.left, Math.min(anchorX + margin, rightEdge - tooltipMaxWidth)) + : Math.max( + CHART_PADDING.left, + Math.min(anchorX - margin - tooltipMaxWidth, rightEdge - tooltipMaxWidth) + ) + const top = Math.min( + Math.max(anchorY - 26, CHART_PADDING.top), + height - CHART_PADDING.bottom - 18 + ) + return { left, top } +} + +/** Width estimate for the longest `label value` row, used by {@link positionChartTooltip}. */ +export function estimateTooltipWidth(longestRowLength: number): number { + return Math.min(220, Math.max(80, 7 * longestRowLength + 24)) +} + +interface ChartTooltipProps { + left: number + top: number + /** Header line; omitted when the timestamp could not be formatted. */ + date?: string + children: ReactNode +} + +export function ChartTooltip({ left, top, date, children }: ChartTooltipProps) { + return ( +
+ {date &&
{date}
} + {children} +
+ ) +} + +interface ChartTooltipRowProps { + color: string + label?: string + value: string +} + +export function ChartTooltipRow({ color, label, value }: ChartTooltipRowProps) { + return ( +
+
+ ) +} diff --git a/apps/sim/components/charts/index.ts b/apps/sim/components/charts/index.ts new file mode 100644 index 00000000000..138241f0436 --- /dev/null +++ b/apps/sim/components/charts/index.ts @@ -0,0 +1,12 @@ +export { BarChart, type BarChartPoint } from '@/components/charts/bar-chart' +export { + formatChartCompactNumber, + formatChartLatency, + formatChartTimestamp, +} from '@/components/charts/chart-format' +export { CHART_DEFAULT_HEIGHT } from '@/components/charts/chart-geometry' +export { + LineChart, + type LineChartMultiSeries, + type LineChartPoint, +} from '@/components/charts/line-chart' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/line-chart.tsx b/apps/sim/components/charts/line-chart.tsx similarity index 74% rename from apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/line-chart.tsx rename to apps/sim/components/charts/line-chart.tsx index 9717ab254e7..6cfd8d43979 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/line-chart/line-chart.tsx +++ b/apps/sim/components/charts/line-chart.tsx @@ -1,7 +1,34 @@ -import { memo, useEffect, useMemo, useRef, useState } from 'react' +'use client' + +import { memo, useId, useMemo, useState } from 'react' import { Button, cn } from '@sim/emcn' -import { generateShortId } from '@sim/utils/id' -import { formatDate, formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' +import { + formatChartCompactNumber, + formatChartLatency, + formatChartTimestamp, +} from '@/components/charts/chart-format' +import { + CHART_DEFAULT_HEIGHT, + CHART_GRID_FRACTIONS, + CHART_PADDING, + CHART_TICK_FILL, + CHART_TICK_FONT_SIZE, + chartPlotBand, + formatTimeTick, + resolveSpanMs, + resolveTimeTickIndices, +} from '@/components/charts/chart-geometry' +import { + ChartTooltip, + ChartTooltipRow, + estimateTooltipWidth, + positionChartTooltip, +} from '@/components/charts/chart-tooltip' +import { + useChartWidth, + useIsDarkTheme, + useResolvedChartColors, +} from '@/components/charts/use-chart-theme' export interface LineChartPoint { timestamp: string @@ -16,83 +43,50 @@ export interface LineChartMultiSeries { dashed?: boolean } +interface LineChartProps { + data: LineChartPoint[] + /** Pass `''` for the caller-owned-wrapper form: no card chrome, title, or legend. */ + label: string + color: string + unit?: string + series?: LineChartMultiSeries[] + height?: number +} + function LineChartComponent({ data, label, color, unit, series, -}: { - data: LineChartPoint[] - label: string - color: string - unit?: string - series?: LineChartMultiSeries[] -}) { - const containerRef = useRef(null) - const uniqueId = useRef(`chart-${generateShortId(7)}`).current - const [containerWidth, setContainerWidth] = useState(null) + height = CHART_DEFAULT_HEIGHT, +}: LineChartProps) { + /* + `useId`, not `useRef(generateShortId())`: a ref initializer is evaluated on + every render and all but the first result thrown away, and React already has + a hook whose whole job is a stable unique id. + */ + const uniqueId = useId().replace(/:/g, '') + const [containerRef, containerWidth] = useChartWidth() const width = containerWidth ?? 0 - const height = 166 - const padding = { top: 16, right: 28, bottom: 26, left: 26 } - useEffect(() => { - if (!containerRef.current) return - const element = containerRef.current - const ro = new ResizeObserver((entries) => { - const entry = entries[0] - if (entry?.contentRect && entry.contentRect.width > 0) { - const w = Math.max(280, Math.floor(entry.contentRect.width)) - setContainerWidth(w) - } - }) - ro.observe(element) - const rect = element.getBoundingClientRect() - if (rect?.width && rect.width > 0) setContainerWidth(Math.max(280, Math.floor(rect.width))) - return () => ro.disconnect() - }, []) + const padding = CHART_PADDING const chartWidth = width - padding.left - padding.right const chartHeight = height - padding.top - padding.bottom const [hoverIndex, setHoverIndex] = useState(null) - const [isDark, setIsDark] = useState(true) + const isDark = useIsDarkTheme() const [hoverSeriesId, setHoverSeriesId] = useState(null) const [activeSeriesId, setActiveSeriesId] = useState(null) const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null) - const [resolvedColors, setResolvedColors] = useState>({}) - - useEffect(() => { - if (typeof window === 'undefined') return - const el = document.documentElement - const update = () => setIsDark(el.classList.contains('dark')) - update() - const observer = new MutationObserver(update) - observer.observe(el, { attributes: true, attributeFilter: ['class'] }) - return () => observer.disconnect() - }, []) - - useEffect(() => { - if (typeof window === 'undefined') return - - const resolveColor = (c: string): string => { - if (!c.startsWith('var(')) return c - - const tempEl = document.createElement('div') - tempEl.style.color = c - document.body.appendChild(tempEl) - const computed = window.getComputedStyle(tempEl).color - document.body.removeChild(tempEl) - return computed - } - - const colorMap: Record = { base: resolveColor(color) } - const allSeriesToResolve = Array.isArray(series) && series.length > 0 ? series : [] - for (const s of allSeriesToResolve) { + const colorTokens = useMemo(() => { + const tokens: Record = { base: color } + for (const s of series ?? []) { const id = s.id || s.label || '' - if (id) colorMap[id] = resolveColor(s.color) + if (id) tokens[id] = s.color } - - setResolvedColors(colorMap) + return tokens }, [color, series]) + const resolvedColors = useResolvedChartColors(colorTokens) const hasExternalWrapper = !label || label === '' @@ -135,8 +129,7 @@ function LineChartComponent({ } }, [allSeries, unit]) - const yMin = padding.top + 3 - const yMax = padding.top + chartHeight - 3 + const { yMin, yMax } = chartPlotBand(height) const scaledPoints = useMemo( () => @@ -202,26 +195,8 @@ function LineChartComponent({ return d }, [scaledPoints, yMin, yMax]) - const getCompactDateLabel = (timestamp?: string) => { - if (!timestamp) return '' - try { - const f = formatDate(timestamp) - return `${f.compactDate} ${f.compactTime}` - } catch (e) { - const d = new Date(timestamp) - if (Number.isNaN(d.getTime())) return '' - return d.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }) - } - } - const currentHoverDate = - hoverIndex !== null && data[hoverIndex] ? getCompactDateLabel(data[hoverIndex].timestamp) : '' + hoverIndex !== null && data[hoverIndex] ? formatChartTimestamp(data[hoverIndex].timestamp) : '' if (containerWidth === null) { return ( @@ -237,10 +212,16 @@ function LineChartComponent({ return (

No data

@@ -251,7 +232,14 @@ function LineChartComponent({
@@ -371,7 +359,7 @@ function LineChartComponent({ strokeWidth='1' /> - {[0.25, 0.5, 0.75].map((p) => ( + {CHART_GRID_FRACTIONS.map((p) => ( { if (data.length < 2) return null const usableW = Math.max(1, chartWidth) - const firstTs = new Date(data[0].timestamp) - const lastTs = new Date(data[data.length - 1].timestamp) - const spanMs = Math.abs(lastTs.getTime() - firstTs.getTime()) - - const approxLabelWidth = 64 - const desired = Math.min(8, Math.max(3, Math.floor(usableW / approxLabelWidth))) - const rawIdx = Array.from({ length: desired }, (_, i) => - Math.round((i * (data.length - 1)) / Math.max(1, desired - 1)) - ) - const seen = new Set() - const idx = rawIdx.filter((i) => { - if (seen.has(i)) return false - seen.add(i) - return true - }) - - const formatTick = (d: Date) => { - if (spanMs <= 36 * 60 * 60 * 1000) { - return d.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }) - } - if (spanMs <= 90 * 24 * 60 * 60 * 1000) { - return d.toLocaleString('en-US', { month: 'short', day: 'numeric' }) - } - return d.toLocaleString('en-US', { month: 'short', year: 'numeric' }) - } + const spanMs = resolveSpanMs(data) + const idx = resolveTimeTickIndices(data.length, usableW) return idx.map((i) => { const x = padding.left + (i / (data.length - 1 || 1)) * usableW const tsSource = data[i]?.timestamp if (!tsSource) return null const ts = new Date(tsSource) - const labelStr = Number.isNaN(ts.getTime()) ? '' : formatTick(ts) + const labelStr = Number.isNaN(ts.getTime()) ? '' : formatTimeTick(ts, spanMs) return ( {labelStr} @@ -577,16 +538,8 @@ function LineChartComponent({ const showInTicks = unitSuffix === '%' const isLatency = unitSuffix.toLowerCase() === 'latency' const fmtCompact = (v: number) => { - if (isLatency) { - if (v === 0) return '0' - return formatLatency(v) - } - return new Intl.NumberFormat('en-US', { - notation: 'compact', - maximumFractionDigits: 1, - }) - .format(v) - .toLowerCase() + if (isLatency) return v === 0 ? '0' : formatChartLatency(v) + return formatChartCompactNumber(v) } return ( <> @@ -594,8 +547,8 @@ function LineChartComponent({ x={padding.left - 8} y={padding.top} textAnchor='end' - fontSize='9' - fill='var(--text-tertiary)' + fontSize={CHART_TICK_FONT_SIZE} + fill={CHART_TICK_FILL} > {fmtCompact(maxValue)} {showInTicks && !isLatency ? unit : ''} @@ -604,8 +557,8 @@ function LineChartComponent({ x={padding.left - 8} y={height - padding.bottom} textAnchor='end' - fontSize='9' - fill='var(--text-tertiary)' + fontSize={CHART_TICK_FONT_SIZE} + fill={CHART_TICK_FILL} > {fmtCompact(minValue)} {showInTicks && !isLatency ? unit : ''} @@ -640,7 +593,7 @@ function LineChartComponent({ if (typeof v !== 'number' || !Number.isFinite(v)) return '—' const u = unit || '' if (u.includes('%')) return `${v.toFixed(1)}%` - if (u.toLowerCase() === 'latency') return formatLatency(v) + if (u.toLowerCase() === 'latency') return formatChartLatency(v) if (u.toLowerCase().includes('ms')) return `${Math.round(v)}ms` if (u.toLowerCase().includes('exec')) return `${Math.round(v)}` return `${Math.round(v)}${u}` @@ -654,31 +607,15 @@ function LineChartComponent({ const len = `${labelStr} ${valueStr}`.length return Math.max(m, len) }, 0) - const tooltipMaxW = Math.min(220, Math.max(80, 7 * longest + 24)) - const anchorX = hoverPos?.x ?? pt.x - const margin = 10 - const preferRight = anchorX + margin + tooltipMaxW <= width - padding.right - const left = preferRight - ? Math.max( - padding.left, - Math.min(anchorX + margin, width - padding.right - tooltipMaxW) - ) - : Math.max( - padding.left, - Math.min(anchorX - margin - tooltipMaxW, width - padding.right - tooltipMaxW) - ) - const anchorY = hoverPos?.y ?? pt.y - const top = Math.min(Math.max(anchorY - 26, padding.top), height - padding.bottom - 18) + const { left, top } = positionChartTooltip({ + anchorX: hoverPos?.x ?? pt.x, + anchorY: hoverPos?.y ?? pt.y, + width, + height, + tooltipMaxWidth: estimateTooltipWidth(longest), + }) return ( -
- {currentHoverDate && ( -
- {currentHoverDate} -
- )} + {toDisplay.map((s) => { const seriesIndex = allSeries.findIndex((x) => x.id === s.id) const val = allSeries[seriesIndex]?.data?.[hoverIndex]?.value @@ -686,19 +623,15 @@ function LineChartComponent({ const showLabel = seriesLabel && seriesLabel !== 'base' && seriesLabel.trim() !== '' return ( -
- - {showLabel && ( - {seriesLabel} - )} - {fmt(val)} -
+ ) })} -
+ ) })()}
diff --git a/apps/sim/components/charts/use-chart-theme.ts b/apps/sim/components/charts/use-chart-theme.ts new file mode 100644 index 00000000000..30bcd9fd588 --- /dev/null +++ b/apps/sim/components/charts/use-chart-theme.ts @@ -0,0 +1,84 @@ +'use client' + +import { type RefObject, useEffect, useRef, useState } from 'react' +import { CHART_MIN_WIDTH } from '@/components/charts/chart-geometry' + +/** + * Whether the document is in dark mode, tracked by observing the class the theme + * toggle writes. Charts need this as a *value* rather than a CSS class because SVG + * stroke opacity and blend mode are set per element, not by a selector. + */ +export function useIsDarkTheme(): boolean { + const [isDark, setIsDark] = useState(true) + + useEffect(() => { + if (typeof window === 'undefined') return + const element = document.documentElement + const update = () => setIsDark(element.classList.contains('dark')) + update() + const observer = new MutationObserver(update) + observer.observe(element, { attributes: true, attributeFilter: ['class'] }) + return () => observer.disconnect() + }, []) + + return isDark +} + +/** + * Resolves `var(--token)` colors to concrete `rgb()` strings. + * + * SVG `stroke` and gradient `stopColor` do not accept a CSS variable that is defined + * on an ancestor, so the value has to be computed once and passed literally. Callers + * still declare colors as tokens; this is the one place that materializes them. + */ +export function useResolvedChartColors(colors: Record): Record { + const [resolved, setResolved] = useState>({}) + const serialized = JSON.stringify(colors) + + useEffect(() => { + if (typeof window === 'undefined') return + + const resolveColor = (value: string): string => { + if (!value.startsWith('var(')) return value + const probe = document.createElement('div') + probe.style.color = value + document.body.appendChild(probe) + const computed = window.getComputedStyle(probe).color + probe.remove() + return computed + } + + const next: Record = {} + for (const [key, value] of Object.entries(JSON.parse(serialized) as Record)) { + next[key] = resolveColor(value) + } + setResolved(next) + }, [serialized]) + + return resolved +} + +/** + * Observed container width, floored at {@link CHART_MIN_WIDTH}. `null` until the + * first measurement, which callers render as an empty box of the right height so the + * chart does not reflow the page when it appears. + */ +export function useChartWidth(): [RefObject, number | null] { + const containerRef = useRef(null) + const [width, setWidth] = useState(null) + + useEffect(() => { + const element = containerRef.current + if (!element) return + const observer = new ResizeObserver((entries) => { + const measured = entries[0]?.contentRect?.width + if (measured && measured > 0) setWidth(Math.max(CHART_MIN_WIDTH, Math.floor(measured))) + }) + observer.observe(element) + const rect = element.getBoundingClientRect() + if (rect?.width > 0) setWidth(Math.max(CHART_MIN_WIDTH, Math.floor(rect.width))) + return () => observer.disconnect() + }, []) + + return [containerRef, width] +} diff --git a/apps/sim/components/emails/render-notifications.test.ts b/apps/sim/components/emails/render-notifications.test.ts index fb5286cfc08..49e6f08a881 100644 --- a/apps/sim/components/emails/render-notifications.test.ts +++ b/apps/sim/components/emails/render-notifications.test.ts @@ -73,7 +73,7 @@ describe('renderUsageLimitReachedEmail', () => { scope: 'organization', currentUsage: 500, limit: 500, - ctaLink: 'https://sim.ai/organization/org_1/settings/billing', + ctaLink: 'https://sim.ai/workspace/ws_1/settings/billing', }) expect(html).toContain('Raise Organization Limit') diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 8420aba40bb..df3a5f0d5cd 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -11,17 +11,15 @@ import { buildUnifiedSettingsNavigation, canMutateWorkspaceSettingsSection, getAccountSettingsHref, - getOrganizationSettingsHref, getWorkspaceSettingsHref, isOrganizationSettingsSectionAvailable, ORGANIZATION_PLANE_UNIFIED_SECTIONS, - ORGANIZATION_SETTINGS_ITEMS, - ORGANIZATION_SETTINGS_PATH_ALIASES, parseSettingsPathSection, resolveOrganizationSectionAccess, resolveWorkspaceNavigation, SELFHOST_SETTINGS_ITEMS, SETTINGS_SECTION_REGISTRY, + UNIFIED_TO_ORGANIZATION_SECTION, WORKSPACE_SETTINGS_ITEMS, WORKSPACE_SETTINGS_PATH_ALIASES, } from '@/components/settings/navigation' @@ -55,6 +53,7 @@ describe('settings navigation boundaries', () => { 'billing', 'teammates', 'organization', + 'usage', 'secrets', 'credential-groups', 'custom-tools', @@ -83,17 +82,6 @@ describe('settings navigation boundaries', () => { 'mothership', ]) expect(SELFHOST_SETTINGS_ITEMS.map(({ id }) => id)).toEqual(['general', 'billing', 'chat-keys']) - expect(ORGANIZATION_SETTINGS_ITEMS.map(({ id }) => id)).toEqual([ - 'members', - 'billing', - 'access-control', - 'audit-logs', - 'sso', - 'sessions', - 'data-retention', - 'data-drains', - 'whitelabeling', - ]) expect(WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id)).toEqual([ 'teammates', 'secrets', @@ -199,9 +187,6 @@ describe('settings navigation boundaries', () => { const accountIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) => planes?.account ? [planes.account.id] : [] ) - const organizationIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) => - planes?.organization ? [planes.organization.id] : [] - ) const selfHostIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) => planes?.selfhost ? [planes.selfhost.id] : [] ) @@ -211,7 +196,6 @@ describe('settings navigation boundaries', () => { expect(new Set(unifiedIds).size).toBe(unifiedIds.length) expect(new Set(accountIds).size).toBe(accountIds.length) - expect(new Set(organizationIds).size).toBe(organizationIds.length) expect(new Set(selfHostIds).size).toBe(selfHostIds.length) expect(new Set(workspaceIds).size).toBe(workspaceIds.length) expect([...unifiedIds].sort()).toEqual( @@ -220,9 +204,6 @@ describe('settings navigation boundaries', () => { .sort() ) expect([...accountIds].sort()).toEqual(ACCOUNT_SETTINGS_ITEMS.map(({ id }) => id).sort()) - expect([...organizationIds].sort()).toEqual( - ORGANIZATION_SETTINGS_ITEMS.map(({ id }) => id).sort() - ) expect([...selfHostIds].sort()).toEqual(SELFHOST_SETTINGS_ITEMS.map(({ id }) => id).sort()) expect([...workspaceIds].sort()).toEqual(WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id).sort()) }) @@ -237,27 +218,37 @@ describe('settings navigation boundaries', () => { 'organization', 'sessions', 'sso', + 'usage', 'whitelabeling', ]) }) - it('shares labels, icons, and docs links across projections', () => { - const unifiedSso = buildUnifiedSettingsNavigation().find(({ id }) => id === 'sso') - const organizationSso = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'sso') - - expect(organizationSso?.label).toBe(unifiedSso?.label) - expect(organizationSso?.icon).toBe(unifiedSso?.icon) - expect(organizationSso?.docsLink).toBe(unifiedSso?.docsLink) + it('maps every organization-scoped unified section to its organization counterpart', () => { + // The section page reads this map to decide whether to apply the organization + // gate at all, so a section missing from it is not "ungated by omission" — it + // is a section any workspace member could open. + expect(UNIFIED_TO_ORGANIZATION_SECTION).toEqual({ + organization: 'members', + billing: 'billing', + 'access-control': 'access-control', + 'audit-logs': 'audit-logs', + sso: 'sso', + sessions: 'sessions', + 'data-retention': 'data-retention', + 'data-drains': 'data-drains', + whitelabeling: 'whitelabeling', + usage: 'usage', + }) + expect(Object.keys(UNIFIED_TO_ORGANIZATION_SECTION).sort()).toEqual( + [...ORGANIZATION_PLANE_UNIFIED_SECTIONS].sort() + ) }) - it('uses scope-specific labels consistently across settings surfaces', () => { - const organizationMembers = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'members') + it('labels the members section consistently', () => { const unifiedOrganization = buildUnifiedSettingsNavigation().find( ({ id }) => id === 'organization' ) - expect(organizationMembers?.label).toBe('Members') - expect(organizationMembers?.description).toBe('Manage organization members, roles, and seats.') expect(unifiedOrganization?.label).toBe('Members') }) @@ -293,9 +284,6 @@ describe('settings navigation boundaries', () => { it('builds canonical settings hrefs across all three planes', () => { expect(getAccountSettingsHref('general')).toBe('/account/settings/general') - expect(getOrganizationSettingsHref('organization-a', 'members')).toBe( - '/organization/organization-a/settings/members' - ) expect(getWorkspaceSettingsHref('workspace-a', 'teammates')).toBe( '/workspace/workspace-a/settings/teammates' ) @@ -328,20 +316,6 @@ describe('settings navigation boundaries', () => { expect(parseAccountPath('/account/settings', 'general')).toBe('general') }) - it('parses canonical, aliased, and invalid organization settings paths', () => { - const parseOrganizationPath = (path: string) => - parseSettingsPathSection({ - path, - items: ORGANIZATION_SETTINGS_ITEMS, - defaultSection: null, - aliases: ORGANIZATION_SETTINGS_PATH_ALIASES, - }) - - expect(parseOrganizationPath('sso')).toBe('sso') - expect(parseOrganizationPath('/organization/org-a/settings/organization')).toBe('members') - expect(parseOrganizationPath('/organization/org-a/settings/not-a-section')).toBeNull() - }) - it('parses canonical, aliased, and invalid workspace settings paths', () => { const parseWorkspacePath = (path: string) => parseSettingsPathSection({ @@ -359,7 +333,6 @@ describe('settings navigation boundaries', () => { it('keeps API keys split between account and workspace settings', () => { expect(ACCOUNT_SETTINGS_ITEMS.some(({ id }) => id === 'api-keys')).toBe(true) expect(WORKSPACE_SETTINGS_ITEMS.some(({ id }) => id === 'api-keys')).toBe(true) - expect(ORGANIZATION_SETTINGS_ITEMS.some(({ id }) => String(id) === 'api-keys')).toBe(false) }) it('requires target-organization membership and admin authority', () => { diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 64704f531d5..dcb92ef1beb 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -1,5 +1,6 @@ import type { ComponentType } from 'react' import { + ChartColumn, ClipboardList, Clock, Credit, @@ -40,10 +41,11 @@ import { isSandboxesEnabled, isSessionPoliciesEnabled, isSsoEnabled, + isUsageMonitoringEnabled, isWhitelabelingEnabled, } from '@/lib/core/config/env-flags' -export type SettingsPlane = 'account' | 'organization' | 'selfhost' | 'workspace' +export type SettingsPlane = 'account' | 'selfhost' | 'workspace' export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin' | 'mothership' @@ -56,6 +58,7 @@ export type SelfHostSettingsSection = 'general' | 'billing' | 'chat-keys' export type OrganizationSettingsSection = | 'members' | 'billing' + | 'usage' | 'access-control' | 'audit-logs' | 'sso' @@ -86,8 +89,6 @@ export type SettingsSection = | SelfHostSettingsSection | WorkspaceSettingsSection -export type OrganizationSettingsRouteSection = OrganizationSettingsSection | 'unavailable' - export interface SettingsNavigationItem
{ id: Section label: string @@ -112,6 +113,7 @@ export type UnifiedSettingsSection = | 'billing' | 'teammates' | 'organization' + | 'usage' | 'sso' | 'whitelabeling' | 'forks' @@ -164,6 +166,16 @@ export interface UnifiedSettingsNavigationItem { hideForEnterprise?: boolean externalUrl?: string docsLink?: string + /** + * The organization-scoped counterpart of this section. Declaring it marks the + * section as acting on the host organization rather than the workspace, which + * routes it through the organization gate (host organization present, org-admin + * viewer, plan entitlement) in both the sidebar and the section page. + * + * This is the single source for {@link ORGANIZATION_PLANE_UNIFIED_SECTIONS} and + * {@link UNIFIED_TO_ORGANIZATION_SECTION}, so the two cannot drift apart. + */ + organizationSection?: OrganizationSettingsSection } interface UnifiedSettingsProjection @@ -173,7 +185,6 @@ interface UnifiedSettingsProjection interface SettingsPlaneSectionMap { account: AccountSettingsSection - organization: OrganizationSettingsSection selfhost: SelfHostSettingsSection workspace: WorkspaceSettingsSection } @@ -220,6 +231,7 @@ const SETTINGS_SELF_HOSTED_OVERRIDES = { sandboxes: isSandboxesEnabled, sessionPolicies: isSessionPoliciesEnabled, sso: isSsoEnabled, + usageMonitoring: isUsageMonitoringEnabled, whitelabeling: isWhitelabelingEnabled, } as const @@ -249,17 +261,6 @@ export function getSelfHostSettingsHref( return withSettingsSearchParams(`/selfhost/settings/${section}`, searchParams) } -export function getOrganizationSettingsHref( - organizationId: string, - section: OrganizationSettingsRouteSection, - searchParams?: SettingsHrefSearchParams -): string { - return withSettingsSearchParams( - `/organization/${organizationId}/settings/${section}`, - searchParams - ) -} - export function getWorkspaceSettingsHref( workspaceId: string, section: WorkspaceSettingsSection, @@ -272,12 +273,6 @@ export const ACCOUNT_SETTINGS_PATH_ALIASES = { apikeys: 'api-keys', } as const satisfies Readonly> -export const ORGANIZATION_SETTINGS_PATH_ALIASES = { - organization: 'members', - // Verified domains moved into the SSO page; keep old links working. - domains: 'sso', -} as const satisfies Readonly> - export const WORKSPACE_SETTINGS_PATH_ALIASES = { apikeys: 'api-keys', } as const satisfies Readonly> @@ -341,7 +336,6 @@ export const SETTINGS_PLANE_CHROME: Record< { label: string; showWordmark: boolean } > = { account: { label: 'Account', showWordmark: false }, - organization: { label: 'Organization', showWordmark: false }, selfhost: { label: 'Self-host', showWordmark: true }, } @@ -350,12 +344,6 @@ export const SELFHOST_SETTINGS_GROUPS = [ { key: 'developer', title: 'Developer' }, ] as const -export const ORGANIZATION_SETTINGS_GROUPS = [ - { key: 'organization', title: 'Organization' }, - { key: 'security', title: 'Security' }, - { key: 'enterprise', title: 'Enterprise' }, -] as const - export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] = [ { label: 'General', @@ -412,13 +400,11 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'access-control', description: 'Manage permission groups across your organization.', group: 'organization', - order: 3, + order: 4, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, - }, - planes: { - organization: { id: 'access-control', group: 'security', order: 2 }, + organizationSection: 'access-control', }, }, { @@ -429,13 +415,11 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'audit-logs', description: 'Review activity and changes across your organization.', group: 'organization', - order: 4, + order: 5, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, - }, - planes: { - organization: { id: 'audit-logs', group: 'security', order: 3 }, + organizationSection: 'audit-logs', }, }, { @@ -446,7 +430,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'forks', description: 'Fork this workspace and sync changes with its parent.', group: 'organization', - order: 2, + order: 3, }, planes: { workspace: { id: 'forks', group: 'enterprise', order: 10 }, @@ -461,6 +445,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] group: 'account', order: 1, hideWhenBillingDisabled: true, + organizationSection: 'billing', }, planes: { account: { @@ -475,12 +460,6 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] group: 'account', order: 1, }, - organization: { - id: 'billing', - description: 'Manage the organization plan, usage, and invoices.', - group: 'organization', - order: 1, - }, }, }, { @@ -507,14 +486,38 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] hideWhenBillingDisabled: true, requiresHosted: true, requiresTeam: true, + /** + * A plain member sees the roster read-only — `resolveOrganizationSectionAccess` + * grants them `'view'` on this one section, and `TeamManagement` renders + * without management controls. Every other organization section stays + * admin-only. + */ + allowNonOrgAdmin: true, + organizationSection: 'members', }, - planes: { - organization: { - id: 'members', - description: 'Manage organization members, roles, and seats.', - group: 'organization', - order: 0, - }, + }, + { + label: 'Usage tracking', + icon: ChartColumn, + unified: { + id: 'usage', + description: 'Monitor credit usage across your organization.', + group: 'organization', + order: 1, + /** + * Deliberately no `hideWhenBillingDisabled`, unlike Members above. + * + * The sidebar applies that filter *before* it consults `selfHostedOverride`, + * so pairing the two hid this section from exactly the deployment the + * override exists to serve: self-hosted, billing off, `USAGE_MONITORING_ENABLED` + * on. Members can carry the flag because it has no override to reach. Here the + * two gates below already answer both cases — hosted needs the plan, and + * self-hosted needs the flag. + */ + requiresHosted: true, + requiresEnterprise: true, + selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.usageMonitoring, + organizationSection: 'usage', }, }, { @@ -704,13 +707,11 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'sso', description: 'Configure single sign-on for your organization.', group: 'organization', - order: 6, + order: 7, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sso, - }, - planes: { - organization: { id: 'sso', group: 'security', order: 4 }, + organizationSection: 'sso', }, }, { @@ -721,13 +722,11 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'sessions', description: 'Limit session lifetimes and sign out members org-wide.', group: 'organization', - order: 7, + order: 8, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, - }, - planes: { - organization: { id: 'sessions', group: 'security', order: 5 }, + organizationSection: 'sessions', }, }, { @@ -739,13 +738,11 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] description: 'Control data retention windows and PII redaction. Workspaces without an override inherit the organization defaults.', group: 'organization', - order: 8, + order: 9, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, - }, - planes: { - organization: { id: 'data-retention', group: 'enterprise', order: 6 }, + organizationSection: 'data-retention', }, }, { @@ -756,13 +753,11 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'data-drains', description: 'Stream your logs and events to external destinations.', group: 'organization', - order: 9, + order: 10, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, - }, - planes: { - organization: { id: 'data-drains', group: 'enterprise', order: 7 }, + organizationSection: 'data-drains', }, }, { @@ -773,13 +768,11 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'whitelabeling', description: 'Customize your workspace branding and appearance.', group: 'organization', - order: 5, + order: 6, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, - }, - planes: { - organization: { id: 'whitelabeling', group: 'enterprise', order: 8 }, + organizationSection: 'whitelabeling', }, }, { @@ -790,7 +783,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'custom-blocks', description: 'Publish workflows as reusable blocks for your organization.', group: 'organization', - order: 1, + order: 2, requiresHosted: true, requiresEnterprise: true, allowNonOrgAdmin: true, @@ -878,9 +871,6 @@ function buildPlaneSettingsItems( export const ACCOUNT_SETTINGS_ITEMS: SettingsNavigationItem[] = buildPlaneSettingsItems('account') -export const ORGANIZATION_SETTINGS_ITEMS: SettingsNavigationItem[] = - buildPlaneSettingsItems('organization') - export const SELFHOST_SETTINGS_ITEMS: SettingsNavigationItem[] = buildPlaneSettingsItems('selfhost') @@ -895,7 +885,26 @@ export const WORKSPACE_SETTINGS_ITEMS: SettingsNavigationItem = new Set( SETTINGS_SECTION_REGISTRY.flatMap((entry) => - entry.planes?.organization && entry.unified ? [entry.unified.id] : [] + entry.unified?.organizationSection ? [entry.unified.id] : [] + ) +) + +/** + * Unified section id to the organization-scoped section it acts on, for the gates + * that take an {@link OrganizationSettingsSection} (`canOpenOrganizationSettingsSection`, + * `isOrganizationSettingsSectionAvailable`). + * + * Derived from the registry rather than hand-listed: a section added to one and + * forgotten in the other used to mean the page applied *no* organization gate at + * all, since an unmapped section reads as "not organization-scoped". + */ +export const UNIFIED_TO_ORGANIZATION_SECTION: Readonly< + Partial> +> = Object.fromEntries( + SETTINGS_SECTION_REGISTRY.flatMap((entry) => + entry.unified?.organizationSection + ? [[entry.unified.id, entry.unified.organizationSection] as const] + : [] ) ) @@ -938,6 +947,7 @@ export function getOrganizationSettingsFeatures( sessions: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, 'data-retention': SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, 'data-drains': SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, + usage: SETTINGS_SELF_HOSTED_OVERRIDES.usageMonitoring, whitelabeling: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, }, } @@ -1097,11 +1107,9 @@ export function getSettingsSectionMeta( const catalog = plane === 'account' ? ACCOUNT_SETTINGS_ITEMS - : plane === 'organization' - ? ORGANIZATION_SETTINGS_ITEMS - : plane === 'selfhost' - ? SELFHOST_SETTINGS_ITEMS - : WORKSPACE_SETTINGS_ITEMS + : plane === 'selfhost' + ? SELFHOST_SETTINGS_ITEMS + : WORKSPACE_SETTINGS_ITEMS const item = catalog.find((candidate) => candidate.id === section) return item ? { label: item.label, description: item.description, docsLink: item.docsLink } : null } diff --git a/apps/sim/components/settings/organization-settings-renderer.tsx b/apps/sim/components/settings/organization-settings-renderer.tsx deleted file mode 100644 index 66ae7a1efc3..00000000000 --- a/apps/sim/components/settings/organization-settings-renderer.tsx +++ /dev/null @@ -1,79 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import dynamic from 'next/dynamic' -import { usePostHog } from 'posthog-js/react' -import type { OrganizationSettingsSection } from '@/components/settings/navigation' -import { captureEvent } from '@/lib/posthog/client' - -const TeamManagement = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then( - (module) => module.TeamManagement - ) -) -const Billing = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( - (module) => module.Billing - ) -) -const AccessControl = dynamic(() => - import('@/ee/access-control/components/access-control').then((module) => module.AccessControl) -) -const AuditLogs = dynamic(() => - import('@/ee/audit-logs/components/audit-logs').then((module) => module.AuditLogs) -) -const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((module) => module.SSO)) -const SessionPolicySettings = dynamic(() => - import('@/ee/session-policy/components/session-policy-settings').then( - (module) => module.SessionPolicySettings - ) -) -const DataRetentionSettings = dynamic(() => - import('@/ee/data-retention/components/data-retention-settings').then( - (module) => module.DataRetentionSettings - ) -) -const DataDrainsSettings = dynamic(() => - import('@/ee/data-drains/components/data-drains-settings').then( - (module) => module.DataDrainsSettings - ) -) -const WhitelabelingSettings = dynamic( - () => - import('@/ee/whitelabeling/components/whitelabeling-settings').then( - (module) => module.WhitelabelingSettings - ), - { ssr: false } -) - -interface OrganizationSettingsRendererProps { - organizationId: string - section: OrganizationSettingsSection -} - -export function OrganizationSettingsRenderer({ - organizationId, - section, -}: OrganizationSettingsRendererProps) { - const posthog = usePostHog() - - useEffect(() => { - captureEvent(posthog, 'settings_tab_viewed', { plane: 'organization', section }) - }, [posthog, section]) - - if (section === 'members') return - if (section === 'billing') return - if (section === 'access-control') { - return - } - if (section === 'audit-logs') return - if (section === 'sso') return - if (section === 'sessions') { - return - } - if (section === 'data-retention') { - return - } - if (section === 'data-drains') return - return -} diff --git a/apps/sim/components/settings/settings-header-shell.test.tsx b/apps/sim/components/settings/settings-header-shell.test.tsx index e2d588159ff..ecf53c2706a 100644 --- a/apps/sim/components/settings/settings-header-shell.test.tsx +++ b/apps/sim/components/settings/settings-header-shell.test.tsx @@ -181,8 +181,8 @@ describe('SettingsHeaderShell static meta', () => { }) it('lets a body claim the header with nothing in it, suppressing the meta entirely', () => { - // How SettingsUnavailable opts out: it renders its own centred heading, so the routed - // section's catalog title must not caption it. + // How a surface that draws its own centred heading opts out: the routed section's + // catalog title must not also caption it. function OwnHeadingBody() { useSettingsHeader(EMPTY_HEADER) return
diff --git a/apps/sim/components/settings/settings-header.tsx b/apps/sim/components/settings/settings-header.tsx index 7c1b3ae4c69..27788ef7293 100644 --- a/apps/sim/components/settings/settings-header.tsx +++ b/apps/sim/components/settings/settings-header.tsx @@ -274,7 +274,7 @@ export function SettingsHeaderShell({ meta, children }: SettingsHeaderShellProps * field-by-field: a body that registers an explicit `title` and no `description` is * deliberately suppressing the meta description, so the two must never be merged — and a * body that registers an empty config is deliberately asking for no heading at all, which - * is how a surface that renders its own (`SettingsUnavailable`) opts out. + * is how a surface that renders its own heading opts out. */ const config: SettingsHeaderConfig = registered ?? meta ?? EMPTY_CONFIG const { title, description, docsLink, back, actions, search, scrollContainerRef } = config diff --git a/apps/sim/components/settings/settings-unavailable.tsx b/apps/sim/components/settings/settings-unavailable.tsx deleted file mode 100644 index 806ab078c3f..00000000000 --- a/apps/sim/components/settings/settings-unavailable.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { ChipLink, cn } from '@sim/emcn' - -interface SettingsUnavailableProps { - title?: string - description?: string - embedded?: boolean -} - -export function SettingsUnavailable({ - title = 'Settings unavailable', - description = 'You do not have access to manage this organization. Contact an organization owner or admin for help.', - embedded = false, -}: SettingsUnavailableProps) { - return ( -
-
-

{title}

-

{description}

- Back to workspaces -
-
- ) -} diff --git a/apps/sim/components/settings/standalone-settings-shell.test.ts b/apps/sim/components/settings/standalone-settings-shell.test.ts index 720bcdfff56..49d462e8f76 100644 --- a/apps/sim/components/settings/standalone-settings-shell.test.ts +++ b/apps/sim/components/settings/standalone-settings-shell.test.ts @@ -5,8 +5,6 @@ import { describe, expect, it } from 'vitest' import { ACCOUNT_SETTINGS_ITEMS, ACCOUNT_SETTINGS_PATH_ALIASES, - ORGANIZATION_SETTINGS_ITEMS, - ORGANIZATION_SETTINGS_PATH_ALIASES, parseSettingsPathSection, SELFHOST_SETTINGS_ITEMS, } from '@/components/settings/navigation' @@ -23,17 +21,6 @@ describe('standalone settings section resolution', () => { ).toBe('billing') }) - it('resolves the organization section from its pathname', () => { - expect( - parseSettingsPathSection({ - path: '/organization/org-1/settings/audit-logs', - items: ORGANIZATION_SETTINGS_ITEMS, - defaultSection: 'members', - aliases: ORGANIZATION_SETTINGS_PATH_ALIASES, - }) - ).toBe('audit-logs') - }) - it('keeps Subscription active for the self-host billing route', () => { expect( parseSettingsPathSection({ diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx index 2991fe68cfe..ac31197e7b6 100644 --- a/apps/sim/components/settings/standalone-settings-shell.tsx +++ b/apps/sim/components/settings/standalone-settings-shell.tsx @@ -7,15 +7,8 @@ import { ACCOUNT_SETTINGS_ITEMS, ACCOUNT_SETTINGS_PATH_ALIASES, getAccountSettingsHref, - getOrganizationSettingsFeatures, - getOrganizationSettingsHref, getSelfHostSettingsHref, - isOrganizationSettingsSectionAvailable, - ORGANIZATION_SETTINGS_GROUPS, - ORGANIZATION_SETTINGS_ITEMS, - ORGANIZATION_SETTINGS_PATH_ALIASES, parseSettingsPathSection, - resolveOrganizationSectionAccess, SELFHOST_SETTINGS_GROUPS, SELFHOST_SETTINGS_ITEMS, SETTINGS_PLANE_CHROME, @@ -40,40 +33,19 @@ interface SelfHostSettingsShellProps extends StandaloneSettingsShellBaseProps { plane: 'selfhost' } -interface OrganizationSettingsShellProps extends StandaloneSettingsShellBaseProps { - plane: 'organization' - organizationId: string - hasEnterprisePlan: boolean - isOrganizationAdmin: boolean -} - -type StandaloneSettingsShellProps = - | AccountSettingsShellProps - | OrganizationSettingsShellProps - | SelfHostSettingsShellProps +type StandaloneSettingsShellProps = AccountSettingsShellProps | SelfHostSettingsShellProps export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { const { children, plane } = props useSettingsBeforeUnload() const pathname = usePathname() - const hasEnterprisePlan = plane === 'organization' ? props.hasEnterprisePlan : false - const isOrganizationAdmin = plane === 'organization' ? props.isOrganizationAdmin : false const isSuperUser = plane === 'account' ? (props.isSuperUser ?? false) : false - const organizationFeatures = getOrganizationSettingsFeatures(hasEnterprisePlan) const accountItems = ACCOUNT_SETTINGS_ITEMS.filter((item) => { if (item.id === 'billing' && !isBillingEnabled) return false if ((item.id === 'admin' || item.id === 'mothership') && !isSuperUser) return false return true }) - const organizationItems = ORGANIZATION_SETTINGS_ITEMS.filter( - (item) => - resolveOrganizationSectionAccess({ - section: item.id, - isTargetOrganizationMember: true, - isTargetOrganizationAdmin: isOrganizationAdmin, - }) !== 'unavailable' && isOrganizationSettingsSectionAvailable(item.id, organizationFeatures) - ) const selfHostItems = SELFHOST_SETTINGS_ITEMS.filter((item) => { if (item.id === 'billing' && !isBillingEnabled) return false // Chat keys are issued by the managed service, so there are none to list on @@ -93,28 +65,9 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { defaultSection: 'general', aliases: ACCOUNT_SETTINGS_PATH_ALIASES, }) - const organizationSection = parseSettingsPathSection({ - path: pathname, - items: ORGANIZATION_SETTINGS_ITEMS, - defaultSection: 'members', - aliases: ORGANIZATION_SETTINGS_PATH_ALIASES, - }) - const activeSection = - plane === 'account' - ? accountSection - : plane === 'selfhost' - ? selfHostSection - : organizationSection + const activeSection = plane === 'account' ? accountSection : selfHostSection const sidebar = - plane === 'selfhost' ? ( - - ) : plane === 'account' ? ( + plane === 'account' ? ( ) : ( getOrganizationSettingsHref(props.organizationId, section)} - items={organizationItems} + groups={SELFHOST_SETTINGS_GROUPS} + hrefForSection={getSelfHostSettingsHref} + items={selfHostItems} /> ) diff --git a/apps/sim/ee/organization-usage/components/usage-consumers.tsx b/apps/sim/ee/organization-usage/components/usage-consumers.tsx new file mode 100644 index 00000000000..e20de0359ac --- /dev/null +++ b/apps/sim/ee/organization-usage/components/usage-consumers.tsx @@ -0,0 +1,195 @@ +'use client' + +import type { ComponentType } from 'react' +import { cn } from '@sim/emcn' +import { ChevronRight } from '@sim/emcn/icons' +import { formatChartCompactNumber } from '@/components/charts' +import { + AnthropicIcon, + AzureIcon, + CerebrasIcon, + DeepseekIcon, + GoogleIcon, + GroqIcon, + MistralIcon, + OllamaIcon, + OpenAIIcon, + OpenRouterIcon, + xAIIcon, +} from '@/components/icons' +import type { + OrganizationUsageBreakdown, + OrganizationUsageBreakdownRow, + UsageBreakdownDimension, +} from '@/lib/api/contracts/organization-usage' +import { + type RowAction, + RowActionsMenu, +} from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { USAGE_TAB_EMPTY_COPY } from '@/ee/organization-usage/constants' + +/** + * Provider brand marks, keyed by the `providerId` the server resolves. + * + * Kept here rather than read from `providers/models.ts`: that module carries the + * whole model registry and would land in this settings chunk for two dozen glyphs. + */ +const PROVIDER_ICONS: Readonly>> = { + openai: OpenAIIcon, + anthropic: AnthropicIcon, + google: GoogleIcon, + 'azure-openai': AzureIcon, + deepseek: DeepseekIcon, + xai: xAIIcon, + groq: GroqIcon, + cerebras: CerebrasIcon, + ollama: OllamaIcon, + openrouter: OpenRouterIcon, + mistral: MistralIcon, +} + +interface UsageConsumerRowProps { + row: OrganizationUsageBreakdownRow + /** BYOK rows carry no cost, so tokens are the only usage they can show. */ + showTokensOnly: boolean + onSelect?: (row: OrganizationUsageBreakdownRow) => void + actions?: RowAction[] +} + +/** + * Width of each trailing affordance, so a list that carries one can reserve the + * same slot on its `Other` row and keep every figure in one column. + */ +const TRAILING_SLOT_CLASSES = { + /** `ChevronRight` at the platform icon size. */ + chevron: 'size-[14px]', + /** `RowActionsMenu`'s trigger: a 14px glyph in a `chipVariants()` pill. */ + menu: 'size-[30px]', +} as const + +/** + * A tabular row, not `SettingsResourceRow` — tabular columns are the sanctioned + * exception in `sim-settings-pages.md`, alongside billing invoices and credit usage. + */ +function UsageConsumerRow({ row, showTokensOnly, onSelect, actions }: UsageConsumerRowProps) { + const ProviderIcon = row.providerId ? PROVIDER_ICONS[row.providerId] : undefined + const Row = onSelect ? 'button' : 'div' + + return ( + onSelect(row), + 'aria-label': `Open ${row.label}`, + } + : {})} + className={cn( + 'flex w-full items-center gap-2.5 rounded-lg p-2 text-left', + onSelect && 'transition-colors hover:bg-[var(--surface-active)]' + )} + > + {ProviderIcon && ( + + )} + {row.label} +