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..4c32a1f 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -11,11 +11,16 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@cloudscape-design/components": "3.0.1324", + "@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..aec5b8d 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,12 @@ import { SimulatorConsoleClient } from './client'; import { consumeLaunchToken } from './launch-token'; import './styles.css'; +// OS のカラースキーム設定に追従する。判定できない環境では旧 bespoke テーマと +// 視覚的連続性のある dark を既定にする。 +const prefersLight = + window.matchMedia?.('(prefers-color-scheme: light)').matches ?? false; +applyMode(prefersLight ? Mode.Light : 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..8399c61 100644 --- a/apps/console/src/view.tsx +++ b/apps/console/src/view.tsx @@ -1,10 +1,34 @@ -import type { - JsonValue, - SimulatorDiagnostic, - SimulatorEvent, - SimulatorResourceRecord, +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, + SIMULATOR_PROTOCOL_VERSION, + type SimulatorDiagnostic, + type SimulatorEvent, + type SimulatorResourceRecord, } from '@tenkacloud/simulator-contracts'; -import { useActionState, useRef } from 'react'; +import { useActionState, useRef, useState } from 'react'; import { type ConsoleLoadState, diagnostics, @@ -48,43 +72,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 +122,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 +149,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 +252,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 } + : {})} + /> + + ))} + + +