Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions console/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -342,6 +346,12 @@ export interface ApiClient {
listAutomationExecutions(automationId: string, before?: string, state?: string, limit?: number): Promise<AutomationExecutionsPage>;
getAutomationExecution(automationId: string, executionId: string): Promise<AutomationExecution>;
runAutomationNow(automationId: string, idempotencyKey: string): Promise<AutomationExecution>;
getAutomationUsage(automationId: string, from?: string, to?: string): Promise<UsageSummary>;
getProjectUsage(projectId: string, groupBy: 'service' | 'automation' | 'model', from?: string, to?: string): Promise<UsageSummaryEnvelope>;
getAccountUsage(groupBy: 'device' | 'model' | 'grant', from?: string, to?: string): Promise<UsageSummaryEnvelope>;
getServiceUsage(serviceId: string, from?: string, to?: string): Promise<UsageSummary>;
listModelPricingRevisions(modelId: string): Promise<ModelPricingRevision[]>;
createModelPricingRevision(modelId: string, input: CreateModelPricingRevisionInput): Promise<ModelPricingRevision>;
getServiceKanban(serviceId: string): Promise<ServiceKanbanBinding>;
getServiceKanbanPolicy(serviceId: string): Promise<ServiceKanbanPolicy>;
listServiceKanbanCardExecutions(serviceId: string, workspaceId: string, documentPath: string, before?: string, limit?: number): Promise<KanbanCardExecutionsPage>;
Expand Down Expand Up @@ -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 = {},
Expand Down Expand Up @@ -844,6 +862,35 @@ export function createHttpClient(
req<AutomationExecution>(`/automations/${encodeURIComponent(automationId)}/executions`, {
method: 'POST', body: JSON.stringify({ idempotency_key: idempotencyKey }),
}),
getAutomationUsage: (automationId, from, to) => {
const params = usageParams(from, to);
return req<UsageSummary>(`/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<UsageSummaryEnvelope>(`/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<UsageSummaryEnvelope>(`/account/usage?${params.toString()}`);
},
getServiceUsage: (serviceId, from, to) => {
const params = usageParams(from, to);
return req<UsageSummary>(`/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<ModelPricingRevision>(`/system/models/${encodeURIComponent(modelId)}/pricing-revisions`, {
method: 'POST',
body: JSON.stringify(input),
}),
getServiceKanban: (serviceId) => req<ServiceKanbanBinding>(`/services/${encodeURIComponent(serviceId)}/kanban`),
getServiceKanbanPolicy: (serviceId) => req<ServiceKanbanPolicy>(`/services/${encodeURIComponent(serviceId)}/kanban/policy`),
listServiceKanbanCardExecutions: (serviceId, workspaceId, documentPath, before, limit = 20) => {
Expand Down
40 changes: 39 additions & 1 deletion console/src/api/mockClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ import type {
UpdateProviderModelInput,
UpdateServiceInput,
UserSearchResult,
UsageSummary,
ModelPricingRevision,
} from './types';
import { providerForRepoUrl } from '../lib/repo';
import { isReservedEnvKey, isValidEnvKey } from '../lib/env';
Expand All @@ -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).
Expand Down Expand Up @@ -2225,14 +2238,38 @@ 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,
};
automationExecutions.set(automationId, [item, ...(automationExecutions.get(automationId) ?? [])]);
automationIdempotency.set(`${automationId}|${idempotencyKey}`, id);
return delay(structuredClone(item));
},
async getAutomationUsage(): Promise<UsageSummary> {
return delay(unavailableUsage());
},
async getProjectUsage() {
return delay({ summary: unavailableUsage(), groups: [] });
},
async getAccountUsage() {
return delay({ summary: unavailableUsage(), groups: [] });
},
async getServiceUsage(): Promise<UsageSummary> {
return delay(unavailableUsage());
},
async listModelPricingRevisions(): Promise<ModelPricingRevision[]> {
return delay([]);
},
async createModelPricingRevision(modelId, input): Promise<ModelPricingRevision> {
return delay({
id: genId('price'),
model_id: modelId,
model_name: modelId,
...input,
created_at: nowISO(),
});
},
async getServiceKanban(serviceId: string): Promise<ProjectAutomationSpec> {
const spec = [...projectAutomations.values()].find((item) =>
item.automation.service_id === serviceId && item.automation.trigger_kind === 'kanban');
Expand Down Expand Up @@ -2265,6 +2302,7 @@ export function createMockClient(): ApiClient {
return delay({
claim: null,
items: [],
usage_summary: unavailableUsage(),
next_cursor: null,
});
},
Expand Down
9 changes: 8 additions & 1 deletion console/src/api/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
};
Expand Down
65 changes: 65 additions & 0 deletions console/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
UpdateProjectInput,
UpdateProviderModelInput,
UpdateServiceInput,
CreateModelPricingRevisionInput,
} from './types';
import { isTerminal } from './types';
import { reconcileRunSnapshot } from './runCache';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
67 changes: 66 additions & 1 deletion console/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions console/src/components/UsageSummary.module.css
Original file line number Diff line number Diff line change
@@ -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)); }
}
Loading
Loading