diff --git a/console/src/api/client.ts b/console/src/api/client.ts index acfc542e..b0134c58 100644 --- a/console/src/api/client.ts +++ b/console/src/api/client.ts @@ -88,6 +88,10 @@ import type { UpdateServiceInput, UserSearchResult, UsersEnvelope, + UsageSummary, + UsageSummaryEnvelope, + ModelPricingRevision, + CreateModelPricingRevisionInput, } from './types'; // ApiError / apiErrorCode / TokenSource live in @jcloud/device-ui (M6 shared @@ -342,6 +346,12 @@ export interface ApiClient { listAutomationExecutions(automationId: string, before?: string, state?: string, limit?: number): Promise; getAutomationExecution(automationId: string, executionId: string): Promise; runAutomationNow(automationId: string, idempotencyKey: string): Promise; + getAutomationUsage(automationId: string, from?: string, to?: string): Promise; + getProjectUsage(projectId: string, groupBy: 'service' | 'automation' | 'model', from?: string, to?: string): Promise; + getAccountUsage(groupBy: 'device' | 'model' | 'grant', from?: string, to?: string): Promise; + getServiceUsage(serviceId: string, from?: string, to?: string): Promise; + listModelPricingRevisions(modelId: string): Promise; + createModelPricingRevision(modelId: string, input: CreateModelPricingRevisionInput): Promise; getServiceKanban(serviceId: string): Promise; getServiceKanbanPolicy(serviceId: string): Promise; listServiceKanbanCardExecutions(serviceId: string, workspaceId: string, documentPath: string, before?: string, limit?: number): Promise; @@ -415,6 +425,14 @@ export interface HttpClientOptions { onUnauthorized?: () => void; } +function usageParams(from?: string, to?: string): string { + const params = new URLSearchParams(); + if (from) params.set('from', from); + if (to) params.set('to', to); + const value = params.toString(); + return value ? `?${value}` : ''; +} + export function createHttpClient( token: TokenSource, opts: HttpClientOptions = {}, @@ -844,6 +862,35 @@ export function createHttpClient( req(`/automations/${encodeURIComponent(automationId)}/executions`, { method: 'POST', body: JSON.stringify({ idempotency_key: idempotencyKey }), }), + getAutomationUsage: (automationId, from, to) => { + const params = usageParams(from, to); + return req(`/automations/${encodeURIComponent(automationId)}/usage${params}`); + }, + getProjectUsage: (projectId, groupBy, from, to) => { + const params = new URLSearchParams({ group_by: groupBy }); + if (from) params.set('from', from); + if (to) params.set('to', to); + return req(`/projects/${encodeURIComponent(projectId)}/usage?${params.toString()}`); + }, + getAccountUsage: (groupBy, from, to) => { + const params = new URLSearchParams({ group_by: groupBy }); + if (from) params.set('from', from); + if (to) params.set('to', to); + return req(`/account/usage?${params.toString()}`); + }, + getServiceUsage: (serviceId, from, to) => { + const params = usageParams(from, to); + return req(`/services/${encodeURIComponent(serviceId)}/usage${params}`); + }, + listModelPricingRevisions: async (modelId) => + (await req<{ pricing_revisions: ModelPricingRevision[] }>( + `/system/models/${encodeURIComponent(modelId)}/pricing-revisions`, + )).pricing_revisions ?? [], + createModelPricingRevision: (modelId, input) => + req(`/system/models/${encodeURIComponent(modelId)}/pricing-revisions`, { + method: 'POST', + body: JSON.stringify(input), + }), getServiceKanban: (serviceId) => req(`/services/${encodeURIComponent(serviceId)}/kanban`), getServiceKanbanPolicy: (serviceId) => req(`/services/${encodeURIComponent(serviceId)}/kanban/policy`), listServiceKanbanCardExecutions: (serviceId, workspaceId, documentPath, before, limit = 20) => { diff --git a/console/src/api/mockClient.ts b/console/src/api/mockClient.ts index 82c17e12..ee71ac61 100644 --- a/console/src/api/mockClient.ts +++ b/console/src/api/mockClient.ts @@ -80,6 +80,8 @@ import type { UpdateProviderModelInput, UpdateServiceInput, UserSearchResult, + UsageSummary, + ModelPricingRevision, } from './types'; import { providerForRepoUrl } from '../lib/repo'; import { isReservedEnvKey, isValidEnvKey } from '../lib/env'; @@ -95,6 +97,17 @@ function nowISO(offsetMs = 0): string { return new Date(Date.now() + offsetMs).toISOString(); } +function unavailableUsage(): UsageSummary { + return { + availability: 'unavailable', + reason: 'no_requests', + requests: 0, + capture: { reported: 0, partial: 0, unavailable: 0, parse_error: 0 }, + tokens: { input: null, output: null, cache_read: null, cache_write: null }, + costs: { reported: [], estimated: [], uncosted: [] }, + }; +} + // Demo runner-image prewarm state: '' until the Cluster page's "sync runner // image" button is pressed, then the sync timestamp (so the demo flow // roundtrips: sync → snapshot shows last_sync + all nodes cached). @@ -2225,7 +2238,7 @@ export function createMockClient(): ApiClient { available: false, } : null, writeback_state: outputMode === 'create_card' ? 'pending' : '', - usage: { state: 'unavailable' }, + usage_summary: unavailableUsage(), created_at: now, updated_at: now, }; @@ -2233,6 +2246,30 @@ export function createMockClient(): ApiClient { automationIdempotency.set(`${automationId}|${idempotencyKey}`, id); return delay(structuredClone(item)); }, + async getAutomationUsage(): Promise { + return delay(unavailableUsage()); + }, + async getProjectUsage() { + return delay({ summary: unavailableUsage(), groups: [] }); + }, + async getAccountUsage() { + return delay({ summary: unavailableUsage(), groups: [] }); + }, + async getServiceUsage(): Promise { + return delay(unavailableUsage()); + }, + async listModelPricingRevisions(): Promise { + return delay([]); + }, + async createModelPricingRevision(modelId, input): Promise { + return delay({ + id: genId('price'), + model_id: modelId, + model_name: modelId, + ...input, + created_at: nowISO(), + }); + }, async getServiceKanban(serviceId: string): Promise { const spec = [...projectAutomations.values()].find((item) => item.automation.service_id === serviceId && item.automation.trigger_kind === 'kanban'); @@ -2265,6 +2302,7 @@ export function createMockClient(): ApiClient { return delay({ claim: null, items: [], + usage_summary: unavailableUsage(), next_cursor: null, }); }, diff --git a/console/src/api/queries.test.ts b/console/src/api/queries.test.ts index 9276d930..81a39018 100644 --- a/console/src/api/queries.test.ts +++ b/console/src/api/queries.test.ts @@ -51,7 +51,14 @@ function automationExecution( run: null, card: null, writeback_state: '', - usage: { state: 'unavailable' }, + usage_summary: { + availability: 'unavailable', + reason: 'no_requests', + requests: 0, + capture: { reported: 0, partial: 0, unavailable: 0, parse_error: 0 }, + tokens: { input: null, output: null, cache_read: null, cache_write: null }, + costs: { reported: [], estimated: [], uncosted: [] }, + }, created_at: '', updated_at: '', }; diff --git a/console/src/api/queries.ts b/console/src/api/queries.ts index 137ca5ca..d3dc237d 100644 --- a/console/src/api/queries.ts +++ b/console/src/api/queries.ts @@ -39,6 +39,7 @@ import type { UpdateProjectInput, UpdateProviderModelInput, UpdateServiceInput, + CreateModelPricingRevisionInput, } from './types'; import { isTerminal } from './types'; import { reconcileRunSnapshot } from './runCache'; @@ -102,7 +103,14 @@ export const qk = { ['project-automation', projectId, automationId] as const, automationExecutions: (automationId: string, state: string) => ['automation-executions', automationId, state] as const, + automationUsage: (automationId: string, from: string, to: string) => + ['automation-usage', automationId, from, to] as const, + projectUsage: (projectId: string, groupBy: string, from: string, to: string) => + ['project-usage', projectId, groupBy, from, to] as const, + accountUsage: (groupBy: string, from: string, to: string) => + ['account-usage', groupBy, from, to] as const, providerCapabilities: (provider: ProviderKind) => ['provider-capabilities', provider] as const, + modelPricingRevisions: (modelId: string) => ['model-pricing-revisions', modelId] as const, githubAppInstallations: (projectId: string) => ['github-app-installations', projectId] as const, jtypePluginConnect: (projectId: string, installationId: string, connectId: string) => ['jtype-plugin-connect', projectId, installationId, connectId] as const, @@ -1022,6 +1030,44 @@ export function useAutomationExecutions(automationId: string, state = '', enable }); } +export function useAutomationUsage(automationId: string, from = '', to = '', enabled = true) { + const api = useApi(); + return useQuery({ + queryKey: qk.automationUsage(automationId, from, to), + queryFn: () => api.getAutomationUsage(automationId, from || undefined, to || undefined), + enabled: enabled && !!automationId, + }); +} + +export function useProjectUsage( + projectId: string, + groupBy: 'service' | 'automation' | 'model', + from: string, + to: string, + enabled = true, +) { + const api = useApi(); + return useQuery({ + queryKey: qk.projectUsage(projectId, groupBy, from, to), + queryFn: () => api.getProjectUsage(projectId, groupBy, from, to), + enabled: enabled && !!projectId, + }); +} + +export function useAccountUsage( + groupBy: 'device' | 'model' | 'grant', + from: string, + to: string, + enabled = true, +) { + const api = useApi(); + return useQuery({ + queryKey: qk.accountUsage(groupBy, from, to), + queryFn: () => api.getAccountUsage(groupBy, from, to), + enabled, + }); +} + export function useRunAutomationNow(automationId: string) { const api = useApi(); const qc = useQueryClient(); @@ -1042,6 +1088,25 @@ export function useProviderCapabilities(provider: ProviderKind, enabled = true) }); } +export function useModelPricingRevisions(modelId: string, enabled = true) { + const api = useApi(); + return useQuery({ + queryKey: qk.modelPricingRevisions(modelId), + queryFn: () => api.listModelPricingRevisions(modelId), + enabled: enabled && !!modelId, + }); +} + +export function useCreateModelPricingRevision(modelId: string) { + const api = useApi(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateModelPricingRevisionInput) => + api.createModelPricingRevision(modelId, input), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.modelPricingRevisions(modelId) }), + }); +} + export function useCreateProjectAutomation(projectId: string) { const api = useApi(); const qc = useQueryClient(); diff --git a/console/src/api/types.ts b/console/src/api/types.ts index c2d4d462..918c827b 100644 --- a/console/src/api/types.ts +++ b/console/src/api/types.ts @@ -259,6 +259,68 @@ export interface ProjectAutomationAggregate { updated_at: string; } export interface ScmAutomationAction { event_family: string; action: string; } +export interface UsageMoneyTotal { + currency: string; + micros: number; + pricing_revision_ids?: string[]; +} +export interface UsageSummary { + availability: 'available' | 'unavailable'; + reason?: 'no_requests' | 'usage_not_reported' | string; + requests: number; + capture: { + reported: number; + partial: number; + unavailable: number; + parse_error: number; + }; + tokens: { + input: number | null; + output: number | null; + cache_read: number | null; + cache_write: number | null; + }; + costs: { + reported: UsageMoneyTotal[]; + estimated: UsageMoneyTotal[]; + uncosted: { category: string; tokens: number }[]; + }; + from?: string; + to?: string; +} +export interface UsageGroup { + kind: 'service' | 'automation' | 'model' | 'device' | 'grant'; + id: string; + name: string; + summary: UsageSummary; +} +export interface UsageSummaryEnvelope { + summary: UsageSummary; + groups: UsageGroup[]; +} +export interface ModelPricingRevision { + id: string; + model_id: string; + provider_id?: string; + provider_name?: string; + model_name: string; + currency: string; + input_micros_per_million: number | null; + output_micros_per_million: number | null; + cache_read_micros_per_million: number | null; + cache_write_micros_per_million: number | null; + effective_at: string; + created_by?: string; + created_at: string; +} +export interface CreateModelPricingRevisionInput { + currency: string; + input_micros_per_million: number | null; + output_micros_per_million: number | null; + cache_read_micros_per_million: number | null; + cache_write_micros_per_million: number | null; + effective_at: string; +} export interface ProjectAutomationSpec { automation: ProjectAutomationAggregate; scm?: { automation_id?: string; branch?: string; path_pattern?: string; conclusion?: string; include_drafts?: boolean }; @@ -301,7 +363,7 @@ export interface AutomationExecution { } | null; external_url?: string; writeback_state: string; - usage: { state: 'unavailable' }; + usage_summary: UsageSummary; created_at: string; updated_at: string; terminal_at?: string; @@ -343,10 +405,12 @@ export interface KanbanCardExecution { created_at: string; updated_at: string; terminal_at?: string; + usage_summary?: UsageSummary; } export interface KanbanCardExecutionsPage { claim: { document_path: string; external_ref_available: boolean } | null; items: KanbanCardExecution[]; + usage_summary: UsageSummary; next_cursor: string | null; } export interface PutServiceKanbanInput { @@ -588,6 +652,7 @@ export interface Run { origin_comment_url?: string | null; origin_automation_id?: string | null; origin_event_key?: string | null; + usage_summary?: UsageSummary; /** * The catalog model (D21) this run was dispatched with, chosen by the * resolution chain at create time. Absent/null when the run resolved to the diff --git a/console/src/components/UsageSummary.module.css b/console/src/components/UsageSummary.module.css new file mode 100644 index 00000000..ac3c1aa3 --- /dev/null +++ b/console/src/components/UsageSummary.module.css @@ -0,0 +1,31 @@ +.root { display: grid; gap: var(--space-3); color: var(--color-text-muted); font-size: var(--fs-tiny); } +.heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); } +.heading > strong { color: var(--color-text-faint); font-family: var(--font-mono); font-size: var(--fs-tiny); letter-spacing: var(--tracking-label); text-transform: uppercase; } +.capture, .unavailable { padding: 2px 7px; border: 1px solid color-mix(in srgb, var(--color-accent) 40%, var(--color-border)); border-radius: var(--radius-pill); color: var(--color-accent-dim); font-family: var(--font-mono); font-size: var(--fs-tiny); } +.capture { display: inline-flex; align-items: center; gap: 5px; } +.capture strong { font-size: inherit; } +.capture strong + span::before { content: '· '; } +.unavailable { border-color: var(--color-border); color: var(--color-text-faint); } +.reason { margin: 0; color: var(--color-text-faint); line-height: var(--lh-normal); } +.disclaimer { margin: 0; color: var(--color-text-faint); font-size: var(--fs-tiny); line-height: var(--lh-normal); } +.unavailableDetails { display: grid; gap: var(--space-1); margin: 0; } +.unavailableDetails > div { display: flex; justify-content: space-between; gap: var(--space-3); } +.unavailableDetails dt { color: var(--color-text-faint); } +.unavailableDetails dd { margin: 0; color: var(--color-text-muted); font-family: var(--font-mono); } +.tokens { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-border); } +.tokens > div { display: grid; gap: 3px; padding: var(--space-2) var(--space-3); background: var(--color-panel); } +.tokens span, .costs span { color: var(--color-text-faint); } +.tokens strong { color: var(--color-text); font-family: var(--font-mono); font-size: var(--fs-sm); } +.costs { display: grid; gap: var(--space-2); } +.costs > div { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: var(--space-2); } +.costs strong { color: var(--color-text-muted); font-family: var(--font-mono); font-size: var(--fs-tiny); font-weight: var(--fw-medium); overflow-wrap: anywhere; } +.details summary { color: var(--color-text-faint); cursor: pointer; } +.details dl { display: grid; gap: var(--space-2); margin: var(--space-3) 0 0; padding-left: var(--space-3); border-left: 1px solid var(--color-border); } +.details dl > div { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: var(--space-2); } +.details dt { color: var(--color-text-faint); } +.details dd { margin: 0; font-family: var(--font-mono); overflow-wrap: anywhere; } +.root[data-compact] .tokens { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.root[data-compact] .tokens > div { padding: var(--space-2); } +@media (max-width: 520px) { + .root[data-compact] .tokens { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} diff --git a/console/src/components/UsageSummary.test.tsx b/console/src/components/UsageSummary.test.tsx new file mode 100644 index 00000000..f6147ebc --- /dev/null +++ b/console/src/components/UsageSummary.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { UsageSummary as UsageSummaryValue } from '../api/types'; +import { UsageSummary } from './UsageSummary'; + +const available: UsageSummaryValue = { + availability: 'available', + requests: 2, + capture: { reported: 1, partial: 1, unavailable: 0, parse_error: 0 }, + tokens: { input: 1200, output: 80, cache_read: null, cache_write: 40 }, + costs: { + reported: [{ currency: 'USD', micros: 3210 }], + estimated: [{ currency: 'USD', micros: 1720, pricing_revision_ids: ['price-v3'] }], + uncosted: [{ category: 'cache_write', tokens: 40 }], + }, +}; + +describe('UsageSummary', () => { + it('keeps Reported, Estimated, and Uncosted distinct and discloses capture provenance', () => { + render(); + expect(screen.getByText('USD 0.003210')).toBeTruthy(); + expect(screen.getByText('USD 0.001720')).toBeTruthy(); + expect(screen.getByText('40 cache write')).toBeTruthy(); + expect(screen.getByText('2 / 2 captured')).toBeTruthy(); + expect(screen.getByText('price-v3')).toBeTruthy(); + expect(screen.getByText('Usage is operational telemetry, not a provider invoice.')).toBeTruthy(); + }); + + it('shows unavailable as an explicit state without fabricating zero tokens', () => { + render(); + expect(screen.getByText('Unavailable')).toBeTruthy(); + expect(screen.getByText('The provider did not report usage for this request.')).toBeTruthy(); + expect(screen.getByText('Usage is operational telemetry, not a provider invoice.')).toBeTruthy(); + expect(screen.queryByText(/0 tokens/i)).toBeNull(); + }); + + it('does not disguise parser failures as provider omission', () => { + render(); + expect(screen.getByText('Cloud couldn’t parse the provider usage payload.')).toBeTruthy(); + expect(screen.getByText('Parse errors').parentElement?.textContent).toBe('Parse errors1'); + expect(screen.queryByText('The provider did not report usage for this request.')).toBeNull(); + }); +}); diff --git a/console/src/components/UsageSummary.tsx b/console/src/components/UsageSummary.tsx new file mode 100644 index 00000000..9850c888 --- /dev/null +++ b/console/src/components/UsageSummary.tsx @@ -0,0 +1,117 @@ +import { useTranslation } from 'react-i18next'; +import type { UsageMoneyTotal, UsageSummary as UsageSummaryValue } from '../api/types'; +import styles from './UsageSummary.module.css'; + +function integer(value: number): string { + return new Intl.NumberFormat().format(value); +} + +export function formatUsageMoney(value: UsageMoneyTotal): string { + const sign = value.micros < 0 ? '-' : ''; + const absolute = Math.abs(value.micros); + const whole = Math.trunc(absolute / 1_000_000); + const fractional = Math.trunc(absolute % 1_000_000).toString().padStart(6, '0'); + return `${value.currency} ${sign}${whole}.${fractional}`; +} + +export function UsageSummary({ + value, + compact = false, + hideCosts = false, +}: { + value?: UsageSummaryValue; + compact?: boolean; + hideCosts?: boolean; +}) { + const { t } = useTranslation(); + if (!value || value.availability === 'unavailable') { + const reason = (value?.capture.parse_error ?? 0) > 0 + ? t('usage.reasonParseError') + : value?.reason === 'usage_not_reported' + ? t('usage.reasonNotReported') + : t('usage.reasonNoRequests'); + return ( +
+
+ {t('usage.title')} + {t('usage.unavailable')} +
+

{reason}

+ {value && value.requests > 0 && ( +
+
{t('usage.requests')}
{integer(value.requests)}
+
{t('usage.unavailableCapture')}
{integer(value.capture.unavailable)}
+
{t('usage.parseErrors')}
{integer(value.capture.parse_error)}
+
+ )} +

{t('usage.disclaimer')}

+
+ ); + } + + const tokens = [ + ['input', t('usage.input'), value.tokens.input], + ['output', t('usage.output'), value.tokens.output], + ['cache-read', t('usage.cacheRead'), value.tokens.cache_read], + ['cache-write', t('usage.cacheWrite'), value.tokens.cache_write], + ] as const; + const revisions = [...new Set(value.costs.estimated.flatMap((item) => item.pricing_revision_ids ?? []))]; + const captured = value.capture.reported + value.capture.partial; + const captureLabel = value.capture.partial > 0 || value.capture.unavailable > 0 || value.capture.parse_error > 0 + ? t('usage.captureIncomplete') + : t('usage.captureReported'); + + return ( +
+
+ {t('usage.title')} + + {captureLabel} + {t('usage.captured', { captured, requests: value.requests })} + +
+
+ {tokens.map(([key, label, amount]) => amount == null ? null : ( +
+ {label} + {integer(amount)} +
+ ))} +
+ {!hideCosts && ( +
+ + + `${integer(item.tokens)} ${t(`usage.category.${item.category}`)}`)} + /> +
+ )} +
+ {t('usage.details')} +
+
{t('usage.requests')}
{integer(value.requests)}
+
{t('usage.reportedCapture')}
{integer(value.capture.reported)}
+
{t('usage.partialCapture')}
{integer(value.capture.partial)}
+
{t('usage.unavailableCapture')}
{integer(value.capture.unavailable)}
+
{t('usage.parseErrors')}
{integer(value.capture.parse_error)}
+ {revisions.length > 0 && ( +
{t('usage.pricingRevisions')}
{revisions.join(', ')}
+ )} +
+
+

{t('usage.disclaimer')}

+
+ ); +} + +function UsageCostRow({ label, values }: { label: string; values: string[] }) { + const { t } = useTranslation(); + return ( +
+ {label} + {values.length > 0 ? values.join(' · ') : t('usage.none')} +
+ ); +} diff --git a/console/src/i18n/locales/en.ts b/console/src/i18n/locales/en.ts index 0ab47b93..0f758f56 100644 --- a/console/src/i18n/locales/en.ts +++ b/console/src/i18n/locales/en.ts @@ -1890,6 +1890,74 @@ export default { }, }, + usage: { + title: 'Usage', + disclaimer: 'Usage is operational telemetry, not a provider invoice.', + accountTitle: 'Cloud model usage', + accountDescription: 'Model usage from this account’s devices.', + proxyOnly: 'Only requests sent through Cloud Model Proxy are included.', + unavailable: 'Unavailable', + reasonNotReported: 'The provider did not report usage for this request.', + reasonNoRequests: 'No usage-bearing requests in this range.', + reasonParseError: 'Cloud couldn’t parse the provider usage payload.', + input: 'Input', + output: 'Output', + cacheRead: 'Cache read', + cacheWrite: 'Cache write', + captured: '{captured} / {requests} captured', + captureReported: 'Reported', + captureIncomplete: 'Incomplete', + reported: 'Reported', + estimated: 'Estimated', + uncosted: 'Uncosted', + details: 'Pricing & capture details', + requests: 'Requests', + reportedCapture: 'Reported capture', + partialCapture: 'Partial capture', + unavailableCapture: 'Unavailable capture', + parseErrors: 'Parse errors', + pricingRevisions: 'Pricing revisions', + none: 'None', + pricing: 'Pricing', + pricingTitle: '{model} pricing', + pricingImmutable: 'Pricing revisions are immutable. Add a new effective revision instead of editing history.', + addPricingRevision: 'Add revision', + currency: 'Currency', + effectiveAt: 'Effective at', + inputRate: 'Input / 1M tokens', + outputRate: 'Output / 1M tokens', + cacheReadRate: 'Cache read / 1M tokens', + cacheWriteRate: 'Cache write / 1M tokens', + pricingHistory: 'Revision history', + loadingPricing: 'Loading pricing…', + pricingLoadError: 'Couldn’t load pricing', + noPricingRevisions: 'No pricing revisions yet. Usage remains Uncosted unless the provider reports cost.', + pricingValidation: 'Use a valid currency, effective time, and at least one non-negative rate.', + pricingCreateError: 'Couldn’t create pricing revision', + loading: 'Loading usage…', + loadError: 'Couldn’t load usage', + groupBy: 'Group by', + group: { service: 'Service', automation: 'Automation', model: 'Model', device: 'Device', grant: 'Grant' }, + range: 'Range', + range24h: '24 hours', + range7: '7 days', + range30: '30 days', + range90: '90 days', + windowNote: 'Showing {range} in {timeZone}. Aggregation uses UTC hourly buckets.', + captureHealth: 'Capture health', + costSources: 'Cost sources', + reportedHelp: 'Returned by the provider; never combined with Cloud estimates.', + estimatedHelp: 'Calculated from immutable pricing revisions.', + uncostedHelp: 'Tokens without an applicable category rate.', + groupedUsage: 'Usage by {group}', + noGroups: 'No attributed usage in this range.', + category: { + input: 'input', + output: 'output', + cache_read: 'cache read', + cache_write: 'cache write', + }, + }, runDetail: { loadingRun: 'Loading run…', loadRunError: 'Couldn\'t load run', diff --git a/console/src/i18n/locales/ja.ts b/console/src/i18n/locales/ja.ts index 2ebd0801..e730833d 100644 --- a/console/src/i18n/locales/ja.ts +++ b/console/src/i18n/locales/ja.ts @@ -1712,6 +1712,74 @@ export default { }, }, + usage: { + title: '使用量', + disclaimer: '使用量は運用テレメトリであり、プロバイダーの請求書ではありません。', + accountTitle: 'クラウドモデル使用量', + accountDescription: 'このアカウントのデバイスによるモデル使用量です。', + proxyOnly: 'Cloud Model Proxy 経由で送信されたリクエストのみが含まれます。', + unavailable: '利用不可', + reasonNotReported: 'プロバイダーはこのリクエストの使用量を報告しませんでした。', + reasonNoRequests: 'この期間に使用量を伴うリクエストはありません。', + reasonParseError: 'Cloud はプロバイダーの使用量データを解析できませんでした。', + input: '入力', + output: '出力', + cacheRead: 'キャッシュ読み取り', + cacheWrite: 'キャッシュ書き込み', + captured: '{captured} / {requests} 件取得', + captureReported: '報告済み', + captureIncomplete: '取得不完全', + reported: '報告値', + estimated: '推定値', + uncosted: '未価格', + details: '価格と取得の詳細', + requests: 'リクエスト', + reportedCapture: '完全取得', + partialCapture: '部分取得', + unavailableCapture: '利用不可', + parseErrors: '解析エラー', + pricingRevisions: '価格リビジョン', + none: 'なし', + pricing: '価格', + pricingTitle: '{model} の価格', + pricingImmutable: '価格リビジョンは変更できません。変更には新しい有効リビジョンを追加します。', + addPricingRevision: 'リビジョンを追加', + currency: '通貨', + effectiveAt: '有効日時', + inputRate: '入力 / 100万 token', + outputRate: '出力 / 100万 token', + cacheReadRate: 'キャッシュ読み取り / 100万 token', + cacheWriteRate: 'キャッシュ書き込み / 100万 token', + pricingHistory: 'リビジョン履歴', + loadingPricing: '価格を読み込み中…', + pricingLoadError: '価格を読み込めません', + noPricingRevisions: '価格リビジョンはまだありません。プロバイダー報告がなければ未価格のままです。', + pricingValidation: '有効な通貨、有効日時、1つ以上の非負レートを入力してください。', + pricingCreateError: '価格リビジョンを作成できません', + loading: '使用量を読み込み中…', + loadError: '使用量を読み込めません', + groupBy: 'グループ', + group: { service: 'サービス', automation: '自動化', model: 'モデル', device: 'デバイス', grant: '権限範囲' }, + range: '期間', + range24h: '24時間', + range7: '7日', + range30: '30日', + range90: '90日', + windowNote: '{timeZone} での {range} を表示しています。集計は UTC の1時間単位です。', + captureHealth: '取得状況', + costSources: 'コストの出所', + reportedHelp: 'プロバイダーからの報告値です。Cloud の推定値とは合算しません。', + estimatedHelp: '変更不可の価格リビジョンから算出します。', + uncostedHelp: '該当カテゴリのレートがない token です。', + groupedUsage: '{group}別の使用量', + noGroups: 'この期間に帰属する使用量はありません。', + category: { + input: '入力', + output: '出力', + cache_read: 'キャッシュ読み取り', + cache_write: 'キャッシュ書き込み', + }, + }, runDetail: { loadingRun: '実行を読み込み中…', loadRunError: '実行を読み込めませんでした', diff --git a/console/src/i18n/locales/ko.ts b/console/src/i18n/locales/ko.ts index acd1a73e..26fd2aa9 100644 --- a/console/src/i18n/locales/ko.ts +++ b/console/src/i18n/locales/ko.ts @@ -1712,6 +1712,74 @@ export default { }, }, + usage: { + title: '사용량', + disclaimer: '사용량은 운영 텔레메트리이며 제공업체 청구서가 아닙니다.', + accountTitle: '클라우드 모델 사용량', + accountDescription: '이 계정의 기기에서 발생한 모델 사용량입니다.', + proxyOnly: 'Cloud Model Proxy를 통해 전송된 요청만 포함됩니다.', + unavailable: '사용 불가', + reasonNotReported: '공급자가 이 요청의 사용량을 보고하지 않았습니다.', + reasonNoRequests: '이 기간에 사용량이 발생한 요청이 없습니다.', + reasonParseError: 'Cloud가 공급자 사용량 데이터를 해석하지 못했습니다.', + input: '입력', + output: '출력', + cacheRead: '캐시 읽기', + cacheWrite: '캐시 쓰기', + captured: '{captured} / {requests} 캡처', + captureReported: '보고됨', + captureIncomplete: '캡처 불완전', + reported: '보고됨', + estimated: '추정', + uncosted: '가격 미설정', + details: '가격 및 캡처 세부 정보', + requests: '요청', + reportedCapture: '전체 캡처', + partialCapture: '부분 캡처', + unavailableCapture: '사용 불가', + parseErrors: '파싱 오류', + pricingRevisions: '가격 리비전', + none: '없음', + pricing: '가격', + pricingTitle: '{model} 가격', + pricingImmutable: '가격 리비전은 변경할 수 없습니다. 변경하려면 새 유효 리비전을 추가하세요.', + addPricingRevision: '리비전 추가', + currency: '통화', + effectiveAt: '적용 시각', + inputRate: '입력 / 백만 token', + outputRate: '출력 / 백만 token', + cacheReadRate: '캐시 읽기 / 백만 token', + cacheWriteRate: '캐시 쓰기 / 백만 token', + pricingHistory: '리비전 기록', + loadingPricing: '가격 로드 중…', + pricingLoadError: '가격을 불러올 수 없음', + noPricingRevisions: '가격 리비전이 없습니다. 공급자 보고가 없으면 가격 미설정으로 유지됩니다.', + pricingValidation: '유효한 통화, 적용 시각, 하나 이상의 음수가 아닌 요금을 입력하세요.', + pricingCreateError: '가격 리비전을 만들 수 없음', + loading: '사용량 로드 중…', + loadError: '사용량을 불러올 수 없음', + groupBy: '그룹', + group: { service: '서비스', automation: '자동화', model: '모델', device: '기기', grant: '권한 범위' }, + range: '기간', + range24h: '24시간', + range7: '7일', + range30: '30일', + range90: '90일', + windowNote: '{timeZone} 기준 {range}을 표시합니다. 집계는 UTC 시간 단위 버킷을 사용합니다.', + captureHealth: '캡처 상태', + costSources: '비용 출처', + reportedHelp: '공급자가 반환한 값이며 Cloud 추정치와 합산하지 않습니다.', + estimatedHelp: '변경할 수 없는 가격 리비전으로 계산합니다.', + uncostedHelp: '해당 카테고리 요금이 없는 token입니다.', + groupedUsage: '{group}별 사용량', + noGroups: '이 기간에 귀속된 사용량이 없습니다.', + category: { + input: '입력', + output: '출력', + cache_read: '캐시 읽기', + cache_write: '캐시 쓰기', + }, + }, runDetail: { loadingRun: '실행 불러오는 중…', loadRunError: '실행을 불러올 수 없습니다', diff --git a/console/src/i18n/locales/zh-Hans.ts b/console/src/i18n/locales/zh-Hans.ts index 5f6703a0..8505c787 100644 --- a/console/src/i18n/locales/zh-Hans.ts +++ b/console/src/i18n/locales/zh-Hans.ts @@ -1724,6 +1724,74 @@ export default { }, }, + usage: { + title: '用量', + disclaimer: '用量是运营遥测,不是供应商账单。', + accountTitle: '云端模型用量', + accountDescription: '此账号下设备产生的模型用量。', + proxyOnly: '仅包含通过 Cloud Model Proxy 发送的请求。', + unavailable: '不可用', + reasonNotReported: '提供商没有报告这次请求的用量。', + reasonNoRequests: '此时间范围内没有产生用量的请求。', + reasonParseError: 'Cloud 无法解析提供商的用量数据。', + input: '输入', + output: '输出', + cacheRead: '缓存读取', + cacheWrite: '缓存写入', + captured: '已捕获 {captured} / {requests}', + captureReported: '已报告', + captureIncomplete: '捕获不完整', + reported: '提供商报告', + estimated: '估算', + uncosted: '未计价', + details: '定价与捕获详情', + requests: '请求数', + reportedCapture: '完整捕获', + partialCapture: '部分捕获', + unavailableCapture: '不可用', + parseErrors: '解析错误', + pricingRevisions: '定价版本', + none: '无', + pricing: '定价', + pricingTitle: '{model} 定价', + pricingImmutable: '定价版本不可修改。需要变更时请新增一个带生效时间的版本。', + addPricingRevision: '新增版本', + currency: '币种', + effectiveAt: '生效时间', + inputRate: '输入 / 百万 token', + outputRate: '输出 / 百万 token', + cacheReadRate: '缓存读取 / 百万 token', + cacheWriteRate: '缓存写入 / 百万 token', + pricingHistory: '版本历史', + loadingPricing: '正在加载定价…', + pricingLoadError: '无法加载定价', + noPricingRevisions: '还没有定价版本。除非提供商报告费用,否则用量会保持“未计价”。', + pricingValidation: '请填写有效币种、生效时间,以及至少一个非负费率。', + pricingCreateError: '无法创建定价版本', + loading: '正在加载用量…', + loadError: '无法加载用量', + groupBy: '分组方式', + group: { service: '服务', automation: '自动化', model: '模型', device: '设备', grant: '授权范围' }, + range: '时间范围', + range24h: '24 小时', + range7: '7 天', + range30: '30 天', + range90: '90 天', + windowNote: '显示 {timeZone} 时区下的 {range}。聚合采用 UTC 小时桶。', + captureHealth: '采集健康度', + costSources: '成本来源', + reportedHelp: '由提供商返回,绝不与 Cloud 估算值合并。', + estimatedHelp: '根据不可变的定价版本计算。', + uncostedHelp: '没有适用类别费率的 token。', + groupedUsage: '按{group}查看用量', + noGroups: '此时间范围内没有可归属的用量。', + category: { + input: '输入', + output: '输出', + cache_read: '缓存读取', + cache_write: '缓存写入', + }, + }, runDetail: { loadingRun: '加载运行…', loadRunError: '无法加载运行', diff --git a/console/src/i18n/locales/zh-Hant.ts b/console/src/i18n/locales/zh-Hant.ts index 434b6239..7770fcad 100644 --- a/console/src/i18n/locales/zh-Hant.ts +++ b/console/src/i18n/locales/zh-Hant.ts @@ -1712,6 +1712,74 @@ export default { }, }, + usage: { + title: '用量', + disclaimer: '用量是營運遙測,不是供應商帳單。', + accountTitle: '雲端模型用量', + accountDescription: '此帳號下裝置產生的模型用量。', + proxyOnly: '僅包含透過 Cloud Model Proxy 傳送的請求。', + unavailable: '不可用', + reasonNotReported: '提供商沒有回報這次請求的用量。', + reasonNoRequests: '此時間範圍內沒有產生用量的請求。', + reasonParseError: 'Cloud 無法解析提供商的用量資料。', + input: '輸入', + output: '輸出', + cacheRead: '快取讀取', + cacheWrite: '快取寫入', + captured: '已擷取 {captured} / {requests}', + captureReported: '已回報', + captureIncomplete: '擷取不完整', + reported: '提供商回報', + estimated: '估算', + uncosted: '未計價', + details: '定價與擷取詳情', + requests: '請求數', + reportedCapture: '完整擷取', + partialCapture: '部分擷取', + unavailableCapture: '不可用', + parseErrors: '解析錯誤', + pricingRevisions: '定價版本', + none: '無', + pricing: '定價', + pricingTitle: '{model} 定價', + pricingImmutable: '定價版本不可修改。需要變更時請新增一個帶生效時間的版本。', + addPricingRevision: '新增版本', + currency: '幣種', + effectiveAt: '生效時間', + inputRate: '輸入 / 百萬 token', + outputRate: '輸出 / 百萬 token', + cacheReadRate: '快取讀取 / 百萬 token', + cacheWriteRate: '快取寫入 / 百萬 token', + pricingHistory: '版本歷史', + loadingPricing: '正在載入定價…', + pricingLoadError: '無法載入定價', + noPricingRevisions: '還沒有定價版本。除非提供商回報費用,否則用量會保持「未計價」。', + pricingValidation: '請填寫有效幣種、生效時間,以及至少一個非負費率。', + pricingCreateError: '無法建立定價版本', + loading: '正在載入用量…', + loadError: '無法載入用量', + groupBy: '分組方式', + group: { service: '服務', automation: '自動化', model: '模型', device: '裝置', grant: '授權範圍' }, + range: '時間範圍', + range24h: '24 小時', + range7: '7 天', + range30: '30 天', + range90: '90 天', + windowNote: '顯示 {timeZone} 時區下的 {range}。彙總採用 UTC 小時桶。', + captureHealth: '擷取健康度', + costSources: '成本來源', + reportedHelp: '由提供商回報,絕不與 Cloud 估算值合併。', + estimatedHelp: '根據不可變的定價版本計算。', + uncostedHelp: '沒有適用類別費率的 token。', + groupedUsage: '按{group}查看用量', + noGroups: '此時間範圍內沒有可歸屬的用量。', + category: { + input: '輸入', + output: '輸出', + cache_read: '快取讀取', + cache_write: '快取寫入', + }, + }, runDetail: { loadingRun: '正在載入執行…', loadRunError: '無法載入執行', diff --git a/console/src/pages/AccountUsagePanel.module.css b/console/src/pages/AccountUsagePanel.module.css new file mode 100644 index 00000000..aa1e3cde --- /dev/null +++ b/console/src/pages/AccountUsagePanel.module.css @@ -0,0 +1,27 @@ +.panel { display: grid; gap: var(--space-4); margin-top: var(--space-6); padding: var(--space-5); border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-panel); } +.header { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-5); } +.header h2 { margin: 0; color: var(--color-text); font-size: var(--fs-h3); } +.header p { margin: var(--space-1) 0 0; color: var(--color-text-muted); font-size: var(--fs-caption); } +.header small { display: block; margin-top: var(--space-1); color: var(--color-text-faint); font-size: var(--fs-tiny); } +.controls { display: flex; align-items: flex-end; gap: var(--space-3); } +.controls > div { display: grid; gap: var(--space-1); } +.controls > div > span { color: var(--color-text-faint); font-family: var(--font-mono); font-size: var(--fs-tiny); letter-spacing: var(--tracking-label); text-transform: uppercase; } +.segmented { display: flex; gap: 3px; padding: 3px; border-radius: var(--radius-lg); background: var(--color-bg-inset); } +.segmented button { min-height: 30px; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-text-faint); font-size: var(--fs-caption); } +.segmented button[aria-pressed='true'] { background: var(--color-panel); color: var(--color-text); box-shadow: var(--shadow-1); } +.content { display: grid; gap: var(--space-4); } +.total { padding-top: var(--space-4); border-top: 1px solid var(--color-border-subtle); } +.groups { display: grid; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); } +.groups article { display: grid; grid-template-columns: minmax(140px, .35fr) minmax(0, 1fr); gap: var(--space-4); padding: var(--space-3); } +.groups article + article { border-top: 1px solid var(--color-border); } +.identity { display: grid; align-content: start; gap: 3px; } +.identity a, .identity strong { color: var(--color-text); font-size: var(--fs-sm); } +.identity a { text-decoration: none; } +.identity a:hover { color: var(--color-accent); } +.identity small { color: var(--color-text-faint); font-family: var(--font-mono); font-size: var(--fs-tiny); text-transform: uppercase; } +.empty { margin: 0; padding: var(--space-5); border: 1px dashed var(--color-border); border-radius: var(--radius-lg); color: var(--color-text-faint); text-align: center; } +@media (max-width: 760px) { + .header, .controls { align-items: stretch; flex-direction: column; } + .segmented { overflow-x: auto; } + .groups article { grid-template-columns: 1fr; } +} diff --git a/console/src/pages/AccountUsagePanel.test.tsx b/console/src/pages/AccountUsagePanel.test.tsx new file mode 100644 index 00000000..2a0c1ae1 --- /dev/null +++ b/console/src/pages/AccountUsagePanel.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { ApiProvider } from '../api/ApiProvider'; +import type { ApiClient } from '../api/client'; +import type { UsageSummaryEnvelope } from '../api/types'; +import { AccountUsagePanel } from './AccountUsagePanel'; + +const response: UsageSummaryEnvelope = { + summary: { + availability: 'available', + requests: 2, + capture: { reported: 2, partial: 0, unavailable: 0, parse_error: 0 }, + tokens: { input: 800, output: 120, cache_read: null, cache_write: null }, + costs: { reported: [], estimated: [], uncosted: [] }, + }, + groups: [{ + kind: 'device', + id: 'device-1', + name: 'Jack’s Mac', + summary: { + availability: 'available', + requests: 2, + capture: { reported: 2, partial: 0, unavailable: 0, parse_error: 0 }, + tokens: { input: 800, output: 120, cache_read: null, cache_write: null }, + costs: { reported: [], estimated: [], uncosted: [] }, + }, + }], +}; + +describe('AccountUsagePanel', () => { + it('shows Device Cloud Proxy usage and refetches by grant without mixing Project usage', async () => { + const getAccountUsage = vi.fn(async (groupBy: 'device' | 'model' | 'grant') => ({ + ...response, + groups: response.groups.map((group) => ({ ...group, kind: groupBy })), + })); + render( + + + + + + + , + ); + + expect(await screen.findByTestId('account-usage')).toBeTruthy(); + expect(screen.getByText('Only requests sent through Cloud Model Proxy are included.')).toBeTruthy(); + expect((await screen.findByRole('link', { name: 'Jack’s Mac' })).getAttribute('href')).toBe('/devices/device-1'); + fireEvent.click(screen.getByRole('button', { name: 'Grant' })); + await waitFor(() => expect(getAccountUsage.mock.calls.at(-1)?.[0]).toBe('grant')); + }); +}); diff --git a/console/src/pages/AccountUsagePanel.tsx b/console/src/pages/AccountUsagePanel.tsx new file mode 100644 index 00000000..f21a03c4 --- /dev/null +++ b/console/src/pages/AccountUsagePanel.tsx @@ -0,0 +1,112 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useAccountUsage } from '../api/queries'; +import type { UsageGroup } from '../api/types'; +import { UsageSummary } from '../components/UsageSummary'; +import { ErrorBlock, LoadingBlock } from '../components/States'; +import styles from './AccountUsagePanel.module.css'; + +type RangeDays = 7 | 30 | 90; +type GroupBy = 'device' | 'model' | 'grant'; + +export function AccountUsagePanel({ enabled = true }: { enabled?: boolean }) { + const { t } = useTranslation(); + const [days, setDays] = useState(30); + const [groupBy, setGroupBy] = useState('device'); + const range = useMemo(() => { + const to = new Date(); + const from = new Date(to.getTime() - days * 24 * 60 * 60 * 1000); + return { from: from.toISOString(), to: to.toISOString() }; + }, [days]); + const query = useAccountUsage(groupBy, range.from, range.to, enabled); + + if (!enabled) return null; + + return ( +
+
+
+

{t('usage.accountTitle')}

+

{t('usage.accountDescription')}

+ {t('usage.proxyOnly')} +
+
+ t(`usage.range${value}`)} + onChange={setDays} + /> + t(`usage.group.${value}`)} + onChange={setGroupBy} + /> +
+
+ {query.isLoading ? ( + + ) : query.isError || !query.data ? ( + void query.refetch()} title={t('usage.loadError')} /> + ) : ( +
+
+ {query.data.groups.length === 0 ? ( +

{t('usage.noGroups')}

+ ) : ( +
+ {query.data.groups.map((group) => ( +
+
+ + {t(`usage.group.${group.kind}`)} +
+ +
+ ))} +
+ )} +
+ )} +
+ ); +} + +function UsageSelector({ + label, + values, + active, + labelFor, + onChange, +}: { + label: string; + values: T[]; + active: T; + labelFor: (value: T) => string; + onChange: (value: T) => void; +}) { + return ( +
+ {label} +
+ {values.map((value) => ( + + ))} +
+
+ ); +} + +function AccountUsageGroupLink({ group }: { group: UsageGroup }) { + const label = group.name || group.id; + if (group.kind === 'device') { + return {label}; + } + return {label}; +} diff --git a/console/src/pages/AutomationDetailPage.module.css b/console/src/pages/AutomationDetailPage.module.css index f1573c14..8b6d049b 100644 --- a/console/src/pages/AutomationDetailPage.module.css +++ b/console/src/pages/AutomationDetailPage.module.css @@ -37,6 +37,7 @@ .repair { display: flex; align-items: flex-start; gap: var(--space-2); margin-top: var(--space-4); padding: var(--space-3); border-left: 2px solid var(--color-warning); background: color-mix(in srgb, var(--color-warning) 8%, var(--color-panel)); color: var(--color-text-muted); font-size: var(--fs-caption); line-height: 1.5; } .repair strong, .repair small { display: block; } .repair strong { color: var(--color-warning); font-family: var(--font-mono); font-size: var(--fs-tiny); } +.usage { margin-top: var(--space-5); padding-top: var(--space-4); border-top: 1px solid var(--color-border); } @media (max-width: 860px) { .page { width: min(100% - 24px, 680px); } .head { align-items: flex-start; flex-direction: column; } diff --git a/console/src/pages/AutomationDetailPage.test.tsx b/console/src/pages/AutomationDetailPage.test.tsx index 18811a28..ec55a0ba 100644 --- a/console/src/pages/AutomationDetailPage.test.tsx +++ b/console/src/pages/AutomationDetailPage.test.tsx @@ -38,7 +38,14 @@ const blocked: AutomationExecution = { run: null, card: null, writeback_state: '', - usage: { state: 'unavailable' }, + usage_summary: { + availability: 'unavailable', + reason: 'no_requests', + requests: 0, + capture: { reported: 0, partial: 0, unavailable: 0, parse_error: 0 }, + tokens: { input: null, output: null, cache_read: null, cache_write: null }, + costs: { reported: [], estimated: [], uncosted: [] }, + }, created_at: '2026-07-31T09:00:00Z', updated_at: '2026-07-31T09:00:00Z', }; diff --git a/console/src/pages/AutomationDetailPage.tsx b/console/src/pages/AutomationDetailPage.tsx index 63b9dac3..3a165dd1 100644 --- a/console/src/pages/AutomationDetailPage.tsx +++ b/console/src/pages/AutomationDetailPage.tsx @@ -3,6 +3,7 @@ import { Link, useParams } from 'react-router-dom'; import { ArrowLeft, ArrowSquareOut, Play, Wrench } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import { Button } from '../components/Button'; +import { UsageSummary } from '../components/UsageSummary'; import { ErrorBlock, LoadingBlock } from '../components/States'; import { useAutomationExecutions, @@ -183,8 +184,8 @@ function ExecutionInspector({ execution }: { execution?: AutomationExecution }) ? {execution.output.label} : execution.output.label}
{t('automationExecutions.writeback')}
{execution.writeback_state || t('automationExecutions.notApplicable')}
-
{t('automationExecutions.usage')}
{t('automationExecutions.usageUnavailable')}
+
{execution.reason && (
diff --git a/console/src/pages/DevicesPage.test.tsx b/console/src/pages/DevicesPage.test.tsx index c78ccd25..c78b2451 100644 --- a/console/src/pages/DevicesPage.test.tsx +++ b/console/src/pages/DevicesPage.test.tsx @@ -22,6 +22,10 @@ vi.mock('@jcloud/device-ui', async (importOriginal) => ({ }), })); +vi.mock('./AccountUsagePanel', () => ({ + AccountUsagePanel: () => null, +})); + function device(overrides: Partial): Device { return { id: 'd1', name: 'dev', online: true, ...overrides }; } diff --git a/console/src/pages/DevicesPage.tsx b/console/src/pages/DevicesPage.tsx index 14afa510..80c6ebb0 100644 --- a/console/src/pages/DevicesPage.tsx +++ b/console/src/pages/DevicesPage.tsx @@ -12,6 +12,7 @@ import { ErrorBlock, LoadingBlock } from '../components/States'; import { e2eeBadgeTooltip, platformBadgeLabel } from '../lib/deviceBadges'; import { timeAgo } from '../lib/format'; import styles from './DevicesPage.module.css'; +import { AccountUsagePanel } from './AccountUsagePanel'; function lastSeenLabel(device: Device, t: TFunction): string { return device.last_seen_at @@ -37,6 +38,7 @@ export function DevicesPage() { } /> + {devices.isLoading ? (
diff --git a/console/src/pages/KanbanBoardModal.module.css b/console/src/pages/KanbanBoardModal.module.css index c75f43a5..f1479b5a 100644 --- a/console/src/pages/KanbanBoardModal.module.css +++ b/console/src/pages/KanbanBoardModal.module.css @@ -159,6 +159,23 @@ min-width: 0; } +.cardUsage, +.executionUsage { + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-inset); +} + +.executionUsage { + margin-top: var(--space-2); +} + +.executionHistory li > section { + flex: 1 0 100%; + padding: var(--space-2) 0; +} + .executionCurrent { display: grid; gap: var(--space-2); diff --git a/console/src/pages/KanbanBoardModal.test.tsx b/console/src/pages/KanbanBoardModal.test.tsx index 24b90197..54e796c4 100644 --- a/console/src/pages/KanbanBoardModal.test.tsx +++ b/console/src/pages/KanbanBoardModal.test.tsx @@ -211,6 +211,13 @@ describe('KanbanBoardModal', () => { it('shows the execution policy and blocked receipt inside native Card details', async () => { const listExecutions = vi.fn(async () => ({ claim: { document_path: 'cards/payment.md', external_ref_available: true }, + usage_summary: { + availability: 'available' as const, + requests: 1, + capture: { reported: 1, partial: 0, unavailable: 0, parse_error: 0 }, + tokens: { input: 800, output: 120, cache_read: null, cache_write: null }, + costs: { reported: [], estimated: [], uncosted: [] }, + }, items: [{ id: 'occ_1', status: 'blocked' as const, @@ -261,6 +268,7 @@ describe('KanbanBoardModal', () => { expect(receipt.textContent).toContain('Model not configured'); expect(receipt.textContent).toContain('Project owner'); expect(receipt.textContent).not.toContain('Card writeback pending'); + expect(screen.getByTestId('kanban-card-usage').textContent).toContain('800'); expect(listExecutions).toHaveBeenCalledWith('svc_1', 'ws_team', 'cards/payment.md', undefined); }); diff --git a/console/src/pages/KanbanBoardModal.tsx b/console/src/pages/KanbanBoardModal.tsx index 850ccc4b..5eab1973 100644 --- a/console/src/pages/KanbanBoardModal.tsx +++ b/console/src/pages/KanbanBoardModal.tsx @@ -38,6 +38,7 @@ import { Button } from '../components/Button'; import { Modal } from '../components/Modal'; import { SelectField } from '../components/Field'; import { LoadingBlock } from '../components/States'; +import { UsageSummary } from '../components/UsageSummary'; import { makeBoardProxyClient } from '../kanban/boardProxyClient'; import { resolveBoardPathById } from '../kanban/resolveBoardPathById'; import type { BoardEmbedLink, KanbanCardExecution, PluginBoardResource } from '../api/types'; @@ -251,6 +252,9 @@ function CardExecutionsSupplement({ {t('kanban.cardUnavailable')}
)} +
+ +
{t('kanban.writebackPending')}} {current.receipt.writeback === 'unavailable' && {t('kanban.writebackUnavailable')}} + {current.usage_summary && ( +
+ +
+ )}
{history.length > 0 && (
@@ -281,7 +290,8 @@ function CardExecutionsSupplement({
  • {executionStateLabel(execution, t)} - {execution.run && {t('kanban.openRun')}} + {execution.run && {t('kanban.openRun')}} + {execution.usage_summary && }
  • ))} diff --git a/console/src/pages/ProjectDetailPage.tsx b/console/src/pages/ProjectDetailPage.tsx index 0f84e46f..e0224c2c 100644 --- a/console/src/pages/ProjectDetailPage.tsx +++ b/console/src/pages/ProjectDetailPage.tsx @@ -48,6 +48,7 @@ import { TaskComposer } from '../project-workspace/TaskComposer'; import { ProjectAutomationsPanel } from '../project-workspace/ProjectAutomationsPanel'; import { serviceMark, serviceProviderLabel, serviceSource } from '../project-workspace/presentation'; import { KanbanBoardModal } from './KanbanBoardModal'; +import { ProjectUsagePanel } from './ProjectUsagePanel'; import { ProjectSettingsPage, ProjectSettingsSubnav, @@ -711,6 +712,10 @@ export function ProjectDetailPage() { )} + {!projectSettingsOpen && workspaceTab === 'usage' && ( + + )} + {!projectSettingsOpen && workspaceTab === 'settings' && canManage && ( div { display: grid; gap: var(--space-2); } +.toolbar > div > span { color: var(--color-text-faint); font-family: var(--font-mono); font-size: var(--fs-tiny); letter-spacing: var(--tracking-label); text-transform: uppercase; } +.segmented { display: flex; gap: 3px; padding: 3px; border-radius: var(--radius-lg); background: var(--color-bg-inset); } +.segmented button { min-height: 30px; padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-text-faint); font-size: var(--fs-caption); } +.segmented button[aria-pressed='true'] { background: var(--color-panel); color: var(--color-text); box-shadow: var(--shadow-1); } +.total { padding: var(--space-5); border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-panel); } +.windowNote { margin: calc(var(--space-2) * -1) 0 0; color: var(--color-text-faint); font-size: var(--fs-tiny); } +.overview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(220px, .38fr); gap: var(--space-4); } +.captureHealth { padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-panel); } +.captureHealth h2 { margin: 0 0 var(--space-3); color: var(--color-text); font-size: var(--fs-sm); } +.captureHealth dl { display: grid; gap: var(--space-2); margin: 0; } +.captureHealth dl > div { display: flex; justify-content: space-between; gap: var(--space-3); } +.captureHealth dt { color: var(--color-text-faint); font-size: var(--fs-tiny); } +.captureHealth dd { margin: 0; color: var(--color-text); font-family: var(--font-mono); font-size: var(--fs-tiny); } +.costSources { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); } +.costSources article { display: grid; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-panel); } +.costSources span { color: var(--color-text-faint); font-size: var(--fs-caption); } +.costSources strong { color: var(--color-text); font-family: var(--font-mono); font-size: var(--fs-sm); overflow-wrap: anywhere; } +.costSources small { color: var(--color-text-faint); font-size: var(--fs-tiny); line-height: var(--lh-normal); } +.groups { display: grid; gap: var(--space-3); } +.groups h2 { margin: 0; color: var(--color-text); font-size: var(--fs-h3); } +.table { overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-panel); } +.table article { display: grid; grid-template-columns: minmax(150px, .4fr) minmax(0, 1fr); gap: var(--space-5); padding: var(--space-4); } +.table article + article { border-top: 1px solid var(--color-border); } +.identity { display: grid; align-content: start; gap: 4px; } +.identity a, .identity strong { color: var(--color-text); font-size: var(--fs-sm); } +.identity a { text-decoration: none; } +.identity a:hover { color: var(--color-accent); } +.identity small { color: var(--color-text-faint); font-family: var(--font-mono); font-size: var(--fs-tiny); text-transform: uppercase; } +.empty { margin: 0; padding: var(--space-8); border: 1px dashed var(--color-border); border-radius: var(--radius-xl); color: var(--color-text-faint); text-align: center; } +@media (max-width: 720px) { + .toolbar { align-items: stretch; flex-direction: column; } + .segmented { overflow-x: auto; } + .overview, .costSources { grid-template-columns: 1fr; } + .table article { grid-template-columns: 1fr; } +} diff --git a/console/src/pages/ProjectUsagePanel.test.tsx b/console/src/pages/ProjectUsagePanel.test.tsx new file mode 100644 index 00000000..4c5ad96b --- /dev/null +++ b/console/src/pages/ProjectUsagePanel.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { ApiProvider } from '../api/ApiProvider'; +import type { ApiClient } from '../api/client'; +import type { UsageSummaryEnvelope } from '../api/types'; +import { ProjectUsagePanel } from './ProjectUsagePanel'; + +const response: UsageSummaryEnvelope = { + summary: { + availability: 'available', + requests: 3, + capture: { reported: 2, partial: 1, unavailable: 0, parse_error: 0 }, + tokens: { input: 3000, output: 900, cache_read: 200, cache_write: null }, + costs: { reported: [], estimated: [], uncosted: [] }, + }, + groups: [{ + kind: 'service', + id: 'svc-1', + name: 'API', + summary: { + availability: 'available', + requests: 3, + capture: { reported: 2, partial: 1, unavailable: 0, parse_error: 0 }, + tokens: { input: 3000, output: 900, cache_read: 200, cache_write: null }, + costs: { reported: [], estimated: [], uncosted: [] }, + }, + }], +}; + +describe('ProjectUsagePanel', () => { + it('shows the project total and refetches with an explicit grouping', async () => { + const getProjectUsage = vi.fn(async ( + _projectId: string, + groupBy: 'service' | 'automation' | 'model', + ) => ({ + ...response, + groups: response.groups.map((group) => ({ ...group, kind: groupBy })), + })); + render( + + + + + + + , + ); + + expect(await screen.findByTestId('project-usage')).toBeTruthy(); + expect(screen.getAllByText('3 / 3 captured').length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: '24 hours' })).toBeTruthy(); + expect(screen.getByTestId('project-cost-sources')).toBeTruthy(); + expect(screen.getByTestId('project-capture-health')).toBeTruthy(); + expect(screen.getByText(/UTC hourly buckets/)).toBeTruthy(); + expect(screen.getByRole('link', { name: 'API' }).getAttribute('href')).toContain('service=svc-1'); + fireEvent.click(screen.getByRole('button', { name: 'Automation' })); + await waitFor(() => expect(getProjectUsage.mock.calls.at(-1)?.[1]).toBe('automation')); + }); +}); diff --git a/console/src/pages/ProjectUsagePanel.tsx b/console/src/pages/ProjectUsagePanel.tsx new file mode 100644 index 00000000..8277d493 --- /dev/null +++ b/console/src/pages/ProjectUsagePanel.tsx @@ -0,0 +1,141 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useProjectUsage } from '../api/queries'; +import type { UsageGroup, UsageMoneyTotal, UsageSummary as UsageSummaryValue } from '../api/types'; +import { formatUsageMoney, UsageSummary } from '../components/UsageSummary'; +import { ErrorBlock, LoadingBlock } from '../components/States'; +import styles from './ProjectUsagePanel.module.css'; + +type RangePreset = '24h' | '7d' | '30d'; +type GroupBy = 'service' | 'automation' | 'model'; + +const rangeMilliseconds: Record = { + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, +}; +const rangeLabelKey: Record = { + '24h': 'usage.range24h', + '7d': 'usage.range7', + '30d': 'usage.range30', +}; + +export function ProjectUsagePanel({ projectId }: { projectId: string }) { + const { t } = useTranslation(); + const [rangePreset, setRangePreset] = useState('7d'); + const [groupBy, setGroupBy] = useState('service'); + const [rangeAnchor] = useState(() => Date.now()); + const range = useMemo(() => { + const to = new Date(rangeAnchor); + const from = new Date(rangeAnchor - rangeMilliseconds[rangePreset]); + return { from: from.toISOString(), to: to.toISOString() }; + }, [rangeAnchor, rangePreset]); + const query = useProjectUsage(projectId, groupBy, range.from, range.to); + + if (query.isLoading) return ; + if (query.isError || !query.data) { + return void query.refetch()} title={t('usage.loadError')} />; + } + + return ( +
    +
    +
    + {t('usage.range')} +
    + {(['24h', '7d', '30d'] as RangePreset[]).map((value) => ( + + ))} +
    +
    +
    + {t('usage.groupBy')} +
    + {(['service', 'automation', 'model'] as GroupBy[]).map((value) => ( + + ))} +
    +
    +
    +

    + {t('usage.windowNote', { + range: t(rangeLabelKey[rangePreset]), + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + })} +

    +
    +
    + +
    + +
    + +
    +

    {t('usage.groupedUsage', { group: t(`usage.group.${groupBy}`) })}

    + {query.data.groups.length === 0 ? ( +

    {t('usage.noGroups')}

    + ) : ( +
    + {query.data.groups.map((group) => ( +
    +
    + + {group.kind} +
    +
    +
    + ))} +
    + )} +
    +
    + ); +} + +function ProjectCaptureHealth({ value }: { value: UsageSummaryValue }) { + const { t } = useTranslation(); + return ( +
    +

    {t('usage.captureHealth')}

    +
    +
    {t('usage.reportedCapture')}
    {value.capture.reported}
    +
    {t('usage.partialCapture')}
    {value.capture.partial}
    +
    {t('usage.unavailableCapture')}
    {value.capture.unavailable}
    +
    {t('usage.parseErrors')}
    {value.capture.parse_error}
    +
    +
    + ); +} + +function ProjectCostSources({ value }: { value: UsageSummaryValue }) { + const { t } = useTranslation(); + const money = (items: UsageMoneyTotal[]) => items.length > 0 + ? items.map(formatUsageMoney).join(' · ') + : t('usage.none'); + const uncosted = value.costs.uncosted.length > 0 + ? value.costs.uncosted.map((item) => `${item.tokens.toLocaleString()} ${t(`usage.category.${item.category}`)}`).join(' · ') + : t('usage.none'); + return ( +
    +
    {t('usage.reported')}{money(value.costs.reported)}{t('usage.reportedHelp')}
    +
    {t('usage.estimated')}{money(value.costs.estimated)}{t('usage.estimatedHelp')}
    +
    {t('usage.uncosted')}{uncosted}{t('usage.uncostedHelp')}
    +
    + ); +} + +function UsageGroupLink({ projectId, group }: { projectId: string; group: UsageGroup }) { + const label = group.name || group.id; + if (group.kind === 'service') { + return {label}; + } + if (group.kind === 'automation') { + return {label}; + } + return {label}; +} diff --git a/console/src/pages/RunDetailPage.module.css b/console/src/pages/RunDetailPage.module.css index a81846bf..f33cf839 100644 --- a/console/src/pages/RunDetailPage.module.css +++ b/console/src/pages/RunDetailPage.module.css @@ -66,6 +66,7 @@ .inspectorSection { display: grid; gap: var(--space-4); padding: 0 0 var(--space-5) var(--space-5); } .inspectorSection + .inspectorSection { padding-top: var(--space-5); border-top: 1px solid var(--color-border); } .inspectorSection h2 { margin: 0; color: var(--color-text-faint); font-family: var(--font-mono); font-size: var(--fs-tiny); font-weight: var(--fw-semibold); letter-spacing: var(--tracking-label); text-transform: uppercase; } +.inspectorUsage { display: block; } .facts { display: grid; gap: var(--space-3); margin: 0; } .facts > div { display: grid; grid-template-columns: 76px minmax(0, 1fr); align-items: start; gap: var(--space-2); } .facts dt { color: var(--color-text-faint); font-size: var(--fs-tiny); } diff --git a/console/src/pages/RunDetailPage.test.tsx b/console/src/pages/RunDetailPage.test.tsx index fb9ad493..f004217d 100644 --- a/console/src/pages/RunDetailPage.test.tsx +++ b/console/src/pages/RunDetailPage.test.tsx @@ -112,6 +112,28 @@ function renderPage(client: ApiClient, seed?: Run) { } describe('RunDetailPage — resilient error states', () => { + it('shows captured Run usage in the inspector without merging cost sources', async () => { + const { client, ctl } = makeClient(); + const value = baseRun({ + usage_summary: { + availability: 'available', + requests: 1, + capture: { reported: 1, partial: 0, unavailable: 0, parse_error: 0 }, + tokens: { input: 120, output: 40, cache_read: null, cache_write: null }, + costs: { + reported: [{ currency: 'USD', micros: 3000 }], + estimated: [{ currency: 'USD', micros: 2000 }], + uncosted: [], + }, + }, + }); + ctl.getRun.mockResolvedValue(value); + renderPage(client, value); + expect(await screen.findByTestId('run-usage')).toBeTruthy(); + expect(screen.getByText('USD 0.003000')).toBeTruthy(); + expect(screen.getByText('USD 0.002000')).toBeTruthy(); + }); + it('never regresses a terminal cached run to a stale non-terminal GET response', async () => { const { client, ctl } = makeClient(); const succeeded = baseRun({ diff --git a/console/src/pages/RunDetailPage.tsx b/console/src/pages/RunDetailPage.tsx index 04187271..ca6d4a34 100644 --- a/console/src/pages/RunDetailPage.tsx +++ b/console/src/pages/RunDetailPage.tsx @@ -31,6 +31,7 @@ import { StatusBadge } from '../components/StatusBadge'; import { LanguageToggle } from '../components/LanguageToggle'; import { ThemeToggle } from '../components/ThemeToggle'; import { useToast } from '../components/Toast'; +import { UsageSummary } from '../components/UsageSummary'; import { Wordmark } from '../components/Wordmark'; import { useRunStream } from '../hooks/useRunStream'; import { formatDateTime, formatDuration, shortId } from '../lib/format'; @@ -796,6 +797,9 @@ function RunInspector({ return (