From 003ea357f9b47b1b2b9ff17f5cc0dd3e8834e7ef Mon Sep 17 00:00:00 2001 From: host Date: Sat, 31 Oct 2026 02:35:38 +0000 Subject: [PATCH] =?UTF-8?q?feat(console):=20CHV=20production-grade=20front?= =?UTF-8?q?end=20=E2=80=94=20worker=20availability,=20attachment=20guard,?= =?UTF-8?q?=20queue=20hint,=20build:console=20fix,=20vitest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 + cloud/console-react/README.md | 20 +- cloud/console-react/package-lock.json | 253 +++++++++++++++++- cloud/console-react/package.json | 6 +- cloud/console-react/scripts/copy-build.mjs | 20 +- cloud/console-react/src/api/chv.test.ts | 62 +++++ cloud/console-react/src/api/chv.ts | 51 ++++ .../src/sections/DispatchSection.tsx | 32 ++- .../console-react/src/sections/EnvSection.tsx | 4 +- .../src/sections/TaskDetailPanel.tsx | 4 + .../src/sections/TaskListSection.tsx | 5 + .../src/sections/WorkersSection.tsx | 7 + cloud/console.html | 60 ++--- 13 files changed, 474 insertions(+), 55 deletions(-) create mode 100644 cloud/console-react/src/api/chv.test.ts create mode 100644 cloud/console-react/src/api/chv.ts diff --git a/README.md b/README.md index 999ce80..acc03f3 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,11 @@ python3 -m cloud.server # 默认 127.0.0.1:8765,生产经 nginx + TLS # cloud-hypervisor,环境接口 allowlist 为 local/docker/cloud-hypervisor); # 需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务一直排队,带附件 # 时建任务即被后端拒绝,拒绝文案经控制台原样展示(“提交失败:…”)。 +# 生产级前端新增:新建任务页与 Workers 区用现有 /workers 展示 CHV worker +# 在线数(非管理员只能看到聚合数时写未知、不写成 0);零 CHV worker 在线 +# 时选 CHV 出强提示但不硬拦提交(worker 随后上线可认领,后端是最终权威); +# 选 CHV 时附件输入禁用并可一键清空;排队中的 CHV 任务在列表与详情里给 +# “可能原因:无 CHV worker 在线”提示(基于 /workers 推断)。 # 详情页只有实验提示,没有 VM 暂停/恢复/关机/快照等控制按钮;VM 状态、 # IP、快照、预热池字段后端任务接口不返回,前端不展示、不编造。 ``` diff --git a/cloud/console-react/README.md b/cloud/console-react/README.md index 07c7084..2d999b3 100644 --- a/cloud/console-react/README.md +++ b/cloud/console-react/README.md @@ -31,20 +31,20 @@ npm run build:console `npm run build:console` = `tsc -b && vite build`(vite-plugin-singlefile 把 JS/CSS 全部内联成一个 `dist/index.html`),再由 `scripts/copy-build.mjs` **原样**拷到 -`../console-redesign/console-react.html`(审阅用),并打印字节数与 -sha256。脚本不改动产物内容。 +`../console.html`(即 GET /console 现读的文件),并打印字节数与 +sha256、校验拷后内容与 dist 一致。脚本不改动产物内容。 +旧目标 `../console-redesign/console-react.html` 在仓库里不存在, +已废弃,不再靠手工拷兜底。 ## 自动部署链路(不留手工步骤) ``` -改 src → npm run build:console → 产物拷到 cloud/console.html → 提交 → 部署 +改 src → npm run build:console → 产物直接拷到 cloud/console.html → 提交 → 部署 ``` -具体到仓库里的一步(由主 agent 在审阅后执行,本项目不部署生产): +具体到仓库里的一步(本项目不部署生产): ```bash -cp console-redesign/console-react.html <仓库>/cloud/console.html -# 或在仓库分支里直接用构建产物覆盖 cloud/console.html git add cloud/console.html && git commit -m "console: React 版" # 随后走仓库已有的 PR → CI → 合并 → deploy.py git 模式部署 ``` @@ -58,10 +58,10 @@ git add cloud/console.html && git commit -m "console: React 版" ## 怎么验证 ```bash -npm run build:console # 必须 0 退出,tsc 无错误 -ls -l ../console-redesign/console-react.html -sha256sum ../console-redesign/console-react.html -grep -E 'src="/assets|href="/assets' ../console-redesign/console-react.html # 应无输出 +npm run build:console # 必须 0 退出,tsc 无错误,且拷后与 dist/index.html 内容一致 +ls -l ../console.html +sha256sum ../console.html dist/index.html # 两者 sha 应相同 +grep -E 'src="/assets|href="/assets' ../console.html # 应无输出 ``` 部署后另行核对(主 agent 执行):公网 `/console` 的字节数/sha 与 diff --git a/cloud/console-react/package-lock.json b/cloud/console-react/package-lock.json index edde064..c97ddc7 100644 --- a/cloud/console-react/package-lock.json +++ b/cloud/console-react/package-lock.json @@ -17,7 +17,8 @@ "@vitejs/plugin-react": "^4.4.1", "typescript": "~5.8.3", "vite": "^6.3.5", - "vite-plugin-singlefile": "^2.2.0" + "vite-plugin-singlefile": "^2.2.0", + "vitest": "^5.0.5" } }, "node_modules/@babel/code-frame": { @@ -1213,6 +1214,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1261,6 +1280,26 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.34", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", @@ -1329,6 +1368,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1368,6 +1417,13 @@ "dev": true, "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1420,6 +1476,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1506,6 +1582,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1542,6 +1628,20 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1694,6 +1794,33 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1853,6 +1980,130 @@ } } }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/cloud/console-react/package.json b/cloud/console-react/package.json index 16fbcc0..5ac17c2 100644 --- a/cloud/console-react/package.json +++ b/cloud/console-react/package.json @@ -7,7 +7,8 @@ "build": "tsc -b && vite build", "dev": "vite", "preview": "vite preview", - "build:console": "npm run build && node scripts/copy-build.mjs" + "build:console": "npm run build && node scripts/copy-build.mjs", + "test": "vitest run" }, "dependencies": { "react": "^19.1.0", @@ -19,6 +20,7 @@ "@vitejs/plugin-react": "^4.4.1", "typescript": "~5.8.3", "vite": "^6.3.5", - "vite-plugin-singlefile": "^2.2.0" + "vite-plugin-singlefile": "^2.2.0", + "vitest": "^5.0.5" } } diff --git a/cloud/console-react/scripts/copy-build.mjs b/cloud/console-react/scripts/copy-build.mjs index 299448c..a95ef2d 100644 --- a/cloud/console-react/scripts/copy-build.mjs +++ b/cloud/console-react/scripts/copy-build.mjs @@ -1,17 +1,23 @@ // build:console 的第二步:把 vite-plugin-singlefile 产出的单文件 // 原样拷到可部署位置。绝不改动产物内容——以后改页面只改 src 再构建。 -// 在本施工目录,目标是 ../console-redesign/console-react.html(主 agent -// 审阅用);进仓库后,主 agent 把同一文件拷到 cloud/console.html。 -import { copyFileSync, statSync } from "node:fs"; -import { createHash } from "node:crypto"; +// 目标就是 GET /console 现读的文件 cloud/console.html +// (cloud/server.py 与 console.html 同目录、每次请求现读), +// 不再拷到仓库里不存在的 cloud/console-redesign/console-react.html, +// 也不再靠手工拷兜底:npm run build:console 一次跑通即产物就位。 +import { copyFileSync } from "node:fs"; +import { createHash, timingSafeEqual } from "node:crypto"; import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const src = join(root, "dist", "index.html"); -const dest = join(root, "..", "console-redesign", "console-react.html"); +const dest = join(root, "..", "console.html"); copyFileSync(src, dest); -const buf = readFileSync(dest); -console.log(`console-react.html ${buf.length} bytes sha256=${createHash("sha256").update(buf).digest("hex")}`); +const srcBuf = readFileSync(src); +const destBuf = readFileSync(dest); +if (srcBuf.length !== destBuf.length || !timingSafeEqual(srcBuf, destBuf)) { + throw new Error("copy-build: dest 与 dist/index.html 内容不一致,构建失败"); +} +console.log(`console.html ${destBuf.length} bytes sha256=${createHash("sha256").update(destBuf).digest("hex")}`); console.log(`built: ${src} -> ${dest}`); diff --git a/cloud/console-react/src/api/chv.test.ts b/cloud/console-react/src/api/chv.test.ts new file mode 100644 index 0000000..fec6f70 --- /dev/null +++ b/cloud/console-react/src/api/chv.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { chvAvailability, chvQueueHint, countChvWorkers, isWorkerAlive } from "./chv"; + +// 纯逻辑测试:只测 chv.ts,不渲染组件、不发请求、不读 localStorage。 +// alive 规则与 WorkersSection 现有口径一致:alive 字段优先, +// 缺失时用 last_seen 距今 <= 120 秒判定在线。 +describe("isWorkerAlive", () => { + it("alive 字段优先", () => { + expect(isWorkerAlive({ executor: "cloud-hypervisor", alive: true })).toBe(true); + expect(isWorkerAlive({ executor: "cloud-hypervisor", alive: false, last_seen: Date.now() / 1000 })).toBe(false); + }); + it("无 alive 时用 last_seen 120 秒门槛", () => { + const now = Date.now() / 1000; + expect(isWorkerAlive({ executor: "cloud-hypervisor", last_seen: now - 10 })).toBe(true); + expect(isWorkerAlive({ executor: "cloud-hypervisor", last_seen: now - 121 })).toBe(false); + expect(isWorkerAlive({ executor: "cloud-hypervisor" })).toBe(false); + }); +}); + +describe("countChvWorkers", () => { + it("只数 cloud-hypervisor,不数 docker/local", () => { + const now = Date.now() / 1000; + expect(countChvWorkers([ + { executor: "cloud-hypervisor", alive: true }, + { executor: "cloud-hypervisor", last_seen: now - 9999 }, + { executor: "docker", alive: true }, + ])).toEqual({ online: 1, total: 2 }); + }); +}); + +describe("chvAvailability", () => { + it("有清单时按清单数", () => { + expect(chvAvailability([{ executor: "cloud-hypervisor", alive: true }], "", "")).toEqual( + { state: "known", online: 1, total: 1 }, + ); + }); + it("无清单但聚合文案时是未知,不是 0", () => { + expect(chvAvailability(null, "在线 2 / 离线 1(共 3;完整 worker 清单仅全局管理员可见)", "")).toEqual( + { state: "unknown", reason: "aggregate-only" }, + ); + }); + it("无 worker 注册时是已知 0", () => { + expect(chvAvailability(null, "无 worker 注册", "")).toEqual({ state: "known", online: 0, total: 0 }); + }); + it("加载失败时是未知", () => { + expect(chvAvailability(null, "", "加载失败:x")).toEqual({ state: "unknown", reason: "load-failed" }); + }); + it("尚未加载时是未知", () => { + expect(chvAvailability(null, "未加载", "")).toEqual({ state: "unknown", reason: "not-loaded" }); + }); +}); + +describe("chvQueueHint", () => { + it("仅 CHV + 排队 + 已知零在线时给提示", () => { + const knownZero = { state: "known" as const, online: 0, total: 0 }; + expect(chvQueueHint("cloud-hypervisor", "queued", knownZero)).toContain("可能原因"); + expect(chvQueueHint("docker", "queued", knownZero)).toBe(""); + expect(chvQueueHint("cloud-hypervisor", "running", knownZero)).toBe(""); + expect(chvQueueHint("cloud-hypervisor", "queued", { state: "unknown" as const, reason: "aggregate-only" })).toBe(""); + expect(chvQueueHint("cloud-hypervisor", "queued", { state: "known" as const, online: 1, total: 1 })).toBe(""); + }); +}); diff --git a/cloud/console-react/src/api/chv.ts b/cloud/console-react/src/api/chv.ts new file mode 100644 index 0000000..4decbe6 --- /dev/null +++ b/cloud/console-react/src/api/chv.ts @@ -0,0 +1,51 @@ +import type { Worker } from "./types"; +import { workerAgeText } from "./client"; + +// CHV 可用性纯逻辑:不发请求、不拼 URL,只从 ctx 已有的 +// /workers 数据推断。非管理员的 /workers 只返回聚合数 +// (workers_total/online/offline),没有按执行器的分项, +// 此时 CHV 在线数是未知,绝不能把未知写成 0,也绝不能 +// 把聚合在线数当成 CHV 在线数。 +export function isWorkerAlive(w: Worker): boolean { + if (w.alive !== undefined && w.alive !== null) return !!w.alive; + const info = workerAgeText(w); + return info.age !== null && info.age <= 120; +} + +export function countChvWorkers(workers: Worker[]): { online: number; total: number } { + const chv = workers.filter((w) => w.executor === "cloud-hypervisor"); + return { online: chv.filter(isWorkerAlive).length, total: chv.length }; +} + +export type ChvAvailability = + | { state: "known"; online: number; total: number } + | { state: "unknown"; reason: "not-loaded" | "aggregate-only" | "load-failed" }; + +export function chvAvailability( + workers: Worker[] | null, + workersSummary: string, + workersError: string, +): ChvAvailability { + if (workers) return { state: "known", ...countChvWorkers(workers) }; + if (workersError) return { state: "unknown", reason: "load-failed" }; + if (workersSummary.includes("无 worker 注册")) return { state: "known", online: 0, total: 0 }; + if (workersSummary.includes("完整 worker 清单仅全局管理员可见")) { + return { state: "unknown", reason: "aggregate-only" }; + } + return { state: "unknown", reason: "not-loaded" }; +} + +// 排队原因提示:只在“已知零 CHV worker 在线 + CHV 任务排队”时 +// 给出,文案明确写“可能原因”,且不编造后端未返回的 +// VM 状态/IP/快照。未知时返回空串(不提示、不猜测)。 +export function chvQueueHint( + executor: string | undefined | null, + status: string | undefined | null, + availability: ChvAvailability, +): string { + if (executor !== "cloud-hypervisor" || status !== "queued") return ""; + if (availability.state === "known" && availability.online === 0) { + return "可能原因:当前无 cloud-hypervisor worker 在线,任务在排队(基于 /workers 推断;VM 状态/IP/快照等后端未返回字段不展示)。请先确认 KVM 宿主上的 CHV worker 已起,worker 上线后可认领。"; + } + return ""; +} diff --git a/cloud/console-react/src/sections/DispatchSection.tsx b/cloud/console-react/src/sections/DispatchSection.tsx index 05db3ef..04f7237 100644 --- a/cloud/console-react/src/sections/DispatchSection.tsx +++ b/cloud/console-react/src/sections/DispatchSection.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from "react"; import { useConsole } from "../context"; import { Card, Bar } from "../components/ui"; import { gatherAttachments } from "../api/client"; +import { chvAvailability } from "../api/chv"; import { EFFORTS } from "../api/types"; export default function DispatchSection() { @@ -22,7 +23,15 @@ export default function DispatchSection() { const filesRef = useRef(null); const dirRef = useRef(null); const imgRef = useRef(null); + const [hasAttachments, setHasAttachments] = useState(false); const [msg, setMsg] = useState(""); + const chvAvail = chvAvailability(ctx.workers, ctx.workersSummary, ctx.workersError); + const isChv = executor === "cloud-hypervisor"; + function refreshAttachments() { + setHasAttachments( + !!(filesRef.current?.files?.length || dirRef.current?.files?.length || imgRef.current?.files?.length), + ); + } async function submit() { setMsg(""); @@ -60,6 +69,7 @@ export default function DispatchSection() { if (goalMode || goalInvincible || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; } } const atts = gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null); + if (isChv && atts.length) { setMsg("cloud-hypervisor 任务不支持附件:请先清空附件再提交(后端对带附件的 CHV 任务会 400)"); return; } try { const result = await ctx.dispatch(body, atts); setMsg(result); @@ -67,6 +77,7 @@ export default function DispatchSection() { if (filesRef.current) filesRef.current.value = ""; if (dirRef.current) dirRef.current.value = ""; if (imgRef.current) imgRef.current.value = ""; + setHasAttachments(false); } catch (e: any) { setMsg("提交失败:" + (e?.message || String(e))); } } @@ -84,7 +95,19 @@ export default function DispatchSection() { - {executor === "cloud-hypervisor" ?
实验执行器,默认关闭:需后端已开启 CHV worker 且宿主有 KVM,否则任务无人认领、一直排队;带附件时建任务即被后端拒绝(错误文案原样显示在下方)。前端只传 executor 值,不绕过后端白名单;默认仍是 docker。
: null} + {isChv ?
实验执行器,默认关闭:需后端已开启 CHV worker 且宿主有 KVM,否则任务无人认领、一直排队;带附件时建任务即被后端拒绝(错误文案原样显示在下方)。前端只传 executor 值,不绕过后端白名单;默认仍是 docker。
: null} + {isChv ? ( +
+ {chvAvail.state === "known" + ? <>CHV worker 在线 {chvAvail.online} / 共 {chvAvail.total}(来自 /workers){chvAvail.online === 0 ? :当前无 CHV worker 在线,任务将一直排队,请先确认宿主 worker 已起;本提示不拦提交,worker 随后上线可认领,后端是最终权威。 : null} + : chvAvail.reason === "aggregate-only" + ? "CHV worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。" + : chvAvail.reason === "load-failed" + ? "CHV worker 在线数未知:/workers 加载失败,请到 Workers 区刷新后重试。" + : "CHV worker 在线数未知:/workers 尚未加载,请到 Workers 区刷新。"} + {" "} +
+ ) : null}