diff --git a/console/package.json b/console/package.json index d1d5bb26..413d30c5 100644 --- a/console/package.json +++ b/console/package.json @@ -22,7 +22,7 @@ "i18next": "^25.3.2", "jcode-ui": "file:../../jcode/packages/jcode-ui", "jcode-ui-core": "file:../../jcode/packages/jcode-ui-core", - "jtype-board-react": "github:cnjack/jtype#eff1d85fbff89d05d950b02135ef0a3d534a6c4b&path:/packages/board-react", + "jtype-board-react": "github:cnjack/jtype#a83f55b8112cc534f42c66e9f8f1b6274ce3706d&path:/packages/board-react", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^15.6.1", diff --git a/console/pnpm-lock.yaml b/console/pnpm-lock.yaml index da7e66fa..7fa1594f 100644 --- a/console/pnpm-lock.yaml +++ b/console/pnpm-lock.yaml @@ -34,8 +34,8 @@ importers: specifier: file:../../jcode/packages/jcode-ui-core version: file:../../jcode/packages/jcode-ui-core(react-dom@18.3.1(react@18.3.1))(react@18.3.1) jtype-board-react: - specifier: github:cnjack/jtype#eff1d85fbff89d05d950b02135ef0a3d534a6c4b&path:/packages/board-react - version: https://codeload.github.com/cnjack/jtype/tar.gz/eff1d85fbff89d05d950b02135ef0a3d534a6c4b#path:/packages/board-react(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: github:cnjack/jtype#a83f55b8112cc534f42c66e9f8f1b6274ce3706d&path:/packages/board-react + version: https://codeload.github.com/cnjack/jtype/tar.gz/a83f55b8112cc534f42c66e9f8f1b6274ce3706d#path:/packages/board-react(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -1194,9 +1194,9 @@ packages: engines: {node: '>=6'} hasBin: true - jtype-board-react@https://codeload.github.com/cnjack/jtype/tar.gz/eff1d85fbff89d05d950b02135ef0a3d534a6c4b#path:/packages/board-react: - resolution: {gitHosted: true, integrity: sha512-hSyr7i8htucwfCKr/WNfywjLf5eMH1zc6i/DYhzNqjbwbFtpGqqMoO1Dsa2J3yAlqHUEs4emxcfTNed1qdJqjg==, path: /packages/board-react, tarball: https://codeload.github.com/cnjack/jtype/tar.gz/eff1d85fbff89d05d950b02135ef0a3d534a6c4b} - version: 0.1.1 + jtype-board-react@https://codeload.github.com/cnjack/jtype/tar.gz/a83f55b8112cc534f42c66e9f8f1b6274ce3706d#path:/packages/board-react: + resolution: {gitHosted: true, integrity: sha512-1aLCgPjtU2y/oahY0Pt+JZoRfcnBR5ljwEee31RIBh8UuAwj2Z0Arclsr0kce0l+5EUpEiC1vdwlizDYZKQUQA==, path: /packages/board-react, tarball: https://codeload.github.com/cnjack/jtype/tar.gz/a83f55b8112cc534f42c66e9f8f1b6274ce3706d} + version: 0.1.2 peerDependencies: react: ^18.2.0 || ^19.0.0 react-dom: ^18.2.0 || ^19.0.0 @@ -2532,7 +2532,7 @@ snapshots: json5@2.2.3: {} - jtype-board-react@https://codeload.github.com/cnjack/jtype/tar.gz/eff1d85fbff89d05d950b02135ef0a3d534a6c4b#path:/packages/board-react(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + jtype-board-react@https://codeload.github.com/cnjack/jtype/tar.gz/a83f55b8112cc534f42c66e9f8f1b6274ce3706d#path:/packages/board-react(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) diff --git a/console/src/App.tsx b/console/src/App.tsx index e9a1557b..ffdf8ae6 100644 --- a/console/src/App.tsx +++ b/console/src/App.tsx @@ -9,6 +9,7 @@ import { NewProjectPage } from './pages/NewProjectPage'; import { ProjectDetailPage } from './pages/ProjectDetailPage'; import { ProjectPluginDetailPage } from './pages/ProjectPluginDetailPage'; import { AutomationEditorPage } from './pages/AutomationEditorPage'; +import { AutomationDetailPage } from './pages/AutomationDetailPage'; import { RunDetailPage } from './pages/RunDetailPage'; import { DevicesPage } from './pages/DevicesPage'; import { DeviceWelcomePage } from './pages/DeviceWelcomePage'; @@ -70,6 +71,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/console/src/api/client.test.ts b/console/src/api/client.test.ts index 98fdd8c7..3ee63590 100644 --- a/console/src/api/client.test.ts +++ b/console/src/api/client.test.ts @@ -148,6 +148,22 @@ describe('httpClient — request shaping', () => { expect(JSON.parse(calls[0]!.init!.body as string)).toEqual({ prompt: 'do it' }); }); + it('encodes Card execution scope and opaque pagination cursor', async () => { + const { calls } = mockFetch(() => ({ + body: { claim: null, items: [], next_cursor: null }, + })); + const client = createHttpClient('t'); + await client.listServiceKanbanCardExecutions( + 'service/1', 'workspace 1', 'cards/payment fix.md', 'opaque+/cursor', 12, + ); + const url = new URL(calls[0]!.url, 'https://console.test'); + expect(url.pathname).toBe('/api/v1/services/service%2F1/kanban/card-executions'); + expect(url.searchParams.get('workspace_id')).toBe('workspace 1'); + expect(url.searchParams.get('document_path')).toBe('cards/payment fix.md'); + expect(url.searchParams.get('before')).toBe('opaque+/cursor'); + expect(url.searchParams.get('limit')).toBe('12'); + }); + it('stages an attachment as JSON and uploads its raw body through Cloud', async () => { const { calls } = mockFetch(({ url }) => { if (url.endsWith('/attachments/intents')) { diff --git a/console/src/api/client.ts b/console/src/api/client.ts index 8958c383..b0134c58 100644 --- a/console/src/api/client.ts +++ b/console/src/api/client.ts @@ -41,6 +41,8 @@ import type { PrInfo, Project, ProjectAutomationSpec, + AutomationExecution, + AutomationExecutionsPage, ProjectPlugin, PluginAuditEvent, PluginConsentInput, @@ -48,6 +50,7 @@ import type { GitHubAppInstallation, GitHubInstallationConsentPreview, JTypePluginConnectStatus, + KanbanCardExecutionsPage, ProviderKind, PluginRepositoryResource, PluginWorkspaceResource, @@ -77,6 +80,7 @@ import type { UpdateProjectAutomationInput, PutServiceKanbanInput, ServiceKanbanBinding, + ServiceKanbanPolicy, UpdateModelInput, UpdateModelProviderInput, UpdateProjectInput, @@ -84,6 +88,10 @@ import type { UpdateServiceInput, UserSearchResult, UsersEnvelope, + UsageSummary, + UsageSummaryEnvelope, + ModelPricingRevision, + CreateModelPricingRevisionInput, } from './types'; // ApiError / apiErrorCode / TokenSource live in @jcloud/device-ui (M6 shared @@ -335,7 +343,18 @@ export interface ApiClient { createProjectAutomation(projectId: string, input: CreateProjectAutomationInput): Promise; updateProjectAutomation(projectId: string, automationId: string, input: UpdateProjectAutomationInput): Promise; deleteProjectAutomation(projectId: string, automationId: string): Promise; + 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; putServiceKanban(serviceId: string, input: PutServiceKanbanInput): Promise; deleteServiceKanban(serviceId: string): Promise; /** Lists branches through the Service's bound Plugin; never exposes a Git credential. */ @@ -406,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 = {}, @@ -823,7 +850,60 @@ export function createHttpClient( }), deleteProjectAutomation: (_projectId, automationId) => req(`/automations/${encodeURIComponent(automationId)}`, { method: 'DELETE' }), + listAutomationExecutions: (automationId, before, state, limit = 20) => { + const params = new URLSearchParams({ limit: String(limit) }); + if (before) params.set('before', before); + if (state) params.set('state', state); + return req(`/automations/${encodeURIComponent(automationId)}/executions?${params.toString()}`); + }, + getAutomationExecution: (automationId, executionId) => + req(`/automations/${encodeURIComponent(automationId)}/executions/${encodeURIComponent(executionId)}`), + runAutomationNow: (automationId, idempotencyKey) => + 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) => { + const params = new URLSearchParams({ + workspace_id: workspaceId, + document_path: documentPath, + limit: String(limit), + }); + if (before) params.set('before', before); + return req( + `/services/${encodeURIComponent(serviceId)}/kanban/card-executions?${params.toString()}`, + ); + }, putServiceKanban: (serviceId, input) => req(`/services/${encodeURIComponent(serviceId)}/kanban`, { method: 'PUT', body: JSON.stringify(input), }), diff --git a/console/src/api/mockClient.ts b/console/src/api/mockClient.ts index 3fdf02f5..ee71ac61 100644 --- a/console/src/api/mockClient.ts +++ b/console/src/api/mockClient.ts @@ -22,6 +22,8 @@ import type { import type { AddMemberInput, ApiKey, + AutomationExecution, + AutomationExecutionsPage, BoardEmbedLink, CatalogModel, ClusterProviderConfig, @@ -66,6 +68,8 @@ import type { ResumeSessionOptions, RunStatus, Service, + KanbanCardExecutionsPage, + ServiceKanbanPolicy, ServiceBranch, SystemInfo, UpdateClusterProviderConfigInput, @@ -76,6 +80,8 @@ import type { UpdateProviderModelInput, UpdateServiceInput, UserSearchResult, + UsageSummary, + ModelPricingRevision, } from './types'; import { providerForRepoUrl } from '../lib/repo'; import { isReservedEnvKey, isValidEnvKey } from '../lib/env'; @@ -91,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). @@ -260,6 +277,8 @@ export function createMockClient(): ApiClient { ], }; const projectAutomations = new Map(); + const automationExecutions = new Map(); + const automationIdempotency = new Map(); const projectPlugins = new Map>(); const clusterProviders = new Map((['github', 'gitlab', 'gitea', 'jtype'] as const).map((provider): [ProviderKind, ClusterProviderConfig] => [provider, { provider, base_url: provider === 'github' ? 'https://github.com' : '', login_enabled: provider !== 'jtype', plugin_enabled: true, configured: false, health: 'unknown' }])); @@ -2169,12 +2188,124 @@ export function createMockClient(): ApiClient { projectAutomations.delete(automationId); return delay(undefined); }, + async listAutomationExecutions(automationId: string, _before?: string, state?: string, limit = 20): Promise { + const items = (automationExecutions.get(automationId) ?? []) + .filter((item) => !state || item.state === state) + .slice(0, limit); + return delay({ items: structuredClone(items), next_cursor: null }); + }, + async getAutomationExecution(automationId: string, executionId: string): Promise { + const item = (automationExecutions.get(automationId) ?? []).find((value) => value.id === executionId); + if (!item) throw new ApiError(404, 'automation execution not found'); + return delay(structuredClone(item)); + }, + async runAutomationNow(automationId: string, idempotencyKey: string): Promise { + const spec = projectAutomations.get(automationId); + if (!spec) throw new ApiError(404, 'automation not found'); + const replayId = automationIdempotency.get(`${automationId}|${idempotencyKey}`); + const existing = (automationExecutions.get(automationId) ?? []).find((item) => item.id === replayId); + if (existing) return delay(structuredClone(existing)); + const service = [...services.values()].flat().find((item) => item.id === spec.automation.service_id); + if (!service) throw new ApiError(404, 'service not found'); + const now = nowISO(); + const outputMode = spec.cron?.output_mode ?? 'run_only'; + const id = genId('execution'); + let run: StoredRun | undefined; + if (outputMode === 'run_only' && spec.automation.run_kind !== 'review') { + run = makeRun(service.project_id, service.id, spec.automation.prompt_template); + } + const item: AutomationExecution = { + id, + automation_id: automationId, + automation_name: spec.automation.name, + trigger_kind: 'manual', + state: run ? 'queued' : spec.automation.run_kind === 'review' ? 'blocked' : 'accepted', + output_mode: outputMode, + reason_code: spec.automation.run_kind === 'review' ? 'manual_review_requires_pull_request' : undefined, + reason: spec.automation.run_kind === 'review' ? 'Run now needs a pull request for native review output.' : undefined, + repair_role: spec.automation.run_kind === 'review' ? 'project_owner' : undefined, + requested_actor: { kind: 'cloud_user', id: 'u_ada', label: 'Ada Lovelace' }, + accountable_actor: { kind: 'cloud_user', id: 'u_ada', label: 'Ada Lovelace' }, + output: run + ? { kind: 'run', label: `Run ${run.id}`, href: `/runs/${run.id}`, available: true } + : outputMode === 'create_card' + ? { kind: 'card', label: `jcode-automation/${automationId}/${id}.md`, available: false } + : { kind: 'none', label: 'No output', available: false }, + run: run ? { id: run.id, status: run.status, href: `/runs/${run.id}` } : null, + card: outputMode === 'create_card' ? { + workspace_id: '', + document_path: `jcode-automation/${automationId}/${id}.md`, + available: false, + } : null, + writeback_state: outputMode === 'create_card' ? 'pending' : '', + 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 { + 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'); if (!spec) throw new ApiError(404, 'Kanban is not enabled'); return delay(structuredClone(spec)); }, + async getServiceKanbanPolicy(serviceId: string): Promise { + const service = [...services.values()].flat().find((item) => item.id === serviceId); + const spec = [...projectAutomations.values()].find((item) => + item.automation.service_id === serviceId && item.automation.trigger_kind === 'kanban'); + if (!service || !spec?.kanban) throw new ApiError(404, 'Kanban is not enabled'); + const plugin = [...pluginList(service.project_id).values()].find((item) => item.id === spec.kanban!.installation_id); + return delay({ + service_id: serviceId, + service_name: service.name, + repository: service.repo_owner_name ?? service.raw_repo_url ?? '', + model: { label: 'Demo model' }, + board: { workspace_id: plugin?.workspace_id ?? '', ref: spec.kanban.board_ref }, + trigger_column: { key: spec.kanban.trigger_column, label: spec.kanban.trigger_column }, + done_column: { key: spec.kanban.done_column, label: spec.kanban.done_column }, + output: spec.kanban.done_column ? 'comment_and_move_on_success' : 'comment_only', + health: { + state: spec.automation.enabled ? 'ready' : 'blocked', + blocker: spec.automation.enabled ? null : 'binding_disabled', + repair_role: spec.automation.enabled ? null : 'project_owner', + }, + }); + }, + async listServiceKanbanCardExecutions(_serviceId: string, _workspaceId: string, _documentPath: string): Promise { + return delay({ + claim: null, + items: [], + usage_summary: unavailableUsage(), + next_cursor: null, + }); + }, async putServiceKanban(serviceId, input): Promise { const projectEntry = [...services.entries()].find(([, list]) => list.some((service) => service.id === serviceId)); if (!projectEntry) throw new ApiError(404, 'service not found'); diff --git a/console/src/api/queries.test.ts b/console/src/api/queries.test.ts new file mode 100644 index 00000000..81a39018 --- /dev/null +++ b/console/src/api/queries.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import type { AutomationExecution, KanbanCardExecution } from './types'; +import { + automationExecutionPollInterval, + kanbanCardExecutionPollInterval, +} from './queries'; + +function execution(status: KanbanCardExecution['status']): KanbanCardExecution { + return { + id: `occ-${status}`, + status, + summary: status, + reason: null, + repair_role: null, + requested_actor: null, + run: null, + receipt: { external: 'not_required', writeback: 'not_required' }, + created_at: '', + updated_at: '', + }; +} + +describe('kanbanCardExecutionPollInterval', () => { + it('keeps polling an empty Card until its first receipt appears', () => { + expect(kanbanCardExecutionPollInterval(undefined)).toBe(5_000); + expect(kanbanCardExecutionPollInterval([])).toBe(5_000); + }); + + it('polls active or repairable receipts and stops for terminal-only history', () => { + for (const status of ['received', 'blocked', 'queued', 'running'] as const) { + expect(kanbanCardExecutionPollInterval([execution(status)])).toBe(5_000); + } + expect(kanbanCardExecutionPollInterval([execution('terminal')])).toBe(false); + }); +}); + +function automationExecution( + state: AutomationExecution['state'], + outputMode: AutomationExecution['output_mode'] = 'run_only', +): AutomationExecution { + return { + id: `execution-${state}`, + automation_id: 'automation', + automation_name: 'Nightly', + trigger_kind: 'cron', + state, + output_mode: outputMode, + requested_actor: null, + accountable_actor: null, + output: { kind: 'none', label: 'No output', available: false }, + run: null, + card: null, + writeback_state: '', + 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: '', + }; +} + +describe('automationExecutionPollInterval', () => { + it('polls active executions and blocked Card materialization that can recover', () => { + for (const state of ['accepted', 'queued', 'running'] as const) { + expect(automationExecutionPollInterval([automationExecution(state)])).toBe(5_000); + } + expect(automationExecutionPollInterval([ + automationExecution('blocked', 'create_card'), + ])).toBe(5_000); + }); + + it('stops for terminal decisions and blocked direct Runs', () => { + expect(automationExecutionPollInterval([ + automationExecution('terminal'), + automationExecution('ignored'), + automationExecution('blocked', 'run_only'), + ])).toBe(false); + }); +}); diff --git a/console/src/api/queries.ts b/console/src/api/queries.ts index dfde4fad..d3dc237d 100644 --- a/console/src/api/queries.ts +++ b/console/src/api/queries.ts @@ -3,6 +3,7 @@ * centralised so SSE/status changes can invalidate precisely. */ import { + useInfiniteQuery, useMutation, useQuery, useQueryClient, @@ -10,6 +11,7 @@ import { import { useApi } from './ApiProvider'; import type { AddMemberInput, + AutomationExecution, CreateProjectAutomationInput, CreateApiKeyInput, CreateApiKeyResponse, @@ -20,6 +22,7 @@ import type { CreateRunInput, CreateServiceInput, ServiceBranch, + KanbanCardExecution, Member, Model, Project, @@ -36,10 +39,30 @@ import type { UpdateProjectInput, UpdateProviderModelInput, UpdateServiceInput, + CreateModelPricingRevisionInput, } from './types'; import { isTerminal } from './types'; import { reconcileRunSnapshot } from './runCache'; +export function kanbanCardExecutionPollInterval( + items: readonly KanbanCardExecution[] | undefined, +): number | false { + if (!items || items.length === 0) return 5_000; + return items.some((item) => + item.status === 'received' || item.status === 'blocked' || + item.status === 'queued' || item.status === 'running') + ? 5_000 : false; +} + +export function automationExecutionPollInterval( + items: readonly AutomationExecution[] | undefined, +): number | false { + return items?.some((item) => + item.state === 'accepted' || item.state === 'queued' || item.state === 'running' || + (item.state === 'blocked' && item.output_mode === 'create_card')) + ? 5_000 : false; +} + export const qk = { me: ['me'] as const, projects: ['projects'] as const, @@ -60,6 +83,9 @@ export const qk = { // button + feeds the embed modal's selector. projectBoardLinks: (projectId: string) => ['project-board-links', projectId] as const, serviceKanban: (serviceId: string) => ['service-kanban', serviceId] as const, + serviceKanbanPolicy: (serviceId: string) => ['service-kanban-policy', serviceId] as const, + serviceKanbanCardExecutions: (serviceId: string, workspaceId: string, documentPath: string) => + ['service-kanban-card-executions', serviceId, workspaceId, documentPath] as const, serviceBranches: (serviceId: string) => ['service-branches', serviceId] as const, projectPlugins: (projectId: string) => ['project-plugins', projectId] as const, projectPluginImpact: (projectId: string, installationId: string) => @@ -75,7 +101,16 @@ export const qk = { projectAutomations: (projectId: string) => ['project-automations', projectId] as const, projectAutomation: (projectId: string, automationId: string) => ['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, @@ -760,6 +795,42 @@ export function usePutServiceKanban(projectId: string, serviceId: string) { }); } +export function useServiceKanbanPolicy(serviceId: string, enabled = true) { + const api = useApi(); + return useQuery({ + queryKey: qk.serviceKanbanPolicy(serviceId), + queryFn: () => api.getServiceKanbanPolicy(serviceId), + enabled: enabled && !!serviceId, + retry: false, + }); +} + +export function useServiceKanbanCardExecutions( + serviceId: string, + workspaceId: string, + documentPath: string, + enabled = true, +) { + const api = useApi(); + return useInfiniteQuery({ + queryKey: qk.serviceKanbanCardExecutions(serviceId, workspaceId, documentPath), + queryFn: ({ pageParam }) => api.listServiceKanbanCardExecutions( + serviceId, workspaceId, documentPath, pageParam ?? undefined, + ), + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + enabled: enabled && !!serviceId && !!workspaceId && !!documentPath, + retry: false, + refetchInterval: (query) => { + const items = query.state.data?.pages.flatMap((page) => page.items); + // Keep polling an empty Card so a receipt appears without closing and + // reopening the detail after the user moves it into the trigger column. + // Stop only once every known occurrence is terminal. + return kanbanCardExecutionPollInterval(items); + }, + }); +} + export function useDeleteServiceKanban(projectId: string, serviceId: string) { const api = useApi(); const qc = useQueryClient(); @@ -943,6 +1014,69 @@ export function useProjectAutomation(projectId: string, automationId: string, en }); } +export function useAutomationExecutions(automationId: string, state = '', enabled = true) { + const api = useApi(); + return useInfiniteQuery({ + queryKey: qk.automationExecutions(automationId, state), + queryFn: ({ pageParam }) => api.listAutomationExecutions( + automationId, pageParam ?? undefined, state || undefined, + ), + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + enabled: enabled && !!automationId, + refetchInterval: (query) => automationExecutionPollInterval( + query.state.data?.pages.flatMap((page) => page.items), + ), + }); +} + +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(); + return useMutation({ + mutationFn: (idempotencyKey: string) => api.runAutomationNow(automationId, idempotencyKey), + onSuccess: () => qc.invalidateQueries({ queryKey: ['automation-executions', automationId] }), + }); +} + export function useProviderCapabilities(provider: ProviderKind, enabled = true) { const api = useApi(); return useQuery({ @@ -954,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 cfe3f804..918c827b 100644 --- a/console/src/api/types.ts +++ b/console/src/api/types.ts @@ -259,14 +259,160 @@ 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 }; actions?: ScmAutomationAction[]; - kanban?: { automation_id?: string; installation_id: string; board_ref: string; trigger_column: string; done_column?: string }; - cron?: { automation_id?: string; cron_expr: string }; + kanban?: { + automation_id?: string; + installation_id: string; + board_ref: string; + trigger_column: string; + trigger_label?: string; + done_column?: string; + done_label?: string; + }; + cron?: { automation_id?: string; cron_expr: string; output_mode?: 'run_only' | 'create_card' }; +} +export type AutomationExecutionState = + | 'accepted' | 'ignored' | 'duplicate' | 'superseded' + | 'blocked' | 'queued' | 'running' | 'terminal'; +export interface AutomationExecution { + id: string; + automation_id: string; + automation_name: string; + trigger_kind: 'scm' | 'cron' | 'manual'; + state: AutomationExecutionState; + outcome?: 'succeeded' | 'failed' | 'canceled'; + output_mode: 'run_only' | 'create_card'; + reason_code?: string; + reason?: string; + repair_role?: 'project_owner' | 'cluster_admin'; + requested_actor: ProvenanceActorRef | null; + accountable_actor: ProvenanceActorRef | null; + output: { kind: 'run' | 'card' | 'none'; label: string; href?: string; available: boolean }; + run: { id: string; status: RunStatus; href: string } | null; + card: { + workspace_id: string; + document_id?: string; + document_path: string; + href?: string; + available: boolean; + } | null; + external_url?: string; + writeback_state: string; + usage_summary: UsageSummary; + created_at: string; + updated_at: string; + terminal_at?: string; +} +export interface AutomationExecutionsPage { + items: AutomationExecution[]; + next_cursor: string | null; } export type ServiceKanbanBinding = ProjectAutomationSpec; +export interface ServiceKanbanPolicy { + service_id: string; + service_name: string; + repository: string; + model: { id?: string; label: string }; + board: { workspace_id: string; ref: string }; + trigger_column: { key: string; label: string }; + done_column: { key?: string; label?: string }; + output: 'comment_only' | 'comment_and_move_on_success'; + health: { + state: 'ready' | 'blocked'; + blocker: string | null; + repair_role?: 'project_owner' | 'cluster_admin' | null; + }; +} +export interface KanbanCardExecution { + id: string; + status: 'received' | 'blocked' | 'queued' | 'running' | 'terminal'; + outcome?: 'succeeded' | 'failed' | 'canceled'; + summary: string; + reason: string | null; + reason_code?: string; + repair_role: 'project_owner' | 'cluster_admin' | null; + requested_actor: { label: string; precision: 'display_only' } | null; + run: { id: string; status: RunStatus; href: string } | null; + receipt: { + external: 'not_required' | 'pending' | 'written' | 'unavailable'; + writeback: 'not_required' | 'pending' | 'complete' | 'unavailable'; + }; + 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 { installation_id: string; board_ref: string; @@ -303,7 +449,7 @@ export interface CreateProjectAutomationInput { ignore_jcode?: boolean; scm?: { branch?: string; path_pattern?: string; conclusion?: string; include_drafts?: boolean; actions: ScmAutomationAction[] }; kanban?: { installation_id: string; board_ref: string; trigger_column: string; done_column?: string }; - cron?: { cron_expr: string }; + cron?: { cron_expr: string; output_mode?: 'run_only' | 'create_card' }; } export type UpdateProjectAutomationInput = Partial; @@ -506,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 @@ -532,6 +679,43 @@ export interface Run { permission_mode?: 'approval' | ''; /** When the run entered awaiting_input (idle-timeout epoch). */ awaiting_since?: string | null; + /** + * Read-only identity/source projection frozen at Run creation. These display + * references are audit metadata only and must never be used for authorization. + */ + provenance?: RunProvenance; +} + +export interface ProvenanceActorRef { + kind: 'cloud_user' | 'external_actor' | 'service_principal' | 'automation_principal' | 'provider_bot' | string; + id?: string; + label: string; + provider?: string; + external_id?: string; + external_label?: string; +} + +export interface RunProvenance { + requested_actor?: ProvenanceActorRef; + accountable_actor?: ProvenanceActorRef; + attribution_source: string; + precision: 'exact' | 'linked_external' | 'rule_owner' | 'unattributed' | string; + trigger: { + kind: string; + label: string; + ref?: string; + href?: string; + }; + executed_for: { + project_id: string; + project_label: string; + service_id: string; + service_label: string; + repository?: string; + model?: string; + }; + runtime_principal: ProvenanceActorRef; + writeback_actor?: ProvenanceActorRef; } /* ---- session permission approval (F8b / D22) ------------------------------ */ 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 3cdafe2a..0f758f56 100644 --- a/console/src/i18n/locales/en.ts +++ b/console/src/i18n/locales/en.ts @@ -1034,6 +1034,64 @@ export default { title: 'Kanban', boardLabel: 'Board', openingBoard: 'Opening board…', + loadingPolicy: 'Loading execution policy…', + policyUnavailable: 'Execution policy could not be loaded.', + policyTrigger: 'Starts jcode when Cards enter {column}', + policyWriteback: 'Success comments and moves to {column}', + policyCommentOnly: 'Results are written as Card comments', + policyReady: 'Ready', + policyBlocked: 'Blocked · {blocker}', + policyBlockers: { + binding_disabled: 'Automation disabled', + plugin_unavailable: 'JType connection unavailable', + event_feed_unavailable: 'JType event feed unavailable', + bootstrap_unavailable: 'JType Card bootstrap unavailable', + card_index_unavailable: 'JType Card index unavailable', + card_read_unavailable: 'JType Card temporarily unreadable', + card_unavailable: 'JType Card unavailable', + board_validation_unavailable: 'JType board validation unavailable', + board_drift: 'Board or Automation column changed', + service_unavailable: 'Service unavailable', + repository_not_configured: 'Repository not configured', + repository_unavailable: 'Repository connection unavailable', + provider_unavailable: 'Repository provider unavailable', + runner_unavailable: 'Runner unavailable', + run_unavailable: 'Linked Cloud Run unavailable', + model_not_configured: 'Model not configured', + model_effort_unsupported: 'Reasoning effort unsupported', + }, + executionsTitle: 'Cloud executions', + loadingExecutions: 'Loading Cloud executions…', + executionsUnavailable: 'Cloud executions could not be loaded.', + noExecutions: 'No Cloud execution yet. Move this Card into the Starts jcode column to request one.', + executionState: { + received: 'Request received', + blocked: 'Blocked', + queued: 'Queued', + running: 'Running', + terminal: 'Finished', + succeeded: 'Succeeded', + failed: 'Failed', + canceled: 'Canceled', + }, + requestedByExternal: 'Requested by {actor}', + projectOwner: 'Project owner', + clusterAdmin: 'Cluster administrator', + openRun: 'Open Run', + writebackPending: 'Card writeback pending', + writebackUnavailable: 'Source Card unavailable', + executionSummary: { + received: 'The Card request was received.', + queued: 'The Run is queued.', + running: 'The Run is applying the requested change.', + succeeded: 'The Run completed successfully.', + failed: 'The Run failed.', + canceled: 'The Run was canceled.', + }, + priorExecutions_one: '{count} prior execution', + priorExecutions_other: '{count} prior executions', + cardUnavailable: 'This Card no longer exists in JType. Cloud execution history is preserved.', + loadEarlierExecutions: 'Load earlier executions', linkDisabledTitle: 'This Kanban automation link is disabled.', linkDisabledBody: 'The board is viewable, but card-triggered runs and writeback are disabled.', }, @@ -1388,6 +1446,11 @@ export default { eventUnavailable: 'Not supported by {provider}{version}.', cronExpression: 'Cron expression', cronHint: 'Five-field cron expression. The minimum interval is enforced by the server.', + outputMode: 'Output', + outputRun: 'Create a Run directly', + outputCard: 'Create a jtype Card, then use Service Kanban', + outputHint: 'Card output preserves the jtype Work Item and uses the normal Kanban claim and writeback loop.', + outputCardUnavailable: 'Enable a healthy Service Kanban policy before selecting Card output.', create: 'Create Automation', save: 'Save Automation', review: { @@ -1418,6 +1481,7 @@ export default { required: 'Name, Service, model, and task Prompt are required.', eventRequired: 'Select at least one event supported by this Service provider.', cronRequired: 'Cron expression is required.', + cardOutputUnavailable: 'Card output requires a healthy Service Kanban policy.', }, apiError: { overlap: 'Another Automation already uses one of the selected SCM events for this Service.', @@ -1579,6 +1643,56 @@ export default { send: 'Send', }, + automationExecutions: { + loading: 'Loading Automation…', + loadError: 'Could not load Automation.', + back: 'Back to Automations', + cardOutput: 'Card output', + runOutput: 'Direct Run output', + runNow: 'Run now', + runNowError: 'The execution could not be accepted. Retry uses the same idempotency key.', + history: 'Execution history', + loadingHistory: 'Loading execution history…', + historyError: 'Could not load execution history.', + empty: 'No executions yet.', + loadMore: 'Load older executions', + select: 'Select an execution to inspect it.', + selected: 'Selected execution', + execution: 'Execution', + trigger: 'Trigger', + kind: 'Kind', + requested: 'Requested by', + accountable: 'Accountable to', + notApplicable: 'Not applicable', + unattributed: 'Unattributed', + output: 'Output', + expected: 'Expected', + cardThenRun: 'jtype Card → Kanban Run', + directRun: 'Direct Run', + actual: 'Actual', + writeback: 'Writeback', + usage: 'Usage', + usageUnavailable: 'Unavailable', + filter: { all: 'All', blocked: 'Blocked', running: 'Running', terminal: 'Terminal' }, + state: { + accepted: 'Accepted', ignored: 'Ignored', duplicate: 'Duplicate', + superseded: 'Superseded', blocked: 'Blocked', queued: 'Queued', + running: 'Running', terminal: 'Terminal', + }, + outcome: { succeeded: 'Succeeded', failed: 'Failed', canceled: 'Canceled' }, + repair: { + project_owner: 'A Project owner can repair this dependency.', + cluster_admin: 'A Cluster administrator must repair this dependency.', + requester: 'The requester must repair this dependency.', + }, + title: { + blocked: 'Blocked before output', + ignored: 'Trigger intentionally ignored', + card: 'Card output', + run: 'Run output', + }, + }, + projectAutomations: { eyebrow: 'Project automation', title: 'Automations', @@ -1776,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', @@ -1884,6 +2066,28 @@ export default { }, inspector: { runDetailsLabel: 'Run details', + identityAndSource: 'Identity & source', + requestedBy: 'Requested by', + accountableTo: 'Accountable to', + triggeredFrom: 'Triggered from', + executedFor: 'Executed for', + project: 'Project', + repository: 'Repository', + dispatchSnapshot: 'Dispatch-time snapshot', + writtenBackAs: 'Written back as', + externalActor: 'External actor', + ruleOwner: 'Rule owner', + botOrApp: 'Bot / App', + notAttributed: 'Not attributed', + notApplicable: 'Not applicable', + technicalIdentity: 'Technical identity', + runtimePrincipal: 'Runtime', + attribution: 'Attribution', + source: 'Source', + precisionExact: 'Exact', + precisionLinkedExternal: 'Linked external identity', + precisionRuleOwner: 'Attributed to rule owner', + precisionUnattributed: 'Unattributed', runOverview: 'Run overview', service: 'Service', unavailable: 'Unavailable', diff --git a/console/src/i18n/locales/ja.ts b/console/src/i18n/locales/ja.ts index f362a1a8..e730833d 100644 --- a/console/src/i18n/locales/ja.ts +++ b/console/src/i18n/locales/ja.ts @@ -699,6 +699,64 @@ export default { title: 'Kanban', boardLabel: 'ボード', openingBoard: 'ボードを開いています…', + loadingPolicy: '実行ポリシーを読み込み中…', + policyUnavailable: '実行ポリシーを読み込めませんでした。', + policyTrigger: 'Card が {column} に入ると jcode を開始', + policyWriteback: '成功時にコメントして {column} へ移動', + policyCommentOnly: '結果を Card コメントに書き戻す', + policyReady: '実行可能', + policyBlocked: 'ブロック中 · {blocker}', + policyBlockers: { + binding_disabled: '自動化が無効', + plugin_unavailable: 'JType 接続が利用不可', + event_feed_unavailable: 'JType イベントフィードが利用不可', + bootstrap_unavailable: 'JType カードの初期化が利用不可', + card_index_unavailable: 'JType カードインデックスが利用不可', + card_read_unavailable: 'JType カードを一時的に読み取れません', + card_unavailable: 'JType カードが利用不可', + board_validation_unavailable: 'JType ボードを検証できません', + board_drift: 'ボードまたは自動化の列が変更されました', + service_unavailable: 'Service が利用不可', + repository_not_configured: 'リポジトリ未設定', + repository_unavailable: 'リポジトリ接続が利用不可', + provider_unavailable: 'リポジトリ Provider が利用不可', + runner_unavailable: 'Runner が利用不可', + run_unavailable: 'リンクされた Cloud Run が利用不可', + model_not_configured: 'モデル未設定', + model_effort_unsupported: '選択した推論強度は未対応', + }, + executionsTitle: 'Cloud 実行', + loadingExecutions: 'Cloud 実行を読み込み中…', + executionsUnavailable: 'Cloud 実行履歴を読み込めませんでした。', + noExecutions: 'Cloud 実行はまだありません。この Card を「jcode を開始」列に移動すると要求できます。', + executionState: { + received: '要求を受信', + blocked: 'ブロック中', + queued: '待機中', + running: '実行中', + terminal: '終了', + succeeded: '成功', + failed: '失敗', + canceled: 'キャンセル済み', + }, + requestedByExternal: '要求者: {actor}', + projectOwner: 'プロジェクト所有者', + clusterAdmin: 'Cluster 管理者', + openRun: 'Run を開く', + writebackPending: 'Card への書き戻し待ち', + writebackUnavailable: '元の Card は利用不可', + executionSummary: { + received: 'Card の要求を受信しました。', + queued: 'Run は待機中です。', + running: 'Run は要求された変更を実行中です。', + succeeded: 'Run は正常に完了しました。', + failed: 'Run は失敗しました。', + canceled: 'Run はキャンセルされました。', + }, + priorExecutions_one: '過去の実行 {count} 件', + priorExecutions_other: '過去の実行 {count} 件', + cardUnavailable: 'この Card は JType に存在しません。Cloud の実行履歴は保持されています。', + loadEarlierExecutions: '以前の実行を読み込む', linkDisabledTitle: 'この Kanban 自動化リンクは無効化されています。', linkDisabledBody: 'ボードは表示できますが、カードトリガー実行と書き戻しは無効化されています。', }, @@ -1279,6 +1337,9 @@ export default { conclusion: 'CI 結果', conclusionHint: '選択したすべてのイベントが check.completed の場合のみ使用できます。', events: 'イベント', moreEvents: 'その他のイベント', thisProvider: 'このプロバイダー', eventUnavailable: '{provider}{version} ではサポートされていません。', cronExpression: 'Cron 式', cronHint: '5 フィールドの Cron 式を使用します。最小間隔はサーバーで強制されます。', + outputMode: '出力', outputRun: 'Run を直接作成', outputCard: 'jtype Card を作成して Service Kanban で実行', + outputHint: 'Card 出力は jtype の作業項目を残し、通常の Kanban 受付・書き戻しフローを使用します。', + outputCardUnavailable: '正常な Service Kanban を有効にしてから Card 出力を選択してください。', create: '自動化を作成', save: '自動化を保存', review: { eyebrow: 'GitHub コードレビュー', title: 'Pull Request をレビュー', subtitle: '新しい手順を覚えなくても、GitHub ネイティブのレビューを有効にできます。', @@ -1291,7 +1352,7 @@ export default { draftsTitle: 'Draft を含める', draftsBody: '通常はオフにして、作者の最初の確認を待ちます。', create: 'レビューを有効化', save: 'レビュー設定を保存', }, - validation: { noPermission: '自動化を編集する権限がありません。', required: '名前、サービス、モデル、タスクプロンプトは必須です。', eventRequired: 'このサービスのプロバイダーがサポートするイベントを 1 つ以上選択してください。', cronRequired: 'Cron 式は必須です。' }, + validation: { noPermission: '自動化を編集する権限がありません。', required: '名前、サービス、モデル、タスクプロンプトは必須です。', eventRequired: 'このサービスのプロバイダーがサポートするイベントを 1 つ以上選択してください。', cronRequired: 'Cron 式は必須です。', cardOutputUnavailable: 'Card 出力には正常な Service Kanban が必要です。' }, apiError: { overlap: '選択した SCM イベントのいずれかを、このサービスの別の自動化がすでに使用しています。', webhook: '自動化は保存されましたが、プロバイダー Webhook を同期できませんでした。プラグイン接続を確認して再試行してください。', @@ -1303,6 +1364,34 @@ export default { }, }, + automationExecutions: { + loading: '自動化を読み込み中…', loadError: '自動化を読み込めませんでした。', back: '自動化一覧に戻る', + cardOutput: 'Card 出力', runOutput: '直接 Run 出力', runNow: '今すぐ実行', + runNowError: '実行を受理できませんでした。再試行時も同じ冪等キーを使用します。', + history: '実行履歴', loadingHistory: '実行履歴を読み込み中…', historyError: '実行履歴を読み込めませんでした。', + empty: '実行履歴はまだありません。', loadMore: '以前の実行を読み込む', select: '実行を選択して詳細を確認します。', + selected: '選択した実行', execution: '実行', trigger: 'トリガー', kind: '種類', + requested: '依頼者', accountable: '責任者', notApplicable: '該当なし', unattributed: '帰属なし', + output: '出力', expected: '予定', cardThenRun: 'jtype Card → Kanban Run', directRun: '直接 Run', + actual: '実際', writeback: '書き戻し', usage: '使用量', usageUnavailable: '利用不可', + filter: { all: 'すべて', blocked: 'ブロック', running: '実行中', terminal: '完了済み' }, + state: { + accepted: '受付済み', ignored: '無視', duplicate: '重複', + superseded: '置換済み', blocked: 'ブロック', queued: '待機中', + running: '実行中', terminal: '完了済み', + }, + outcome: { succeeded: '成功', failed: '失敗', canceled: 'キャンセル' }, + repair: { + project_owner: 'プロジェクト所有者がこの依存関係を修復できます。', + cluster_admin: 'クラスター管理者がこの依存関係を修復する必要があります。', + requester: 'リクエストしたユーザーがこの依存関係を修復する必要があります。', + }, + title: { + blocked: '出力前にブロック', ignored: 'ルールにより無視', + card: 'Card 出力', run: 'Run 出力', + }, + }, + projectAutomations: { eyebrow: 'プロジェクト自動化', title: '自動化', subtitle: 'プロジェクト内の任意のサービスに対して SCM とスケジュール作業を作成します。Kanban はサービスヘッダーから有効化します。', loading: '自動化を読み込み中…', loadError: '自動化を読み込めませんでした。', new: '新しい自動化', noServices: '自動化を作成する前にサービスを接続してください。', @@ -1623,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: '実行を読み込めませんでした', @@ -1731,6 +1888,28 @@ export default { }, inspector: { runDetailsLabel: '実行の詳細', + identityAndSource: '依頼者と実行元', + requestedBy: 'リクエスト元', + accountableTo: '責任者', + triggeredFrom: 'トリガー元', + executedFor: '実行対象', + project: 'プロジェクト', + repository: 'リポジトリ', + dispatchSnapshot: 'ディスパッチ時のスナップショット', + writtenBackAs: '書き戻し ID', + externalActor: '外部 ID', + ruleOwner: 'ルール所有者', + botOrApp: 'Bot / アプリ', + notAttributed: '帰属なし', + notApplicable: '該当なし', + technicalIdentity: '技術的な実行情報', + runtimePrincipal: 'ランタイム', + attribution: '帰属', + source: 'ソース', + precisionExact: '正確', + precisionLinkedExternal: '外部 ID にリンク', + precisionRuleOwner: 'ルール所有者に帰属', + precisionUnattributed: '帰属なし', runOverview: '実行の概要', service: 'サービス', unavailable: '利用不可', diff --git a/console/src/i18n/locales/ko.ts b/console/src/i18n/locales/ko.ts index 62ab6947..26fd2aa9 100644 --- a/console/src/i18n/locales/ko.ts +++ b/console/src/i18n/locales/ko.ts @@ -699,6 +699,64 @@ export default { title: 'Kanban', boardLabel: '보드', openingBoard: '보드 여는 중…', + loadingPolicy: '실행 정책 불러오는 중…', + policyUnavailable: '실행 정책을 불러올 수 없습니다.', + policyTrigger: 'Card가 {column}에 들어가면 jcode 시작', + policyWriteback: '성공 시 댓글을 남기고 {column}(으)로 이동', + policyCommentOnly: '결과를 Card 댓글로 기록', + policyReady: '실행 가능', + policyBlocked: '차단됨 · {blocker}', + policyBlockers: { + binding_disabled: '자동화 비활성화됨', + plugin_unavailable: 'JType 연결 사용 불가', + event_feed_unavailable: 'JType 이벤트 피드 사용 불가', + bootstrap_unavailable: 'JType 카드 초기화 사용 불가', + card_index_unavailable: 'JType Card 인덱스 사용 불가', + card_read_unavailable: 'JType 카드를 일시적으로 읽을 수 없음', + card_unavailable: 'JType 카드 사용 불가', + board_validation_unavailable: 'JType 보드를 검증할 수 없음', + board_drift: '보드 또는 자동화 열이 변경됨', + service_unavailable: 'Service 사용 불가', + repository_not_configured: '저장소가 구성되지 않음', + repository_unavailable: '저장소 연결 사용 불가', + provider_unavailable: '저장소 Provider 사용 불가', + runner_unavailable: 'Runner 사용 불가', + run_unavailable: '연결된 Cloud Run 사용 불가', + model_not_configured: '모델이 구성되지 않음', + model_effort_unsupported: '선택한 추론 강도를 지원하지 않음', + }, + executionsTitle: 'Cloud 실행', + loadingExecutions: 'Cloud 실행 불러오는 중…', + executionsUnavailable: 'Cloud 실행 기록을 불러올 수 없습니다.', + noExecutions: '아직 Cloud 실행이 없습니다. 이 Card를 “jcode 시작” 열로 옮겨 요청하세요.', + executionState: { + received: '요청 수신', + blocked: '차단됨', + queued: '대기 중', + running: '실행 중', + terminal: '종료', + succeeded: '성공', + failed: '실패', + canceled: '취소됨', + }, + requestedByExternal: '요청자: {actor}', + projectOwner: '프로젝트 소유자', + clusterAdmin: 'Cluster 관리자', + openRun: 'Run 열기', + writebackPending: 'Card 라이트백 대기 중', + writebackUnavailable: '원본 Card 사용 불가', + executionSummary: { + received: 'Card 요청을 받았습니다.', + queued: 'Run이 대기 중입니다.', + running: 'Run이 요청된 변경을 수행 중입니다.', + succeeded: 'Run이 성공적으로 완료되었습니다.', + failed: 'Run이 실패했습니다.', + canceled: 'Run이 취소되었습니다.', + }, + priorExecutions_one: '이전 실행 {count}개', + priorExecutions_other: '이전 실행 {count}개', + cardUnavailable: '이 Card는 더 이상 JType에 없습니다. Cloud 실행 기록은 보존됩니다.', + loadEarlierExecutions: '이전 실행 불러오기', linkDisabledTitle: '이 Kanban 자동화 링크가 비활성화되어 있습니다.', linkDisabledBody: '보드는 볼 수 있지만 카드 트리거 실행과 라이트백은 비활성화되어 있습니다.', }, @@ -1279,6 +1337,9 @@ export default { conclusion: 'CI 결과', conclusionHint: '선택한 모든 이벤트가 check.completed인 경우에만 사용할 수 있습니다.', events: '이벤트', moreEvents: '추가 이벤트', thisProvider: '이 공급자', eventUnavailable: '{provider}{version}에서 지원하지 않습니다.', cronExpression: 'Cron 표현식', cronHint: '5개 필드 Cron 표현식을 사용합니다. 최소 간격은 서버에서 적용됩니다.', + outputMode: '출력', outputRun: 'Run 직접 생성', outputCard: 'jtype Card 생성 후 Service Kanban에서 실행', + outputHint: 'Card 출력은 jtype 작업 항목을 유지하고 표준 Kanban 접수 및 기록 흐름을 사용합니다.', + outputCardUnavailable: '정상 상태의 Service Kanban을 활성화한 후 Card 출력을 선택하세요.', create: '자동화 만들기', save: '자동화 저장', review: { eyebrow: 'GitHub 코드 리뷰', title: 'Pull Request 리뷰', subtitle: '팀이 새 흐름을 배우지 않아도 GitHub 네이티브 리뷰를 켤 수 있습니다.', @@ -1291,7 +1352,7 @@ export default { draftsTitle: 'Draft 포함', draftsBody: '보통 끄고 작성자의 첫 검토가 끝날 때까지 기다립니다.', create: '리뷰 켜기', save: '리뷰 설정 저장', }, - validation: { noPermission: '자동화를 편집할 권한이 없습니다.', required: '이름, 서비스, 모델 및 작업 프롬프트는 필수입니다.', eventRequired: '이 서비스 공급자가 지원하는 이벤트를 하나 이상 선택하세요.', cronRequired: 'Cron 표현식은 필수입니다.' }, + validation: { noPermission: '자동화를 편집할 권한이 없습니다.', required: '이름, 서비스, 모델 및 작업 프롬프트는 필수입니다.', eventRequired: '이 서비스 공급자가 지원하는 이벤트를 하나 이상 선택하세요.', cronRequired: 'Cron 표현식은 필수입니다.', cardOutputUnavailable: 'Card 출력에는 정상 상태의 Service Kanban 정책이 필요합니다.' }, apiError: { overlap: '선택한 SCM 이벤트 중 하나 이상을 이 서비스의 다른 자동화가 이미 사용하고 있습니다.', webhook: '자동화는 저장되었지만 공급자 Webhook을 동기화할 수 없습니다. 플러그인 연결을 확인하고 다시 시도하세요.', @@ -1303,6 +1364,34 @@ export default { }, }, + automationExecutions: { + loading: '자동화 불러오는 중…', loadError: '자동화를 불러오지 못했습니다.', back: '자동화 목록으로', + cardOutput: 'Card 출력', runOutput: '직접 Run 출력', runNow: '지금 실행', + runNowError: '실행을 접수하지 못했습니다. 재시도에도 같은 멱등성 키를 사용합니다.', + history: '실행 기록', loadingHistory: '실행 기록 불러오는 중…', historyError: '실행 기록을 불러오지 못했습니다.', + empty: '아직 실행 기록이 없습니다.', loadMore: '이전 실행 불러오기', select: '실행을 선택해 상세 내용을 확인하세요.', + selected: '선택한 실행', execution: '실행', trigger: '트리거', kind: '종류', + requested: '요청자', accountable: '책임자', notApplicable: '해당 없음', unattributed: '귀속 없음', + output: '출력', expected: '예상', cardThenRun: 'jtype Card → Kanban Run', directRun: '직접 Run', + actual: '실제', writeback: '기록', usage: '사용량', usageUnavailable: '사용 불가', + filter: { all: '전체', blocked: '차단됨', running: '실행 중', terminal: '종료됨' }, + state: { + accepted: '접수됨', ignored: '무시됨', duplicate: '중복', + superseded: '대체됨', blocked: '차단됨', queued: '대기 중', + running: '실행 중', terminal: '종료됨', + }, + outcome: { succeeded: '성공', failed: '실패', canceled: '취소됨' }, + repair: { + project_owner: '프로젝트 소유자가 이 종속성을 복구할 수 있습니다.', + cluster_admin: '클러스터 관리자가 이 종속성을 복구해야 합니다.', + requester: '요청자가 이 종속성을 복구해야 합니다.', + }, + title: { + blocked: '출력 전에 차단됨', ignored: '규칙에 따라 무시됨', + card: 'Card 출력', run: 'Run 출력', + }, + }, + projectAutomations: { eyebrow: '프로젝트 자동화', title: '자동화', subtitle: '프로젝트의 모든 서비스에 대해 SCM 및 예약 작업을 만듭니다. Kanban은 서비스 헤더에서 활성화합니다.', loading: '자동화 불러오는 중…', loadError: '자동화를 불러올 수 없습니다.', new: '새 자동화', noServices: '자동화를 만들기 전에 서비스를 연결하세요.', @@ -1623,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: '실행을 불러올 수 없습니다', @@ -1731,6 +1888,28 @@ export default { }, inspector: { runDetailsLabel: '실행 세부 정보', + identityAndSource: '요청자 및 실행 출처', + requestedBy: '요청자', + accountableTo: '책임자', + triggeredFrom: '트리거 소스', + executedFor: '실행 대상', + project: '프로젝트', + repository: '저장소', + dispatchSnapshot: '디스패치 시점 스냅샷', + writtenBackAs: '기록 ID', + externalActor: '외부 ID', + ruleOwner: '규칙 소유자', + botOrApp: 'Bot / 앱', + notAttributed: '귀속되지 않음', + notApplicable: '해당 없음', + technicalIdentity: '기술 실행 정보', + runtimePrincipal: '런타임', + attribution: '귀속', + source: '소스', + precisionExact: '정확', + precisionLinkedExternal: '외부 ID 연결', + precisionRuleOwner: '규칙 소유자에게 귀속', + precisionUnattributed: '귀속되지 않음', runOverview: '실행 개요', service: '서비스', unavailable: '사용 불가', diff --git a/console/src/i18n/locales/zh-Hans.ts b/console/src/i18n/locales/zh-Hans.ts index 0793d7fc..8505c787 100644 --- a/console/src/i18n/locales/zh-Hans.ts +++ b/console/src/i18n/locales/zh-Hans.ts @@ -699,6 +699,64 @@ export default { title: 'Kanban', boardLabel: '看板', openingBoard: '正在打开看板…', + loadingPolicy: '正在加载执行策略…', + policyUnavailable: '无法加载执行策略。', + policyTrigger: 'Card 进入 {column} 时启动 jcode', + policyWriteback: '成功后评论并移动到 {column}', + policyCommentOnly: '结果写入 Card 评论', + policyReady: '可执行', + policyBlocked: '已阻塞 · {blocker}', + policyBlockers: { + binding_disabled: '自动化已禁用', + plugin_unavailable: 'JType 连接不可用', + event_feed_unavailable: 'JType 事件流不可用', + bootstrap_unavailable: 'JType 卡片初始化不可用', + card_index_unavailable: 'JType 卡片索引不可用', + card_read_unavailable: 'JType 卡片暂时无法读取', + card_unavailable: 'JType 卡片不可用', + board_validation_unavailable: 'JType 看板校验不可用', + board_drift: '看板或自动化列已变更', + service_unavailable: 'Service 不可用', + repository_not_configured: '尚未配置代码仓库', + repository_unavailable: '代码仓库连接不可用', + provider_unavailable: '代码仓库 Provider 不可用', + runner_unavailable: 'Runner 不可用', + run_unavailable: '关联的 Cloud Run 不可用', + model_not_configured: '尚未配置模型', + model_effort_unsupported: '模型不支持所选推理强度', + }, + executionsTitle: 'Cloud 执行', + loadingExecutions: '正在加载 Cloud 执行…', + executionsUnavailable: '无法加载 Cloud 执行记录。', + noExecutions: '尚无 Cloud 执行。将此 Card 移入“启动 jcode”列即可请求执行。', + executionState: { + received: '已收到请求', + blocked: '已阻塞', + queued: '排队中', + running: '执行中', + terminal: '已结束', + succeeded: '成功', + failed: '失败', + canceled: '已取消', + }, + requestedByExternal: '请求者:{actor}', + projectOwner: '项目所有者', + clusterAdmin: '集群管理员', + openRun: '打开 Run', + writebackPending: 'Card 回写待完成', + writebackUnavailable: '来源 Card 不可用', + executionSummary: { + received: '已收到 Card 请求。', + queued: 'Run 正在排队。', + running: 'Run 正在执行所请求的变更。', + succeeded: 'Run 已成功完成。', + failed: 'Run 执行失败。', + canceled: 'Run 已取消。', + }, + priorExecutions_one: '{count} 次历史执行', + priorExecutions_other: '{count} 次历史执行', + cardUnavailable: '此 Card 已不在 JType 中,Cloud 执行历史仍会保留。', + loadEarlierExecutions: '加载更早的执行', linkDisabledTitle: '此 Kanban 自动化链接已禁用。', linkDisabledBody: '看板可查看,但卡片触发的运行和回写已禁用。', }, @@ -1285,6 +1343,9 @@ export default { conclusion: 'CI 结果', conclusionHint: '仅当所有选中事件均为 check.completed 时可用。', events: '事件', moreEvents: '更多事件', thisProvider: '当前提供方', eventUnavailable: '{provider}{version} 不支持此事件。', cronExpression: 'Cron 表达式', cronHint: '使用五段式 Cron 表达式;服务器会强制执行最小时间间隔。', + outputMode: '输出方式', outputRun: '直接创建 Run', outputCard: '先创建 jtype Card,再由服务看板执行', + outputHint: 'Card 输出会保留 jtype 工作项,并复用标准的看板受理与回写闭环。', + outputCardUnavailable: '请先启用健康的服务看板,再选择 Card 输出。', create: '创建自动化', save: '保存自动化', review: { eyebrow: 'GitHub 代码评审', title: '评审 Pull Request', subtitle: '无需让团队学习新流程,即可启用实用的 GitHub 原生评审。', @@ -1302,6 +1363,7 @@ export default { required: '名称、服务、模型和任务提示词均为必填项。', eventRequired: '请至少选择一个该服务提供方支持的事件。', cronRequired: 'Cron 表达式为必填项。', + cardOutputUnavailable: 'Card 输出需要健康的服务看板策略。', }, apiError: { overlap: '此服务的另一个自动化已经使用了所选 SCM 事件中的至少一个。', @@ -1314,6 +1376,34 @@ export default { }, }, + automationExecutions: { + loading: '正在加载自动化…', loadError: '无法加载自动化。', back: '返回自动化列表', + cardOutput: 'Card 输出', runOutput: '直接 Run 输出', runNow: '立即运行', + runNowError: '本次执行未能受理;重试会继续使用同一个幂等键。', + history: '执行历史', loadingHistory: '正在加载执行历史…', historyError: '无法加载执行历史。', + empty: '还没有执行记录。', loadMore: '加载更早的执行', select: '选择一条执行记录查看详情。', + selected: '选中的执行', execution: '执行', trigger: '触发来源', kind: '类型', + requested: '请求者', accountable: '责任人', notApplicable: '不适用', unattributed: '未归因', + output: '输出', expected: '预期', cardThenRun: 'jtype Card → 看板 Run', directRun: '直接 Run', + actual: '实际结果', writeback: '回写', usage: '用量', usageUnavailable: '暂不可用', + filter: { all: '全部', blocked: '受阻', running: '运行中', terminal: '已结束' }, + state: { + accepted: '已受理', ignored: '已忽略', duplicate: '重复', + superseded: '已取代', blocked: '受阻', queued: '排队中', + running: '运行中', terminal: '已结束', + }, + outcome: { succeeded: '成功', failed: '失败', canceled: '已取消' }, + repair: { + project_owner: '项目所有者可以修复此依赖。', + cluster_admin: '需要集群管理员修复此依赖。', + requester: '需要请求者修复此依赖。', + }, + title: { + blocked: '在生成输出前受阻', ignored: '触发已按规则忽略', + card: 'Card 输出', run: 'Run 输出', + }, + }, + projectAutomations: { eyebrow: '项目自动化', title: '自动化', subtitle: '针对项目中的任意服务创建 SCM 和定时任务。Kanban 从服务标题区域启用。', loading: '正在加载自动化…', loadError: '无法加载自动化。', new: '新建自动化', noServices: '创建自动化前请先连接服务。', @@ -1634,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: '无法加载运行', @@ -1742,6 +1900,28 @@ export default { }, inspector: { runDetailsLabel: '运行详情', + identityAndSource: '身份与来源', + requestedBy: '请求者', + accountableTo: '责任人', + triggeredFrom: '触发来源', + executedFor: '执行对象', + project: '项目', + repository: '代码仓库', + dispatchSnapshot: '调度时快照', + writtenBackAs: '回写身份', + externalActor: '外部身份', + ruleOwner: '规则负责人', + botOrApp: 'Bot / 应用', + notAttributed: '无法归属', + notApplicable: '不适用', + technicalIdentity: '技术身份', + runtimePrincipal: '运行主体', + attribution: '归属精度', + source: '来源', + precisionExact: '精确', + precisionLinkedExternal: '已关联外部身份', + precisionRuleOwner: '归属到规则负责人', + precisionUnattributed: '无法归属', runOverview: '运行概览', service: '服务', unavailable: '不可用', diff --git a/console/src/i18n/locales/zh-Hant.ts b/console/src/i18n/locales/zh-Hant.ts index 402136fd..7770fcad 100644 --- a/console/src/i18n/locales/zh-Hant.ts +++ b/console/src/i18n/locales/zh-Hant.ts @@ -699,6 +699,64 @@ export default { title: 'Kanban', boardLabel: '看板', openingBoard: '正在開啟看板…', + loadingPolicy: '正在載入執行策略…', + policyUnavailable: '無法載入執行策略。', + policyTrigger: 'Card 進入 {column} 時啟動 jcode', + policyWriteback: '成功後留言並移動到 {column}', + policyCommentOnly: '結果寫入 Card 留言', + policyReady: '可執行', + policyBlocked: '已阻塞 · {blocker}', + policyBlockers: { + binding_disabled: '自動化已停用', + plugin_unavailable: 'JType 連線無法使用', + event_feed_unavailable: 'JType 事件流無法使用', + bootstrap_unavailable: 'JType 卡片初始化無法使用', + card_index_unavailable: 'JType 卡片索引無法使用', + card_read_unavailable: 'JType 卡片暫時無法讀取', + card_unavailable: 'JType 卡片無法使用', + board_validation_unavailable: 'JType 看板驗證無法使用', + board_drift: '看板或自動化欄位已變更', + service_unavailable: 'Service 無法使用', + repository_not_configured: '尚未設定程式碼儲存庫', + repository_unavailable: '程式碼儲存庫連線無法使用', + provider_unavailable: '程式碼儲存庫 Provider 無法使用', + runner_unavailable: 'Runner 無法使用', + run_unavailable: '關聯的 Cloud Run 無法使用', + model_not_configured: '尚未設定模型', + model_effort_unsupported: '模型不支援所選推理強度', + }, + executionsTitle: 'Cloud 執行', + loadingExecutions: '正在載入 Cloud 執行…', + executionsUnavailable: '無法載入 Cloud 執行記錄。', + noExecutions: '尚無 Cloud 執行。將此 Card 移入「啟動 jcode」欄即可要求執行。', + executionState: { + received: '已收到要求', + blocked: '受阻', + queued: '排隊中', + running: '執行中', + terminal: '已結束', + succeeded: '成功', + failed: '失敗', + canceled: '已取消', + }, + requestedByExternal: '要求者:{actor}', + projectOwner: '專案擁有者', + clusterAdmin: 'Cluster 管理員', + openRun: '開啟 Run', + writebackPending: 'Card 回寫待完成', + writebackUnavailable: '來源 Card 無法使用', + executionSummary: { + received: '已收到 Card 要求。', + queued: 'Run 正在排隊。', + running: 'Run 正在執行所要求的變更。', + succeeded: 'Run 已成功完成。', + failed: 'Run 執行失敗。', + canceled: 'Run 已取消。', + }, + priorExecutions_one: '{count} 次歷史執行', + priorExecutions_other: '{count} 次歷史執行', + cardUnavailable: '此 Card 已不在 JType 中,Cloud 執行歷史仍會保留。', + loadEarlierExecutions: '載入更早的執行', linkDisabledTitle: '此 Kanban 自動化連結已停用。', linkDisabledBody: '看板可供檢視,但卡片觸發的執行與回寫已停用。', }, @@ -1279,6 +1337,9 @@ export default { conclusion: 'CI 結果', conclusionHint: '僅當所有選取事件都是 check.completed 時可用。', events: '事件', moreEvents: '更多事件', thisProvider: '目前供應商', eventUnavailable: '{provider}{version} 不支援此事件。', cronExpression: 'Cron 表達式', cronHint: '使用五段式 Cron 表達式;伺服器會強制執行最小時間間隔。', + outputMode: '輸出方式', outputRun: '直接建立 Run', outputCard: '先建立 jtype Card,再由服務看板執行', + outputHint: 'Card 輸出會保留 jtype 工作項目,並沿用標準看板受理與回寫閉環。', + outputCardUnavailable: '請先啟用健康的服務看板,再選擇 Card 輸出。', create: '建立自動化', save: '儲存自動化', review: { eyebrow: 'GitHub 程式碼審查', title: '審查 Pull Request', subtitle: '無需讓團隊學習新流程,即可啟用實用的 GitHub 原生審查。', @@ -1291,7 +1352,7 @@ export default { draftsTitle: '包含草稿', draftsBody: '通常保持關閉,讓作者先完成自己的第一輪檢查。', create: '開啟審查', save: '儲存審查設定', }, - validation: { noPermission: '你沒有編輯自動化的權限。', required: '名稱、服務、模型與任務提示詞都是必填項目。', eventRequired: '請至少選擇一個此服務供應商支援的事件。', cronRequired: 'Cron 表達式為必填項目。' }, + validation: { noPermission: '你沒有編輯自動化的權限。', required: '名稱、服務、模型與任務提示詞都是必填項目。', eventRequired: '請至少選擇一個此服務供應商支援的事件。', cronRequired: 'Cron 表達式為必填項目。', cardOutputUnavailable: 'Card 輸出需要健康的服務看板策略。' }, apiError: { overlap: '此服務的另一個自動化已使用所選 SCM 事件中的至少一個。', webhook: '自動化已儲存,但無法同步供應商 Webhook。請檢查外掛連接後重試。', @@ -1303,6 +1364,34 @@ export default { }, }, + automationExecutions: { + loading: '正在載入自動化…', loadError: '無法載入自動化。', back: '返回自動化列表', + cardOutput: 'Card 輸出', runOutput: '直接 Run 輸出', runNow: '立即執行', + runNowError: '本次執行未能受理;重試會沿用同一個冪等鍵。', + history: '執行歷史', loadingHistory: '正在載入執行歷史…', historyError: '無法載入執行歷史。', + empty: '尚無執行記錄。', loadMore: '載入更早的執行', select: '選擇一筆執行記錄查看詳情。', + selected: '選取的執行', execution: '執行', trigger: '觸發來源', kind: '類型', + requested: '請求者', accountable: '負責人', notApplicable: '不適用', unattributed: '未歸因', + output: '輸出', expected: '預期', cardThenRun: 'jtype Card → 看板 Run', directRun: '直接 Run', + actual: '實際結果', writeback: '回寫', usage: '用量', usageUnavailable: '暫不可用', + filter: { all: '全部', blocked: '受阻', running: '執行中', terminal: '已結束' }, + state: { + accepted: '已受理', ignored: '已忽略', duplicate: '重複', + superseded: '已取代', blocked: '受阻', queued: '排隊中', + running: '執行中', terminal: '已結束', + }, + outcome: { succeeded: '成功', failed: '失敗', canceled: '已取消' }, + repair: { + project_owner: '專案擁有者可以修復此依賴。', + cluster_admin: '需要叢集管理員修復此依賴。', + requester: '需要請求者修復此依賴。', + }, + title: { + blocked: '在產生輸出前受阻', ignored: '觸發已依規則忽略', + card: 'Card 輸出', run: 'Run 輸出', + }, + }, + projectAutomations: { eyebrow: '專案自動化', title: '自動化', subtitle: '針對專案中的任何服務建立 SCM 與排程工作。Kanban 從服務標題區域啟用。', loading: '正在載入自動化…', loadError: '無法載入自動化。', new: '新增自動化', noServices: '建立自動化前請先連接服務。', @@ -1623,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: '無法載入執行', @@ -1731,6 +1888,28 @@ export default { }, inspector: { runDetailsLabel: '執行詳情', + identityAndSource: '身分與來源', + requestedBy: '請求者', + accountableTo: '責任人', + triggeredFrom: '觸發來源', + executedFor: '執行對象', + project: '專案', + repository: '程式碼倉庫', + dispatchSnapshot: '調度時快照', + writtenBackAs: '回寫身分', + externalActor: '外部身分', + ruleOwner: '規則負責人', + botOrApp: 'Bot / 應用程式', + notAttributed: '無法歸屬', + notApplicable: '不適用', + technicalIdentity: '技術身分', + runtimePrincipal: '執行主體', + attribution: '歸屬精度', + source: '來源', + precisionExact: '精確', + precisionLinkedExternal: '已連結外部身分', + precisionRuleOwner: '歸屬到規則負責人', + precisionUnattributed: '無法歸屬', runOverview: '執行總覽', service: '服務', unavailable: '無法使用', 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 new file mode 100644 index 00000000..8b6d049b --- /dev/null +++ b/console/src/pages/AutomationDetailPage.module.css @@ -0,0 +1,54 @@ +.page { width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding: var(--space-6) 0 var(--space-12); } +.back { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--color-text-faint); font-size: var(--fs-sm); text-decoration: none; } +.head { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-6); margin: var(--space-6) 0 var(--space-5); } +.head > div:first-child > span, .inspector header span { color: var(--color-accent-dim); font-family: var(--font-mono); font-size: var(--fs-tiny); font-weight: var(--fw-semibold); letter-spacing: var(--tracking-label); text-transform: uppercase; } +.head h1 { margin: var(--space-2) 0 4px; color: var(--color-text); font-size: clamp(28px, 4vw, 42px); letter-spacing: var(--tracking-tight); } +.head p { color: var(--color-text-dim); } +.headActions { display: flex; align-items: center; gap: var(--space-3); } +.headActions > a { color: var(--color-text-muted); font-size: var(--fs-sm); } +.actionError { margin-bottom: var(--space-4); color: var(--color-danger); font-size: var(--fs-sm); } +.layout { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(300px, .8fr); overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-2xl); background: var(--color-panel); box-shadow: var(--shadow-1); } +.ledger { min-width: 0; } +.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-4); border-bottom: 1px solid var(--color-border); } +.filters { display: flex; gap: 3px; padding: 2px; border-radius: var(--radius-lg); background: var(--color-bg-inset); } +.filters button { min-height: 28px; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-text-faint); font-size: var(--fs-caption); } +.filters button[aria-pressed='true'] { background: var(--color-panel); color: var(--color-text); box-shadow: var(--shadow-1); } +.list { margin: 0; padding: 0; list-style: none; } +.list li > button { display: grid; width: 100%; grid-template-columns: 96px minmax(0, 1fr) auto; gap: var(--space-3); padding: var(--space-4); border: 0; border-bottom: 1px solid var(--color-border); background: transparent; color: inherit; text-align: left; } +.list li > button:hover, .list li > button.selected { background: color-mix(in srgb, var(--color-accent) 6%, var(--color-panel)); } +.list li > button.selected { box-shadow: inset 3px 0 var(--color-accent); } +.list strong, .list small { display: block; } +.list small { margin-top: 4px; overflow: hidden; color: var(--color-text-faint); font-size: var(--fs-caption); text-overflow: ellipsis; white-space: nowrap; } +.list time { color: var(--color-text-faint); font-family: var(--font-mono); font-size: var(--fs-tiny); white-space: nowrap; } +.state { align-self: start; width: max-content; padding: 2px 7px; border: 1px solid var(--color-border); border-radius: var(--radius-pill); color: var(--color-text-muted); font-family: var(--font-mono); font-size: var(--fs-tiny); font-weight: var(--fw-semibold); } +.blocked { border-color: color-mix(in srgb, var(--color-warning) 55%, var(--color-border)); color: var(--color-warning); } +.running, .queued, .accepted { border-color: color-mix(in srgb, var(--color-accent) 55%, var(--color-border)); color: var(--color-accent-dim); } +.ignored, .superseded, .duplicate { color: var(--color-text-faint); } +.empty { padding: var(--space-8); color: var(--color-text-faint); text-align: center; } +.more { width: 100%; padding: var(--space-3); border: 0; background: transparent; color: var(--color-accent-dim); } +.inspector { min-width: 0; border-left: 1px solid var(--color-border); background: var(--color-bg-inset); } +.inspector header, .inspector section { padding: var(--space-5); border-bottom: 1px solid var(--color-border); } +.inspector h2 { margin-top: var(--space-2); color: var(--color-text); font-size: var(--fs-h2); } +.inspector h3 { margin-bottom: var(--space-3); color: var(--color-text-faint); font-family: var(--font-mono); font-size: var(--fs-tiny); letter-spacing: var(--tracking-label); text-transform: uppercase; } +.inspector dl { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: var(--space-2) var(--space-3); margin: 0; font-size: var(--fs-sm); } +.inspector dt { color: var(--color-text-faint); } +.inspector dd { margin: 0; overflow-wrap: anywhere; color: var(--color-text-muted); } +.inspector dd a { display: inline-flex; align-items: center; gap: 4px; color: var(--color-accent-dim); } +.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; } + .layout { grid-template-columns: 1fr; } + .inspector { border-top: 1px solid var(--color-border); border-left: 0; } +} +@media (max-width: 600px) { + .toolbar { align-items: flex-start; flex-direction: column; } + .filters { max-width: 100%; overflow-x: auto; } + .list li > button { grid-template-columns: 1fr auto; } + .list li > button > span:nth-child(2) { grid-column: 1 / -1; grid-row: 2; } + .state { grid-column: 1; grid-row: 1; } + .list time { grid-column: 2; grid-row: 1; } +} diff --git a/console/src/pages/AutomationDetailPage.test.tsx b/console/src/pages/AutomationDetailPage.test.tsx new file mode 100644 index 00000000..ec55a0ba --- /dev/null +++ b/console/src/pages/AutomationDetailPage.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { ApiProvider } from '../api/ApiProvider'; +import type { ApiClient } from '../api/client'; +import type { AutomationExecution, Project, ProjectAutomationSpec } from '../api/types'; +import { AutomationDetailPage } from './AutomationDetailPage'; + +const spec: ProjectAutomationSpec = { + automation: { + id: 'auto-1', + service_id: 'svc-1', + name: 'Weekday issue sweep', + trigger_kind: 'cron', + prompt_template: 'Triage issues', + enabled: true, + ignore_jcode: true, + created_at: '2026-07-01T00:00:00Z', + updated_at: '2026-07-01T00:00:00Z', + }, + cron: { cron_expr: '0 9 * * 1-5', output_mode: 'create_card' }, +}; + +const blocked: AutomationExecution = { + id: 'execution-blocked', + automation_id: 'auto-1', + automation_name: spec.automation.name, + trigger_kind: 'cron', + state: 'blocked', + output_mode: 'create_card', + reason_code: 'jtype_unavailable', + reason: 'Reconnect the Project JType plugin.', + repair_role: 'project_owner', + requested_actor: null, + accountable_actor: { kind: 'cloud_user', id: 'owner', label: 'Ada' }, + output: { kind: 'none', label: 'No output', available: false }, + run: null, + card: null, + writeback_state: '', + 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', +}; + +function renderPage(options: { + role?: 'owner' | 'member' | 'viewer'; + items?: AutomationExecution[]; + runAutomationNow?: ApiClient['runAutomationNow']; +} = {}) { + const project: Project = { + id: 'p1', name: 'Payments', role: options.role ?? 'member', created_at: '', + services: [{ + id: 'svc-1', project_id: 'p1', name: 'API', repo_kind: 'raw', + raw_repo_url: 'https://example.invalid/repo.git', default_branch: 'main', + git_mode: 'readonly', created_at: '', + }], + }; + const client = { + getProject: async () => project, + getProjectAutomation: async () => spec, + listAutomationExecutions: async () => ({ items: options.items ?? [blocked], next_cursor: null }), + runAutomationNow: options.runAutomationNow ?? vi.fn(async () => blocked), + } as unknown as ApiClient; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + render( + + + + + } /> + + + + , + ); +} + +describe('AutomationDetailPage', () => { + it('keeps blocked reason, accountability, output, and unavailable usage distinct', async () => { + renderPage(); + expect(await screen.findByRole('heading', { name: 'Weekday issue sweep' })).toBeTruthy(); + expect(screen.getAllByText('Blocked before output').length).toBeGreaterThan(0); + expect(screen.getAllByText('Reconnect the Project JType plugin.')).toHaveLength(2); + expect(screen.getByText('Ada')).toBeTruthy(); + expect(screen.getByText('jtype Card → Kanban Run')).toBeTruthy(); + expect(screen.getByText('Unavailable')).toBeTruthy(); + expect(screen.queryByText('0 tokens')).toBeNull(); + }); + + it('keeps the same idempotency key when Run now is retried after a network failure', async () => { + const calls: string[] = []; + const runNow = vi.fn(async (_automationId: string, key: string) => { + calls.push(key); + if (calls.length === 1) throw new Error('network'); + return blocked; + }); + renderPage({ runAutomationNow: runNow }); + const button = await screen.findByRole('button', { name: 'Run now' }); + fireEvent.click(button); + await screen.findByRole('alert'); + fireEvent.click(button); + await waitFor(() => expect(calls).toHaveLength(2)); + expect(calls[0]).toBe(calls[1]); + }); + + it('lets a Viewer inspect history but not trigger a new execution', async () => { + renderPage({ role: 'viewer' }); + const button = await screen.findByRole('button', { name: 'Run now' }) as HTMLButtonElement; + expect(button.disabled).toBe(true); + expect(screen.getAllByText('Reconnect the Project JType plugin.')).toHaveLength(2); + }); +}); diff --git a/console/src/pages/AutomationDetailPage.tsx b/console/src/pages/AutomationDetailPage.tsx new file mode 100644 index 00000000..3a165dd1 --- /dev/null +++ b/console/src/pages/AutomationDetailPage.tsx @@ -0,0 +1,208 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +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, + useProject, + useProjectAutomation, + useRunAutomationNow, +} from '../api/queries'; +import type { AutomationExecution, AutomationExecutionState } from '../api/types'; +import styles from './AutomationDetailPage.module.css'; + +type Filter = '' | 'blocked' | 'running' | 'terminal'; + +export function AutomationDetailPage() { + const { t } = useTranslation(); + const { projectId = '', automationId = '' } = useParams(); + const project = useProject(projectId); + const automation = useProjectAutomation(projectId, automationId); + const [filter, setFilter] = useState(''); + const executions = useAutomationExecutions(automationId, filter); + const runNow = useRunAutomationNow(automationId); + const retryKey = useRef(''); + const [selectedId, setSelectedId] = useState(''); + const items = useMemo( + () => executions.data?.pages.flatMap((page) => page.items) ?? [], + [executions.data], + ); + const selected = items.find((item) => item.id === selectedId) ?? items[0]; + const canRun = project.data?.role !== 'viewer'; + + useEffect(() => { + if (!selectedId && items[0]) setSelectedId(items[0].id); + }, [items, selectedId]); + + if (project.isLoading || automation.isLoading) { + return ; + } + if (project.isError || automation.isError) { + return ; + } + const spec = automation.data; + if (!spec) return null; + const service = project.data?.services?.find((item) => item.id === spec.automation.service_id); + + const triggerSummary = spec.scm + ? (spec.actions ?? []).map((action) => `${action.event_family}.${action.action}`).join(', ') + : `${spec.cron?.cron_expr ?? ''} · ${spec.cron?.output_mode === 'create_card' + ? t('automationExecutions.cardOutput') + : t('automationExecutions.runOutput')}`; + + const triggerNow = () => { + if (!retryKey.current) retryKey.current = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; + runNow.mutate(retryKey.current, { + onSuccess: (value) => { + retryKey.current = ''; + setSelectedId(value.id); + }, + }); + }; + + return ( +
+ + {t('automationExecutions.back')} + +
+
+ {spec.automation.trigger_kind} · {spec.automation.enabled + ? t('projectAutomations.enabled') : t('projectAutomations.disabled')} +

{spec.automation.name}

+

{service?.name ?? spec.automation.service_id} · {triggerSummary}

+
+
+ + {t('projectAutomations.edit')} + + +
+
+ {runNow.error &&

{t('automationExecutions.runNowError')}

} + +
+
+
+ {t('automationExecutions.history')} +
+ {(['', 'blocked', 'running', 'terminal'] as Filter[]).map((value) => ( + + ))} +
+
+ {executions.isLoading ? ( + + ) : executions.isError ? ( + void executions.refetch()} title={t('automationExecutions.historyError')} /> + ) : !items.length ? ( +

{t('automationExecutions.empty')}

+ ) : ( + <> +
    + {items.map((item) => ( +
  1. + +
  2. + ))} +
+ {executions.hasNextPage && ( + + )} + + )} +
+ +
+
+ ); +} + +function StateBadge({ state, outcome }: { state: AutomationExecutionState; outcome?: string }) { + const { t } = useTranslation(); + const label = state === 'terminal' && outcome + ? t(`automationExecutions.outcome.${outcome}`) + : t(`automationExecutions.state.${state}`); + return {label}; +} + +function ExecutionInspector({ execution }: { execution?: AutomationExecution }) { + const { t } = useTranslation(); + if (!execution) { + return ; + } + return ( + + ); +} + +function executionTitle(item: AutomationExecution, t: (key: string) => string): string { + if (item.state === 'blocked') return t('automationExecutions.title.blocked'); + if (item.state === 'ignored') return t('automationExecutions.title.ignored'); + if (item.output.kind === 'card') return t('automationExecutions.title.card'); + if (item.output.kind === 'run') return t('automationExecutions.title.run'); + return t('automationExecutions.execution'); +} diff --git a/console/src/pages/AutomationEditorPage.test.tsx b/console/src/pages/AutomationEditorPage.test.tsx index a6365334..a9e6b3f8 100644 --- a/console/src/pages/AutomationEditorPage.test.tsx +++ b/console/src/pages/AutomationEditorPage.test.tsx @@ -154,6 +154,51 @@ describe('AutomationEditorPage', () => { expect(create).not.toHaveBeenCalled(); }); + it('enables Card output only when Service Kanban is healthy and sends the explicit mode', async () => { + const { create } = renderEditor({ + getServiceKanbanPolicy: async () => ({ + service_id: 'svc-1', + service_name: 'API', + repository: 'acme/api', + model: { id: 'glm-52', label: 'GLM 5.2' }, + board: { workspace_id: 'ws', ref: 'board' }, + trigger_column: { key: 'agent', label: 'Agent' }, + done_column: {}, + output: 'comment_only', + health: { state: 'ready', blocker: null }, + }), + }); + await screen.findByRole('heading', { name: 'Create Automation' }); + fireEvent.click(screen.getByRole('button', { name: 'Cron' })); + await waitFor(() => { + const option = screen.getByRole('option', { name: /jtype Card/ }) as HTMLOptionElement; + expect(option.disabled).toBe(false); + }); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Issue sweep' } }); + fireEvent.change(screen.getByLabelText('Model'), { target: { value: 'glm-52' } }); + fireEvent.change(screen.getByLabelText('Prompt template'), { target: { value: 'Triage open issues' } }); + fireEvent.change(screen.getByLabelText('Output'), { target: { value: 'create_card' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create Automation' })); + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)); + expect(create.mock.calls[0]?.[1].cron).toEqual({ + cron_expr: '0 9 * * 1-5', + output_mode: 'create_card', + }); + }); + + it('keeps Card output visibly unavailable without a healthy Service Kanban policy', async () => { + renderEditor({ + getServiceKanbanPolicy: async () => { + throw new Error('not configured'); + }, + }); + await screen.findByRole('heading', { name: 'Create Automation' }); + fireEvent.click(screen.getByRole('button', { name: 'Cron' })); + const option = await screen.findByRole('option', { name: /jtype Card/ }) as HTMLOptionElement; + expect(option.disabled).toBe(true); + expect(screen.getByText('Enable a healthy Service Kanban policy before selecting Card output.')).toBeTruthy(); + }); + it('keeps common SCM events visible and places the complete low-frequency matrix behind More events', async () => { renderEditor(); await screen.findByRole('heading', { name: 'Create Automation' }); diff --git a/console/src/pages/AutomationEditorPage.tsx b/console/src/pages/AutomationEditorPage.tsx index d12d3a30..b9bfd26d 100644 --- a/console/src/pages/AutomationEditorPage.tsx +++ b/console/src/pages/AutomationEditorPage.tsx @@ -11,6 +11,7 @@ import { useProjectAutomation, useProjectModels, useProviderCapabilities, + useServiceKanbanPolicy, useUpdateProjectAutomation, } from '../api/queries'; import type { @@ -124,6 +125,7 @@ export function AutomationEditorPage() { const [includeDrafts, setIncludeDrafts] = useState(false); const [showMoreActions, setShowMoreActions] = useState(false); const [cronExpr, setCronExpr] = useState('0 9 * * 1-5'); + const [cronOutputMode, setCronOutputMode] = useState<'run_only' | 'create_card'>('run_only'); const [formError, setFormError] = useState(''); const selectedService = services.find((service) => service.id === serviceId); @@ -136,6 +138,8 @@ export function AutomationEditorPage() { selectedService?.provider === 'gitea' ) ? selectedService.provider as ProviderKind : 'github'; const capabilities = useProviderCapabilities(scmProvider, kind === 'scm' && !!selectedService); + const kanbanPolicy = useServiceKanbanPolicy(serviceId, kind === 'cron' && !!serviceId); + const cardOutputReady = kanbanPolicy.data?.health.state === 'ready'; const supported = useMemo(() => { if (!capabilities.data) return supportedActions(selectedService?.provider); return new Set(capabilities.data.capabilities.flatMap((group) => @@ -203,6 +207,7 @@ export function AutomationEditorPage() { setIncludeDrafts(spec.scm?.include_drafts ?? false); setActions((spec.actions ?? []).map(actionName)); setCronExpr(spec.cron?.cron_expr ?? '0 9 * * 1-5'); + setCronOutputMode(spec.cron?.output_mode ?? 'run_only'); } function toggleAction(action: NormalizedScmAction) { @@ -250,6 +255,10 @@ export function AutomationEditorPage() { setFormError(t('automationEditor.validation.cronRequired')); return; } + if (kind === 'cron' && cronOutputMode === 'create_card' && !cardOutputReady) { + setFormError(t('automationEditor.validation.cardOutputUnavailable')); + return; + } const input: CreateProjectAutomationInput = { service_id: serviceId, name: name.trim(), @@ -270,7 +279,7 @@ export function AutomationEditorPage() { actions: actions.filter((action) => supported.has(action)).map(actionParts), }, } : {}), - ...(kind === 'cron' ? { cron: { cron_expr: cronExpr.trim() } } : {}), + ...(kind === 'cron' ? { cron: { cron_expr: cronExpr.trim(), output_mode: cronOutputMode } } : {}), }; const onSuccess = () => navigate(`/projects/${encodeURIComponent(projectId)}?tab=automations&service=${encodeURIComponent(serviceId)}`); if (editing) update.mutate({ automationId, input }, { onSuccess }); @@ -506,6 +515,15 @@ export function AutomationEditorPage() {
{t('automationEditor.cronHint')} + + {!cardOutputReady + ? kanbanPolicy.data?.health.blocker ?? t('automationEditor.outputCardUnavailable') + : t('automationEditor.outputHint')}
)} } 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 c3b3e658..f1479b5a 100644 --- a/console/src/pages/KanbanBoardModal.module.css +++ b/console/src/pages/KanbanBoardModal.module.css @@ -19,6 +19,45 @@ min-width: 16rem; } +.policyStrip { + display: grid; + grid-template-columns: minmax(10rem, 1.3fr) repeat(4, minmax(8rem, 1fr)); + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-panel-raised); + color: var(--color-text-muted); + font-size: var(--fs-caption); + line-height: var(--lh-normal); +} + +.policyStrip[data-state='blocked'] { + border-color: var(--color-warning); +} + +.policyLead { + display: grid; + min-width: 0; +} + +.policyLead strong { + color: var(--color-text); + font-size: var(--fs-sm); +} + +.policyLead span, +.policyStrip > span { + overflow-wrap: anywhere; +} + +.policyHealth { + justify-self: end; + color: var(--color-text); + font-weight: var(--fw-semibold); +} + .setupPanel { display: grid; width: min(100%, 46rem); @@ -114,6 +153,125 @@ overflow: auto; } +.executions { + display: grid; + gap: var(--space-3); + 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); + padding: var(--space-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-panel-raised); + overflow-wrap: anywhere; +} + +.executionCurrent[data-state='blocked'], +.executionCurrent[data-state='failed'], +.executionCurrent[data-state='canceled'] { + border-color: var(--color-warning); +} + +.executionCurrent[data-state='succeeded'] { + border-color: var(--color-success); +} + +.executionHeading, +.executionMeta, +.executionHistory li { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2) var(--space-3); +} + +.executionHeading { + justify-content: space-between; +} + +.executionHeading strong { + color: var(--color-text); +} + +.executionHeading time, +.executionMeta, +.executionHistory { + color: var(--color-text-dim); + font-size: var(--fs-caption); +} + +.executionCurrent p, +.executionEmpty { + margin: 0; + color: var(--color-text-muted); + font-size: var(--fs-sm); + line-height: var(--lh-normal); +} + +.executionMeta a, +.executionHistory a { + color: var(--color-accent); + font-weight: var(--fw-semibold); +} + +.executionHistory summary { + cursor: pointer; + color: var(--color-text); + font-weight: var(--fw-medium); +} + +.executionHistory ol { + display: grid; + gap: var(--space-2); + margin: var(--space-2) 0 0; + padding-left: var(--space-5); +} + +.executionLoading { + min-height: 4rem; + border-radius: var(--radius-md); + background: var(--color-panel-raised); + color: var(--color-text-dim); + font-size: var(--fs-caption); +} + +.executionError { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + color: var(--color-danger); + font-size: var(--fs-sm); +} +.executionUnavailable { + border: 1px solid color-mix(in srgb, var(--color-warning) 42%, var(--color-border)); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-warning) 9%, var(--color-panel)); + padding: var(--space-2) var(--space-3); + color: var(--color-text-muted); + font-size: var(--fs-caption); +} + /* Fail-visible panel: the board could not be opened (deleted / renamed / access lost). Never a blank modal (red line #1). */ .failPanel { @@ -141,6 +299,14 @@ grid-template-columns: 1fr; } + .policyStrip { + grid-template-columns: 1fr; + } + + .policyHealth { + justify-self: start; + } + .columnStatus { grid-column: auto; } diff --git a/console/src/pages/KanbanBoardModal.test.tsx b/console/src/pages/KanbanBoardModal.test.tsx index f162a631..54e796c4 100644 --- a/console/src/pages/KanbanBoardModal.test.tsx +++ b/console/src/pages/KanbanBoardModal.test.tsx @@ -5,6 +5,7 @@ * is a light typed Error (the proxy client + resolver depend on it). */ import { describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ApiProvider } from '../api/ApiProvider'; @@ -18,14 +19,22 @@ vi.mock('jtype-board-react', () => ({ boardRef: string; live?: boolean; readOnly?: boolean; + initialCardPath?: string; + additionalCardRoots?: readonly string[]; + renderCardSupplement?: (card: { id: string; title: string }) => ReactNode; }) => ( -
+ <> +
+ {p.renderCardSupplement?.({ id: 'cards/payment.md', title: 'Payment card' })} + ), JTypeApiError: class extends Error { status: number; @@ -94,15 +103,27 @@ function makeApi( updatedClock: 2, mergeStatus: 'accepted' as const, }), + getServiceKanbanPolicy: async (serviceId: string) => ({ + service_id: serviceId, + service_name: 'Service', + repository: 'acme/service', + model: { label: 'Demo model' }, + board: { workspace_id: 'ws_team', ref: 'b_123' }, + trigger_column: { key: 'ai', label: 'AI' }, + done_column: { key: 'done', label: 'Done' }, + output: 'comment_and_move_on_success' as const, + health: { state: 'ready' as const, blocker: null }, + }), + listServiceKanbanCardExecutions: async () => ({ claim: null, items: [], next_cursor: null }), } as unknown as ApiClient; } -function renderModal(api: ApiClient, links: BoardEmbedLink[]) { +function renderModal(api: ApiClient, links: BoardEmbedLink[], serviceId?: string) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( - {}} /> + {}} /> , ); @@ -150,7 +171,20 @@ describe('KanbanBoardModal', () => { it('single link: renders the board with the workspace and live=false', async () => { const api = makeApi({ ws_team: [{ path: 'jtype.board', configId: 'b_123' }] }); - renderModal(api, [link()]); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + {}} + /> + + , + ); // The board is a working surface, not a form: it opts into Modal's bounded // wide layout so horizontal board scrolling stays inside the dialog. @@ -159,10 +193,156 @@ describe('KanbanBoardModal', () => { expect(board.getAttribute('data-workspace')).toBe('ws_team'); // No SSE proxy → the board is handed live=false (visible polling). expect(board.getAttribute('data-live')).toBe('false'); + expect(board.getAttribute('data-readonly')).toBe('false'); + expect(board.getAttribute('data-initial-card')).toBe('cards/payment.md'); + expect(board.getAttribute('data-additional-card-roots')).toBe('jcode-automation'); // Single link → no selector. expect(screen.queryByTestId('kanban-board-select')).toBeNull(); }); + it('makes the embedded board read-only when the caller cannot manage Cards', async () => { + const api = makeApi({ ws_team: [{ path: 'jtype.board', configId: 'b_123' }] }); + renderModal(api, [link()]); + + const board = await screen.findByTestId('jtype-board'); + expect(board.getAttribute('data-readonly')).toBe('true'); + }); + + 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, + summary: 'Execution is blocked', + reason: 'Choose an allowed model for this Service.', + reason_code: 'model_not_configured', + repair_role: 'project_owner' as const, + requested_actor: { label: 'External editor', precision: 'display_only' as const }, + run: null, + receipt: { external: 'written' as const, writeback: 'not_required' as const }, + created_at: '2026-07-31T00:00:00Z', + updated_at: '2026-07-31T00:00:00Z', + }], + next_cursor: null, + })); + const api = { + ...makeApi({ ws_team: [{ path: 'jtype.board', configId: 'b_123' }] }), + getServiceKanbanPolicy: async () => ({ + service_id: 'svc_1', + service_name: 'payments-api', + repository: 'acme/payments', + model: { id: 'model_1', label: 'Claude Sonnet' }, + board: { workspace_id: 'ws_team', ref: 'b_123' }, + trigger_column: { key: 'ai', label: 'Agent queue' }, + done_column: { key: 'done', label: 'Done' }, + output: 'comment_and_move_on_success' as const, + health: { state: 'ready' as const, blocker: null }, + }), + listServiceKanbanCardExecutions: listExecutions, + } as unknown as ApiClient; + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + {}} + /> + + , + ); + + expect((await screen.findByTestId('kanban-policy')).textContent).toContain('payments-api'); + expect(screen.getByTestId('kanban-policy').textContent).toContain('Agent queue'); + const receipt = await screen.findByTestId('kanban-execution-current'); + 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); + }); + + it('keeps execution load failures visible and retries into the empty state', async () => { + const listExecutions = vi.fn() + .mockRejectedValueOnce(new Error('temporary API failure')) + .mockResolvedValueOnce({ claim: null, items: [], next_cursor: null }); + const api = { + ...makeApi({ ws_team: [{ path: 'jtype.board', configId: 'b_123' }] }), + listServiceKanbanCardExecutions: listExecutions, + } as unknown as ApiClient; + renderModal(api, [link()], 'svc_1'); + + expect(await screen.findByText('Cloud executions could not be loaded.')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(await screen.findByText(/Move this Card into the Starts jcode column/)).toBeTruthy(); + expect(listExecutions).toHaveBeenCalledTimes(2); + }); + + it('preserves deleted-Card history and loads older occurrences by opaque cursor', async () => { + const listExecutions = vi.fn(async (_serviceId: string, _workspaceId: string, _path: string, before?: string) => ( + before + ? { + claim: { document_path: 'cards/payment.md', external_ref_available: false }, + items: [{ + id: 'occ_old', + status: 'terminal' as const, + outcome: 'failed', + summary: 'Run failed', + reason: null, + repair_role: null, + requested_actor: null, + run: null, + receipt: { external: 'written' as const, writeback: 'complete' as const }, + created_at: '2026-07-30T00:00:00Z', + updated_at: '2026-07-30T00:01:00Z', + }], + next_cursor: null, + } + : { + claim: { document_path: 'cards/payment.md', external_ref_available: false }, + items: [{ + id: 'occ_current', + status: 'terminal' as const, + outcome: 'succeeded', + summary: 'Run completed successfully', + reason: null, + repair_role: null, + requested_actor: null, + run: null, + receipt: { external: 'written' as const, writeback: 'complete' as const }, + created_at: '2026-07-31T00:00:00Z', + updated_at: '2026-07-31T00:01:00Z', + }], + next_cursor: 'opaque-before', + } + )); + const api = { + ...makeApi({ ws_team: [{ path: 'jtype.board', configId: 'b_123' }] }), + listServiceKanbanCardExecutions: listExecutions, + } as unknown as ApiClient; + renderModal(api, [link()], 'svc_1'); + + expect(await screen.findByText(/no longer exists in JType/)).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Load earlier executions' })); + expect(await screen.findByText('1 prior execution')).toBeTruthy(); + expect(listExecutions).toHaveBeenLastCalledWith( + 'svc_1', + 'ws_team', + 'cards/payment.md', + 'opaque-before', + ); + }); + it('resolves board_ref (config id) → relativePath before rendering', async () => { const api = makeApi({ ws_team: [ diff --git a/console/src/pages/KanbanBoardModal.tsx b/console/src/pages/KanbanBoardModal.tsx index d0470ade..5eab1973 100644 --- a/console/src/pages/KanbanBoardModal.tsx +++ b/console/src/pages/KanbanBoardModal.tsx @@ -21,6 +21,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; import { JTypeApiError, JTypeBoard, type BoardLocale } from 'jtype-board-react'; import 'jtype-board-react/style.css'; import { useApi } from '../api/ApiProvider'; @@ -30,16 +31,21 @@ import { usePluginBoards, useProjectPlugins, usePutServiceKanban, + useServiceKanbanCardExecutions, + useServiceKanbanPolicy, } from '../api/queries'; 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, PluginBoardResource } from '../api/types'; +import type { BoardEmbedLink, KanbanCardExecution, PluginBoardResource } from '../api/types'; import styles from './KanbanBoardModal.module.css'; +const CLOUD_MANAGED_CARD_ROOTS = ['jcode-automation'] as const; + /** Map the browser locale to a board-supported one; default 'en'. */ function boardLocale(): BoardLocale { const lang = (typeof navigator !== 'undefined' ? navigator.language : 'en') @@ -125,11 +131,195 @@ interface Props { projectId: string; serviceId?: string; links: BoardEmbedLink[]; + initialCardPath?: string; canManage?: boolean; onClose: () => void; } -export function KanbanBoardModal({ projectId, serviceId = '', links, canManage = false, onClose }: Props) { +function KanbanPolicyStrip({ serviceId }: { serviceId: string }) { + const { t } = useTranslation(); + const policy = useServiceKanbanPolicy(serviceId, !!serviceId); + if (!serviceId) return null; + if (policy.isLoading) { + return
{t('kanban.loadingPolicy')}
; + } + if (policy.isError || !policy.data) { + return ( +
+ {t('kanban.policyUnavailable')} + +
+ ); + } + const value = policy.data; + const blocker = value.health.blocker + ? t(`kanban.policyBlockers.${value.health.blocker}`, { defaultValue: value.health.blocker }) + : ''; + return ( +
+
+ {value.service_name} + {value.repository} +
+ + {t('kanban.policyTrigger', { + column: value.trigger_column.label || value.trigger_column.key, + })} + + {value.model.label} + + {value.done_column.key + ? t('kanban.policyWriteback', { column: value.done_column.label || value.done_column.key }) + : t('kanban.policyCommentOnly')} + + + {value.health.state === 'ready' + ? t('kanban.policyReady') + : ( + <> + {t('kanban.policyBlocked', { blocker })} + {value.health.repair_role === 'project_owner' && ` · ${t('kanban.projectOwner')}`} + {value.health.repair_role === 'cluster_admin' && ` · ${t('kanban.clusterAdmin')}`} + + )} + +
+ ); +} + +function executionStateLabel(execution: KanbanCardExecution, t: TFunction): string { + if (execution.status === 'terminal' && execution.outcome) { + return t(`kanban.executionState.${execution.outcome}`); + } + return t(`kanban.executionState.${execution.status}`); +} + +function executionDescription(execution: KanbanCardExecution, t: TFunction): string { + if (execution.status === 'blocked' && execution.reason_code) { + return t(`kanban.policyBlockers.${execution.reason_code}`, { + defaultValue: execution.reason ?? execution.summary, + }); + } + const key = execution.status === 'terminal' && execution.outcome + ? execution.outcome + : execution.status; + return t(`kanban.executionSummary.${key}`, { defaultValue: execution.summary }); +} + +function CardExecutionsSupplement({ + serviceId, + workspaceId, + documentPath, +}: { + serviceId: string; + workspaceId: string; + documentPath: string; +}) { + const { t } = useTranslation(); + const query = useServiceKanbanCardExecutions(serviceId, workspaceId, documentPath); + if (query.isLoading) { + return
{t('kanban.loadingExecutions')}
; + } + if (query.isError) { + return ( +
+ {t('kanban.executionsUnavailable')} + +
+ ); + } + const pages = query.data?.pages ?? []; + const executions = pages.flatMap((page) => page.items); + const claim = pages[0]?.claim ?? null; + if (executions.length === 0) { + return

{t('kanban.noExecutions')}

; + } + const current = executions[0]!; + const history = executions.slice(1); + return ( +
+ {claim && !claim.external_ref_available && ( +
+ {t('kanban.cardUnavailable')} +
+ )} +
+ +
+
+
+ {executionStateLabel(current, t)} + +
+

{executionDescription(current, t)}

+
+ {current.requested_actor && ( + {t('kanban.requestedByExternal', { actor: current.requested_actor.label })} + )} + {current.repair_role === 'project_owner' && {t('kanban.projectOwner')}} + {current.repair_role === 'cluster_admin' && {t('kanban.clusterAdmin')}} + {current.run && {t('kanban.openRun')}} + {current.receipt.writeback === 'pending' && {t('kanban.writebackPending')}} + {current.receipt.writeback === 'unavailable' && {t('kanban.writebackUnavailable')}} +
+ {current.usage_summary && ( +
+ +
+ )} +
+ {history.length > 0 && ( +
+ {t('kanban.priorExecutions', { count: history.length })} +
    + {history.map((execution) => ( +
  1. + {executionStateLabel(execution, t)} + + {execution.run && {t('kanban.openRun')}} + {execution.usage_summary && } +
  2. + ))} +
+
+ )} + {query.hasNextPage && ( + + )} +
+ ); +} + +export function KanbanBoardModal({ + projectId, + serviceId = '', + links, + initialCardPath, + canManage = false, + onClose, +}: Props) { const { t } = useTranslation(); const api = useApi(); // Memoize the injected client: a new identity per render restarts the board. @@ -290,6 +480,7 @@ export function KanbanBoardModal({ projectId, serviceId = '', links, canManage = />
)} + {link && serviceId && } {!link ? null : resolved.isPending ? ( @@ -400,8 +591,18 @@ export function KanbanBoardModal({ projectId, serviceId = '', links, canManage = client={proxyClient} workspaceId={link.workspace_id} boardRef={resolved.data} + initialCardPath={initialCardPath} + additionalCardRoots={CLOUD_MANAGED_CARD_ROOTS} live={false} + readOnly={!canManage} locale={boardLocale()} + renderCardSupplement={serviceId ? (card) => ( + + ) : undefined} />
diff --git a/console/src/pages/ProjectDetailPage.test.tsx b/console/src/pages/ProjectDetailPage.test.tsx index 80d77959..22d0bf2d 100644 --- a/console/src/pages/ProjectDetailPage.test.tsx +++ b/console/src/pages/ProjectDetailPage.test.tsx @@ -33,6 +33,22 @@ import type { import { pickOption } from '../test/select'; import { qk } from '../api/queries'; +vi.mock('./KanbanBoardModal', () => ({ + KanbanBoardModal: (props: { + initialCardPath?: string; + canManage?: boolean; + onClose: () => void; + }) => ( +
+ +
+ ), +})); + import { ProjectDetailPage } from './ProjectDetailPage'; function svc(id: string, name: string): Service { @@ -667,6 +683,37 @@ describe('ProjectDetailPage — multi-repo workspace', () => { }); describe('ProjectDetailPage — workspace sections', () => { + it('opens an exact Automation Card deep link read-only for a Viewer and canonicalizes the URL', async () => { + const { client } = makeClient(project('viewer', [svc('svc_default', 'default')])); + const listProjectBoardLinks = vi.fn(async () => [{ + id: 'kanban-1', + workspace_id: 'workspace-1', + board_ref: 'board-1', + board_title: 'Delivery', + service_id: 'svc_default', + trigger_column: 'agent', + enabled: true, + }]); + (client as { listProjectBoardLinks?: unknown }).listProjectBoardLinks = listProjectBoardLinks; + renderPage( + client, + undefined, + '/projects/p1?service=svc_default&tab=automations&kanban=1&card=jcode-automation%2Fauto-1%2Fexec-1.md', + ); + + const modal = await screen.findByTestId('kanban-modal-stub'); + expect(modal.getAttribute('data-initial-card')).toBe('jcode-automation/auto-1/exec-1.md'); + expect(modal.getAttribute('data-can-manage')).toBe('false'); + expect(listProjectBoardLinks).toHaveBeenCalledWith('p1'); + await waitFor(() => { + const search = screen.getByTestId('workspace-location').textContent ?? ''; + expect(search).toContain('service=svc_default'); + expect(search).toContain('tab=automations'); + expect(search).not.toContain('kanban='); + expect(search).not.toContain('card='); + }); + }); + it('renders the unified Project Automation list without legacy schedule surfaces', async () => { const { client } = makeClient(project('owner', [svc('svc_default', 'default')]), { projectAutomations: [{ diff --git a/console/src/pages/ProjectDetailPage.tsx b/console/src/pages/ProjectDetailPage.tsx index 08ff56e4..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, @@ -91,6 +92,7 @@ export function ProjectDetailPage() { const [askApproval, setAskApproval] = useState(false); const [runFilter, setRunFilter] = useState('all'); const [kanbanOpen, setKanbanOpen] = useState(false); + const [kanbanCardPath, setKanbanCardPath] = useState(''); const [repoQuery, setRepoQuery] = useState(''); const [pickerInstallationId, setPickerInstallationId] = useState(''); const deferredQuery = useDeferredValue(repoQuery); @@ -112,6 +114,16 @@ export function ProjectDetailPage() { const projectSettingsOpen = canManage && searchParams.get('view') === 'project-settings'; const projectSettingsSection = resolveProjectSettingsSection(searchParams.get('settings'), canManage); + useEffect(() => { + if (searchParams.get('kanban') !== '1' || !activeService) return; + setKanbanCardPath(searchParams.get('card') ?? ''); + setKanbanOpen(true); + const next = new URLSearchParams(searchParams); + next.delete('kanban'); + next.delete('card'); + setSearchParams(next, { replace: true }); + }, [activeService, searchParams, setSearchParams]); + // A project switch must not retain a previous project's draft/model/form state. useEffect(() => { setPrompt(''); @@ -166,7 +178,9 @@ export function ProjectDetailPage() { }, [p, projectSettingsOpen, projectSettingsSection, searchParams, setSearchParams, workspaceLocation]); const pluginsQuery = useProjectPlugins(projectId, !!p && canRun); - const boardLinks = useProjectBoardLinks(projectId, !!p && canRun); + // Viewers cannot open Kanban manually, but may follow an Automation output + // deep link into the same board with the host enforcing read-only access. + const boardLinks = useProjectBoardLinks(projectId, !!p); const activeBoardLinks = (boardLinks.data ?? []).filter((link) => link.service_id === activeService?.id); const jtypePluginEnabled = (pluginsQuery.data ?? []).some((plugin) => plugin.provider === 'jtype' && plugin.status === 'enabled' && !!plugin.workspace_id); @@ -698,6 +712,10 @@ export function ProjectDetailPage() { )} + {!projectSettingsOpen && workspaceTab === 'usage' && ( + + )} + {!projectSettingsOpen && workspaceTab === 'settings' && canManage && ( setKanbanOpen(false)} + onClose={() => { + setKanbanOpen(false); + setKanbanCardPath(''); + }} /> )} diff --git a/console/src/pages/ProjectUsagePanel.module.css b/console/src/pages/ProjectUsagePanel.module.css new file mode 100644 index 00000000..dc5ae786 --- /dev/null +++ b/console/src/pages/ProjectUsagePanel.module.css @@ -0,0 +1,38 @@ +.page { display: grid; gap: var(--space-5); padding-bottom: var(--space-8); } +.toolbar { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-4); } +.toolbar > 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 cb51747c..f33cf839 100644 --- a/console/src/pages/RunDetailPage.module.css +++ b/console/src/pages/RunDetailPage.module.css @@ -66,11 +66,23 @@ .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); } .facts dd { min-width: 0; margin: 0; color: var(--color-text-muted); font-size: var(--fs-tiny); overflow-wrap: anywhere; } .facts code { font-family: var(--font-mono); } +.provenanceFacts > div { grid-template-columns: 88px minmax(0, 1fr); } +.actorDisplay, .executedFor, .triggerReference { display: grid; gap: 2px; } +.actorDisplay small, .executedFor small, .triggerReference small { color: var(--color-text-faint); font-size: 10px; } +.botIdentity { display: flex; align-items: flex-start; gap: var(--space-2); } +.botMark { display: grid; width: 30px; height: 30px; flex: none; place-items: center; border-radius: var(--radius-md); background: color-mix(in srgb, var(--color-accent) 12%, transparent); color: var(--color-accent); font-family: var(--font-mono); font-size: 10px; font-weight: var(--fw-semibold); } +.botIdentity .actorDisplay > span { color: var(--color-text); font-weight: var(--fw-medium); } +.provenanceTechnical { color: var(--color-text-faint); font-size: var(--fs-tiny); } +.provenanceTechnical summary { width: max-content; cursor: pointer; } +.provenanceTechnical[open] summary { margin-bottom: var(--space-3); } +.provenanceTechnical .facts { padding-left: var(--space-2); border-left: 1px solid var(--color-border); } +.provenanceTechnical a, .provenanceFacts a { display: inline-flex; align-items: center; gap: 3px; color: var(--color-text-muted); } .inspectorHint { margin: 0; color: var(--color-text-faint); font-size: var(--fs-tiny); line-height: var(--lh-normal); } .changeList { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-2); color: var(--color-text-faint); font-size: var(--fs-tiny); } .changeList code { overflow: hidden; color: var(--color-text-muted); font-family: var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } @@ -146,7 +158,7 @@ @media (max-width: 940px) { .taskLayout { grid-template-columns: minmax(0, 1fr); align-content: start; overflow-y: auto; } .conversationColumn { min-height: 560px; } - .inspector { grid-template-columns: repeat(3, minmax(0, 1fr)); padding-top: var(--space-5); border-top: 1px solid var(--color-border); border-left: 0; overflow: visible; } + .inspector { grid-template-columns: repeat(2, minmax(0, 1fr)); padding-top: var(--space-5); border-top: 1px solid var(--color-border); border-left: 0; overflow: visible; } .inspectorSection { padding: var(--space-5) var(--space-4) 0 0; } .inspectorSection + .inspectorSection { padding-top: var(--space-5); padding-left: var(--space-4); border-top: 0; border-left: 1px solid var(--color-border); } } diff --git a/console/src/pages/RunDetailPage.test.tsx b/console/src/pages/RunDetailPage.test.tsx index bf9b3044..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({ @@ -208,6 +230,80 @@ describe('RunDetailPage — resilient error states', () => { expect(screen.queryByTestId('tab-events')).toBeNull(); }); + it('separates requester, rule owner, execution target, and Bot writeback identity', async () => { + const { client, ctl } = makeClient('member'); + const run = baseRun({ + service_id: 'svc-1', + origin: 'kanban', + provenance: { + requested_actor: { + kind: 'external_actor', + label: 'Mei', + provider: 'jtype', + }, + accountable_actor: { + kind: 'cloud_user', + id: 'user-jack', + label: 'Jack', + }, + attribution_source: 'kanban_event', + precision: 'rule_owner', + trigger: { + kind: 'kanban_card', + label: 'JType Card #42', + ref: 'occurrence-42', + }, + executed_for: { + project_id: 'proj1', + project_label: 'commerce', + service_id: 'svc-1', + service_label: 'payments-api', + repository: 'acme/payments', + model: 'anthropic/claude-sonnet', + }, + runtime_principal: { + kind: 'automation_principal', + label: 'Cloud Automation', + }, + writeback_actor: { + kind: 'provider_bot', + label: 'jcode Cloud Bot', + provider: 'jtype', + }, + }, + }); + ctl.getRun.mockResolvedValue(run); + renderPage(client, run); + + expect((await screen.findByTestId('run-inspector')).textContent).toContain('Identity & source'); + expect(screen.getByTestId('provenance-requested').textContent).toContain('Mei'); + expect(screen.getByTestId('provenance-requested').textContent).toContain('External actor'); + expect(screen.getByTestId('provenance-requested').textContent).not.toContain('Bot'); + expect(screen.getByTestId('provenance-accountable').textContent).toContain('Jack'); + expect(screen.getByTestId('provenance-accountable').textContent).toContain('Rule owner'); + expect(screen.getByTestId('provenance-trigger').textContent).toContain('JType Card #42'); + expect(screen.getByTestId('provenance-executed-for').textContent).toContain('commerce'); + expect(screen.getByTestId('provenance-executed-for').textContent).toContain('payments-api'); + expect(screen.getByTestId('provenance-executed-for').textContent).toContain('acme/payments'); + expect(screen.getByTestId('provenance-executed-for').textContent).toContain('anthropic/claude-sonnet'); + expect(screen.getByTestId('provenance-executed-for').textContent).toContain('Dispatch-time snapshot'); + expect(screen.getByTestId('provenance-written-back').textContent).toContain('jcode Cloud Bot'); + expect(screen.getByTestId('provenance-written-back').textContent).toContain('Bot / App'); + }); + + it('labels missing legacy provenance without inventing a person, Bot, or zero value', async () => { + const { client, ctl } = makeClient('viewer'); + const run = baseRun(); + ctl.getRun.mockResolvedValue(run); + renderPage(client, run); + + expect((await screen.findByTestId('provenance-requested')).textContent).toBe('Not attributed'); + expect(screen.getByTestId('provenance-accountable').textContent).toBe('Not attributed'); + expect(screen.getByTestId('provenance-trigger').textContent).toBe('Unavailable'); + expect(screen.getByTestId('provenance-executed-for').textContent).toContain('Unavailable'); + expect(screen.getByTestId('provenance-written-back').textContent).toBe('Not applicable'); + }); + it('truncates a long task title with the complete prompt available on hover', async () => { const prompt = '# A deliberately long task title\n\n## Details\n' + 'keep this context '.repeat(40); const { client, ctl } = makeClient('member'); diff --git a/console/src/pages/RunDetailPage.tsx b/console/src/pages/RunDetailPage.tsx index 97f87a24..ca6d4a34 100644 --- a/console/src/pages/RunDetailPage.tsx +++ b/console/src/pages/RunDetailPage.tsx @@ -19,7 +19,7 @@ import { } from '../api/queries'; import { useApi } from '../api/ApiProvider'; import { ApiError } from '../api/client'; -import { isTerminal, type FailureReason, type ProjectModel, type ResumeSessionOptions, type Run } from '../api/types'; +import { isTerminal, type FailureReason, type ProjectModel, type ProvenanceActorRef, type ResumeSessionOptions, type Run, type RunProvenance } from '../api/types'; import { Button } from '../components/Button'; import { DiffView } from '../components/DiffView'; import { Markdown } from '../components/Markdown'; @@ -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'; @@ -407,7 +408,6 @@ export function RunDetailPage() { + +
+ +
- {serviceName ?? (run.service_id ? shortId(run.service_id) : t('runDetail.inspector.unavailable'))} - {runOriginLabel(run, t)} - {run.model_name || run.model_id || t('runDetail.inspector.notReported')} {run.permission_mode === 'approval' ? t('runDetail.inspector.askBeforeActions') : t('runDetail.permission.fullAccess')} {run.k8s_job_name || t('runDetail.inspector.notReported')}
@@ -830,6 +829,119 @@ function RunInspector({ ); } +function ProvenanceSection({ provenance }: { provenance?: RunProvenance }) { + const { t } = useTranslation(); + const executedFor = provenance?.executed_for; + const unavailable = t('runDetail.inspector.unavailable'); + + return ( + <> + +
+ + + + + + + + + + {provenance?.trigger.href ? ( + + {provenance.trigger.label} + ) : provenance?.trigger.label ?? unavailable} + + {provenance?.trigger.ref && {provenance.trigger.ref}} + + +
+
+ +
+ {executedFor?.project_label || unavailable} + {executedFor?.service_label || unavailable} + {executedFor?.repository || unavailable} + + + {executedFor?.model || unavailable} + {executedFor?.model && {t('runDetail.inspector.dispatchSnapshot')}} + + +
+
+ +
+ {provenance?.writeback_actor ? JC : null} + +
+
+ {provenance && ( +
+
+ {t('runDetail.inspector.technicalIdentity')} +
+ + {provenance.runtime_principal?.label || unavailable} + + + {provenancePrecisionLabel(provenance.precision, t)} + + + {provenance.attribution_source || unavailable} + +
+
+
+ )} + + ); +} + +function ActorDisplay({ + actor, + qualifier, + emptyLabel, + testId, +}: { + actor?: ProvenanceActorRef; + qualifier?: string; + emptyLabel?: string; + testId: string; +}) { + const { t } = useTranslation(); + return ( + + {actor?.label || emptyLabel || t('runDetail.inspector.notAttributed')} + {qualifier && {qualifier}} + + ); +} + +function provenancePrecisionLabel(precision: string, t: TFunction): string { + const labels: Record = { + exact: t('runDetail.inspector.precisionExact'), + linked_external: t('runDetail.inspector.precisionLinkedExternal'), + rule_owner: t('runDetail.inspector.precisionRuleOwner'), + unattributed: t('runDetail.inspector.precisionUnattributed'), + }; + return labels[precision] ?? precision; +} + function DiffSummary({ patch }: { patch: string }) { const files = patch.split('\n').flatMap((line) => { if (!line.startsWith('+++ b/')) return []; diff --git a/console/src/pages/models/ModelsCatalog.module.css b/console/src/pages/models/ModelsCatalog.module.css index c87ce74e..a61cf87c 100644 --- a/console/src/pages/models/ModelsCatalog.module.css +++ b/console/src/pages/models/ModelsCatalog.module.css @@ -41,6 +41,13 @@ /* Project scope: capabilities + the enable Switch + edit/delete controls. */ .modelRowProject { grid-template-columns: minmax(0, 1fr) minmax(6rem, auto) auto; } .modelControls { display: inline-flex; align-items: center; gap: 6px; } +.pricingIdentity, .pricingRates { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.pricingHistory { display: grid; gap: var(--space-3); margin-top: var(--space-5); padding-top: var(--space-4); border-top: 1px solid var(--color-border); } +.pricingHistory h3 { margin: 0; color: var(--color-text); font-size: var(--fs-sm); } +.pricingHistory ol { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; } +.pricingHistory li { display: grid; gap: 3px; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-bg-inset); } +.pricingHistory li strong { color: var(--color-text); font-size: var(--fs-caption); } +.pricingHistory li small { color: var(--color-text-faint); font-family: var(--font-mono); font-size: var(--fs-tiny); } .modelCopy { display: grid; min-width: 0; gap: 2px; } .modelCopy strong { overflow: hidden; font-size: var(--fs-sm); font-weight: var(--fw-medium); text-overflow: ellipsis; white-space: nowrap; } .modelCopy small { overflow: hidden; color: var(--color-text-muted); font-family: var(--font-mono); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; } @@ -144,6 +151,7 @@ .capabilities { grid-column: 1; grid-row: 2; justify-content: flex-start; } .modelRow > button, .modelRow .modelControls { grid-column: 2; grid-row: 2; justify-self: end; } .modelRowProject { grid-template-columns: minmax(0, 1fr) auto; } + .pricingIdentity, .pricingRates { grid-template-columns: 1fr; } .defaultModel { grid-template-columns: 1fr; } .defaultModel > :last-child { grid-column: 1; grid-row: auto; } } diff --git a/console/src/pages/models/ModelsCatalog.tsx b/console/src/pages/models/ModelsCatalog.tsx index 94e063d6..99658c84 100644 --- a/console/src/pages/models/ModelsCatalog.tsx +++ b/console/src/pages/models/ModelsCatalog.tsx @@ -30,6 +30,8 @@ import { ApiError, apiErrorCode } from '../../api/client'; import { useProjects, useSearchUsers, + useCreateModelPricingRevision, + useModelPricingRevisions, useSetModelAccountGrant, useSetModelGrant, } from '../../api/queries'; @@ -240,6 +242,7 @@ function ModelRow({ api, provider, model }: { api: ModelsAdminApi; provider: Mod const toast = useToast(); const [grantsOpen, setGrantsOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); + const [pricingOpen, setPricingOpen] = useState(false); if (api.scope.kind === 'cluster') { const projectGrantCount = (model.granted_project_ids ?? []).length; @@ -252,8 +255,12 @@ function ModelRow({ api, provider, model }: { api: ModelsAdminApi; provider: Mod {accountGrantCount}{t('cluster.models.accountGrants', { count: accountGrantCount })} {projectGrantCount}{t('cluster.models.projectGrants', { count: projectGrantCount })} - - +
+ + + +
+ setPricingOpen(false)} /> setGrantsOpen(false)} /> setEditOpen(false)} /> @@ -296,6 +303,146 @@ function ModelRow({ api, provider, model }: { api: ModelsAdminApi; provider: Mod ); } +export function PricingRevisionDialog({ + model, + open, + onClose, +}: { + model: ProviderModel; + open: boolean; + onClose: () => void; +}) { + const { t } = useTranslation(); + const revisions = useModelPricingRevisions(model.id, open); + const create = useCreateModelPricingRevision(model.id); + const [currency, setCurrency] = useState('USD'); + const [effectiveAt, setEffectiveAt] = useState(''); + const [rates, setRates] = useState({ input: '', output: '', cacheRead: '', cacheWrite: '' }); + const [error, setError] = useState(''); + + useEffect(() => { + if (!open) return; + setCurrency('USD'); + setEffectiveAt(dateTimeLocalValue(new Date())); + setRates({ input: '', output: '', cacheRead: '', cacheWrite: '' }); + setError(''); + }, [open]); + + const toMicros = (value: string): number | null => { + if (value.trim() === '') return null; + const parsed = Number(value); + const micros = Math.round(parsed * 1_000_000); + return Number.isFinite(parsed) && parsed >= 0 && Number.isSafeInteger(micros) + ? micros + : Number.NaN; + }; + const submit = (event: FormEvent) => { + event.preventDefault(); + const values = { + input_micros_per_million: toMicros(rates.input), + output_micros_per_million: toMicros(rates.output), + cache_read_micros_per_million: toMicros(rates.cacheRead), + cache_write_micros_per_million: toMicros(rates.cacheWrite), + }; + if (!/^[A-Z]{3,8}$/.test(currency.trim().toUpperCase()) || + !effectiveAt || + Object.values(values).some((value) => typeof value === 'number' && Number.isNaN(value)) || + Object.values(values).every((value) => value == null)) { + setError(t('usage.pricingValidation')); + return; + } + create.mutate({ + currency: currency.trim().toUpperCase(), + ...values, + effective_at: new Date(effectiveAt).toISOString(), + }, { + onSuccess: () => { + setRates({ input: '', output: '', cacheRead: '', cacheWrite: '' }); + setError(''); + }, + onError: (cause) => setError(errorMessage(cause, t('usage.pricingCreateError'))), + }); + }; + const formId = `pricing-${model.id}`; + + return ( + + + + + )} + > +

{t('usage.pricingImmutable')}

+
+
+ setCurrency(event.target.value.toUpperCase())} /> + setEffectiveAt(event.target.value)} /> +
+
+ setRates((current) => ({ ...current, input }))} /> + setRates((current) => ({ ...current, output }))} /> + setRates((current) => ({ ...current, cacheRead }))} /> + setRates((current) => ({ ...current, cacheWrite }))} /> +
+ {error &&

{error}

} +
+
+

{t('usage.pricingHistory')}

+ {revisions.isLoading ? : revisions.isError ? ( + void revisions.refetch()} title={t('usage.pricingLoadError')} /> + ) : (revisions.data ?? []).length === 0 ? ( +

{t('usage.noPricingRevisions')}

+ ) : ( +
    + {(revisions.data ?? []).map((revision) => ( +
  1. + {revision.currency} · {new Date(revision.effective_at).toLocaleString()} + + {[ + revision.input_micros_per_million == null ? null : `${t('usage.input')} ${revision.input_micros_per_million / 1_000_000}`, + revision.output_micros_per_million == null ? null : `${t('usage.output')} ${revision.output_micros_per_million / 1_000_000}`, + revision.cache_read_micros_per_million == null ? null : `${t('usage.cacheRead')} ${revision.cache_read_micros_per_million / 1_000_000}`, + revision.cache_write_micros_per_million == null ? null : `${t('usage.cacheWrite')} ${revision.cache_write_micros_per_million / 1_000_000}`, + ].filter(Boolean).join(' · ')} + +
  2. + ))} +
+ )} +
+
+ ); +} + +function dateTimeLocalValue(value: Date): string { + const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +} + +function PricingRate({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { + return ( + onChange(event.target.value)} + placeholder="0.00" + /> + ); +} + function CapabilityFields({ value, onChange }: { value: ModelCapabilities; onChange: (next: ModelCapabilities) => void }) { const { t } = useTranslation(); return ( diff --git a/console/src/pages/models/PricingRevisionDialog.test.tsx b/console/src/pages/models/PricingRevisionDialog.test.tsx new file mode 100644 index 00000000..05381a45 --- /dev/null +++ b/console/src/pages/models/PricingRevisionDialog.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { describe, expect, it, vi } from 'vitest'; +import { ApiProvider } from '../../api/ApiProvider'; +import type { ApiClient } from '../../api/client'; +import type { ProviderModel } from '../../api/types'; +import { PricingRevisionDialog } from './ModelsCatalog'; + +const model: ProviderModel = { + id: 'model-1', + provider_id: 'provider-1', + name: 'Reasoner', + model_id: 'reasoner-v1', + runtime_model_name: 'provider/reasoner-v1', + context_window: 128000, + capabilities: { reasoning: true, tools: true, image: false }, + source: 'catalog', +}; + +describe('PricingRevisionDialog', () => { + it('shows immutable history and converts currency-unit rates to micros', async () => { + const create = vi.fn(async (_modelId: string, input: Parameters[1]) => ({ + id: 'price-new', + model_id: 'model-1', + model_name: 'Reasoner', + ...input, + created_at: '2026-07-31T00:00:00Z', + })); + const client = { + listModelPricingRevisions: async () => [{ + id: 'price-old', + model_id: 'model-1', + model_name: 'Reasoner', + currency: 'USD', + input_micros_per_million: 1_000_000, + output_micros_per_million: 5_000_000, + cache_read_micros_per_million: null, + cache_write_micros_per_million: null, + effective_at: '2026-07-01T00:00:00Z', + created_at: '2026-07-01T00:00:00Z', + }], + createModelPricingRevision: create, + } as unknown as ApiClient; + render( + + + {}} /> + + , + ); + + expect(await screen.findByText(/Pricing revisions are immutable/)).toBeTruthy(); + expect(await screen.findByText(/Input 1 · Output 5/)).toBeTruthy(); + fireEvent.change(screen.getByLabelText('Output / 1M tokens'), { target: { value: '7.25' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add revision' })); + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)); + expect(create.mock.calls[0]?.[1].output_micros_per_million).toBe(7_250_000); + expect(create.mock.calls[0]?.[1].input_micros_per_million).toBeNull(); + expect(Math.abs(Date.parse(create.mock.calls[0]?.[1].effective_at ?? '') - Date.now())) + .toBeLessThan(2 * 60_000); + }); +}); diff --git a/console/src/project-workspace/ProjectAutomationsPanel.tsx b/console/src/project-workspace/ProjectAutomationsPanel.tsx index e6e1ad6c..c7cef2df 100644 --- a/console/src/project-workspace/ProjectAutomationsPanel.tsx +++ b/console/src/project-workspace/ProjectAutomationsPanel.tsx @@ -126,7 +126,11 @@ export function ProjectAutomationsPanel({ return (
  • - {automation.name} + + + {automation.name} + +

    {automation.run_kind === 'review' ? t('projectAutomations.review.rowSummary') : automation.prompt_template}

    diff --git a/console/src/project-workspace/ProjectWorkspaceShell.tsx b/console/src/project-workspace/ProjectWorkspaceShell.tsx index 2095452f..0cacdb41 100644 --- a/console/src/project-workspace/ProjectWorkspaceShell.tsx +++ b/console/src/project-workspace/ProjectWorkspaceShell.tsx @@ -159,7 +159,13 @@ export function ProjectWorkspaceShell({ data-active={activeTab === tab || undefined} onClick={() => selectTab(tab)} > - {tab === 'tasks' ? t('projectWorkspace.tabTasks') : tab === 'automations' ? t('projectWorkspace.tabAutomations') : t('projectWorkspace.tabServiceSettings')} + {tab === 'tasks' + ? t('projectWorkspace.tabTasks') + : tab === 'automations' + ? t('projectWorkspace.tabAutomations') + : tab === 'usage' + ? t('usage.title') + : t('projectWorkspace.tabServiceSettings')} ))}
    diff --git a/console/src/project-workspace/location.test.ts b/console/src/project-workspace/location.test.ts index 77adcadf..f8b2b035 100644 --- a/console/src/project-workspace/location.test.ts +++ b/console/src/project-workspace/location.test.ts @@ -25,6 +25,12 @@ describe('resolveWorkspaceLocation', () => { ).toEqual({ serviceId: 'svc-web', tab: 'automations', needsNormalization: false }); }); + it('keeps Usage visible to a member or viewer', () => { + expect( + resolveWorkspaceLocation(services, new URLSearchParams('service=svc-web&tab=usage'), false), + ).toEqual({ serviceId: 'svc-web', tab: 'usage', needsNormalization: false }); + }); + it('normalizes missing or stale route state to the default service and Tasks', () => { expect(resolveWorkspaceLocation(services, new URLSearchParams('service=gone&tab=unknown'), true)).toEqual({ serviceId: 'svc-default', diff --git a/console/src/project-workspace/location.ts b/console/src/project-workspace/location.ts index 11385526..1d39b74c 100644 --- a/console/src/project-workspace/location.ts +++ b/console/src/project-workspace/location.ts @@ -1,6 +1,6 @@ import type { Service } from '../api/types'; -export const WORKSPACE_TABS = ['tasks', 'automations', 'settings'] as const; +export const WORKSPACE_TABS = ['tasks', 'automations', 'usage', 'settings'] as const; export type WorkspaceTab = (typeof WORKSPACE_TABS)[number]; function defaultServiceId(services: readonly Service[]): string { @@ -23,7 +23,7 @@ export function resolveWorkspaceLocation( const requestedTab = search.get('tab'); const tab: WorkspaceTab = - requestedTab === 'automations' || (requestedTab === 'settings' && canManage) + requestedTab === 'automations' || requestedTab === 'usage' || (requestedTab === 'settings' && canManage) ? requestedTab : 'tasks'; diff --git a/design/README.md b/design/README.md index 2a8ef9a6..495498fd 100644 --- a/design/README.md +++ b/design/README.md @@ -37,6 +37,7 @@ icons, and small prototype-only interactions live under `assets/`. | `kanban-link-flow-connect.html` | 项目 Kanban 设置(候选)未连接态:jtype 集成登录卡 + 看板设置锁定 | | `kanban-link-flow-pick.html` | 项目 Kanban 设置(候选)已连接态:服务与工作区/看板/列全部点选,单一当前看板 | | `kanban-link-flow-expired.html` | 项目 Kanban 设置(候选)凭据过期态:看板设置收回,重新登录后恢复 | +| `kanban-agent-executions.html` | Service Kanban 执行策略、内嵌 jtype Card detail supplement 与 accepted/blocked/succeeded/error receipt 状态 | | `device-list.html` | Cloud console device list, populated and empty states | | `device-welcome.html` | Cloud console device detail: new-session composer and session list | | `device-session.html` | Cloud console remote session: live and device-offline states | diff --git a/design/assets/automation-executions.css b/design/assets/automation-executions.css new file mode 100644 index 00000000..45761586 --- /dev/null +++ b/design/assets/automation-executions.css @@ -0,0 +1,46 @@ +.ae-page { min-height: 100vh; background: var(--bg); color: var(--ink); } +.ae-shell { width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding: 28px 0 56px; } +.ae-back { color: var(--muted); font-size: 13px; text-decoration: none; } +.ae-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin: 24px 0 22px; } +.ae-kicker { color: var(--accent); font: 700 11px/1.2 var(--mono); letter-spacing: .12em; text-transform: uppercase; } +.ae-head h1 { margin: 7px 0 5px; font-size: clamp(28px, 4vw, 42px); letter-spacing: -.035em; } +.ae-head p { margin: 0; color: var(--muted); } +.ae-actions { display: flex; gap: 9px; } +.ae-button { min-height: 38px; padding: 0 15px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); color: var(--ink); font-weight: 650; } +.ae-button.primary { border-color: var(--accent); background: var(--accent); color: white; } +.ae-layout { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(300px, .8fr); border: 1px solid var(--border); border-radius: 18px; overflow: hidden; background: var(--panel); box-shadow: var(--shadow); } +.ae-ledger { min-width: 0; } +.ae-toolbar { display: flex; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--border); } +.ae-filters { display: flex; gap: 4px; } +.ae-filter { padding: 6px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); } +.ae-filter.active { background: var(--wash); color: var(--ink); } +.ae-list { list-style: none; margin: 0; padding: 0; } +.ae-row { display: grid; grid-template-columns: 104px minmax(0, 1fr) auto; gap: 15px; padding: 16px 18px; border-bottom: 1px solid var(--border); cursor: pointer; } +.ae-row.selected { background: color-mix(in srgb, var(--accent) 7%, var(--panel)); box-shadow: inset 3px 0 var(--accent); } +.ae-state { align-self: start; width: max-content; padding: 3px 8px; border: 1px solid var(--border); border-radius: 999px; font: 700 11px/1.4 var(--mono); } +.ae-state.blocked { border-color: #d97706; color: #9a4e05; } +.ae-state.running { border-color: var(--accent); color: var(--accent); } +.ae-row strong, .ae-row small { display: block; } +.ae-row small { margin-top: 5px; color: var(--muted); line-height: 1.45; } +.ae-time { color: var(--muted); font: 12px/1.4 var(--mono); white-space: nowrap; } +.ae-inspector { border-left: 1px solid var(--border); background: var(--wash); } +.ae-inspector header, .ae-inspector section { padding: 18px 20px; border-bottom: 1px solid var(--border); } +.ae-inspector h2 { margin: 4px 0 0; font-size: 20px; } +.ae-inspector h3 { margin: 0 0 12px; color: var(--muted); font: 700 11px/1.2 var(--mono); letter-spacing: .1em; text-transform: uppercase; } +.ae-inspector dl { display: grid; grid-template-columns: 98px minmax(0, 1fr); gap: 9px 12px; margin: 0; font-size: 13px; } +.ae-inspector dt { color: var(--muted); } +.ae-inspector dd { margin: 0; overflow-wrap: anywhere; } +.ae-repair { margin-top: 13px; padding: 10px 12px; border-left: 2px solid #d97706; background: #fff8ea; color: #754006; font-size: 12px; line-height: 1.5; } +@media (max-width: 820px) { + .ae-shell { width: min(100% - 24px, 680px); } + .ae-head { align-items: start; flex-direction: column; } + .ae-layout { grid-template-columns: 1fr; } + .ae-inspector { border-top: 1px solid var(--border); border-left: 0; } +} +@media (max-width: 560px) { + .ae-toolbar { align-items: start; flex-direction: column; } + .ae-row { grid-template-columns: 1fr auto; } + .ae-row > div:nth-child(2) { grid-column: 1 / -1; grid-row: 2; } + .ae-state { grid-row: 1; } + .ae-time { grid-column: 2; grid-row: 1; } +} diff --git a/design/assets/kanban-agent-executions.css b/design/assets/kanban-agent-executions.css new file mode 100644 index 00000000..6895def2 --- /dev/null +++ b/design/assets/kanban-agent-executions.css @@ -0,0 +1,399 @@ +/* Feature Loop 1 — policy, embedded jtype board, and Card execution supplement. + Uses the shared prototype tokens; sample data lives in the page only. */ + +.ka-main { + position: relative; + min-height: 0; + height: calc(100dvh - 3.25rem); + overflow: hidden; + padding: 1rem 1.25rem 1.25rem; + background: var(--surface); +} + +.ka-heading, +.ka-state-nav, +.ka-policy, +.ka-board-toolbar, +.ka-column > header, +.ka-card footer, +.ka-dialog-head, +.ka-section-head, +.ka-executions > header, +.ka-executions > header > span, +.ka-history summary { + display: flex; + align-items: center; +} + +.ka-heading { + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.ka-heading h1 { + margin-top: 0.125rem; + font-size: 1.35rem; + letter-spacing: -0.035em; +} + +.ka-state-nav { + position: relative; + z-index: 7; + gap: 0.35rem; + margin-bottom: 0.75rem; + color: var(--text-faint); + font-size: 0.6875rem; +} + +.ka-state-nav > span { margin-right: 0.25rem; } +.ka-state-nav button, +.ka-view-tabs button, +.ka-compact-control { + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + padding: 0.25rem 0.55rem; + color: var(--text-dim); + cursor: pointer; +} + +.ka-state-nav button:hover, +.ka-view-tabs button:hover, +.ka-compact-control:hover { background: var(--surface-hover); color: var(--text); } +.ka-state-nav button[aria-pressed="true"] { + border-color: var(--border-strong); + background: var(--surface-raised); + box-shadow: var(--shadow); + color: var(--text); + font-weight: 650; +} + +.ka-policy { + position: relative; + z-index: 1; + gap: 1rem; + min-height: 4.25rem; + margin-bottom: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 0.75rem 0.875rem; + background: var(--surface-raised); + box-shadow: var(--shadow); +} + +.ka-policy-lead { + display: flex; + align-items: center; + min-width: 17rem; + gap: 0.7rem; +} + +.ka-policy-lead > span:last-child, +.ka-receipt > span:last-child { display: grid; gap: 0.1rem; } +.ka-policy-lead strong { font-size: 0.75rem; } +.ka-policy-lead small, +.ka-receipt small { color: var(--text-muted); font-size: 0.625rem; } +.ka-policy-icon { + display: grid; + width: 2rem; + height: 2rem; + flex: none; + place-items: center; + border-radius: var(--radius-md); + background: var(--accent-soft); + color: var(--accent); +} + +.ka-policy-facts { + display: grid; + flex: 1; + grid-template-columns: repeat(4, minmax(6rem, 1fr)); + gap: 0.5rem 1rem; +} + +.ka-policy-facts div { display: grid; min-width: 0; gap: 0.05rem; } +.ka-policy-facts dt, +.ka-execution-meta dt { color: var(--text-faint); font-size: 0.625rem; } +.ka-policy-facts dd { + overflow: hidden; + margin: 0; + color: var(--text-dim); + font-size: 0.6875rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ka-policy-status { flex: none; gap: 0.3rem; } +.ka-policy[data-ka-policy="blocked"] .ka-policy-icon { background: var(--warning-bg); color: var(--warning); } + +.ka-board-shell { + position: relative; + height: calc(100% - 9.2rem); + min-height: 22rem; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface-soft); +} + +.ka-board-toolbar { + min-height: 2.8rem; + gap: 0.5rem; + border-bottom: 1px solid var(--border-soft); + padding: 0.45rem 0.75rem; + background: color-mix(in srgb, var(--surface) 86%, transparent); +} + +.ka-view-tabs { display: inline-flex; border-radius: var(--radius-md); background: var(--surface-inset); padding: 0.15rem; } +.ka-view-tabs button { font-size: 0.625rem; } +.ka-view-tabs button[aria-pressed="true"] { background: var(--surface); box-shadow: var(--shadow); color: var(--text); font-weight: 650; } +.ka-toolbar-spacer { flex: 1; } +.ka-compact-control { border-color: var(--border); font-size: 0.625rem; } +.ka-search { + display: flex; + align-items: center; + width: 11rem; + gap: 0.35rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.3rem 0.5rem; + background: var(--surface); + color: var(--text-faint); +} +.ka-search input { width: 100%; border: 0; outline: 0; background: transparent; font-size: 0.625rem; } + +.ka-board { + display: grid; + height: calc(100% - 2.8rem); + grid-template-columns: repeat(4, minmax(12rem, 1fr)); + gap: 0.65rem; + overflow: auto; + padding: 0.65rem; +} + +.ka-column { + min-width: 12rem; + border: 1px solid var(--border-soft); + border-radius: var(--radius-md); + padding: 0.45rem; + background: color-mix(in srgb, var(--surface-inset) 30%, transparent); +} + +.ka-column[data-trigger-column] { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + background: color-mix(in srgb, var(--accent-soft) 30%, var(--surface-soft)); +} + +.ka-column > header { gap: 0.4rem; min-height: 1.8rem; padding: 0 0.25rem 0.35rem; color: var(--text-muted); font-size: 0.625rem; } +.ka-column > header strong { color: var(--text-dim); font-size: 0.6875rem; } +.ka-column > header em { margin-left: auto; color: var(--accent); font-size: 0.5625rem; font-style: normal; font-weight: 650; } +.ka-column-dot { width: 0.45rem; height: 0.45rem; border-radius: 99px; background: var(--text-faint); } +.ka-column-dot[data-tone="accent"] { background: var(--accent); } +.ka-column-dot[data-tone="info"] { background: var(--info); } +.ka-column-dot[data-tone="success"] { background: var(--success); } + +.ka-card { + display: grid; + gap: 0.4rem; + margin-bottom: 0.45rem; + border: 1px solid var(--border-soft); + border-radius: var(--radius-md); + padding: 0.65rem; + background: var(--surface); + box-shadow: var(--shadow); +} +.ka-card[data-selected] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.ka-card-ticket { color: var(--text-faint); font-family: var(--font-mono); font-size: 0.5625rem; } +.ka-card h2 { font-size: 0.6875rem; line-height: 1.35; } +.ka-card p { color: var(--text-muted); font-size: 0.5625rem; line-height: 1.45; } +.ka-card footer { justify-content: space-between; color: var(--text-faint); font-size: 0.5625rem; } +.ka-priority { color: var(--danger); } +.ka-done { color: var(--success); } +.ka-polling { + position: absolute; + right: 0.55rem; + bottom: 0.45rem; + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid var(--border-soft); + border-radius: 99px; + padding: 0.15rem 0.45rem; + background: color-mix(in srgb, var(--surface) 90%, transparent); + color: var(--text-faint); + font-size: 0.5625rem; +} +.ka-polling span { width: 0.35rem; height: 0.35rem; border-radius: 99px; background: var(--text-faint); } + +.ka-dialog-backdrop { + position: absolute; + z-index: 5; + inset: 0; + background: color-mix(in srgb, var(--text) 18%, transparent); + backdrop-filter: blur(1px); +} + +.ka-card-dialog { + position: absolute; + z-index: 6; + inset: 6.5rem 2.25rem 1.8rem; + display: flex; + min-width: 0; + flex-direction: column; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--text) 9%, var(--border)); + border-radius: var(--radius-xl); + background: var(--surface); + box-shadow: var(--shadow-float); +} + +.ka-dialog-head { + min-height: 2.9rem; + justify-content: space-between; + border-bottom: 1px solid var(--border-soft); + padding: 0.5rem 0.8rem 0.5rem 1.4rem; +} +.ka-dialog-grid { display: grid; min-height: 0; flex: 1; grid-template-columns: minmax(0, 1fr) 20rem; } +.ka-card-content { min-width: 0; overflow: auto; padding: 1.35rem 2rem 2.5rem; } +.ka-title-input { + width: 100%; + margin-bottom: 1.35rem; + border: 0; + outline: 0; + background: transparent; + color: var(--text); + font-size: 1.35rem; + font-weight: 700; + letter-spacing: -0.035em; +} +.ka-section-head { gap: 0.65rem; margin-bottom: 0.45rem; } +.ka-section-head h2, +.ka-native-section h2, +.ka-properties h2, +.ka-executions h2 { font-size: 0.6875rem; } +.ka-section-head h2 { margin-right: auto; } +.ka-section-head span { color: var(--text-faint); font-size: 0.5625rem; } +.ka-card-content textarea { + width: 100%; + min-height: 10rem; + resize: vertical; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.8rem; + background: var(--surface-soft); + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 0.625rem; + line-height: 1.6; +} +.ka-native-section { display: grid; gap: 0.5rem; margin-top: 1.35rem; border-top: 1px solid var(--border-soft); padding-top: 1rem; color: var(--text-dim); font-size: 0.625rem; } +.ka-native-section h2 span { margin-left: 0.35rem; color: var(--text-faint); font-weight: 500; } +.ka-native-section label { display: flex; align-items: center; gap: 0.45rem; } + +.ka-inspector { + min-width: 0; + overflow: auto; + border-left: 1px solid var(--border); + background: color-mix(in srgb, var(--surface-soft) 75%, var(--surface)); +} +.ka-properties { padding: 1.15rem 1.15rem 1rem; } +.ka-properties dl { display: grid; gap: 0.15rem; margin-top: 0.65rem; } +.ka-properties dl > div { display: grid; min-height: 2.1rem; grid-template-columns: 5.3rem minmax(0, 1fr); align-items: center; gap: 0.4rem; } +.ka-properties dt { color: var(--text-muted); font-size: 0.625rem; } +.ka-properties dd { min-width: 0; margin: 0; } +.ka-properties button { width: 100%; overflow: hidden; border: 0; border-radius: var(--radius-sm); background: transparent; padding: 0.3rem 0.4rem; color: var(--text-dim); text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } +.ka-properties button:hover { background: var(--surface); } + +.ka-executions { + border-top: 1px solid var(--border); + padding: 1rem 1.15rem 1.35rem; +} +.ka-executions > header { justify-content: space-between; gap: 0.5rem; margin-bottom: 0.75rem; } +.ka-executions > header > span { gap: 0.4rem; } +.ka-executions > header > span .icon { color: var(--accent); } +.ka-executions > header small { color: var(--text-faint); font-size: 0.5625rem; } +.ka-execution-state { display: grid; gap: 0.65rem; } +.ka-receipt { display: flex; align-items: center; gap: 0.6rem; border: 1px solid var(--border); border-radius: var(--radius-md); padding: 0.65rem; } +.ka-receipt[data-tone="info"] { border-color: color-mix(in srgb, var(--info) 35%, var(--border)); background: var(--info-bg); } +.ka-receipt[data-tone="warning"] { border-color: color-mix(in srgb, var(--warning) 38%, var(--border)); background: var(--warning-bg); } +.ka-receipt[data-tone="success"] { border-color: color-mix(in srgb, var(--success) 35%, var(--border)); background: var(--success-bg); } +.ka-receipt[data-tone="danger"] { border-color: color-mix(in srgb, var(--danger) 38%, var(--border)); background: var(--danger-bg); } +.ka-receipt strong { font-size: 0.6875rem; } +.ka-receipt-icon { display: grid; width: 1.65rem; height: 1.65rem; flex: none; place-items: center; border-radius: 99px; background: color-mix(in srgb, var(--surface) 72%, transparent); color: currentColor; font-weight: 700; } +.ka-spinner { width: 0.75rem; height: 0.75rem; border: 2px solid color-mix(in srgb, var(--info) 25%, transparent); border-top-color: var(--info); border-radius: 99px; animation: ka-spin 900ms linear infinite; } +@keyframes ka-spin { to { transform: rotate(360deg); } } +.ka-receipt-copy { color: var(--text-muted); font-size: 0.625rem; line-height: 1.55; } +.ka-run-link { width: 100%; min-width: 0; justify-content: flex-start; overflow: hidden; font-size: 0.625rem; } +.ka-run-link .mono { margin-left: auto; overflow: hidden; color: var(--text-faint); text-overflow: ellipsis; } +.ka-next-action { display: grid; gap: 0.15rem; border-left: 2px solid var(--warning); padding-left: 0.55rem; color: var(--text-muted); font-size: 0.625rem; } +.ka-next-action strong { color: var(--warning); font-size: 0.5625rem; text-transform: uppercase; letter-spacing: 0.06em; } +.ka-execution-meta { display: grid; gap: 0.3rem; } +.ka-execution-meta > div { display: flex; justify-content: space-between; gap: 0.5rem; } +.ka-execution-meta dd { margin: 0; color: var(--text-dim); font-size: 0.625rem; text-align: right; } +.ka-execution-meta dd span { display: block; color: var(--text-faint); font-size: 0.5625rem; } +.ka-history { margin-top: 0.8rem; border-top: 1px solid var(--border); padding-top: 0.65rem; } +.ka-history summary { justify-content: space-between; color: var(--text-muted); font-size: 0.625rem; cursor: pointer; } +.ka-history summary span:last-child { border-radius: 99px; background: var(--surface-inset); padding: 0.05rem 0.35rem; } +.ka-history a { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.45rem; margin-top: 0.5rem; border-radius: var(--radius-md); padding: 0.45rem; font-size: 0.5625rem; } +.ka-history a:hover { background: var(--surface); } +.ka-history a > span:nth-child(2) { display: grid; } +.ka-history small { color: var(--text-faint); } +.ka-history-dot { width: 0.4rem; height: 0.4rem; border-radius: 99px; background: var(--danger); } + +@media (max-width: 1000px) { + .ka-policy-facts { grid-template-columns: repeat(2, minmax(6rem, 1fr)); } + .ka-policy-facts div:nth-child(n + 3) { display: none; } + .ka-card-dialog { inset-inline: 1rem; } + .ka-dialog-grid { grid-template-columns: minmax(0, 1fr) 18rem; } +} + +@media (max-width: 720px) { + .ka-main { + height: auto; + min-height: calc(100dvh - 3.25rem); + overflow: auto; + padding: 0.75rem; + } + .ka-heading { position: relative; } + .ka-state-nav { + position: fixed; + z-index: 8; + top: 3.5rem; + right: 0.35rem; + left: 0.35rem; + overflow-x: auto; + margin: 0; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.35rem; + background: color-mix(in srgb, var(--surface) 96%, transparent); + box-shadow: var(--shadow); + white-space: nowrap; + } + .ka-policy { align-items: flex-start; flex-wrap: wrap; } + .ka-policy-lead { width: 100%; min-width: 0; } + .ka-policy-facts { width: 100%; flex-basis: 100%; grid-template-columns: 1fr 1fr; } + .ka-policy-facts div { display: grid !important; } + .ka-policy-status { position: absolute; top: 0.75rem; right: 0.75rem; } + .ka-policy-lead > span:last-child { padding-right: 6rem; } + .ka-board-shell { height: 28rem; } + .ka-board { grid-template-columns: repeat(4, 15rem); } + .ka-search { width: 8.5rem; } + .ka-dialog-backdrop { position: fixed; } + .ka-card-dialog { + position: fixed; + inset: 6.15rem 0.35rem 0.35rem; + border-radius: var(--radius-lg); + } + .ka-dialog-grid { display: block; overflow: auto; } + .ka-card-content { overflow: visible; padding: 1.1rem 1rem 1.5rem; } + .ka-title-input { font-size: 1.1rem; } + .ka-inspector { overflow: visible; border-top: 1px solid var(--border); border-left: 0; } + .ka-executions { padding-bottom: 2rem; } +} + +@media (prefers-reduced-motion: reduce) { + .ka-spinner { animation: none; } +} diff --git a/design/assets/kanban-agent-executions.js b/design/assets/kanban-agent-executions.js new file mode 100644 index 00000000..e1f837f5 --- /dev/null +++ b/design/assets/kanban-agent-executions.js @@ -0,0 +1,40 @@ +(() => { + const buttons = [...document.querySelectorAll("[data-ka-state-button]")]; + const panels = [...document.querySelectorAll("[data-ka-state-panel]")]; + const policy = document.querySelector("[data-ka-policy]"); + const policyStatus = policy?.querySelector(".ka-policy-status"); + const policyTitle = policy?.querySelector("#policyTitle"); + const policyHint = policy?.querySelector(".ka-policy-lead small"); + + const setState = (state) => { + buttons.forEach((button) => { + button.setAttribute("aria-pressed", String(button.dataset.kaStateButton === state)); + }); + panels.forEach((panel) => { + panel.hidden = panel.dataset.kaStatePanel !== state; + }); + + const blocked = state === "blocked"; + if (policy) policy.dataset.kaPolicy = blocked ? "blocked" : "ready"; + if (policyTitle) { + policyTitle.textContent = blocked + ? "拖入 Agent queue 会被记录,但当前无法执行" + : "拖入 Agent queue 即请求 Cloud Agent 执行"; + } + if (policyHint) { + policyHint.textContent = blocked + ? "缺少可用模型;同一次请求会在修复后继续,不需要重新拖卡。" + : "每次有效进入只受理一次;列内编辑和重扫不会重复运行。"; + } + if (policyStatus) { + policyStatus.dataset.tone = blocked ? "warning" : "success"; + policyStatus.innerHTML = blocked + ? '模型未配置' + : '可以执行'; + } + }; + + buttons.forEach((button) => { + button.addEventListener("click", () => setState(button.dataset.kaStateButton)); + }); +})(); diff --git a/design/assets/run-provenance.css b/design/assets/run-provenance.css new file mode 100644 index 00000000..d4d0a9ad --- /dev/null +++ b/design/assets/run-provenance.css @@ -0,0 +1,39 @@ +.rp-shell { min-height: 100dvh; background: var(--surface); } +.rp-main { height: calc(100dvh - 3.25rem); overflow: hidden; } +.rp-head { display: grid; gap: .7rem; padding: 1rem 1.5rem; border-bottom: 1px solid var(--border); } +.rp-head > a { width: max-content; color: var(--text-muted); font-size: .7rem; } +.rp-head > div, .rp-agent header, .rp-bot > div { display: flex; align-items: center; gap: .7rem; } +.rp-head > div { justify-content: space-between; } +.rp-head h1 { margin: .15rem 0; font-size: 1.15rem; letter-spacing: -.03em; } +.rp-head p, .rp-message small, .rp-agent small, .rp-inspector small { margin: 0; color: var(--text-faint); font-size: .65rem; } +.rp-layout { display: grid; height: calc(100% - 7.8rem); grid-template-columns: minmax(0, 46rem) 17rem; justify-content: center; gap: 2.5rem; padding: 0 2rem; } +.rp-conversation { display: grid; align-content: start; gap: 1.5rem; padding: 2rem 0; overflow-y: auto; } +.rp-date { display: flex; align-items: center; gap: .7rem; color: var(--text-faint); font-size: .6rem; text-transform: uppercase; } +.rp-date::before, .rp-date::after { height: 1px; flex: 1; background: var(--border); content: ""; } +.rp-message, .rp-agent { border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface-raised); box-shadow: var(--shadow); } +.rp-message { display: flex; gap: .8rem; padding: 1rem; } +.rp-message p, .rp-agent p { margin: .35rem 0 0; color: var(--text-dim); font-size: .8rem; line-height: 1.65; } +.rp-agent { padding: 1rem; } +.rp-agent header small { margin-left: auto; } +.rp-tool { display: grid; grid-template-columns: auto 1fr auto; gap: .5rem; margin-top: .8rem; border-radius: var(--radius-md); padding: .55rem .7rem; background: var(--surface-inset); color: var(--text-muted); font-size: .68rem; } +.rp-tool span, .rp-tool strong { color: var(--success); } +.rp-inspector { min-height: 0; padding: 2rem 0; border-left: 1px solid var(--border); overflow-y: auto; } +.rp-inspector section, .rp-inspector details { padding: 0 0 1.25rem 1.25rem; } +.rp-inspector section + section, .rp-inspector details { padding-top: 1.25rem; border-top: 1px solid var(--border); } +.rp-inspector h2 { margin: 0 0 .85rem; color: var(--text-faint); font-family: var(--font-mono); font-size: .62rem; letter-spacing: .08em; text-transform: uppercase; } +.rp-inspector dl { display: grid; gap: .8rem; margin: 0; } +.rp-inspector dl div { display: grid; grid-template-columns: 5rem minmax(0,1fr); gap: .5rem; } +.rp-inspector dt { color: var(--text-faint); font-size: .63rem; } +.rp-inspector dd { display: grid; min-width: 0; gap: .1rem; margin: 0; color: var(--text-dim); font-size: .67rem; overflow-wrap: anywhere; } +.rp-inspector dd strong { color: var(--text); } +.rp-bot > div { align-items: flex-start; } +.rp-bot-mark { display: grid; width: 2rem; height: 2rem; place-items: center; border-radius: .55rem; background: var(--accent-soft); color: var(--accent); font: 700 .65rem var(--font-mono); } +.rp-bot p { display: grid; gap: .15rem; margin: 0; font-size: .7rem; } +.rp-inspector summary { color: var(--text-muted); cursor: pointer; font-size: .68rem; } +.rp-inspector details dl { margin-top: .85rem; } +@media (max-width: 760px) { + .rp-main { height: auto; overflow: visible; } + .rp-layout { height: auto; grid-template-columns: minmax(0,1fr); padding: 0 1.2rem; } + .rp-inspector { border-top: 1px solid var(--border); border-left: 0; } + .rp-inspector section, .rp-inspector details { padding-left: 0; } +} diff --git a/design/assets/usage-ledger.css b/design/assets/usage-ledger.css new file mode 100644 index 00000000..afb18dba --- /dev/null +++ b/design/assets/usage-ledger.css @@ -0,0 +1,203 @@ +/* Feature Loop 4 — page-scoped Usage prototypes. Shared product tokens live in + prototype.css; every number in the HTML is an explicitly labelled fixture. */ + +.usage-shell { min-height: 100dvh; background: var(--bg); } +.usage-main { min-height: calc(100dvh - 3.25rem); background: var(--surface); } +.usage-page { width: min(72rem, calc(100% - 3rem)); margin: 0 auto; padding: 2rem 0 4rem; } +.usage-head, +.usage-head-actions, +.usage-range, +.usage-stat-top, +.usage-money-head, +.usage-capture-row, +.usage-table-head, +.usage-table-row, +.usage-source, +.usage-run-row, +.usage-card-summary { + display: flex; + align-items: center; +} +.usage-head { justify-content: space-between; gap: 2rem; margin-bottom: 1.5rem; } +.usage-head h1 { margin: .2rem 0 .3rem; font-size: 1.75rem; letter-spacing: -.04em; } +.usage-head p { max-width: 44rem; color: var(--text-dim); } +.usage-head-actions { gap: .6rem; } +.usage-range { gap: .2rem; border: 1px solid var(--border); border-radius: var(--radius-md); padding: .15rem; background: var(--surface-raised); } +.usage-range button { border: 0; border-radius: var(--radius-sm); padding: .35rem .65rem; background: transparent; color: var(--text-muted); cursor: pointer; font-size: .7rem; } +.usage-range button[aria-pressed="true"] { background: var(--surface-inset); color: var(--text); font-weight: 650; } +.usage-meta-note { border: 1px solid var(--border); border-radius: var(--radius-md); padding: .65rem .8rem; background: var(--surface-soft); color: var(--text-muted); font-size: .68rem; line-height: 1.55; } + +.usage-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .75rem; } +.usage-stat, +.usage-money, +.usage-chart, +.usage-table, +.usage-capture, +.usage-unavailable { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface-raised); + box-shadow: var(--shadow); +} +.usage-stat { min-height: 7rem; padding: .85rem; } +.usage-stat-top { justify-content: space-between; gap: .5rem; color: var(--text-faint); font-size: .62rem; text-transform: uppercase; letter-spacing: .06em; } +.usage-stat strong { display: block; margin-top: .85rem; font: 650 1.35rem/1 var(--font-mono); letter-spacing: -.04em; } +.usage-stat small { display: block; margin-top: .45rem; color: var(--text-muted); font-size: .62rem; } +.usage-status { display: inline-flex; width: max-content; align-items: center; gap: .3rem; border-radius: 99px; padding: .16rem .42rem; font: 650 .56rem/1.4 var(--font-mono); text-transform: uppercase; } +.usage-status::before { width: .36rem; height: .36rem; border-radius: 99px; background: currentColor; content: ""; } +.usage-status[data-tone="success"] { background: var(--success-bg); color: var(--success); } +.usage-status[data-tone="warning"] { background: var(--warning-bg); color: var(--warning); } +.usage-status[data-tone="danger"] { background: var(--danger-bg); color: var(--danger); } +.usage-status[data-tone="muted"] { background: var(--surface-inset); color: var(--text-muted); } + +.usage-two { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(18rem, .75fr); gap: .75rem; margin-top: .75rem; } +.usage-chart { min-height: 17rem; padding: 1rem; } +.usage-section-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; } +.usage-section-head h2 { font-size: .82rem; } +.usage-section-head p { margin-top: .2rem; color: var(--text-faint); font-size: .62rem; } +.usage-legend { display: flex; gap: .7rem; color: var(--text-muted); font-size: .6rem; } +.usage-legend span { display: inline-flex; align-items: center; gap: .3rem; } +.usage-legend i { width: .5rem; height: .5rem; border-radius: 2px; background: var(--accent); } +.usage-legend span:last-child i { background: color-mix(in srgb, var(--accent) 32%, var(--surface-inset)); } +.usage-bars { display: grid; height: 11rem; grid-template-columns: repeat(14, minmax(.45rem, 1fr)); align-items: end; gap: .42rem; border-bottom: 1px solid var(--border); padding: .6rem .2rem 0; } +.usage-bar { display: grid; height: 100%; align-items: end; gap: 2px; } +.usage-bar i { display: block; min-height: 2px; border-radius: 2px 2px 0 0; background: var(--accent); } +.usage-bar i:last-child { background: color-mix(in srgb, var(--accent) 32%, var(--surface-inset)); } +.usage-axis { display: flex; justify-content: space-between; padding-top: .45rem; color: var(--text-faint); font: .55rem var(--font-mono); } +.usage-capture { padding: 1rem; } +.usage-capture-row { justify-content: space-between; gap: .7rem; padding: .65rem 0; border-top: 1px solid var(--border-soft); color: var(--text-dim); font-size: .68rem; } +.usage-capture-row:first-of-type { border-top: 0; } +.usage-capture-row span:first-child { display: inline-flex; align-items: center; gap: .4rem; } +.usage-capture-row i { width: .42rem; height: .42rem; border-radius: 99px; background: currentColor; } +.usage-capture-row[data-tone="success"] { color: var(--success); } +.usage-capture-row[data-tone="warning"] { color: var(--warning); } +.usage-capture-row[data-tone="danger"] { color: var(--danger); } +.usage-capture-row[data-tone="muted"] { color: var(--text-muted); } +.usage-capture-row strong { color: var(--text); font: 650 .7rem var(--font-mono); } + +.usage-money-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .75rem; margin-top: .75rem; } +.usage-money { min-height: 8.6rem; padding: .9rem; } +.usage-money-head { justify-content: space-between; gap: .5rem; color: var(--text-muted); font-size: .66rem; } +.usage-money-value { margin-top: .8rem; font: 650 1.15rem/1 var(--font-mono); } +.usage-money-copy { margin-top: .55rem; color: var(--text-faint); font-size: .61rem; line-height: 1.5; } +.usage-money details { margin-top: .7rem; color: var(--text-muted); font-size: .6rem; } +.usage-money summary { cursor: pointer; } + +.usage-table { margin-top: .75rem; overflow: hidden; } +.usage-table-head, +.usage-table-row { display: grid; grid-template-columns: minmax(12rem, 1.7fr) repeat(4, minmax(7rem, .8fr)); gap: .7rem; align-items: center; padding: .75rem 1rem; } +.usage-table-head { background: var(--surface-soft); color: var(--text-faint); font-size: .58rem; letter-spacing: .05em; text-transform: uppercase; } +.usage-table-row { border-top: 1px solid var(--border-soft); color: var(--text-dim); font: .66rem var(--font-mono); } +.usage-table-row:hover { background: var(--surface-soft); } +.usage-source { min-width: 0; gap: .55rem; font-family: var(--font-sans); } +.usage-source-mark { display: grid; width: 1.8rem; height: 1.8rem; flex: none; place-items: center; border-radius: var(--radius-md); background: var(--accent-soft); color: var(--accent); font: 700 .58rem var(--font-mono); } +.usage-source-copy { display: grid; min-width: 0; gap: .08rem; } +.usage-source-copy strong { overflow: hidden; color: var(--text); font-size: .68rem; text-overflow: ellipsis; white-space: nowrap; } +.usage-source-copy small { color: var(--text-faint); font-size: .58rem; } +.usage-table-row a { color: var(--text); } +.usage-table-row .uncosted { color: var(--warning); } + +/* Run inspector */ +.usage-run-main { height: calc(100dvh - 3.25rem); overflow: hidden; background: var(--surface); } +.usage-run-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 5rem; border-bottom: 1px solid var(--border); padding: .85rem 1.4rem; } +.usage-run-head h1 { margin: .2rem 0 .25rem; font-size: 1rem; } +.usage-run-head p { color: var(--text-faint); font: .62rem var(--font-mono); } +.usage-run-layout { display: grid; height: calc(100% - 5rem); grid-template-columns: minmax(0, 46rem) 18rem; justify-content: center; gap: 2.5rem; padding: 0 2rem; } +.usage-run-conversation { display: grid; min-width: 0; align-content: start; gap: 1.2rem; padding: 2rem 0; overflow: auto; } +.usage-run-message { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 1rem; background: var(--surface-raised); color: var(--text-dim); font-size: .76rem; line-height: 1.65; } +.usage-run-message header { display: flex; align-items: center; gap: .5rem; margin-bottom: .5rem; color: var(--text); } +.usage-run-message header small { margin-left: auto; color: var(--text-faint); } +.usage-run-inspector { overflow: auto; border-left: 1px solid var(--border); padding: 2rem 0; } +.usage-run-inspector section, +.usage-run-inspector details { padding: 0 0 1.15rem 1.2rem; } +.usage-run-inspector section + section, +.usage-run-inspector details { border-top: 1px solid var(--border); padding-top: 1.15rem; } +.usage-run-inspector h2 { margin-bottom: .8rem; color: var(--text-faint); font: 650 .6rem var(--font-mono); letter-spacing: .08em; text-transform: uppercase; } +.usage-run-token-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; } +.usage-run-token-grid span { display: grid; gap: .12rem; } +.usage-run-token-grid small { color: var(--text-faint); font-size: .56rem; } +.usage-run-token-grid strong { font: 650 .75rem var(--font-mono); } +.usage-run-costs { display: grid; gap: .65rem; margin-top: .9rem; border-top: 1px solid var(--border-soft); padding-top: .8rem; } +.usage-run-costs div { display: grid; grid-template-columns: 4.6rem minmax(0,1fr); gap: .5rem; } +.usage-run-costs dt { color: var(--text-faint); font-size: .59rem; } +.usage-run-costs dd { margin: 0; color: var(--text-dim); font: .63rem var(--font-mono); } +.usage-run-costs dd small { display: block; margin-top: .12rem; color: var(--text-faint); font: .55rem var(--font-sans); } +.usage-run-costs .uncosted { color: var(--warning); } +.usage-run-disclaimer { margin-top: .8rem; color: var(--text-faint); font-size: .56rem; line-height: 1.5; } +.usage-run-inspector summary { color: var(--text-muted); cursor: pointer; font-size: .62rem; } +.usage-run-technical { display: grid; gap: .45rem; margin-top: .7rem; color: var(--text-faint); font: .56rem var(--font-mono); } + +/* Card supplement */ +.usage-card-backdrop { min-height: calc(100dvh - 3.25rem); padding: 1.5rem; background: color-mix(in srgb, var(--surface-inset) 72%, var(--surface)); } +.usage-card-board { display: grid; height: calc(100dvh - 6.25rem); grid-template-columns: repeat(3, minmax(12rem, 1fr)); gap: .8rem; filter: saturate(.7); opacity: .58; } +.usage-card-column { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: .8rem; background: var(--surface); } +.usage-card-column header { color: var(--text-muted); font-size: .66rem; font-weight: 650; } +.usage-card-ghost { height: 5rem; margin-top: .7rem; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-raised); } +.usage-card-dialog { position: fixed; inset: 4.5rem max(1rem, calc((100vw - 68rem) / 2)) 1.5rem; z-index: 4; display: grid; grid-template-columns: minmax(0, 1fr) 22rem; overflow: hidden; border: 1px solid var(--border-strong); border-radius: var(--radius-xl); background: var(--surface); box-shadow: var(--shadow-float); } +.usage-card-content { overflow: auto; padding: 1.5rem 2rem; } +.usage-card-content h1 { margin: .35rem 0 1.2rem; font-size: 1.35rem; letter-spacing: -.035em; } +.usage-card-copy { border-top: 1px solid var(--border); padding-top: 1rem; color: var(--text-dim); font-size: .72rem; line-height: 1.7; } +.usage-card-inspector { overflow: auto; border-left: 1px solid var(--border); background: var(--surface-soft); } +.usage-card-inspector section { padding: 1rem 1.1rem; border-bottom: 1px solid var(--border); } +.usage-card-inspector h2 { margin-bottom: .75rem; color: var(--text-faint); font: 650 .6rem var(--font-mono); letter-spacing: .07em; text-transform: uppercase; } +.usage-card-summary { justify-content: space-between; gap: .8rem; } +.usage-card-summary strong { font: 650 .9rem var(--font-mono); } +.usage-card-summary small { color: var(--text-faint); font-size: .57rem; } +.usage-card-summary > span { display: grid; gap: .12rem; } +.usage-card-runs { display: grid; gap: .1rem; margin-top: .75rem; border-top: 1px solid var(--border-soft); } +.usage-run-row { justify-content: space-between; gap: .6rem; padding: .65rem 0; border-bottom: 1px solid var(--border-soft); font-size: .62rem; } +.usage-run-row > span:first-child { display: grid; gap: .08rem; min-width: 0; } +.usage-run-row strong { overflow: hidden; color: var(--text); font-size: .64rem; text-overflow: ellipsis; white-space: nowrap; } +.usage-run-row small { color: var(--text-faint); } +.usage-run-row > span:last-child { text-align: right; } +.usage-run-row code { display: block; color: var(--text-dim); } +.usage-card-costs { display: grid; gap: .5rem; } +.usage-card-costs div { display: flex; justify-content: space-between; gap: .6rem; color: var(--text-muted); font-size: .62rem; } +.usage-card-costs strong { color: var(--text); font: 650 .64rem var(--font-mono); } +.usage-card-costs .uncosted { color: var(--warning); } + +@media (max-width: 900px) { + .usage-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .usage-two { grid-template-columns: 1fr; } + .usage-table { overflow-x: auto; } + .usage-table-head, .usage-table-row { min-width: 48rem; } + .usage-run-layout { grid-template-columns: minmax(0, 1fr); overflow-y: auto; } + .usage-run-inspector { border-top: 1px solid var(--border); border-left: 0; } + .usage-card-dialog { grid-template-columns: minmax(0, 1fr) 19rem; } +} +@media (max-width: 680px) { + .usage-page { width: min(100% - 1.5rem, 34rem); padding-top: 1rem; } + .usage-head { align-items: flex-start; flex-direction: column; } + .usage-grid, .usage-money-grid { grid-template-columns: 1fr; } + .usage-table { overflow: hidden; } + .usage-table-head { display: none; } + .usage-table-row { + min-width: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: .8rem 1rem; + padding: 1rem; + } + .usage-table-row .usage-source { + grid-column: 1 / -1; + padding-bottom: .75rem; + border-bottom: 1px solid var(--border-soft); + } + .usage-table-row > span:not(.usage-source) { display: grid; gap: .2rem; } + .usage-table-row > span:not(.usage-source)::before { + color: var(--text-faint); + font: .54rem var(--font-sans); + letter-spacing: .05em; + text-transform: uppercase; + } + .usage-table-row > span:nth-child(2)::before { content: "Input"; } + .usage-table-row > span:nth-child(3)::before { content: "Output"; } + .usage-table-row > span:nth-child(4)::before { content: "Cost"; } + .usage-table-row > span:nth-child(5)::before { content: "Capture"; } + .usage-run-main { height: auto; } + .usage-run-layout { height: auto; padding: 0 1rem; } + .usage-run-head { align-items: flex-start; flex-direction: column; } + .usage-card-dialog { position: relative; inset: auto; grid-template-columns: 1fr; margin: -1rem 0 0; } + .usage-card-inspector { border-top: 1px solid var(--border); border-left: 0; } + .usage-card-backdrop { padding: .75rem; } + .usage-card-board { display: none; } +} diff --git a/design/assets/usage-ledger.js b/design/assets/usage-ledger.js new file mode 100644 index 00000000..ba53ea0f --- /dev/null +++ b/design/assets/usage-ledger.js @@ -0,0 +1,10 @@ +document.addEventListener("click", (event) => { + const button = event.target.closest("[data-usage-range]"); + if (!button) return; + const group = button.closest("[data-usage-range-group]"); + group?.querySelectorAll("[data-usage-range]").forEach((candidate) => { + candidate.setAttribute("aria-pressed", String(candidate === button)); + }); + const label = document.querySelector("[data-usage-current-label]"); + if (label) label.textContent = button.dataset.usageRangeLabel || button.textContent; +}); diff --git a/design/automation-executions.html b/design/automation-executions.html new file mode 100644 index 00000000..393c5978 --- /dev/null +++ b/design/automation-executions.html @@ -0,0 +1,78 @@ + + + + + + Automation executions · jcode Cloud + + + + +
    +
    + ← Payments API / Automations +
    +
    + Cron · enabled +

    Weekday issue sweep

    +

    At 09:00 UTC · creates a Card for the Payments API agent queue

    +
    +
    + + +
    +
    +
    +
    +
    + Execution history +
    + + + +
    +
    +
      +
    1. + Blocked +
      Scheduled fire · Card outputJType connection needs attention. No direct Run was created.
      + +
    2. +
    3. + Running +
      Card #payments-184 → Run 8F2AAgent is working; accepted receipt written to jtype.
      + +
    4. +
    5. + Succeeded +
      Card #payments-179 → Run 76D1Card comment written and moved to Done.
      + +
    6. +
    7. + Ignored +
      SCM event did not match filtersRecorded as ignored; it is not a successful Run.
      + +
    8. +
    +
    + +
    +
    +
    + + diff --git a/design/index.html b/design/index.html index 0758ba85..72fb4341 100644 --- a/design/index.html +++ b/design/index.html @@ -28,6 +28,16 @@

    One surface per file.