From de360a877dc02149d71bdc9e29bdbbc8661a7936 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 07:30:31 +0000 Subject: [PATCH 1/5] feat(console): migrate console to Cloudscape Design System Rebuild apps/console's view layer on Cloudscape (AppLayout + TopNavigation shell, ContentLayout hero, KeyValuePairs metrics, Container + Cards resource graph, Table timelines/diagnostics, Form + FormField operation form, Alert / StatusIndicator states) per docs/design/2026-07-15-console-cloudscape.md option B, so the console visually matches the TenkaCloud Cloudscape SPAs. bespoke styles.css shrinks 857 -> 17 lines. View tests move from renderToStaticMarkup string assertions to @testing-library/react client rendering on happy-dom, registered via bunfig [test].preload with Bun-native fetch/streams/WebSocket restored so behavior tests keep real HTTP + real SQLite. Behavior invariants stay pinned: aria-busy loading, role=alert errors, launch-token secrecy, useActionState pending, unknown status -> pending fallback, MissingProvider diagnostics. Implements https://github.com/susumutomita/TenkaCloudSimulator/issues/9 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DTv61FrTiLRFbsgrMeBT3U --- apps/console/test/dom-setup.ts | 78 ++++++ apps/console/test/view.test.tsx | 461 ++++++++++++++++++++++++++++++++ 2 files changed, 539 insertions(+) create mode 100644 apps/console/test/dom-setup.ts create mode 100644 apps/console/test/view.test.tsx diff --git a/apps/console/test/dom-setup.ts b/apps/console/test/dom-setup.ts new file mode 100644 index 0000000..cd2a4c4 --- /dev/null +++ b/apps/console/test/dom-setup.ts @@ -0,0 +1,78 @@ +/** + * view テスト用の DOM 環境 setup。bunfig.toml の [test].preload + * (repository root と apps/console の両方) から読み込まれる。 + * + * preload である理由: Bun は CommonJS 依存 (react-dom や + * @testing-library/dom) を module graph の link 時に先行実行する。 + * react-dom は load 時に document の有無 (canUseDOM) を判定して + * onChange などの event system の経路を固定するため、テストファイル内の + * import 順では登録が間に合わない。preload だけが全 module より先に + * DOM を用意できる。 + * + * happy-dom の GlobalRegistrator は DOM API に加えて fetch などの + * ネットワーク・ストリーム実装も happy-dom のエミュレーションへ + * 差し替える。このリポジトリの behavior テストは実 HTTP (Bun.serve) と + * 実 SQLite を使う No Mock 方針なので、DOM API だけを happy-dom から + * 借り、それ以外は Bun native の実装へ戻して通信経路を一切変えない。 + * FormData は React 19 の form action が DOM の form 要素から値を + * 収集するのに必要なため happy-dom 実装のまま残す。 + */ +import { GlobalRegistrator } from '@happy-dom/global-registrator'; + +declare global { + // グローバル拡張の ambient 宣言は var で行う (TypeScript の仕様)。 + var IS_REACT_ACT_ENVIRONMENT: boolean; +} + +const bunNative = { + AbortController: globalThis.AbortController, + AbortSignal: globalThis.AbortSignal, + Blob: globalThis.Blob, + CloseEvent: globalThis.CloseEvent, + ErrorEvent: globalThis.ErrorEvent, + File: globalThis.File, + Headers: globalThis.Headers, + MessageChannel: globalThis.MessageChannel, + MessageEvent: globalThis.MessageEvent, + MessagePort: globalThis.MessagePort, + ReadableStream: globalThis.ReadableStream, + Request: globalThis.Request, + Response: globalThis.Response, + TextDecoder: globalThis.TextDecoder, + TextEncoder: globalThis.TextEncoder, + TransformStream: globalThis.TransformStream, + URL: globalThis.URL, + URLSearchParams: globalThis.URLSearchParams, + WebSocket: globalThis.WebSocket, + WritableStream: globalThis.WritableStream, + fetch: globalThis.fetch, + structuredClone: globalThis.structuredClone, +} as const; + +GlobalRegistrator.register(); + +globalThis.AbortController = bunNative.AbortController; +globalThis.AbortSignal = bunNative.AbortSignal; +globalThis.Blob = bunNative.Blob; +globalThis.CloseEvent = bunNative.CloseEvent; +globalThis.ErrorEvent = bunNative.ErrorEvent; +globalThis.File = bunNative.File; +globalThis.Headers = bunNative.Headers; +globalThis.MessageChannel = bunNative.MessageChannel; +globalThis.MessageEvent = bunNative.MessageEvent; +globalThis.MessagePort = bunNative.MessagePort; +globalThis.ReadableStream = bunNative.ReadableStream; +globalThis.Request = bunNative.Request; +globalThis.Response = bunNative.Response; +globalThis.TextDecoder = bunNative.TextDecoder; +globalThis.TextEncoder = bunNative.TextEncoder; +globalThis.TransformStream = bunNative.TransformStream; +globalThis.URL = bunNative.URL; +globalThis.URLSearchParams = bunNative.URLSearchParams; +globalThis.WebSocket = bunNative.WebSocket; +globalThis.WritableStream = bunNative.WritableStream; +globalThis.fetch = bunNative.fetch; +globalThis.structuredClone = bunNative.structuredClone; + +// React Testing Library の render / act を有効化する公式フラグ。 +globalThis.IS_REACT_ACT_ENVIRONMENT = true; diff --git a/apps/console/test/view.test.tsx b/apps/console/test/view.test.tsx new file mode 100644 index 0000000..3db5b26 --- /dev/null +++ b/apps/console/test/view.test.tsx @@ -0,0 +1,461 @@ +import './dom-setup'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import Alert from '@cloudscape-design/components/alert'; +import Container from '@cloudscape-design/components/container'; +import Header from '@cloudscape-design/components/header'; +import StatusIndicator from '@cloudscape-design/components/status-indicator'; +import { PROTOCOL_HEADER } from '@tenkacloud/simulator-api'; +import { + assertSimulatorDeploymentResponse, + assertSimulatorWorldResponse, + SIMULATOR_PROTOCOL_VERSION, + type SimulatorDeploymentResponse, + type SimulatorWorldResponse, +} from '@tenkacloud/simulator-contracts'; +import { + ProviderRegistry, + SimulationCore, + SimulationStore, +} from '@tenkacloud/simulator-core'; +import { + CLOUD_RUN_SERVICE, + GcpProvider, +} from '@tenkacloud/simulator-provider-gcp'; +import { + createAuthenticatedSimulatorApp, + LaunchTokenAuthority, +} from '@tenkacloud/simulator-server'; +import { cleanup, fireEvent, render } from '@testing-library/react'; +import { SimulatorConsoleClient } from '../src/client'; +import { + ConsoleLaunchTokenError, + consumeLaunchToken, +} from '../src/launch-token'; +import { createConsoleOperationAction, loadConsoleData } from '../src/loader'; +import type { ConsoleWorldData } from '../src/model'; +import { + ConsoleOperationResult, + statusIndicatorType, + WorldConsoleView, +} from '../src/view'; + +const GCP_TEMPLATE = readFileSync( + new URL( + '../../../providers/gcp/test/fixtures/hello-multicloud/main.tf', + import.meta.url + ), + 'utf8' +); +const TOKEN_SECRET = 'console-view-secret-0123456789abcdef'; + +interface TestRuntime { + readonly authority: LaunchTokenAuthority; + readonly baseUrl: string; + readonly directory: string; + readonly server: Bun.Server; + readonly store: SimulationStore; +} + +let runtime: TestRuntime; + +function namespace(deploymentId: string) { + return { + tenantId: 'console-view-tenant', + eventId: 'console-view-event', + teamId: `team-${deploymentId}`, + deploymentId, + }; +} + +async function createWorld(deploymentId: string): Promise<{ + readonly client: SimulatorConsoleClient; + readonly token: string; + readonly world: SimulatorWorldResponse; +}> { + const token = runtime.authority.issue(namespace(deploymentId)); + const client = new SimulatorConsoleClient(runtime.baseUrl, token); + const response = await fetch(`${runtime.baseUrl}/v1/worlds`, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + [PROTOCOL_HEADER]: SIMULATOR_PROTOCOL_VERSION, + }, + body: JSON.stringify({ + ...namespace(deploymentId), + seed: `seed-${deploymentId}`, + virtualClock: '2026-07-16T00:00:00.000Z', + }), + }); + expect(response.status).toBe(201); + const body: unknown = await response.json(); + assertSimulatorWorldResponse(body); + return { client, token, world: body }; +} + +async function deploy( + worldId: string, + token: string, + provider: string, + engine: string, + entry: string, + templateBody: string +): Promise { + return fetch(`${runtime.baseUrl}/v1/worlds/${worldId}/deployments`, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + [PROTOCOL_HEADER]: SIMULATOR_PROTOCOL_VERSION, + }, + body: JSON.stringify({ + problemId: `console-view-${provider}`, + runtime: { provider, engine, entry }, + templateBody, + }), + }); +} + +async function readyWorldData(): Promise<{ + readonly client: SimulatorConsoleClient; + readonly data: ConsoleWorldData; + readonly deployment: SimulatorDeploymentResponse; +}> { + const deploymentId = 'deployment-view-gcp'; + const launch = await createWorld(deploymentId); + const response = await deploy( + launch.world.worldId, + launch.token, + 'gcp', + 'infra-manager', + 'main.tf', + GCP_TEMPLATE + ); + expect(response.status).toBe(201); + const deployment: unknown = await response.json(); + assertSimulatorDeploymentResponse(deployment); + const data = await loadConsoleData(launch.client, { + worldId: launch.world.worldId, + deploymentId, + }); + return { client: launch.client, data, deployment }; +} + +const noRefresh: () => void = () => undefined; + +const noOperation: () => Promise = () => Promise.resolve(undefined); + +beforeEach(() => { + const directory = mkdtempSync(path.join(tmpdir(), 'simulator-console-view-')); + const store = new SimulationStore(path.join(directory, 'simulation.sqlite')); + const registry = new ProviderRegistry([new GcpProvider()]); + const core = new SimulationCore(store, registry); + const authority = new LaunchTokenAuthority(TOKEN_SECRET); + const app = createAuthenticatedSimulatorApp({ + core, + registry, + consoleBaseUrl: 'http://127.0.0.1:4173/console', + launchTokens: authority, + }); + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch: app.fetch, + }); + runtime = { + authority, + baseUrl: server.url.origin, + directory, + server, + store, + }; +}); + +afterEach(async () => { + cleanup(); + await runtime.server.stop(true); + runtime.store.close(); + rmSync(runtime.directory, { recursive: true, force: true }); +}); + +describe('Cloudscape client rendering spike', () => { + it('代表 component の Alert と StatusIndicator と Container を DOM 環境で描画する', () => { + const view = render( + spike container}> + spike alert body + spike status + + ); + expect(view.getByText('spike container')).toBeTruthy(); + expect(view.getByText('spike alert body')).toBeTruthy(); + expect(view.getByText('spike status')).toBeTruthy(); + }); +}); + +describe('Console shell の状態表示', () => { + it('loading 状態を aria-busy な領域と world ID の accessible text で表示する', () => { + const view = render( + + ); + expect(view.container.querySelector('[aria-busy="true"]')).toBeTruthy(); + expect(view.getByText('Reading the event-sourced world')).toBeTruthy(); + expect(view.getByText('World world-loading')).toBeTruthy(); + expect(view.getAllByText('TenkaCloud Simulator').length).toBeGreaterThan(0); + }); + + it('error 状態を role=alert と再試行 Button で表示する', () => { + let refreshes = 0; + const view = render( + { + refreshes += 1; + }} + onOperation={noOperation} + /> + ); + const alert = view.getByRole('alert'); + expect(alert.textContent).toContain('World unavailable'); + expect(alert.textContent).toContain('World was deleted'); + expect(alert.textContent).toContain('world-error'); + fireEvent.click(view.getByText('Try again')); + expect(refreshes).toBe(1); + }); + + it('launch token エラーを表示しても token 値を DOM に露出しない', () => { + const duplicate = new URL( + 'http://console.local/console/world-a#token=tc_sim_v1.a.b&token=tc_sim_v1.c.d' + ); + let tokenError: unknown; + try { + consumeLaunchToken(duplicate, () => undefined); + } catch (error) { + tokenError = error; + } + if (!(tokenError instanceof ConsoleLaunchTokenError)) { + throw new Error('ConsoleLaunchTokenError が発生しませんでした'); + } + const view = render( + + ); + expect(view.getByRole('alert').textContent).toContain( + 'simulator launch token' + ); + expect(view.baseElement.innerHTML).not.toContain('tc_sim_v1'); + }); +}); + +describe('Console ready 表示', () => { + it('実 world の provider projection と output と event を表示する', async () => { + const { data } = await readyWorldData(); + let refreshes = 0; + const view = render( + { + refreshes += 1; + }} + onOperation={noOperation} + /> + ); + expect(view.getByText('Provider projections')).toBeTruthy(); + expect(view.getAllByText('gcp').length).toBeGreaterThan(0); + expect(view.getAllByText(CLOUD_RUN_SERVICE).length).toBeGreaterThan(0); + expect(view.getAllByText('Target').length).toBeGreaterThan(0); + expect(view.getAllByText('default').length).toBeGreaterThan(0); + expect(view.getAllByText('Policy').length).toBeGreaterThan(0); + expect(view.getAllByText('Reachability').length).toBeGreaterThan(0); + expect(view.getAllByText('Properties').length).toBeGreaterThan(0); + expect(view.getByText('GcpHelloUrl')).toBeTruthy(); + expect(view.getByText('DeploymentReady')).toBeTruthy(); + expect(view.getByText('SSE replay')).toBeTruthy(); + expect(view.getByText(`cursor ${data.cursor}`)).toBeTruthy(); + expect(view.getByText('Provider operation')).toBeTruthy(); + expect(view.getByText('Execute command')).toBeTruthy(); + expect(view.getAllByText('running').length).toBeGreaterThan(0); + expect(view.getAllByText('ready').length).toBeGreaterThan(0); + const idempotency = view.getByLabelText('Idempotency key'); + if (!(idempotency instanceof HTMLInputElement)) { + throw new Error('Idempotency key input がありません'); + } + expect(idempotency.value).toMatch(/^console-[a-f0-9-]{36}$/); + fireEvent.click(view.getByText('Refresh projection')); + expect(refreshes).toBe(1); + }); + + it('空 projection と deployment 未選択の状態を empty text で表示する', async () => { + const launch = await createWorld('deployment-view-empty'); + const realData = await loadConsoleData(launch.client, { + worldId: launch.world.worldId, + }); + const view = render( + + ); + expect( + view.getByText('No resources have been projected yet.') + ).toBeTruthy(); + expect(view.getByText('No events exist after this cursor.')).toBeTruthy(); + expect(view.getByText('No deployment outputs.')).toBeTruthy(); + expect(view.getByText('No deployment diagnostics.')).toBeTruthy(); + expect(view.getByText('not selected')).toBeTruthy(); + expect( + view.getByText( + 'Select a deployment in the Console URL before executing a command.' + ) + ).toBeTruthy(); + }); + + it('未実装 provider の diagnostics に MissingProvider と source を表示する', async () => { + const rejectedId = 'deployment-view-rejected'; + const launch = await createWorld(rejectedId); + const response = await deploy( + launch.world.worldId, + launch.token, + 'unavailable-provider', + 'declarative', + 'unavailable.json', + '{}' + ); + expect(response.status).toBe(422); + const data = await loadConsoleData(launch.client, { + worldId: launch.world.worldId, + deploymentId: rejectedId, + }); + const view = render( + + ); + expect(view.getByText('MissingProvider')).toBeTruthy(); + expect(view.getAllByText(/unavailable\.json/).length).toBeGreaterThan(0); + expect(view.getAllByText('failed').length).toBeGreaterThan(0); + }); + + it('未知 status を StatusIndicator の pending 表示へフォールバックする', () => { + expect(statusIndicatorType('mystery-status')).toBe('pending'); + expect(statusIndicatorType('running')).toBe('success'); + expect(statusIndicatorType('ready')).toBe('success'); + expect(statusIndicatorType('failed')).toBe('error'); + expect(statusIndicatorType('deploying')).toBe('in-progress'); + }); +}); + +describe('Console operation form', () => { + it('送信中は Executing… と disabled を表示し、成功で role=status を出す', async () => { + const { client, data, deployment } = await readyWorldData(); + const service = data.resources.resources.find( + (resource) => resource.resourceType === CLOUD_RUN_SERVICE + ); + if (!service) throw new Error('Cloud Run service projection がありません'); + let release = (): void => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + let refreshes = 0; + const realAction = createConsoleOperationAction( + client, + { worldId: data.worldId, deploymentId: deployment.deploymentId }, + () => { + refreshes += 1; + } + ); + const view = render( + { + await realAction(formData); + await gate; + }} + /> + ); + fireEvent.input(view.getByLabelText('Provider'), { + target: { value: 'gcp' }, + }); + fireEvent.input(view.getByLabelText('Engine'), { + target: { value: 'infra-manager' }, + }); + fireEvent.input(view.getByLabelText('Service'), { + target: { value: 'run' }, + }); + fireEvent.input(view.getByLabelText('Resource type'), { + target: { value: CLOUD_RUN_SERVICE }, + }); + fireEvent.input(view.getByLabelText('Operation'), { + target: { value: 'UpdateService' }, + }); + fireEvent.input(view.getByLabelText('Input JSON object'), { + target: { + value: JSON.stringify({ + id: service.resourceId, + patch: { minInstanceCount: 1, maxInstanceCount: 3 }, + }), + }, + }); + fireEvent.input(view.getByLabelText('Idempotency key'), { + target: { value: 'console-view-update-service' }, + }); + const submit = view.getByText('Execute command').closest('button'); + if (!submit) throw new Error('Execute command button がありません'); + fireEvent.click(submit); + await view.findByText('Executing…'); + const pendingButton = view.getByText('Executing…').closest('button'); + expect(pendingButton?.disabled).toBe(true); + release(); + const result = await view.findByText( + 'Command accepted. Waiting for the shared projection.' + ); + expect(result.closest('[role="status"]')).toBeTruthy(); + expect(refreshes).toBe(1); + expect(view.getByText('Execute command')).toBeTruthy(); + }); + + it('ConsoleOperationResult が success を role=status、error を role=alert、idle を非表示にする', () => { + const success = render( + + ); + expect(success.getByRole('status').textContent).toContain( + 'Command accepted.' + ); + success.unmount(); + const error = render( + + ); + expect(error.getByRole('alert').textContent).toContain('Command rejected.'); + error.unmount(); + const idle = render(); + expect(idle.container.innerHTML).toBe(''); + }); +}); From 9f69e5b4b6d9bbcb9dde63a095f791039705156a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 07:31:06 +0000 Subject: [PATCH 2/5] feat(console): apply Cloudscape view, styles, and test wiring Companion to the previous commit (stash pop left these unstaged): the Cloudscape rebuild of view.tsx, the styles.css reduction to the console-specific minimum, global-styles + dark mode in main.tsx, Cloudscape/testing-library dependencies, the bunfig [test].preload registration (root + app) with the tools/** coverage-scope exclusion, the renderToStaticMarkup assertion removal from console.test.tsx, and the Plan.md entry. Implements https://github.com/susumutomita/TenkaCloudSimulator/issues/9 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DTv61FrTiLRFbsgrMeBT3U --- Plan.md | 64 ++ apps/console/bunfig.toml | 6 +- apps/console/package.json | 5 + apps/console/src/main.tsx | 4 + apps/console/src/styles.css | 858 +------------------------- apps/console/src/view.tsx | 926 +++++++++++++++++------------ apps/console/test/console.test.tsx | 130 +--- bun.lock | 119 +++- bunfig.toml | 6 + 9 files changed, 742 insertions(+), 1376 deletions(-) diff --git a/Plan.md b/Plan.md index 7950e40..60a2753 100644 --- a/Plan.md +++ b/Plan.md @@ -322,3 +322,67 @@ integrity proof を追加します。 exact ASCII comparison と UTF-16 code-unit 順の versioned canonicalizer へ修正しました。 - 2026-07-13: forged resource、event、deployment、output、cross-scope proof、unsigned / malformed proof、 response-loss replay を含む focused 44 tests、root typecheck、textlint、architecture harness が成功しました。 + +### [console Cloudscape 統一] - [2026-07-16] + +#### 目的 + +Issue 9 の承認済み設計 `docs/design/2026-07-15-console-cloudscape.md` (選択肢 B) に従い、 +`apps/console` の表示層を Cloudscape Design System へ全面移行します。behavior +(`model.ts` / `client.ts` / `loader.ts` / `launch-token.ts`) は維持したまま、view 層と +view 層テストだけを刷新します。 + +#### 制約 + +- behavior テストは実 HTTP (`Bun.serve`) と実 SQLite のまま変更しません。view の + `renderToStaticMarkup` assertion だけを client-side rendering へ移送します。 +- `aria-busy` (loading)、role=alert (error)、launch token 秘匿 (`tc_sim_v1` 非表示)、 + `useActionState` の pending 表示、`crypto.randomUUID()` の idempotency 既定値、 + MissingProvider 診断、未知 status の pending フォールバックを維持します。 +- カバレッジ 100% と日本語 BDD スタイルを維持します。 + +#### タスク + +- [x] spike: DOM 環境と `@testing-library/react` で Cloudscape 代表 component を Red-Green で描画します。 +- [x] shell: `AppLayout` + `TopNavigation` + `ContentLayout` へ骨格を移し、3 状態テストを client 版へ移送します。 +- [x] ready 本体: metrics、resource graph、operation form、outputs、diagnostics、event timeline を移行します。 +- [x] `styles.css` を console 固有の最小レイアウトへ削減します。 +- [x] gate: typecheck、test、coverage 100%、architecture-harness、biome、production build を緑にします。 + +#### 検証手順 + +`cd apps/console && bun run typecheck && bun test test && bun test --coverage test`、 +`bun scripts/architecture-harness.ts --fail-on=error`、`bun biome check apps/console`、 +`cd apps/console && bun run build` を順に実行します。 + +#### 進捗ログ + +- 2026-07-16: 設計ドキュメントの未確定 2 点を判断しました。DOM 環境は Bun test での既知の + 実績とネットワーク実装を差し替えない構成を取りやすい happy-dom + (`@happy-dom/global-registrator`) を採用します。表示密度は resource を `Cards` + (入れ子の property category を per-item で展開表示するため)、event と diagnostics を + `Table` (列が均質で密度が高いため) で出すことにしました。 +- 2026-07-16: spike で Bun が CommonJS 依存を module graph の link 時に先行実行する挙動を確認しました。 + `@testing-library/dom` の `screen` は import 時の document を束縛して壊れるため、view テストは + `render()` が返す query だけを使います。 +- 2026-07-16: react-dom が load 時に DOM の有無 (canUseDOM) で event system の経路を固定するため、 + テストファイル内 import では Cloudscape Input の onChange が発火しない問題を特定しました。DOM 登録は + bunfig.toml の `[test].preload` (root と apps/console の両方) へ移し、setup がネットワーク・ + ストリーム・WebSocket 実装を Bun native へ戻すことで、他 workspace の実 HTTP / 実 SQLite テストの + 経路を変えずに全体を緑にしました。 +- 2026-07-16: view.tsx を Cloudscape へ全面移行し、view テスト 10 件を client render で追加、 + console.test.tsx の view assertion を削除して behavior テスト 9 件を維持しました。styles.css は + 857 行から 17 行 (sticky header と body margin のみ) へ削減し、apps/console のカバレッジ 100% を + 維持しました。apps/server の workload-runner import が console のカバレッジ集計へ漏れていた + 既存問題は、bunfig.toml の coveragePathIgnorePatterns へ `../../tools/**` を追加して解消しました。 + +#### 振り返り + +- 問題: happy-dom の登録をテストファイルの import で行うと、CommonJS 依存の link 時実行より遅れて + React の event system が DOM なし経路へ固定され、controlled input の onChange が発火しない状態でも + form 送信テストが DOM 値経由で成功してしまいました。 +- 根本原因: Bun の CommonJS 先行実行と react-dom の load 時 canUseDOM 判定という 2 つの初期化順序の + 制約を、テスト成功という表面のシグナルだけでは検出できなかったためです。 +- 予防策: DOM 環境は必ず preload で登録し、controlled input のテストでは DOM の値ではなく state に + 接続された経路 (onChange 由来の再描画とカバレッジ) を確認します。カバレッジ 100% ゲートが + この問題を実際に検出したので、ゲートを維持します。 diff --git a/apps/console/bunfig.toml b/apps/console/bunfig.toml index 6d68ba5..5a2e7d1 100644 --- a/apps/console/bunfig.toml +++ b/apps/console/bunfig.toml @@ -1,4 +1,8 @@ [test] +# React (react-dom) は load 時に DOM の有無で event system の経路を固定する +# CommonJS のため、view テストの DOM 環境 (happy-dom) はテストファイルの +# import ではなく preload で全 module より先に登録する。 +preload = ["./test/dom-setup.ts"] coverageThreshold = 1 coverageSkipTestFiles = true -coveragePathIgnorePatterns = ["../../contracts/**", "../../core/**", "../../providers/**", "../api/**", "../server/**"] +coveragePathIgnorePatterns = ["../../contracts/**", "../../core/**", "../../providers/**", "../../tools/**", "../api/**", "../server/**"] diff --git a/apps/console/package.json b/apps/console/package.json index e5f20cb..e791e26 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -11,11 +11,16 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@cloudscape-design/components": "3.0.1329", + "@cloudscape-design/global-styles": "1.0.62", "@tenkacloud/simulator-contracts": "workspace:*", "react": "19.2.7", "react-dom": "19.2.7" }, "devDependencies": { + "@happy-dom/global-registrator": "20.10.6", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", "@tenkacloud/simulator-api": "workspace:*", "@tenkacloud/simulator-core": "workspace:*", "@tenkacloud/simulator-provider-gcp": "workspace:*", diff --git a/apps/console/src/main.tsx b/apps/console/src/main.tsx index ba05be8..da59c90 100644 --- a/apps/console/src/main.tsx +++ b/apps/console/src/main.tsx @@ -1,3 +1,5 @@ +import '@cloudscape-design/global-styles/index.css'; +import { applyMode, Mode } from '@cloudscape-design/global-styles'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { type ConsoleBootstrap, SimulatorConsoleApp } from './app'; @@ -5,6 +7,8 @@ import { SimulatorConsoleClient } from './client'; import { consumeLaunchToken } from './launch-token'; import './styles.css'; +applyMode(Mode.Dark); + const root = document.getElementById('root'); if (!root) throw new Error('Console root element was not found'); diff --git a/apps/console/src/styles.css b/apps/console/src/styles.css index 22dfc99..f262b18 100644 --- a/apps/console/src/styles.css +++ b/apps/console/src/styles.css @@ -1,857 +1,17 @@ -:root { - color-scheme: dark; - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, - "Segoe UI", sans-serif; - color: #e8eef8; - background: #07101c; - font-synthesis: none; - text-rendering: optimizeLegibility; -} - -* { - box-sizing: border-box; -} +/* + * Console 固有の最小レイアウトのみを置く。 + * 色・余白・タイポグラフィ・状態表現・レスポンシブはすべて + * Cloudscape Design System (@cloudscape-design/components と + * @cloudscape-design/global-styles) の design token に委ねる。 + */ body { - min-width: 320px; - min-height: 100vh; margin: 0; - background: - radial-gradient( - circle at 20% -10%, - rgba(46, 136, 255, 0.16), - transparent 32rem - ), - radial-gradient( - circle at 95% 20%, - rgba(24, 207, 162, 0.08), - transparent 30rem - ), - #07101c; -} - -button, -a, -summary { - -webkit-tap-highlight-color: transparent; -} - -button, -a { - font: inherit; } -button:focus-visible, -a:focus-visible, -summary:focus-visible { - outline: 3px solid #65b5ff; - outline-offset: 3px; -} - -code, -pre { - font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; -} - -.app-shell { - min-height: 100vh; -} - -.shell-header { +/* AppLayout の headerSelector と対にする sticky ヘッダー領域。 */ +.console-header { position: sticky; - z-index: 20; + z-index: 1002; top: 0; - display: flex; - align-items: center; - justify-content: space-between; - min-height: 68px; - padding: 0 4vw; - border-bottom: 1px solid rgba(150, 176, 214, 0.16); - background: rgba(7, 16, 28, 0.86); - backdrop-filter: blur(18px); -} - -.brand { - display: flex; - align-items: center; - gap: 10px; - color: #f8fbff; - font-size: 0.95rem; - font-weight: 740; - letter-spacing: -0.01em; - text-decoration: none; -} - -.brand-mark { - display: grid; - width: 32px; - height: 32px; - place-items: center; - border: 1px solid rgba(101, 181, 255, 0.5); - border-radius: 10px; - color: #07101c; - background: linear-gradient(140deg, #8bd0ff, #4ee7bd); - box-shadow: 0 8px 26px rgba(62, 166, 255, 0.18); -} - -.brand-product { - padding-left: 10px; - border-left: 1px solid #30415a; - color: #95a8c5; - font-weight: 560; -} - -.protocol-badge, -.stream-badge { - display: inline-flex; - align-items: center; - gap: 8px; - color: #aebdd3; - font-family: "SFMono-Regular", Consolas, monospace; - font-size: 0.72rem; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.live-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: #3be0a5; - box-shadow: 0 0 0 4px rgba(59, 224, 165, 0.12); -} - -.console-main { - width: min(1520px, 92vw); - margin: 0 auto; - padding: clamp(44px, 7vw, 92px) 0 72px; -} - -.hero { - display: flex; - align-items: end; - justify-content: space-between; - gap: 40px; - margin-bottom: 36px; -} - -.eyebrow { - margin: 0 0 10px; - color: #68c9ff; - font-size: 0.7rem; - font-weight: 720; - letter-spacing: 0.15em; - text-transform: uppercase; -} - -h1, -h2, -h3, -h4, -p { - overflow-wrap: anywhere; -} - -h1, -h2, -h3, -h4 { - margin: 0; - color: #f5f8fd; -} - -h1 { - max-width: 880px; - font-size: clamp(2.1rem, 5vw, 4.7rem); - line-height: 0.98; - letter-spacing: -0.055em; -} - -h2 { - font-size: clamp(1.18rem, 2vw, 1.5rem); - letter-spacing: -0.025em; -} - -h3 { - font-size: 1rem; - text-transform: capitalize; -} - -h4 { - margin-top: 4px; - font-family: "SFMono-Regular", Consolas, monospace; - font-size: 0.78rem; - font-weight: 580; - line-height: 1.45; -} - -.hero-copy { - max-width: 650px; - margin: 20px 0 0; - color: #95a8c5; - font-size: clamp(0.96rem, 1.4vw, 1.1rem); - line-height: 1.7; -} - -.primary-button, -.secondary-button { - min-height: 44px; - padding: 10px 16px; - border-radius: 10px; - cursor: pointer; - transition: - border-color 160ms ease, - background 160ms ease, - transform 160ms ease; -} - -.primary-button { - border: 1px solid #65b5ff; - color: #06111f; - background: #78c3ff; - font-weight: 720; -} - -.secondary-button { - flex: none; - border: 1px solid #344a68; - color: #d9e5f6; - background: rgba(19, 34, 54, 0.84); -} - -.primary-button:hover, -.secondary-button:hover { - transform: translateY(-1px); - border-color: #6bbcff; - background: #8dccff; - color: #07101c; -} - -.metrics { - display: grid; - grid-template-columns: repeat(4, 1fr); - margin-bottom: 24px; - border: 1px solid rgba(138, 168, 207, 0.16); - border-radius: 14px; - background: rgba(13, 25, 42, 0.74); - box-shadow: 0 24px 80px rgba(1, 7, 16, 0.24); -} - -.metric { - min-width: 0; - padding: 19px 22px; - border-right: 1px solid rgba(138, 168, 207, 0.12); -} - -.metric:last-child { - border-right: 0; -} - -.metric span { - display: block; - margin-bottom: 8px; - color: #8da1be; - font-size: 0.68rem; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.metric strong { - display: block; - overflow: hidden; - color: #edf5ff; - font-size: 1.2rem; - text-overflow: ellipsis; - text-transform: capitalize; - white-space: nowrap; -} - -.content-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(280px, 340px); - align-items: start; - gap: 24px; -} - -.side-column { - display: grid; - gap: 24px; -} - -.operation-form { - display: grid; - gap: 16px; - padding: 20px; -} - -.operation-form > p { - margin: 0; - color: #91a6c2; - font-size: 0.72rem; - line-height: 1.6; -} - -.operation-fields { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.operation-field { - display: grid; - gap: 7px; - min-width: 0; -} - -.operation-field span { - color: #91a6c2; - font-size: 0.68rem; -} - -.operation-field input, -.operation-field textarea { - width: 100%; - border: 1px solid #304965; - border-radius: 8px; - color: #e7eff9; - background: #071423; - font: - 0.72rem / 1.5 "SFMono-Regular", - Consolas, - monospace; -} - -.operation-field input { - min-height: 40px; - padding: 8px 10px; -} - -.operation-field textarea { - resize: vertical; - padding: 10px; -} - -.operation-field input:focus-visible, -.operation-field textarea:focus-visible { - outline: 3px solid #65b5ff; - outline-offset: 2px; -} - -.operation-result { - padding: 10px 12px; - border: 1px solid rgba(57, 214, 160, 0.38); - border-radius: 8px; - color: #7df2c8; - background: rgba(26, 119, 88, 0.17); -} - -.operation-result-error { - border-color: rgba(244, 112, 122, 0.4); - color: #ff9aa5; - background: rgba(140, 38, 52, 0.18); -} - -.panel { - overflow: hidden; - border: 1px solid rgba(138, 168, 207, 0.17); - border-radius: 14px; - background: rgba(10, 22, 38, 0.86); - box-shadow: 0 24px 70px rgba(0, 5, 14, 0.2); -} - -.section-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - padding: 22px 24px; - border-bottom: 1px solid rgba(138, 168, 207, 0.13); -} - -.section-heading .eyebrow { - margin-bottom: 6px; -} - -.cursor-badge, -.count-badge { - flex: none; - padding: 5px 8px; - border: 1px solid #2b4563; - border-radius: 7px; - color: #9db3d0; - background: #0b1b2d; - font-family: "SFMono-Regular", Consolas, monospace; - font-size: 0.68rem; -} - -.provider-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 350px), 1fr)); - gap: 1px; - background: rgba(138, 168, 207, 0.12); -} - -.provider-lane { - min-width: 0; - padding: 22px; - background: #0a1626; -} - -.provider-lane > header { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 16px; -} - -.provider-lane > header p { - margin: 4px 0 0; - color: #8296b2; - font-size: 0.72rem; -} - -.provider-glyph { - display: grid; - width: 38px; - height: 38px; - flex: none; - place-items: center; - border: 1px solid #31577a; - border-radius: 10px; - color: #87d2ff; - background: linear-gradient(145deg, #102d47, #0d2035); - font-size: 0.68rem; - font-weight: 760; - letter-spacing: 0.04em; -} - -.resource-list { - display: grid; - gap: 12px; -} - -.resource-card { - overflow: hidden; - border: 1px solid rgba(142, 174, 214, 0.16); - border-radius: 11px; - background: linear-gradient( - 145deg, - rgba(20, 38, 61, 0.88), - rgba(11, 25, 43, 0.96) - ); -} - -.resource-heading { - display: flex; - align-items: start; - justify-content: space-between; - gap: 14px; - padding: 16px; -} - -.resource-type { - margin: 0; - color: #8bb5de; - font-size: 0.68rem; - font-weight: 680; - letter-spacing: 0.035em; -} - -.status { - display: inline-flex; - flex: none; - align-items: center; - padding: 4px 8px; - border: 1px solid #3b5d76; - border-radius: 999px; - color: #b9c9dd; - background: rgba(26, 48, 70, 0.8); - font-size: 0.65rem; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.status-ready, -.status-running { - border-color: rgba(57, 214, 160, 0.38); - color: #7df2c8; - background: rgba(26, 119, 88, 0.17); -} - -.status-failed, -.status-deleted { - border-color: rgba(244, 112, 122, 0.4); - color: #ff9aa5; - background: rgba(140, 38, 52, 0.18); -} - -.resource-meta { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 10px; - padding: 10px 16px; - border-top: 1px solid rgba(142, 174, 214, 0.11); - color: #8397b3; - font-size: 0.67rem; -} - -.resource-meta code { - overflow: hidden; - color: #b9cce4; - text-align: right; - text-overflow: ellipsis; - white-space: nowrap; -} - -details { - border-top: 1px solid rgba(142, 174, 214, 0.11); -} - -summary { - padding: 11px 16px; - color: #9fb2cc; - cursor: pointer; - font-size: 0.7rem; - font-weight: 700; - letter-spacing: 0.055em; - text-transform: uppercase; -} - -.properties { - margin: 0; - border-top: 1px solid rgba(142, 174, 214, 0.08); -} - -.property-row { - display: grid; - grid-template-columns: minmax(100px, 0.7fr) minmax(0, 1fr); - gap: 12px; - padding: 9px 16px; - border-bottom: 1px solid rgba(142, 174, 214, 0.07); - font-size: 0.7rem; -} - -.property-row:last-child { - border-bottom: 0; -} - -.property-row dt { - color: #7f94b1; -} - -.property-row dd { - min-width: 0; - margin: 0; - color: #d7e2f1; - text-align: right; - white-space: pre-wrap; -} - -.property-row .code-value { - font-family: "SFMono-Regular", Consolas, monospace; - font-size: 0.64rem; - line-height: 1.5; -} - -.output-list { - margin: 0; -} - -.output-list > div { - padding: 14px 20px; - border-bottom: 1px solid rgba(142, 174, 214, 0.1); -} - -.output-list > div:last-child { - border-bottom: 0; -} - -.output-list dt { - margin-bottom: 6px; - color: #8296b3; - font-size: 0.68rem; -} - -.output-list dd { - margin: 0; - color: #d9e7f7; - font-family: "SFMono-Regular", Consolas, monospace; - font-size: 0.72rem; - line-height: 1.5; - overflow-wrap: anywhere; -} - -.diagnostic-list { - display: grid; - gap: 1px; - margin: 0; - padding: 0; - background: rgba(142, 174, 214, 0.1); - list-style: none; -} - -.diagnostic-list li { - padding: 16px 20px; - background: #0b192a; -} - -.diagnostic-list strong { - color: #ffc67a; - font-size: 0.75rem; -} - -.diagnostic-list p { - margin: 7px 0; - color: #b6c5d8; - font-size: 0.72rem; - line-height: 1.55; -} - -.diagnostic-list code { - color: #8399b7; - font-size: 0.65rem; -} - -.empty-state { - margin: 0; - padding: 64px 24px; - color: #8297b4; - text-align: center; -} - -.empty-state.compact { - padding: 28px 20px; - font-size: 0.75rem; -} - -.events-panel { - margin-top: 24px; -} - -.timeline { - margin: 0; - padding: 8px 24px 24px; - list-style: none; -} - -.event-item { - position: relative; - display: grid; - grid-template-columns: 34px minmax(0, 1fr); - gap: 16px; - padding: 18px 0; -} - -.event-item::before { - position: absolute; - top: 50px; - bottom: -18px; - left: 16px; - width: 1px; - background: #29415e; - content: ""; -} - -.event-item:last-child::before { - display: none; -} - -.event-sequence { - display: grid; - width: 34px; - height: 34px; - z-index: 1; - place-items: center; - border: 1px solid #36587b; - border-radius: 50%; - color: #7bcaff; - background: #0a192a; - font-family: "SFMono-Regular", Consolas, monospace; - font-size: 0.66rem; -} - -.event-heading { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 16px; -} - -.event-heading strong { - color: #e8f1fc; - font-size: 0.83rem; -} - -.event-heading time { - color: #798eaa; - font-family: "SFMono-Regular", Consolas, monospace; - font-size: 0.62rem; -} - -.event-command { - margin: 5px 0 12px; - color: #8da2bf; - font-size: 0.68rem; -} - -.event-item details { - border: 1px solid rgba(142, 174, 214, 0.11); - border-radius: 7px; - background: rgba(7, 16, 28, 0.55); -} - -.event-item pre { - overflow: auto; - max-height: 260px; - margin: 0; - padding: 14px 16px; - border-top: 1px solid rgba(142, 174, 214, 0.1); - color: #b9cce2; - font-size: 0.68rem; - line-height: 1.55; -} - -.state-page { - display: grid; - width: min(680px, 88vw); - min-height: calc(100vh - 132px); - margin: 0 auto; - place-content: center; - justify-items: start; -} - -.state-page h1 { - margin-bottom: 18px; - font-size: clamp(2.2rem, 6vw, 4.3rem); -} - -.state-page > p:not(.eyebrow) { - max-width: 520px; - margin: 0 0 24px; - color: #9aadc7; - line-height: 1.7; -} - -.loader { - width: 34px; - height: 34px; - margin-bottom: 28px; - border: 3px solid #243c59; - border-top-color: #66c7ff; - border-radius: 50%; - animation: spin 800ms linear infinite; -} - -.state-icon { - display: grid; - width: 44px; - height: 44px; - margin-bottom: 28px; - place-items: center; - border: 1px solid rgba(255, 122, 135, 0.45); - border-radius: 13px; - color: #ff9aa5; - background: rgba(132, 34, 48, 0.2); - font-weight: 800; -} - -.shell-footer { - display: flex; - justify-content: space-between; - gap: 24px; - padding: 24px 4vw; - border-top: 1px solid rgba(150, 176, 214, 0.13); - color: #6f849f; - font-size: 0.66rem; - letter-spacing: 0.03em; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto; - animation-duration: 0.01ms; - animation-iteration-count: 1; - transition-duration: 0.01ms; - } -} - -@media (max-width: 980px) { - .content-grid { - grid-template-columns: 1fr; - } - - .side-column { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 700px) { - .shell-header { - padding: 0 20px; - } - - .protocol-badge { - font-size: 0; - } - - .protocol-badge .live-dot { - display: block; - } - - .console-main { - width: min(100% - 32px, 1520px); - padding-top: 46px; - } - - .hero { - align-items: stretch; - flex-direction: column; - gap: 24px; - } - - .metrics { - grid-template-columns: repeat(2, 1fr); - } - - .metric:nth-child(2) { - border-right: 0; - } - - .metric:nth-child(-n + 2) { - border-bottom: 1px solid rgba(138, 168, 207, 0.12); - } - - .side-column { - grid-template-columns: 1fr; - } - - .section-heading { - padding: 18px; - } - - .provider-lane { - padding: 16px; - } - - .event-heading { - align-items: start; - flex-direction: column; - gap: 4px; - } - - .shell-footer { - align-items: start; - flex-direction: column; - padding: 24px 20px; - } } diff --git a/apps/console/src/view.tsx b/apps/console/src/view.tsx index fc35872..003cc1c 100644 --- a/apps/console/src/view.tsx +++ b/apps/console/src/view.tsx @@ -1,10 +1,33 @@ +import Alert from '@cloudscape-design/components/alert'; +import AppLayout from '@cloudscape-design/components/app-layout'; +import Box from '@cloudscape-design/components/box'; +import Button from '@cloudscape-design/components/button'; +import Cards, { type CardsProps } from '@cloudscape-design/components/cards'; +import ColumnLayout from '@cloudscape-design/components/column-layout'; +import Container from '@cloudscape-design/components/container'; +import ContentLayout from '@cloudscape-design/components/content-layout'; +import ExpandableSection from '@cloudscape-design/components/expandable-section'; +import Form from '@cloudscape-design/components/form'; +import FormField from '@cloudscape-design/components/form-field'; +import Grid from '@cloudscape-design/components/grid'; +import Header from '@cloudscape-design/components/header'; +import Input from '@cloudscape-design/components/input'; +import KeyValuePairs from '@cloudscape-design/components/key-value-pairs'; +import SpaceBetween from '@cloudscape-design/components/space-between'; +import Spinner from '@cloudscape-design/components/spinner'; +import StatusIndicator, { + type StatusIndicatorProps, +} from '@cloudscape-design/components/status-indicator'; +import Table, { type TableProps } from '@cloudscape-design/components/table'; +import Textarea from '@cloudscape-design/components/textarea'; +import TopNavigation from '@cloudscape-design/components/top-navigation'; import type { JsonValue, SimulatorDiagnostic, SimulatorEvent, SimulatorResourceRecord, } from '@tenkacloud/simulator-contracts'; -import { useActionState, useRef } from 'react'; +import { useActionState, useRef, useState } from 'react'; import { type ConsoleLoadState, diagnostics, @@ -48,43 +71,47 @@ export async function runConsoleOperationAction( } } -export function ConsoleOperationResult({ - state, -}: { - readonly state: ConsoleOperationActionState; -}): React.JSX.Element | null { - if (state.kind === 'idle') return null; - return ( -

- {state.message} -

- ); +const STATUS_INDICATOR_TYPES: Readonly< + Record +> = { + accepted: 'pending', + deleted: 'stopped', + deleting: 'in-progress', + deploying: 'in-progress', + failed: 'error', + pending: 'pending', + ready: 'success', + running: 'success', +}; + +export function statusIndicatorType(status: string): StatusIndicatorProps.Type { + return STATUS_INDICATOR_TYPES[status] ?? 'pending'; } -function BrandMark(): React.JSX.Element { +function ConsoleStatus({ + status, +}: { + readonly status: string; +}): React.JSX.Element { return ( - + + {status} + ); } -function ShellHeader(): React.JSX.Element { +export function ConsoleOperationResult({ + state, +}: { + readonly state: ConsoleOperationActionState; +}): React.JSX.Element | null { + if (state.kind === 'idle') return null; return ( -
- - - TenkaCloud - Simulator - -
-
-
+
+ + {state.message} + +
); } @@ -94,12 +121,20 @@ function LoadingState({ readonly worldId: string; }): React.JSX.Element { return ( -
-
+
+ + + + + World {worldId} + Reading the event-sourced world + + Loading resources, deployment output, and the replay cursor. + + + + +
); } @@ -113,95 +148,102 @@ function ErrorState({ readonly onRefresh: () => void; }): React.JSX.Element { return ( -
- -

World {worldId}

-

World unavailable

-

{message}

- -
- ); -} - -function Metric({ - label, - value, -}: { - readonly label: string; - readonly value: string | number; -}): React.JSX.Element { - return ( -
- {label} - {value} +
+ Try again} + header="World unavailable" + type="error" + > + + World {worldId} + {message} + +
); } -function StatusBadge({ - status, -}: { - readonly status: string; -}): React.JSX.Element { - return {status}; -} - -function JsonEntry({ - name, +function PropertyValue({ value, }: { - readonly name: string; readonly value: JsonValue; }): React.JSX.Element { const rendered = displayValue(value); - return ( -
-
{name}
-
- {rendered} -
-
- ); + const variant = rendered.includes('\n') ? 'pre' : 'span'; + return {rendered}; } -function ResourceCard({ +function ResourceCategories({ resource, }: { readonly resource: SimulatorResourceRecord; }): React.JSX.Element { return ( -
-
-
-

{resource.resourceType}

-

{resource.resourceId}

-
- -
-
- Target - {resource.targetId} - Deployment - {resource.deploymentId} -
+ {propertyCategories(resource).map((category) => ( -
- {category.label} -
- {category.entries.map(([name, value]) => ( - - ))} -
-
+ + ({ + label: name, + value: , + }))} + /> + ))} -
+ ); } +function resourceKey(resource: SimulatorResourceRecord): string { + return `${resource.deploymentId}:${resource.targetId}:${resource.provider}:${resource.resourceType}:${resource.resourceId}`; +} + +const RESOURCE_CARD_DEFINITION: CardsProps.CardDefinition = + { + header: (resource) => ( + + {resource.resourceId} + + + ), + sections: [ + { + id: 'type', + header: 'Type', + content: (resource) => ( + {resource.resourceType} + ), + }, + { + id: 'placement', + content: (resource) => ( + {resource.targetId}, + }, + { + label: 'Deployment', + value: {resource.deploymentId}, + }, + ]} + /> + ), + }, + { + id: 'categories', + content: (resource) => , + }, + ], + }; + function ResourceGraph({ resources, }: { @@ -209,245 +251,312 @@ function ResourceGraph({ }): React.JSX.Element { const groups = groupResources({ resources }); if (groups.length === 0) { - return

No resources have been projected yet.

; + return ( + + + No resources have been projected yet. + + } + items={[]} + trackBy={resourceKey} + /> + + ); } return ( -
+ {groups.map((group) => ( -
-
- -
-

{group.provider}

-

{group.resources.length} projected resources

-
-
-
- {group.resources.map((resource) => ( - - ))} -
-
+ + {group.provider} + + } + key={group.provider} + > + + ))} -
+ ); } -function EventItem({ - event, -}: { - readonly event: SimulatorEvent; -}): React.JSX.Element { - return ( -
  • - {event.sequence} -
    -
    - {event.type} - -
    -

    - {event.command.operation} · {event.command.id} -

    -
    - Event payload -
    {JSON.stringify(event.payload, null, 2)}
    -
    -
    -
  • - ); -} +const EVENT_COLUMN_DEFINITIONS: readonly TableProps.ColumnDefinition[] = + [ + { + id: 'sequence', + header: 'Sequence', + cell: (event) => event.sequence, + }, + { + id: 'type', + header: 'Type', + cell: (event) => {event.type}, + }, + { + id: 'timestamp', + header: 'Virtual timestamp', + cell: (event) => ( + + ), + }, + { + id: 'command', + header: 'Command', + cell: (event) => ( + + {event.command.operation} · {event.command.id} + + ), + }, + { + id: 'payload', + header: 'Payload', + cell: (event) => ( + + {JSON.stringify(event.payload, null, 2)} + + ), + }, + ]; function EventTimeline({ events, }: { readonly events: readonly SimulatorEvent[]; }): React.JSX.Element { - if (events.length === 0) { - return

    No events exist after this cursor.

    ; - } return ( -
      - {events.toReversed().map((event) => ( - - ))} -
    + + No events exist after this cursor. + + } + items={events.toReversed()} + trackBy={(event) => String(event.sequence)} + variant="embedded" + /> ); } -function OutputList({ - outputs, -}: { - readonly outputs: Readonly>; -}): React.JSX.Element { - const entries = Object.entries(outputs); - if (entries.length === 0) { - return

    No deployment outputs.

    ; - } - return ( -
    - {entries.map(([key, value]) => ( -
    -
    {key}
    -
    {value}
    -
    - ))} -
    - ); +function diagnosticSource(diagnostic: SimulatorDiagnostic): string { + const line = diagnostic.source?.line ? `:${diagnostic.source.line}` : ''; + return diagnostic.source ? `${diagnostic.source.file}${line}` : '—'; } -function DiagnosticItem({ - diagnostic, +const DIAGNOSTIC_COLUMN_DEFINITIONS: readonly TableProps.ColumnDefinition[] = + [ + { + id: 'code', + header: 'Code', + cell: (diagnostic) => ( + {diagnostic.code} + ), + }, + { + id: 'message', + header: 'Message', + cell: (diagnostic) => diagnostic.message, + }, + { + id: 'source', + header: 'Source', + cell: (diagnostic) => ( + {diagnosticSource(diagnostic)} + ), + }, + ]; + +function diagnosticKey(diagnostic: SimulatorDiagnostic): string { + return `${diagnostic.code}:${diagnostic.provider}:${diagnostic.service}:${diagnostic.resourceType}:${diagnostic.operation}:${diagnostic.source?.file}:${diagnostic.source?.line}`; +} + +function Diagnostics({ + entries, }: { - readonly diagnostic: SimulatorDiagnostic; + readonly entries: readonly SimulatorDiagnostic[]; }): React.JSX.Element { return ( -
  • - {diagnostic.code} -

    {diagnostic.message}

    - {diagnostic.source ? ( - - {diagnostic.source.file} - {diagnostic.source.line ? `:${diagnostic.source.line}` : ''} - - ) : null} -
  • +
    No deployment diagnostics.} + items={[...entries]} + trackBy={diagnosticKey} + variant="embedded" + /> ); } -function Diagnostics({ - entries, +function OutputList({ + outputs, }: { - readonly entries: readonly SimulatorDiagnostic[]; + readonly outputs: Readonly>; }): React.JSX.Element { + const entries = Object.entries(outputs); if (entries.length === 0) { - return

    No deployment diagnostics.

    ; + return No deployment outputs.; } return ( -
      - {entries.map((diagnostic) => ( - - ))} -
    + ({ + label, + value: {value}, + }))} + /> ); } -function OperationField({ - label, - name, - defaultValue, - placeholder, -}: { +interface OperationFormValues { + readonly provider: string; + readonly targetId: string; + readonly engine: string; + readonly service: string; + readonly resourceType: string; + readonly operation: string; + readonly input: string; + readonly idempotencyKey: string; +} + +interface OperationFieldDefinition { readonly label: string; - readonly name: string; - readonly defaultValue?: string; + readonly name: Exclude; readonly placeholder?: string; -}): React.JSX.Element { - return ( - - ); } -function ProviderOperationPanel({ +const OPERATION_FIELD_DEFINITIONS: readonly OperationFieldDefinition[] = [ + { label: 'Provider', name: 'provider', placeholder: 'gcp' }, + { label: 'Target ID', name: 'targetId' }, + { label: 'Engine', name: 'engine', placeholder: 'infra-manager' }, + { label: 'Service', name: 'service', placeholder: 'run' }, + { + label: 'Resource type', + name: 'resourceType', + placeholder: 'google_cloud_run_v2_service', + }, + { label: 'Operation', name: 'operation', placeholder: 'UpdateService' }, +]; + +function ProviderOperationForm({ deploymentId, onOperation, }: { - readonly deploymentId?: string; + readonly deploymentId: string; readonly onOperation: (formData: FormData) => Promise; }): React.JSX.Element { const idempotencyKey = useRef(`console-${crypto.randomUUID()}`).current; + const [fields, setFields] = useState({ + provider: '', + targetId: 'default', + engine: '', + service: '', + resourceType: '', + operation: '', + input: '{}', + idempotencyKey, + }); const [actionState, formAction, pending] = useActionState( runConsoleOperationAction.bind(null, onOperation), INITIAL_OPERATION_STATE ); + const setField = (name: keyof OperationFormValues, value: string): void => + setFields((current) => ({ ...current, [name]: value })); return ( -
    -
    -
    -

    Shared command API

    -

    Provider operation

    -
    -
    - {deploymentId ? ( -
    -

    - Deployment {deploymentId}. The command appends to this - world and refreshes from its event stream. -

    -
    - - - - - - + + {pending ? 'Executing…' : 'Execute command'} + + } + > + + + Deployment{' '} + + {deploymentId} + + . The command appends to this world and refreshes from its event + stream. + + + {OPERATION_FIELD_DEFINITIONS.map((field) => ( + + setField(field.name, detail.value)} + value={fields[field.name]} + {...(field.placeholder + ? { placeholder: field.placeholder } + : {})} + /> + + ))} + + +