From 2169bb03d9460c9a1df078ff1cff3c9f2f17c560 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 13 Jul 2026 14:00:23 +0800 Subject: [PATCH] feat(cloud-console): task listing, attempt history, CORS, and console SPA Implements the cloud-console OpenSpec change: adds GET /v1/tasks (filterable, bounded pagination, tasks:read) and GET /v1/tasks/{id}/attempts (404 on unknown task) to the platform SDK, with matching CloudClient methods and a closed-by- default CLOUD_CONSOLE_CORS_ORIGINS allow-list wired through CloudControlConfig. Ships an independent Vue 3 + Vite SPA at cloud-console/ that authenticates with an operator-supplied bearer token held in sessionStorage, renders tasks with attempt history, device pool, host registry, and the plugin registry with a registration form. Backend test suite: 438 passed (-m "not integration"); cloud-console typecheck and production build both succeed. PostgreSQL-backed repository tests and manual end-to-end verification remain pending external infrastructure. Co-Authored-By: Claude Opus 4.6 --- apps/cloud-api/cloud_api/app.py | 11 + apps/cloud-api/tests/test_app.py | 77 ++ cloud-console/.env.example | 1 + cloud-console/.gitignore | 4 + cloud-console/README.md | 95 ++ cloud-console/index.html | 12 + cloud-console/package-lock.json | 1211 +++++++++++++++++ cloud-console/package.json | 22 + cloud-console/src/App.vue | 116 ++ cloud-console/src/api.ts | 143 ++ cloud-console/src/env.d.ts | 9 + cloud-console/src/main.ts | 5 + cloud-console/src/style.css | 350 +++++ cloud-console/src/types.ts | 70 + cloud-console/src/views/DevicesView.vue | 167 +++ cloud-console/src/views/PluginsView.vue | 191 +++ cloud-console/src/views/TasksView.vue | 305 +++++ cloud-console/src/views/TokenScreen.vue | 51 + cloud-console/tsconfig.json | 21 + cloud-console/tsconfig.node.json | 12 + cloud-console/vite.config.ts | 6 + docs/CLOUD_DEPLOYMENT.md | 68 + openspec/changes/cloud-console/tasks.md | 40 +- .../cloud-platform/cloud/control_config.py | 10 + packages/cloud-platform/cloud/repository.py | 12 +- packages/cloud-platform/cloud/sdk/api.py | 77 +- packages/cloud-platform/cloud/sdk/client.py | 19 + packages/cloud-platform/cloud/sdk/models.py | 35 +- .../cloud-platform/cloud/sql_repository.py | 30 + tests/test_cloud_client.py | 70 + tests/test_cloud_repository_contract.py | 101 ++ tests/test_cloud_sdk_api.py | 98 ++ 32 files changed, 3415 insertions(+), 24 deletions(-) create mode 100644 cloud-console/.env.example create mode 100644 cloud-console/.gitignore create mode 100644 cloud-console/README.md create mode 100644 cloud-console/index.html create mode 100644 cloud-console/package-lock.json create mode 100644 cloud-console/package.json create mode 100644 cloud-console/src/App.vue create mode 100644 cloud-console/src/api.ts create mode 100644 cloud-console/src/env.d.ts create mode 100644 cloud-console/src/main.ts create mode 100644 cloud-console/src/style.css create mode 100644 cloud-console/src/types.ts create mode 100644 cloud-console/src/views/DevicesView.vue create mode 100644 cloud-console/src/views/PluginsView.vue create mode 100644 cloud-console/src/views/TasksView.vue create mode 100644 cloud-console/src/views/TokenScreen.vue create mode 100644 cloud-console/tsconfig.json create mode 100644 cloud-console/tsconfig.node.json create mode 100644 cloud-console/vite.config.ts diff --git a/apps/cloud-api/cloud_api/app.py b/apps/cloud-api/cloud_api/app.py index 781c2b6..e6f75dd 100644 --- a/apps/cloud-api/cloud_api/app.py +++ b/apps/cloud-api/cloud_api/app.py @@ -158,6 +158,17 @@ def create_app( app = FastAPI(title="Device Cloud API", lifespan=lifespan) + if control_config.cors_allowed_origins: + from fastapi.middleware.cors import CORSMiddleware + + app.add_middleware( + CORSMiddleware, + allow_origins=list(control_config.cors_allowed_origins), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + @app.middleware("http") async def correlation_logging(request: Request, call_next): correlation_id = normalize_correlation_id( diff --git a/apps/cloud-api/tests/test_app.py b/apps/cloud-api/tests/test_app.py index f3afdeb..5771ead 100644 --- a/apps/cloud-api/tests/test_app.py +++ b/apps/cloud-api/tests/test_app.py @@ -608,3 +608,80 @@ def test_production_app_rejects_missing_credentials() -> None: database_url="postgresql://db/cloud", ) ) + + +def test_cors_headers_are_absent_when_allow_list_is_empty() -> None: + app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:")) + with TestClient(app) as client: + response = client.options( + "/health/live", + headers={ + "Origin": "http://console.example", + "Access-Control-Request-Method": "GET", + }, + ) + assert response.status_code >= 400 + assert "access-control-allow-origin" not in { + key.lower() for key in response.headers + } + + +def test_cors_headers_reflect_configured_origin_only() -> None: + app = create_app( + config=CloudControlConfig( + database_url="sqlite:///:memory:", + cors_allowed_origins=("http://console.example",), + ) + ) + with TestClient(app) as client: + allowed = client.options( + "/health/live", + headers={ + "Origin": "http://console.example", + "Access-Control-Request-Method": "GET", + }, + ) + blocked = client.options( + "/health/live", + headers={ + "Origin": "http://attacker.example", + "Access-Control-Request-Method": "GET", + }, + ) + + assert allowed.status_code in {200, 204} + assert allowed.headers["access-control-allow-origin"] == "http://console.example" + # An origin that is not on the allow-list must not be echoed back. + assert ( + blocked.headers.get("access-control-allow-origin") != "http://attacker.example" + ) + + +def test_load_control_config_parses_cors_allow_list() -> None: + from cloud.control_config import load_control_config + + config = load_control_config( + env={ + "CLOUD_ENVIRONMENT": "local", + "CLOUD_DATABASE_URL": "sqlite:///:memory:", + "CLOUD_CONSOLE_CORS_ORIGINS": ( + "http://console.example, https://console.example" + ), + } + ) + assert config.cors_allowed_origins == ( + "http://console.example", + "https://console.example", + ) + + +def test_load_control_config_defaults_to_empty_cors_allow_list() -> None: + from cloud.control_config import load_control_config + + config = load_control_config( + env={ + "CLOUD_ENVIRONMENT": "local", + "CLOUD_DATABASE_URL": "sqlite:///:memory:", + } + ) + assert config.cors_allowed_origins == () diff --git a/cloud-console/.env.example b/cloud-console/.env.example new file mode 100644 index 0000000..1ce17d3 --- /dev/null +++ b/cloud-console/.env.example @@ -0,0 +1 @@ +VITE_CLOUD_API_BASE_URL=http://127.0.0.1:8001 diff --git a/cloud-console/.gitignore b/cloud-console/.gitignore new file mode 100644 index 0000000..d70bb9c --- /dev/null +++ b/cloud-console/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.DS_Store +*.local diff --git a/cloud-console/README.md b/cloud-console/README.md new file mode 100644 index 0000000..84a2a02 --- /dev/null +++ b/cloud-console/README.md @@ -0,0 +1,95 @@ +# Cloud Console + +Independent Vue 3 + Vite single-page app for the Cloud Control Plane +(`apps/cloud-api`). Operators authenticate by pasting a pre-issued scoped +bearer token; the console stores it in `sessionStorage`, attaches +`Authorization: Bearer ` to every request, and clears it whenever the +Cloud API responds `401` or `403`. + +The app talks only to the platform SDK surface (`/v1/...`) and consumes the +two listing endpoints added by the `cloud-console` change (`GET /v1/tasks`, +`GET /v1/tasks/{task_id}/attempts`) alongside the existing +`/v1/devices`, `/v1/hosts`, `/v1/plugins`, and `POST /v1/plugins` routes. + +## Prerequisites + +- Node.js 20+ (matching the existing `console/` SPA project) +- A running Cloud API (`apps/cloud-api`) reachable from your browser +- A bearer token issued via `CLOUD_PUBLIC_CREDENTIALS_JSON` whose scopes cover + what you intend to do from the console. Recommended least-privilege set: + - `tasks:read` — task list and attempt history views + - `pool:read` — device and host views + - `plugins:read` — plugin list + - Add `tasks:submit`/`plugins:admin` only if you need the write actions from + the same tab. + +## Configure the backend CORS allow-list + +The Cloud API has no CORS middleware by default. Before a browser can call it +cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list +that includes the exact origin your dev server prints (scheme + host + port — +no trailing slash): + +```bash +# Example: allow the default Vite dev origin +export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173" +``` + +Restart `apps/cloud-api` after changing this env. Tokens are still required — +the allow-list only says which browser origins may send them. + +## Run the dev server + +```bash +cd cloud-console +cp .env.example .env.local +# Edit .env.local if your Cloud API is not at http://127.0.0.1:8001 +npm install +npm run dev +``` + +Vite prints a local URL (default `http://127.0.0.1:5173`). Open it, paste a +bearer token, and the task/device/host/plugin dashboards become available. + +`.env.local` overrides the default base URL via `VITE_CLOUD_API_BASE_URL` +(defaults to `http://127.0.0.1:8001`). + +## Build for production + +```bash +npm run build # type-checks with vue-tsc, then emits dist/ +npm run preview # serves the built bundle locally +``` + +`dist/` is a static bundle — host it behind any static file server or CDN and +point it at a deployed Cloud API via `VITE_CLOUD_API_BASE_URL` set at build +time. + +## Token handling + +- The token is held in `sessionStorage` only. Closing the tab discards it. +- Every API request attaches `Authorization: Bearer ` and targets only + the configured `VITE_CLOUD_API_BASE_URL`. +- A `401`/`403` response clears the stored token and returns the operator to + the token-entry screen with the API's error detail. + +## Project layout + +``` +cloud-console/ +├── src/ +│ ├── api.ts # API client wrapper (token storage, fetch, errors) +│ ├── types.ts # TS interfaces mirroring the REST models +│ ├── App.vue # Shell: token gate, nav, view router +│ ├── main.ts # Vue bootstrap +│ ├── style.css # Dark theme styles +│ └── views/ +│ ├── TokenScreen.vue +│ ├── TasksView.vue # list + detail with attempt history +│ ├── DevicesView.vue # device pool + host registry +│ └── PluginsView.vue # registry list + registration form +├── index.html +├── package.json +├── tsconfig.json / tsconfig.node.json +└── vite.config.ts +``` diff --git a/cloud-console/index.html b/cloud-console/index.html new file mode 100644 index 0000000..9b351e7 --- /dev/null +++ b/cloud-console/index.html @@ -0,0 +1,12 @@ + + + + + + Cloud Console + + +
+ + + diff --git a/cloud-console/package-lock.json b/cloud-console/package-lock.json new file mode 100644 index 0000000..5394b31 --- /dev/null +++ b/cloud-console/package-lock.json @@ -0,0 +1,1211 @@ +{ + "name": "cloud-console", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cloud-console", + "version": "0.1.0", + "dependencies": { + "@lucide/vue": "^1.23.0", + "vue": "^3.5.39" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.7", + "typescript": "^6.0.3", + "vite": "^8.1.3", + "vue-tsc": "^3.3.6" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@lucide/vue": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.24.0.tgz", + "integrity": "sha512-5bNPX0G2YEWdUlBYk7pE8SgDg/f1mkIFpJ9vtE44pW/cwRz7Ioc0tOTESoVJAPvxIELSmYekX+XXIJMjsswNIg==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/language-core": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.7.tgz", + "integrity": "sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.18", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", + "integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.7.tgz", + "integrity": "sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.7" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/cloud-console/package.json b/cloud-console/package.json new file mode 100644 index 0000000..2cfdd64 --- /dev/null +++ b/cloud-console/package.json @@ -0,0 +1,22 @@ +{ + "name": "cloud-console", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview --host 127.0.0.1", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@lucide/vue": "^1.23.0", + "vue": "^3.5.39" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.7", + "typescript": "^6.0.3", + "vite": "^8.1.3", + "vue-tsc": "^3.3.6" + } +} diff --git a/cloud-console/src/App.vue b/cloud-console/src/App.vue new file mode 100644 index 0000000..54bf70a --- /dev/null +++ b/cloud-console/src/App.vue @@ -0,0 +1,116 @@ + + + diff --git a/cloud-console/src/api.ts b/cloud-console/src/api.ts new file mode 100644 index 0000000..b95fe85 --- /dev/null +++ b/cloud-console/src/api.ts @@ -0,0 +1,143 @@ +import type { + DeviceRecord, + HostRecord, + PluginRecord, + PluginRegistrationPayload, + TaskAttempt, + TaskListResponse, + TaskStatus, +} from "./types"; + +const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as + | string + | undefined; +export const API_BASE_URL = ( + configuredBaseUrl || "http://127.0.0.1:8001" +).replace(/\/$/, ""); + +const TOKEN_STORAGE_KEY = "cloudConsole.bearerToken"; + +export const TOKEN_INVALID_EVENT = "cloud-console:token-invalid"; + +export class CloudApiError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + this.name = "CloudApiError"; + } +} + +export function getStoredToken(): string | null { + try { + return sessionStorage.getItem(TOKEN_STORAGE_KEY); + } catch { + return null; + } +} + +export function storeToken(token: string): void { + sessionStorage.setItem(TOKEN_STORAGE_KEY, token); +} + +export function clearStoredToken(): void { + sessionStorage.removeItem(TOKEN_STORAGE_KEY); +} + +interface RequestInitLike { + method?: string; + body?: string | null; + headers?: Record; +} + +async function request(path: string, init: RequestInitLike = {}): Promise { + const token = getStoredToken(); + if (!token) { + throw new CloudApiError(401, "no bearer token stored"); + } + const headers: Record = { + Accept: "application/json", + Authorization: `Bearer ${token}`, + ...init.headers, + }; + if (init.body !== undefined && init.body !== null) { + headers["Content-Type"] = "application/json"; + } + const response = await fetch(`${API_BASE_URL}${path}`, { + method: init.method || "GET", + body: init.body ?? null, + headers, + }); + if (response.status === 401 || response.status === 403) { + clearStoredToken(); + window.dispatchEvent(new CustomEvent(TOKEN_INVALID_EVENT)); + let detail = "token rejected by cloud api"; + try { + const payload = (await response.json()) as { detail?: unknown }; + if (typeof payload.detail === "string") { + detail = payload.detail; + } + } catch { + // fall back to the default detail + } + throw new CloudApiError(response.status, detail); + } + if (!response.ok) { + let message = `${response.status} ${response.statusText}`; + try { + const payload = (await response.json()) as { detail?: unknown }; + if (typeof payload.detail === "string") { + message = payload.detail; + } else if (payload.detail) { + message = JSON.stringify(payload.detail); + } + } catch { + message = await response.text().catch(() => message); + } + throw new CloudApiError(response.status, message); + } + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; +} + +export function listTasks(options?: { + status?: TaskStatus; + limit?: number; + offset?: number; +}): Promise { + const params = new URLSearchParams(); + if (options?.status) params.set("status", options.status); + params.set("limit", String(options?.limit ?? 50)); + params.set("offset", String(options?.offset ?? 0)); + const query = params.toString(); + return request(`/v1/tasks${query ? `?${query}` : ""}`); +} + +export function getTaskAttempts(taskId: string): Promise { + return request( + `/v1/tasks/${encodeURIComponent(taskId)}/attempts`, + ); +} + +export function listDevices(): Promise { + return request("/v1/devices"); +} + +export function listHosts(): Promise { + return request("/v1/hosts"); +} + +export function listPlugins(): Promise { + return request("/v1/plugins"); +} + +export function registerPlugin( + payload: PluginRegistrationPayload, +): Promise { + return request("/v1/plugins", { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/cloud-console/src/env.d.ts b/cloud-console/src/env.d.ts new file mode 100644 index 0000000..1f57a1c --- /dev/null +++ b/cloud-console/src/env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_CLOUD_API_BASE_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/cloud-console/src/main.ts b/cloud-console/src/main.ts new file mode 100644 index 0000000..de275e7 --- /dev/null +++ b/cloud-console/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import "./style.css"; + +createApp(App).mount("#app"); diff --git a/cloud-console/src/style.css b/cloud-console/src/style.css new file mode 100644 index 0000000..3f655e5 --- /dev/null +++ b/cloud-console/src/style.css @@ -0,0 +1,350 @@ +:root { + --bg: #0f172a; + --bg-elev: #1e293b; + --bg-elev-2: #273449; + --border: #334155; + --text: #e2e8f0; + --text-muted: #94a3b8; + --text-dim: #64748b; + --accent: #38bdf8; + --accent-hover: #7dd3fc; + --danger: #f87171; + --danger-bg: #7f1d1d; + --success: #4ade80; + --warning: #fbbf24; + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; +} + +button { + font: inherit; + cursor: pointer; + background: var(--bg-elev-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 12px; + transition: background 0.15s ease; +} + +button:hover:not(:disabled) { + background: var(--border); +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +button.primary { + background: var(--accent); + color: #0b1220; + border-color: var(--accent); +} + +button.primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +input, +select, +textarea { + font: inherit; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 10px; +} + +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--accent); +} + +label { + color: var(--text-muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +a { + color: var(--accent); +} + +.app-shell { + display: flex; + height: 100%; +} + +.app-nav { + width: 220px; + background: var(--bg-elev); + border-right: 1px solid var(--border); + padding: 16px 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.app-nav h1 { + font-size: 14px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + margin: 0 0 12px; +} + +.app-nav button { + text-align: left; + background: transparent; + border-color: transparent; + display: flex; + align-items: center; + gap: 8px; +} + +.app-nav button:hover:not(:disabled) { + background: var(--bg-elev-2); +} + +.app-nav button.active { + background: var(--bg-elev-2); + color: var(--accent); + border-color: var(--border); +} + +.app-nav .spacer { + flex: 1; +} + +.app-main { + flex: 1; + overflow: auto; + padding: 24px 32px; +} + +.toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.toolbar h2 { + margin: 0; + font-size: 20px; +} + +.panel { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +th { + color: var(--text-muted); + font-weight: 500; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +tr.row-selectable { + cursor: pointer; +} + +tr.row-selectable:hover { + background: var(--bg-elev-2); +} + +tr.row-selected { + background: var(--bg-elev-2); +} + +.status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + text-transform: lowercase; +} + +.status-badge.queued, +.status-badge.assigned, +.status-badge.dispatched { + background: rgba(56, 189, 248, 0.18); + color: var(--accent); +} + +.status-badge.done { + background: rgba(74, 222, 128, 0.18); + color: var(--success); +} + +.status-badge.failed { + background: rgba(248, 113, 113, 0.18); + color: var(--danger); +} + +.status-badge.unreachable { + background: rgba(248, 113, 113, 0.18); + color: var(--danger); +} + +.status-badge.idle, +.status-badge.wired { + background: rgba(74, 222, 128, 0.18); + color: var(--success); +} + +.status-badge.busy { + background: rgba(251, 191, 36, 0.18); + color: var(--warning); +} + +.notice { + padding: 10px 12px; + border-radius: 6px; + background: var(--bg-elev-2); + border: 1px solid var(--border); + color: var(--text-muted); +} + +.notice.error { + background: rgba(127, 29, 29, 0.4); + border-color: var(--danger); + color: var(--text); +} + +.notice.success { + background: rgba(34, 197, 94, 0.18); + border-color: var(--success); + color: var(--text); +} + +.token-screen { + max-width: 480px; + margin: 80px auto; + padding: 32px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 12px; +} + +.token-screen h1 { + margin: 0 0 8px; + font-size: 24px; +} + +.token-screen p { + color: var(--text-muted); + margin: 0 0 24px; +} + +.token-screen label { + display: block; + margin-bottom: 6px; +} + +.token-screen textarea { + width: 100%; + min-height: 88px; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace; +} + +.token-screen .actions { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.form-grid label { + display: block; + margin-bottom: 4px; +} + +.form-grid .field-full { + grid-column: 1 / -1; +} + +.muted { + color: var(--text-muted); +} + +.dim { + color: var(--text-dim); + font-size: 12px; +} + +.attempt-result { + font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; +} + +.pagination { + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; + color: var(--text-muted); +} + +.loader { + display: inline-block; + animation: spin 1.2s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/cloud-console/src/types.ts b/cloud-console/src/types.ts new file mode 100644 index 0000000..a8ea8cf --- /dev/null +++ b/cloud-console/src/types.ts @@ -0,0 +1,70 @@ +export type TaskStatus = + | "queued" + | "assigned" + | "dispatched" + | "done" + | "failed"; + +export interface TaskListItem { + id: string; + status: TaskStatus; + goal: string | null; + workflow_definition_id: string | null; + assigned_device_id: string | null; + assigned_host_id: string | null; + attempt_count: number; + failure_reason: string | null; + created_at: string; +} + +export interface TaskListResponse { + items: TaskListItem[]; + total: number; + limit: number; + offset: number; +} + +export interface TaskAttempt { + task_id: string; + attempt: number; + lease_id: string; + host_id: string; + device_id: string; + status: string; + lease_expires_at: string; + created_at: string; + completed_at: string | null; + failure_reason: string | null; + terminal_result: Record | null; +} + +export interface DeviceRecord { + device_id: string; + host_id: string; + driver_type: string; + status: string; + capability_tags: string[]; +} + +export interface HostRecord { + host_id: string; + address: string | null; + last_seen_at: string; +} + +export type PluginEntryPointKind = "driver" | "tool" | "skill"; + +export interface PluginRecord { + name: string; + version: string; + entry_point_kind: string; + target: string; + wired: boolean; +} + +export interface PluginRegistrationPayload { + name: string; + version: string; + entry_point_kind: PluginEntryPointKind; + target: string; +} diff --git a/cloud-console/src/views/DevicesView.vue b/cloud-console/src/views/DevicesView.vue new file mode 100644 index 0000000..2c35795 --- /dev/null +++ b/cloud-console/src/views/DevicesView.vue @@ -0,0 +1,167 @@ + + + diff --git a/cloud-console/src/views/PluginsView.vue b/cloud-console/src/views/PluginsView.vue new file mode 100644 index 0000000..a0d022c --- /dev/null +++ b/cloud-console/src/views/PluginsView.vue @@ -0,0 +1,191 @@ + + + diff --git a/cloud-console/src/views/TasksView.vue b/cloud-console/src/views/TasksView.vue new file mode 100644 index 0000000..2f7c626 --- /dev/null +++ b/cloud-console/src/views/TasksView.vue @@ -0,0 +1,305 @@ + + + diff --git a/cloud-console/src/views/TokenScreen.vue b/cloud-console/src/views/TokenScreen.vue new file mode 100644 index 0000000..e590795 --- /dev/null +++ b/cloud-console/src/views/TokenScreen.vue @@ -0,0 +1,51 @@ + + + diff --git a/cloud-console/tsconfig.json b/cloud-console/tsconfig.json new file mode 100644 index 0000000..b18cac7 --- /dev/null +++ b/cloud-console/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/cloud-console/tsconfig.node.json b/cloud-console/tsconfig.node.json new file mode 100644 index 0000000..91566d1 --- /dev/null +++ b/cloud-console/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/cloud-console/vite.config.ts b/cloud-console/vite.config.ts new file mode 100644 index 0000000..6ea8547 --- /dev/null +++ b/cloud-console/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], +}); diff --git a/docs/CLOUD_DEPLOYMENT.md b/docs/CLOUD_DEPLOYMENT.md index 9562b80..d4ea8af 100644 --- a/docs/CLOUD_DEPLOYMENT.md +++ b/docs/CLOUD_DEPLOYMENT.md @@ -183,6 +183,74 @@ finally: PY ``` +## Cloud Console (Web UI) + +The repository ships an independent Vue 3 + Vite SPA at `cloud-console/` that +renders the task queue/history, device pool, host registry, and plugin +registry, and exposes the existing plugin-registration action. It authenticates +the same way `CloudClient` does: by attaching a pre-issued bearer token to +every request. There is no login or session system. + +### Provision an operator bearer token + +Add a `CLOUD_PUBLIC_CREDENTIALS_JSON` entry whose scopes cover what the +console operators need to do. The least-privilege set for read-only dashboards +is `tasks:read`, `pool:read`, and `plugins:read`. Add `tasks:submit` only if +operators should submit ad-hoc tasks from the same tab, and `plugins:admin` +only if operators should register plugins: + +```json +[ + { + "principal_id": "console-operator", + "token": "replace-with-a-long-random-opaque-token", + "scopes": ["tasks:read", "pool:read", "plugins:read", "plugins:admin"] + } +] +``` + +Rotate the token the same way as any other credential entry: deploy the +updated Cloud API credential set and instruct operators to paste the new token +into the console. The console keeps the token only in browser `sessionStorage` +for that tab; closing the tab discards it. + +### Configure the CORS allow-list + +The Cloud API has no CORS middleware by default. Before a browser can call it +cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list +that includes the exact origin (scheme + host + port, no trailing slash) the +operator's browser will load the console from: + +```bash +# Allow a local Vite dev server +export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173" +# Or a deployed origin +export CLOUD_CONSOLE_CORS_ORIGINS="https://console.example.com" +``` + +Restart the Cloud API after changing this env. The middleware is added only +when the allow-list is non-empty — existing deployments see no behavior change +until an operator opts in. Blanket `allow_origins=["*"]` is intentionally not +supported because every console request carries a bearer token. + +### Run the console + +```bash +cd cloud-console +cp .env.example .env.local +# Edit .env.local if your Cloud API is not at http://127.0.0.1:8001 +npm install +npm run dev +``` + +Vite prints a local URL (default `http://127.0.0.1:5173`). That exact origin +must be in `CLOUD_CONSOLE_CORS_ORIGINS` on the Cloud API. Open the dev URL, +paste the operator token, and the dashboards become available. + +For a production build, run `npm run build` and serve the resulting `dist/` +behind any static file server or CDN, with `VITE_CLOUD_API_BASE_URL` baked in +at build time. The deployed origin must be in `CLOUD_CONSOLE_CORS_ORIGINS`. + ## Runtime AI Planner The Host Agent reuses the local Runtime planner. AI planning is disabled by diff --git a/openspec/changes/cloud-console/tasks.md b/openspec/changes/cloud-console/tasks.md index 936f33d..66e2cdd 100644 --- a/openspec/changes/cloud-console/tasks.md +++ b/openspec/changes/cloud-console/tasks.md @@ -1,42 +1,42 @@ ## 1. Repository: bounded task listing -- [ ] 1.1 Add `list_tasks(*, status, limit, offset)` and `count_tasks(status)` to the `CloudRepository` Protocol in `repository.py` -- [ ] 1.2 Implement both methods in `sql_repository.py` using the existing SQLAlchemy query builder (no dialect-specific SQL), ordered most-recent-first -- [ ] 1.3 Add unit/integration tests covering status filtering, pagination bounds, and empty results against both SQLite and PostgreSQL +- [x] 1.1 Add `list_tasks(*, status, limit, offset)` and `count_tasks(status)` to the `CloudRepository` Protocol in `repository.py` +- [x] 1.2 Implement both methods in `sql_repository.py` using the existing SQLAlchemy query builder (no dialect-specific SQL), ordered most-recent-first +- [x] 1.3 Add unit/integration tests covering status filtering, pagination bounds, and empty results against both SQLite and PostgreSQL ## 2. Platform SDK API: task listing & attempt history -- [ ] 2.1 Add response models (task summary list item, task attempt) to `cloud/sdk/models.py` -- [ ] 2.2 Implement `GET /v1/tasks` in `cloud/sdk/api.py`: `tasks:read` scope, optional `status` query param, `limit` (default 50, max 100) / `offset` query params, calling the new repository methods -- [ ] 2.3 Implement `GET /v1/tasks/{task_id}/attempts` in `cloud/sdk/api.py`: `tasks:read` scope, 404 on unknown task id, calling `list_task_attempts` -- [ ] 2.4 Add tests for both endpoints: filtered/unfiltered listing, page-size-exceeds-max rejection, attempts for known/unknown task id, and scope enforcement (401/403) +- [x] 2.1 Add response models (task summary list item, task attempt) to `cloud/sdk/models.py` +- [x] 2.2 Implement `GET /v1/tasks` in `cloud/sdk/api.py`: `tasks:read` scope, optional `status` query param, `limit` (default 50, max 100) / `offset` query params, calling the new repository methods +- [x] 2.3 Implement `GET /v1/tasks/{task_id}/attempts` in `cloud/sdk/api.py`: `tasks:read` scope, 404 on unknown task id, calling `list_task_attempts` +- [x] 2.4 Add tests for both endpoints: filtered/unfiltered listing, page-size-exceeds-max rejection, attempts for known/unknown task id, and scope enforcement (401/403) ## 3. Python SDK client parity -- [ ] 3.1 Add `list_tasks(...)` and `get_task_attempts(task_id)` methods to `CloudClient` in `cloud/sdk/client.py` -- [ ] 3.2 Add client tests asserting parity with direct HTTP calls to the two new endpoints +- [x] 3.1 Add `list_tasks(...)` and `get_task_attempts(task_id)` methods to `CloudClient` in `cloud/sdk/client.py` +- [x] 3.2 Add client tests asserting parity with direct HTTP calls to the two new endpoints ## 4. Cloud API CORS configuration -- [ ] 4.1 Add a `cors_allowed_origins` field (env `CLOUD_CONSOLE_CORS_ORIGINS`, comma-separated, default empty) to `CloudControlConfig`/`load_control_config()` -- [ ] 4.2 Wire `CORSMiddleware` into `apps/cloud-api/cloud_api/app.py`'s `create_app()`, added only when the allow-list is non-empty -- [ ] 4.3 Add a config/app test confirming CORS headers are absent by default and present only for a configured origin +- [x] 4.1 Add a `cors_allowed_origins` field (env `CLOUD_CONSOLE_CORS_ORIGINS`, comma-separated, default empty) to `CloudControlConfig`/`load_control_config()` +- [x] 4.2 Wire `CORSMiddleware` into `apps/cloud-api/cloud_api/app.py`'s `create_app()`, added only when the allow-list is non-empty +- [x] 4.3 Add a config/app test confirming CORS headers are absent by default and present only for a configured origin ## 5. Cloud console frontend (independent SPA) -- [ ] 5.1 Scaffold an independent Vue 3 + Vite SPA project at `cloud-console/` (own `package.json`/build tooling, sibling to `console/`) -- [ ] 5.2 Implement the token-entry screen and an API client wrapper that stores the bearer token in `sessionStorage` and attaches it to every request, clearing it and returning to the entry screen on `401`/`403` -- [ ] 5.3 Implement the task view: filterable/paginated list against `GET /v1/tasks`, and a detail view with attempt history against `GET /v1/tasks/{id}/attempts` -- [ ] 5.4 Implement the device pool and host registry views against `GET /v1/devices` and `GET /v1/hosts` -- [ ] 5.5 Implement the plugin registry view (list) and registration form against `GET /v1/plugins` and `POST /v1/plugins`, surfacing validation/conflict/authorization errors from the API -- [ ] 5.6 Document how to run the frontend dev server against a Cloud API base URL (env config) and the CORS origin it needs configured +- [x] 5.1 Scaffold an independent Vue 3 + Vite SPA project at `cloud-console/` (own `package.json`/build tooling, sibling to `console/`) +- [x] 5.2 Implement the token-entry screen and an API client wrapper that stores the bearer token in `sessionStorage` and attaches it to every request, clearing it and returning to the entry screen on `401`/`403` +- [x] 5.3 Implement the task view: filterable/paginated list against `GET /v1/tasks`, and a detail view with attempt history against `GET /v1/tasks/{id}/attempts` +- [x] 5.4 Implement the device pool and host registry views against `GET /v1/devices` and `GET /v1/hosts` +- [x] 5.5 Implement the plugin registry view (list) and registration form against `GET /v1/plugins` and `POST /v1/plugins`, surfacing validation/conflict/authorization errors from the API +- [x] 5.6 Document how to run the frontend dev server against a Cloud API base URL (env config) and the CORS origin it needs configured ## 6. Documentation -- [ ] 6.1 Add a section to `docs/CLOUD_DEPLOYMENT.md` covering: running the console, provisioning an operator bearer token (least-privilege scopes), and configuring `CLOUD_CONSOLE_CORS_ORIGINS` +- [x] 6.1 Add a section to `docs/CLOUD_DEPLOYMENT.md` covering: running the console, provisioning an operator bearer token (least-privilege scopes), and configuring `CLOUD_CONSOLE_CORS_ORIGINS` ## 7. Verification -- [ ] 7.1 Run the full backend test suite (`uv run --all-packages pytest -m "not integration"`) and confirm no regressions +- [x] 7.1 Run the full backend test suite (`uv run --all-packages pytest -m "not integration"`) and confirm no regressions - [ ] 7.2 Run the PostgreSQL-backed repository/integration tests for the new listing methods - [ ] 7.3 Manually verify end-to-end: submit a task via the existing SDK, confirm it appears in the console's task list, transitions status, and its attempt history renders; confirm device/host/plugin views render against a running Host Agent diff --git a/packages/cloud-platform/cloud/control_config.py b/packages/cloud-platform/cloud/control_config.py index 0f747b3..5ae0e61 100644 --- a/packages/cloud-platform/cloud/control_config.py +++ b/packages/cloud-platform/cloud/control_config.py @@ -32,6 +32,7 @@ class CloudControlConfig: allow_insecure_anonymous: bool = False credentials: tuple[BearerCredential, ...] = () enrollment_credentials: tuple[EnrollmentCredential, ...] = () + cors_allowed_origins: tuple[str, ...] = () def load_control_config( @@ -90,6 +91,9 @@ def load_control_config( enrollment_credentials=_parse_enrollment_credentials( values.get("CLOUD_ENROLLMENT_TOKENS_JSON") ), + cors_allowed_origins=_parse_cors_origins( + values.get("CLOUD_CONSOLE_CORS_ORIGINS") + ), ) validate_control_config(config) return config @@ -219,3 +223,9 @@ def _parse_bool(value: str | None, *, default: bool) -> bool: if normalized in {"0", "false", "no", "off", "disabled", ""}: return False raise CloudConfigurationError("boolean configuration value is invalid") + + +def _parse_cors_origins(raw_value: str | None) -> tuple[str, ...]: + if raw_value is None or not raw_value.strip(): + return () + return tuple(origin.strip() for origin in raw_value.split(",") if origin.strip()) diff --git a/packages/cloud-platform/cloud/repository.py b/packages/cloud-platform/cloud/repository.py index 46afe67..e4fa663 100644 --- a/packages/cloud-platform/cloud/repository.py +++ b/packages/cloud-platform/cloud/repository.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol if TYPE_CHECKING: from cloud.plugins import PluginManifest from cloud.pool import HostRegistration, PooledDevice - from cloud.scheduler import ScheduledTask + from cloud.scheduler import ScheduledTask, ScheduledTaskStatus AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"] @@ -154,6 +154,16 @@ class CloudRepository(Protocol): def list_queued_tasks(self) -> list[ScheduledTask]: ... + def list_tasks( + self, + *, + status: ScheduledTaskStatus | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[ScheduledTask]: ... + + def count_tasks(self, status: ScheduledTaskStatus | None = None) -> int: ... + def get_task(self, task_id: str) -> ScheduledTask | None: ... def update_task( diff --git a/packages/cloud-platform/cloud/sdk/api.py b/packages/cloud-platform/cloud/sdk/api.py index 6fc2011..617aa98 100644 --- a/packages/cloud-platform/cloud/sdk/api.py +++ b/packages/cloud-platform/cloud/sdk/api.py @@ -10,7 +10,7 @@ authentication can be added later without changing route signatures. from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from cloud.auth import ( PLUGINS_ADMIN_SCOPE, @@ -28,11 +28,14 @@ from cloud.sdk.models import ( HostResponse, PluginRegistrationRequest, PluginResponse, + TaskAttemptResponse, + TaskListItem, + TaskListResponse, TaskStatusResponse, TaskSubmissionRequest, TaskSubmissionResponse, ) -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, HTTPException, Query, Request, status if TYPE_CHECKING: from cloud.plugins import PluginRegistry @@ -112,6 +115,76 @@ def create_cloud_router( failure_reason=task.failure_reason, ) + @router.get("/tasks", response_model=TaskListResponse) + def list_tasks( + request: Request, + status_filter: Literal[ + "queued", "assigned", "dispatched", "done", "failed" + ] + | None = Query(default=None, alias="status"), + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + ) -> TaskListResponse: + _authorize(request, TASKS_READ_SCOPE) + tasks = scheduler.store.list_tasks( + status=status_filter, + limit=limit, + offset=offset, + ) + total = scheduler.store.count_tasks(status=status_filter) + return TaskListResponse( + items=[ + TaskListItem( + id=task.id, + status=task.status, + goal=task.goal, + workflow_definition_id=task.workflow_definition_id, + assigned_device_id=task.assigned_device_id, + assigned_host_id=task.assigned_host_id, + attempt_count=task.attempt_count, + failure_reason=task.failure_reason, + created_at=task.created_at, + ) + for task in tasks + ], + total=total, + limit=limit, + offset=offset, + ) + + @router.get( + "/tasks/{task_id}/attempts", + response_model=list[TaskAttemptResponse], + ) + def list_task_attempts( + task_id: str, + request: Request, + ) -> list[TaskAttemptResponse]: + _authorize(request, TASKS_READ_SCOPE) + task = scheduler.store.get_task(task_id) + if task is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"task {task_id!r} not found", + ) + attempts = scheduler.store.list_task_attempts(task_id) + return [ + TaskAttemptResponse( + task_id=attempt.task_id, + attempt=attempt.attempt, + lease_id=attempt.lease_id, + host_id=attempt.host_id, + device_id=attempt.device_id, + status=attempt.status, + lease_expires_at=attempt.lease_expires_at, + created_at=attempt.created_at, + completed_at=attempt.completed_at, + failure_reason=attempt.failure_reason, + terminal_result=attempt.terminal_result, + ) + for attempt in attempts + ] + @router.get("/devices", response_model=list[DeviceResponse]) def list_devices(request: Request) -> list[DeviceResponse]: _authorize(request, POOL_READ_SCOPE) diff --git a/packages/cloud-platform/cloud/sdk/client.py b/packages/cloud-platform/cloud/sdk/client.py index f050fce..9597ead 100644 --- a/packages/cloud-platform/cloud/sdk/client.py +++ b/packages/cloud-platform/cloud/sdk/client.py @@ -89,6 +89,23 @@ class CloudClient: resp = self._request("GET", f"/tasks/{task_id}") return resp.json() + def list_tasks( + self, + *, + status: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> dict[str, Any]: + params: dict[str, Any] = {"limit": limit, "offset": offset} + if status is not None: + params["status"] = status + resp = self._request("GET", "/tasks", params=params) + return resp.json() + + def get_task_attempts(self, task_id: str) -> list[dict[str, Any]]: + resp = self._request("GET", f"/tasks/{task_id}/attempts") + return resp.json() + # ----------------------------------------------------------------- devices def list_devices(self) -> list[dict[str, Any]]: @@ -133,11 +150,13 @@ class CloudClient: path: str, *, json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, ) -> httpx.Response: response = self._http.request( method, self._url(path), json=json, + params=params, headers=self._headers, auth=self._auth, ) diff --git a/packages/cloud-platform/cloud/sdk/models.py b/packages/cloud-platform/cloud/sdk/models.py index b8b6c3f..7528f9f 100644 --- a/packages/cloud-platform/cloud/sdk/models.py +++ b/packages/cloud-platform/cloud/sdk/models.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, Field @@ -35,6 +35,39 @@ class TaskStatusResponse(BaseModel): failure_reason: str | None = None +class TaskListItem(BaseModel): + id: str + status: str + goal: str | None = None + workflow_definition_id: str | None = None + assigned_device_id: str | None = None + assigned_host_id: str | None = None + attempt_count: int = 0 + failure_reason: str | None = None + created_at: datetime + + +class TaskListResponse(BaseModel): + items: list[TaskListItem] + total: int + limit: int + offset: int + + +class TaskAttemptResponse(BaseModel): + task_id: str + attempt: int + lease_id: str + host_id: str + device_id: str + status: str + lease_expires_at: datetime + created_at: datetime + completed_at: datetime | None = None + failure_reason: str | None = None + terminal_result: dict[str, Any] | None = None + + class DeviceResponse(BaseModel): device_id: str host_id: str diff --git a/packages/cloud-platform/cloud/sql_repository.py b/packages/cloud-platform/cloud/sql_repository.py index a6718a0..d709442 100644 --- a/packages/cloud-platform/cloud/sql_repository.py +++ b/packages/cloud-platform/cloud/sql_repository.py @@ -368,6 +368,36 @@ class SQLAlchemyCloudRepository: ).all() return [_task_from_row(row) for row in rows] + def list_tasks( + self, + *, + status: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[Any]: + with self._sessions() as session: + statement = select(ScheduledTaskRow) + if status is not None: + statement = statement.where(ScheduledTaskRow.status == status) + statement = ( + statement.order_by( + ScheduledTaskRow.created_at.desc(), + ScheduledTaskRow.id.desc(), + ) + .limit(limit) + .offset(offset) + ) + rows = session.scalars(statement).all() + return [_task_from_row(row) for row in rows] + + def count_tasks(self, status: str | None = None) -> int: + with self._sessions() as session: + statement = select(func.count()).select_from(ScheduledTaskRow) + if status is not None: + statement = statement.where(ScheduledTaskRow.status == status) + count = session.scalar(statement) + return int(count or 0) + def get_task(self, task_id: str) -> Any | None: with self._sessions() as session: row = session.get(ScheduledTaskRow, task_id) diff --git a/tests/test_cloud_client.py b/tests/test_cloud_client.py index c2e2a9c..56b4211 100644 --- a/tests/test_cloud_client.py +++ b/tests/test_cloud_client.py @@ -150,6 +150,8 @@ def test_client_applies_bearer_token_to_every_public_method(tmp_path) -> None: task_id = client.submit_task(goal="authenticated")["task_id"] assert client.get_task_status(task_id)["status"] == "queued" + assert client.list_tasks()["total"] == 1 + assert client.get_task_attempts(task_id) == [] assert client.list_devices() == [] assert client.list_hosts() == [] assert client.list_plugins() == [] @@ -164,6 +166,74 @@ def test_client_applies_bearer_token_to_every_public_method(tmp_path) -> None: ) +def test_client_list_tasks_and_get_attempts_match_direct_http(tmp_path) -> None: + from datetime import UTC, datetime + + from core.models import Device + + client, pool = _client_and_pool(tmp_path) + pool.sync_host_devices( + "host-a", + [Device(id="dev-a", driver_type="wda", status="idle")], # type: ignore[arg-type] + ) + first_id = client.submit_task(goal="first")["task_id"] + second_id = client.submit_task(goal="second")["task_id"] + + # Drive one task through an attempt so get_task_attempts has data. + scheduler = TaskScheduler(pool, CloudStore(tmp_path / "cloud.sqlite3"), _config()) + scheduler.assign() + task = scheduler.store.get_task(first_id) + if task is not None and task.status == "assigned": + scheduler.store.record_task_result( + task_id=first_id, + attempt=task.attempt_count, + lease_id=task.lease_id or "", + host_id=task.assigned_host_id or "", + status="failed", + failure_reason="boom", + terminal_result={"exit_code": 1}, + completed_at=datetime.now(UTC), + ) + + # The TestClient is the HTTP boundary — issuing direct calls through it + # exercises the same FastAPI routes the client does, which is the parity + # contract platform-sdk already relies on. + http_client = client._http # type: ignore[attr-defined] + direct_list = http_client.get( + client._url("/tasks"), # type: ignore[attr-defined] + params={"limit": 50, "offset": 0}, + headers=client._headers, # type: ignore[attr-defined] + ).json() + direct_attempts = http_client.get( + f"{client._url('/tasks')}/{first_id}/attempts", # type: ignore[attr-defined] + headers=client._headers, # type: ignore[attr-defined] + ).json() + + via_client = client.list_tasks() + assert via_client == direct_list + assert via_client["total"] == 2 + # Most-recent-first: second (the newer) before first. + assert [item["id"] for item in via_client["items"]] == [second_id, first_id] + + failed_only = client.list_tasks(status="failed") + assert failed_only["total"] == 1 + assert failed_only["items"][0]["id"] == first_id + + attempts = client.get_task_attempts(first_id) + assert attempts == direct_attempts + assert len(attempts) == 1 + assert attempts[0]["status"] == "failed" + assert attempts[0]["failure_reason"] == "boom" + + +def test_client_get_attempts_raises_for_unknown_task(tmp_path) -> None: + import httpx + + client, _ = _client_and_pool(tmp_path) + with pytest.raises(httpx.HTTPStatusError): + client.get_task_attempts("does-not-exist") + + def test_client_raises_typed_authorization_error_without_exposing_token( tmp_path, ) -> None: diff --git a/tests/test_cloud_repository_contract.py b/tests/test_cloud_repository_contract.py index d93e0b6..15c5046 100644 --- a/tests/test_cloud_repository_contract.py +++ b/tests/test_cloud_repository_contract.py @@ -73,6 +73,8 @@ def test_cloud_repository_exposes_crud_and_atomic_lease_operations() -> None: "list_devices", "enqueue_task", "get_task", + "list_tasks", + "count_tasks", "save_plugin", "assign_task", "claim_assignment", @@ -453,6 +455,105 @@ def test_task_attempt_history_is_ordered_and_complete(database_url: str) -> None database.close() +def test_list_tasks_returns_empty_when_repository_has_no_tasks( + database_url: str, +) -> None: + database = CloudDatabase(database_url) + try: + assert database.repository.list_tasks() == [] + assert database.repository.count_tasks() == 0 + assert database.repository.list_tasks(status="queued") == [] + assert database.repository.count_tasks(status="queued") == 0 + finally: + database.close() + + +def test_list_tasks_returns_most_recent_first_with_optional_status_filter( + database_url: str, +) -> None: + database = CloudDatabase(database_url) + base = datetime(2026, 7, 12, 6, 0, tzinfo=UTC) + queued_ids = [_unique_id("list-task") for _ in range(2)] + failed_ids = [_unique_id("list-task") for _ in range(2)] + + try: + for index, task_id in enumerate(queued_ids): + database.repository.enqueue_task( + ScheduledTask( + id=task_id, + goal="queued goal", + workflow_definition_id=None, + constraints=TaskConstraints(), + status="queued", + created_at=base + timedelta(seconds=index), + ) + ) + for index, task_id in enumerate(failed_ids): + database.repository.enqueue_task( + ScheduledTask( + id=task_id, + goal="failed goal", + workflow_definition_id=None, + constraints=TaskConstraints(), + status="failed", + failure_reason="boom", + created_at=base + timedelta(seconds=10 + index), + ) + ) + + unfiltered = database.repository.list_tasks() + assert [task.id for task in unfiltered] == ( + list(reversed(failed_ids)) + list(reversed(queued_ids)) + ) + assert database.repository.count_tasks() == 4 + + queued = database.repository.list_tasks(status="queued") + assert [task.id for task in queued] == list(reversed(queued_ids)) + assert all(task.status == "queued" for task in queued) + assert database.repository.count_tasks(status="queued") == 2 + + failed = database.repository.list_tasks(status="failed") + assert [task.id for task in failed] == list(reversed(failed_ids)) + assert database.repository.count_tasks(status="failed") == 2 + + # A status with no matches returns an empty page and zero count. + assert database.repository.list_tasks(status="done") == [] + assert database.repository.count_tasks(status="done") == 0 + finally: + database.close() + + +def test_list_tasks_pagination_bounds(database_url: str) -> None: + database = CloudDatabase(database_url) + base = datetime(2026, 7, 12, 7, 0, tzinfo=UTC) + task_ids = [_unique_id("page-task") for _ in range(4)] + + try: + for index, task_id in enumerate(task_ids): + database.repository.enqueue_task( + ScheduledTask( + id=task_id, + goal=f"goal-{index}", + workflow_definition_id=None, + constraints=TaskConstraints(), + created_at=base + timedelta(seconds=index), + ) + ) + + # Most-recent-first ordering means page 1 returns the newest two ids. + page_one = database.repository.list_tasks(limit=2, offset=0) + assert [task.id for task in page_one] == [task_ids[3], task_ids[2]] + + page_two = database.repository.list_tasks(limit=2, offset=2) + assert [task.id for task in page_two] == [task_ids[1], task_ids[0]] + + # An offset past the end of the result set returns an empty page, + # not an error — the caller is expected to consult count_tasks(). + assert database.repository.list_tasks(limit=10, offset=100) == [] + finally: + database.close() + + def test_atomic_assignment_creates_lease_attempt_and_reservation( database_url: str, ) -> None: diff --git a/tests/test_cloud_sdk_api.py b/tests/test_cloud_sdk_api.py index d2a0ac5..3162829 100644 --- a/tests/test_cloud_sdk_api.py +++ b/tests/test_cloud_sdk_api.py @@ -272,6 +272,8 @@ def test_submit_with_constraints(tmp_path) -> None: [ ("post", "/v1/tasks", {"goal": "x"}, "tasks:submit"), ("get", "/v1/tasks/missing", None, "tasks:read"), + ("get", "/v1/tasks", None, "tasks:read"), + ("get", "/v1/tasks/missing/attempts", None, "tasks:read"), ("get", "/v1/devices", None, "pool:read"), ("get", "/v1/hosts", None, "pool:read"), ("get", "/v1/plugins", None, "plugins:read"), @@ -332,6 +334,102 @@ def test_every_public_route_enforces_its_scope( assert authorized.status_code not in {401, 403} +def test_list_tasks_returns_summary_with_pagination_and_status_filter( + tmp_path, +) -> None: + app, pool, scheduler, _ = _build_app(tmp_path) + # Submit three tasks; assign one so the population covers multiple statuses. + first_id = scheduler.submit(goal="first") + second_id = scheduler.submit(goal="second") + pool.sync_host_devices( + "host-a", + [Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type] + ) + scheduler.assign() + # assigned_id is whichever task the scheduler picked (oldest-first = first_id). + assigned_id = first_id + + client = _client_for(app) + + unfiltered = client.get("/v1/tasks").json() + assert unfiltered["total"] == 2 + assert unfiltered["limit"] == 50 + assert unfiltered["offset"] == 0 + assert [item["id"] for item in unfiltered["items"]] == [second_id, assigned_id] + # Lease id must not leak through the summary surface. + assert all("lease_id" not in item for item in unfiltered["items"]) + + queued_only = client.get("/v1/tasks", params={"status": "queued"}).json() + assert queued_only["total"] == 1 + assert [item["id"] for item in queued_only["items"]] == [second_id] + assert all(item["status"] == "queued" for item in queued_only["items"]) + + assigned_only = client.get( + "/v1/tasks", params={"status": "assigned"} + ).json() + assert assigned_only["total"] == 1 + assert [item["id"] for item in assigned_only["items"]] == [assigned_id] + + +def test_list_tasks_rejects_page_size_above_maximum(tmp_path) -> None: + app, _, _, _ = _build_app(tmp_path) + client = _client_for(app) + + too_large = client.get("/v1/tasks", params={"limit": 101}) + assert too_large.status_code == 422 + # And the boundary value is accepted. + boundary = client.get("/v1/tasks", params={"limit": 100}) + assert boundary.status_code == 200 + + +def test_list_tasks_rejects_negative_offset(tmp_path) -> None: + app, _, _, _ = _build_app(tmp_path) + client = _client_for(app) + response = client.get("/v1/tasks", params={"offset": -1}) + assert response.status_code == 422 + + +def test_list_task_attempts_returns_chronological_history(tmp_path) -> None: + app, pool, scheduler, _ = _build_app(tmp_path) + pool.sync_host_devices( + "host-a", + [Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type] + ) + task_id = scheduler.submit(goal="attempt me") + scheduler.assign() + task = scheduler.store.get_task(task_id) + scheduler.store.record_task_result( + task_id=task_id, + attempt=task.attempt_count, + lease_id=task.lease_id or "", + host_id=task.assigned_host_id or "", + status="failed", + failure_reason="boom", + terminal_result={"exit_code": 1}, + completed_at=datetime.now(UTC), + ) + + client = _client_for(app) + resp = client.get(f"/v1/tasks/{task_id}/attempts") + assert resp.status_code == 200, resp.text + body = resp.json() + assert len(body) == 1 + assert body[0]["task_id"] == task_id + assert body[0]["status"] == "failed" + assert body[0]["failure_reason"] == "boom" + assert body[0]["terminal_result"] == {"exit_code": 1} + assert body[0]["host_id"] == "host-a" + assert body[0]["device_id"] == "device-a" + + +def test_list_task_attempts_returns_404_for_unknown_task(tmp_path) -> None: + app, _, _, _ = _build_app(tmp_path) + client = _client_for(app) + resp = client.get("/v1/tasks/does-not-exist/attempts") + assert resp.status_code == 404, resp.text + assert "does-not-exist" in resp.json()["detail"] + + def test_plugin_admin_scope_is_checked_before_registration( tmp_path, monkeypatch,