diff --git a/internal/cloud/hwpod-topology-read-model.test.ts b/internal/cloud/hwpod-topology-read-model.test.ts index 16348dd1..a2a59b5a 100644 --- a/internal/cloud/hwpod-topology-read-model.test.ts +++ b/internal/cloud/hwpod-topology-read-model.test.ts @@ -58,6 +58,9 @@ test("typed topology covers multi-device, offline, unconfigured, invalid, mismat assert.equal(nodeA.diagnostic.message.includes("[REDACTED]"), true); assert.equal(nodeA.diagnostic.valuesRedacted, true); assert.equal(nodeA.readiness.find((stage: any) => stage.id === "desktop-visible").status, "ready"); + assert.equal(nodeA.readiness.find((stage: any) => stage.id === "desktop-visible").nextAction, null); + assert.equal(nodeA.readiness.find((stage: any) => stage.id === "hwpod-ready").nextAction.entrypoint, "hwpod-topology"); + assert.equal(nodePayload.items.find((item: any) => item.nodeId === "node-b").readiness.find((stage: any) => stage.id === "workspace-ready").blocker.code, "hwpod_node_spec_missing"); assert.equal(nodePayload.items.find((item: any) => item.nodeId === "node-b").status, "unconfigured"); assert.equal(nodePayload.items.find((item: any) => item.nodeId === "node-c").status, "offline"); assert.equal(nodePayload.invalidSpecs[0].blocker.code, "invalid_hwpod_spec"); diff --git a/internal/cloud/hwpod-topology-read-model.ts b/internal/cloud/hwpod-topology-read-model.ts index ffa50071..272abab4 100644 --- a/internal/cloud/hwpod-topology-read-model.ts +++ b/internal/cloud/hwpod-topology-read-model.ts @@ -9,6 +9,15 @@ export const HWPOD_TOPOLOGY_CONTRACT_VERSION = "hwpod-topology-v1"; const DEFAULT_PAGE_LIMIT = 50; const MAX_PAGE_LIMIT = 100; +export const HWPOD_NODE_READINESS_DEFINITIONS = [ + { id: "downloaded", label: "已下载", authority: "user-device", nextAction: { label: "从节点接入页下载并校验已发布 Python 单文件", entrypoint: "cloud-web-hwpod-onboarding" } }, + { id: "installed", label: "已安装", authority: "python-node-runtime", nextAction: { label: "按 owning YAML 选择的交互用户与原生 Python 启动节点", entrypoint: "yaml-managed-hwpod-node" } }, + { id: "desktop-visible", label: "桌面可见", authority: "python-node-runtime", nextAction: { label: "在目标交互登录会话确认窗口或托盘可见", entrypoint: "hwlab-node-ui" } }, + { id: "registered", label: "已注册", authority: "websocket-registry", nextAction: { label: "保持节点运行并通过受控节点状态入口检查注册", entrypoint: "hwpod-node-status" } }, + { id: "workspace-ready", label: "工作区就绪", authority: "hwpod-spec-probe", nextAction: { label: "通过显式 readiness 探测检查 owning workspace", entrypoint: "hwpod-readiness-probe" } }, + { id: "hwpod-ready", label: "HWPOD 就绪", authority: "hwpod-topology", nextAction: { label: "检查挂载 HWPOD 的 blocker、能力与占用状态", entrypoint: "hwpod-topology" } } +] as const; + export function buildHwpodTopologyReadModel(specs: any[], registrySnapshot: any = {}, options: any = {}) { const observedAt = text(options.observedAt) || new Date().toISOString(); const onlineNodes = normalizeOnlineNodes(registrySnapshot); @@ -267,17 +276,18 @@ function nodeReadiness(input: any) { const localObserved = Boolean(input.onlineNode); const stageBlocker = input.blockers[0] ?? null; return [ - readinessStage("downloaded", "已下载", input.onlineNode?.installed === true ? "ready" : "unknown", "python-node-runtime", input.onlineNode?.installed === true ? null : blocker("hwpod_download_not_observed", "云端不以浏览器下载动作作为安装事实。", false)), - readinessStage("installed", "已安装", input.onlineNode?.installed === true ? "ready" : "unknown", "python-node-runtime", input.onlineNode?.installed === true ? null : blocker("hwpod_install_not_observed", "等待 Python 节点报告安装运行时。", true)), - readinessStage("desktop-visible", "桌面可见", input.onlineNode?.desktopVisible === true ? "ready" : localObserved ? "blocked" : "unknown", "python-node-runtime", input.onlineNode?.desktopVisible === true ? null : blocker("hwpod_desktop_not_visible", "尚未证明图形界面与托盘属于交互登录会话。", true)), - readinessStage("registered", "已注册", input.online ? "ready" : "blocked", "websocket-registry", input.online ? null : blocker("hwpod_node_offline", "未观测到已认证的主动出站 WebSocket。", true)), - readinessStage("workspace-ready", "工作区就绪", input.workspaceReady ? "ready" : "blocked", "hwpod-spec-probe", input.workspaceReady ? null : stageBlocker), - readinessStage("hwpod-ready", "HWPOD 就绪", input.hwpodReady && !input.busy ? "ready" : "blocked", "hwpod-topology", input.hwpodReady && !input.busy ? null : stageBlocker) + readinessStage("downloaded", input.onlineNode?.installed === true ? "ready" : "unknown", input.onlineNode?.installed === true ? null : blocker("hwpod_download_not_observed", "云端不以浏览器下载动作作为安装事实。", false)), + readinessStage("installed", input.onlineNode?.installed === true ? "ready" : "unknown", input.onlineNode?.installed === true ? null : blocker("hwpod_install_not_observed", "等待 Python 节点报告安装运行时。", true)), + readinessStage("desktop-visible", input.onlineNode?.desktopVisible === true ? "ready" : localObserved ? "blocked" : "unknown", input.onlineNode?.desktopVisible === true ? null : blocker("hwpod_desktop_not_visible", "尚未证明图形界面与托盘属于交互登录会话。", true)), + readinessStage("registered", input.online ? "ready" : "blocked", input.online ? null : blocker("hwpod_node_offline", "未观测到已认证的主动出站 WebSocket。", true)), + readinessStage("workspace-ready", input.workspaceReady ? "ready" : "blocked", input.workspaceReady ? null : stageBlocker ?? blocker("hwpod_workspace_not_ready", "工作区尚未通过显式 readiness 探测。", true)), + readinessStage("hwpod-ready", input.hwpodReady && !input.busy ? "ready" : "blocked", input.hwpodReady && !input.busy ? null : stageBlocker ?? blocker("hwpod_not_ready", "尚未观测到可用且空闲的挂载 HWPOD。", true)) ]; } -function readinessStage(id: string, label: string, status: string, authority: string, stageBlocker: any) { - return { id, label, status, authority, blocker: stageBlocker }; +function readinessStage(id: string, status: string, stageBlocker: any) { + const definition = HWPOD_NODE_READINESS_DEFINITIONS.find((stage) => stage.id === id)!; + return { ...definition, status, blocker: stageBlocker, nextAction: status === "ready" ? null : definition.nextAction }; } function invalidSpecSummary(spec: any) { diff --git a/internal/cloud/server-hwpod-http.ts b/internal/cloud/server-hwpod-http.ts index 2719c3f3..222673ef 100644 --- a/internal/cloud/server-hwpod-http.ts +++ b/internal/cloud/server-hwpod-http.ts @@ -15,7 +15,8 @@ import { hwpodSpecWorkspaceProbePlan } from "./hwpod-spec-discovery.ts"; import { - buildHwpodTopologyReadModel + buildHwpodTopologyReadModel, + HWPOD_NODE_READINESS_DEFINITIONS } from "./hwpod-topology-read-model.ts"; import { getHeader, @@ -231,14 +232,7 @@ function hwlabNodeReleaseNotes(env) { } function hwlabNodeReadinessStages() { - return [ - { id: "downloaded", label: "已下载", authority: "user-device" }, - { id: "installed", label: "已安装", authority: "python-node-runtime" }, - { id: "desktop-visible", label: "桌面可见", authority: "python-node-runtime" }, - { id: "registered", label: "已注册", authority: "websocket-registry" }, - { id: "workspace-ready", label: "工作区就绪", authority: "hwpod-spec-probe" }, - { id: "hwpod-ready", label: "HWPOD 就绪", authority: "hwpod-topology" } - ]; + return HWPOD_NODE_READINESS_DEFINITIONS.map((stage) => ({ ...stage, nextAction: { ...stage.nextAction } })); } function hwlabNodeDownloadUrl(env) { diff --git a/web/hwlab-cloud-web/src/components/hwpod/HwpodOnboardingWorkspace.vue b/web/hwlab-cloud-web/src/components/hwpod/HwpodOnboardingWorkspace.vue index 2c17a6cd..26ded5f6 100644 --- a/web/hwlab-cloud-web/src/components/hwpod/HwpodOnboardingWorkspace.vue +++ b/web/hwlab-cloud-web/src/components/hwpod/HwpodOnboardingWorkspace.vue @@ -61,7 +61,7 @@ const formatSize = (value: number): string => v-if="metadata?.artifact.downloadUrl" class="download-button" :href="metadata.artifact.downloadUrl" - download="hwlab-node.py" + :download="metadata.artifact.fileName" >下载 Python 单文件

发布 artifact 当前不可用。

@@ -92,29 +92,21 @@ const formatSize = (value: number): string =>
  1. 下载并固定文件 -

    将单文件保存到用户可写目录,不要从浏览器临时目录长期运行。

    +

    将单文件保存到 owning YAML 允许的用户可写目录,不要从浏览器临时目录长期运行。

    - 查看文件建议 - mkdir C:\HWLAB - move .\{{ metadata?.artifact.fileName || "hwlab-node.py" }} C:\HWLAB\hwlab-node.py + 查看配置边界 + 实际运行目录、交互用户和工作区根由 owning YAML 与受控节点配置拥有,网页不猜测目标路径。
  2. 校验 SHA-256

    将本机结果与页面上方发布 SHA 完整比对,不一致时停止运行并重新下载。

    -
    - 查看 PowerShell 命令 - Get-FileHash C:\HWLAB\hwlab-node.py -Algorithm SHA256 -
    +
    查看校验要求使用 Windows 本机文件哈希工具校验实际下载文件,文件名以发布元数据为准。
  3. 在交互用户会话启动 -

    使用本机 Python 3 启动图形节点,保持窗口或托盘程序运行。

    -
    - 查看启动命令 - py -3 C:\HWLAB\hwlab-node.py - python C:\HWLAB\hwlab-node.py -
    +

    使用 owning YAML 选择的 Windows 交互用户与原生 Python 启动图形节点,保持窗口或托盘程序运行。

    +
    查看启动边界不得使用 WSL Python、SSH 辅助解释器、Bun 运行器或非交互 Windows 服务冒充完成态。
  4. 选择 Node 并观察 readiness diff --git a/web/hwlab-cloud-web/src/components/hwpod/HwpodReadinessRail.vue b/web/hwlab-cloud-web/src/components/hwpod/HwpodReadinessRail.vue index b96a5515..a173bf24 100644 --- a/web/hwlab-cloud-web/src/components/hwpod/HwpodReadinessRail.vue +++ b/web/hwlab-cloud-web/src/components/hwpod/HwpodReadinessRail.vue @@ -17,6 +17,7 @@ defineProps<{ stages: HwpodNodeReadinessStage[] }>(); {{ stage.label }} {{ stage.authority }}

    {{ stage.blocker.summary }}

    +

    下一步{{ stage.nextAction.label }}{{ stage.nextAction.entrypoint }}

    {{ stage.status === 'ready' ? '已就绪' : stage.status === 'blocked' ? '阻塞' : '待观测' }}
  5. @@ -33,6 +34,9 @@ li[data-status="blocked"] .stage-index { border-color: #b46b4b; background: #fff .readiness-rail strong { display: block; color: #1d302c; font-size: .86rem; } .readiness-rail small { color: #71807b; font: .67rem/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; } .readiness-rail p { margin: 4px 0 0; color: #79503b; font-size: .75rem; line-height: 1.4; } +.stage-action { display: grid; gap: 2px; color: #31564d !important; } +.stage-action strong { font-size: .7rem; } +.stage-action code { color: #687872; font-size: .66rem; overflow-wrap: anywhere; } .stage-status { margin-top: 4px; color: #5e6f69; font-size: .7rem; font-weight: 800; } li[data-status="ready"] .stage-status { color: #2c7567; } li[data-status="blocked"] .stage-status { color: #9b4f2d; } diff --git a/web/hwlab-cloud-web/src/stores/hwpod.ts b/web/hwlab-cloud-web/src/stores/hwpod.ts index d0b3cc92..9375c8bb 100644 --- a/web/hwlab-cloud-web/src/stores/hwpod.ts +++ b/web/hwlab-cloud-web/src/stores/hwpod.ts @@ -26,6 +26,7 @@ export const useHwpodStore = defineStore("hwpod", () => { const consoleLoading = ref(false); const consoleError = ref(null); const consoleStale = ref(false); + let consoleRequestId = 0; async function refresh(): Promise { loading.value = true; @@ -38,12 +39,14 @@ export const useHwpodStore = defineStore("hwpod", () => { } async function refreshConsole(resource: "device" | "node" | "onboarding", query: TopologyQuery = {}): Promise { + const requestId = ++consoleRequestId; consoleLoading.value = true; consoleError.value = null; consoleStale.value = false; try { if (resource === "device") { const result = await hwpodAPI.devices(query); + if (requestId !== consoleRequestId) return; if (result.ok && result.data) deviceTopology.value = result.data; else { consoleError.value = result.error ?? "HWPOD 设备拓扑不可用"; @@ -51,6 +54,7 @@ export const useHwpodStore = defineStore("hwpod", () => { } } else if (resource === "node") { const result = await hwpodAPI.nodes(query); + if (requestId !== consoleRequestId) return; if (result.ok && result.data) nodeTopology.value = result.data; else { consoleError.value = result.error ?? "HWPOD Node 拓扑不可用"; @@ -58,6 +62,7 @@ export const useHwpodStore = defineStore("hwpod", () => { } } else { const [nodesResult, updateResult] = await Promise.all([hwpodAPI.nodes(query), hwpodAPI.nodeUpdate()]); + if (requestId !== consoleRequestId) return; if (nodesResult.ok && nodesResult.data) nodeTopology.value = nodesResult.data; if (updateResult.ok && updateResult.data) nodeUpdate.value = updateResult.data; if (!nodesResult.ok || !updateResult.ok) { @@ -66,10 +71,11 @@ export const useHwpodStore = defineStore("hwpod", () => { } } } catch (cause) { + if (requestId !== consoleRequestId) return; consoleError.value = cause instanceof Error ? cause.message : "HWPOD 控制台刷新失败"; consoleStale.value = resource === "device" ? Boolean(deviceTopology.value) : resource === "node" ? Boolean(nodeTopology.value) : Boolean(nodeTopology.value || nodeUpdate.value); } finally { - consoleLoading.value = false; + if (requestId === consoleRequestId) consoleLoading.value = false; } } diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 2389d28d..0df701ea 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -454,6 +454,7 @@ export interface HwpodNodeReadinessStage { status: HwpodReadinessStatus; authority: string; blocker: HwpodBlocker | null; + nextAction: { label: string; entrypoint: string } | null; } export interface HwpodNodeTopologyItem { @@ -511,7 +512,7 @@ export interface HwlabNodeUpdateMetadata { releaseNotes: string[]; releaseNotesUrl: string | null; installConditions: Array<{ id: string; label: string; required: boolean }>; - readinessStages: Array<{ id: string; label: string; authority: string }>; + readinessStages: Array<{ id: string; label: string; authority: string; nextAction: { label: string; entrypoint: string } }>; observedAt: string; } export interface CaseRunCaseSummary { caseId: string; title?: string; mode?: string; available?: boolean; hwpodSpec?: string; subject?: Record | null; expected?: Record | null; runtime?: Record | null; [key: string]: unknown } diff --git a/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue b/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue index f43b2466..4800425e 100644 --- a/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue +++ b/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue @@ -34,7 +34,7 @@ const tab = computed(() => { return value === "nodes" || value === "onboarding" ? value : "devices"; }); const pathEntityId = computed(() => tab.value === "devices" ? text(route.params.hwpodId) : text(route.params.nodeId)); -const effectiveQuery = computed(() => viewState.q.value || pathEntityId.value); +const effectiveQuery = computed(() => pathEntityId.value || viewState.q.value); const effectiveCursor = computed(() => pathEntityId.value ? "" : viewState.cursor.value); const view = viewState.view; const density = viewState.density; @@ -51,7 +51,7 @@ const onboardingStages = computed(() => selectedNode. const artifact = computed(() => hwpod.nodeUpdate?.artifact ?? null); const sortField = computed(() => viewState.sort.value.replace(/^-/, "") || "name"); const sortDirection = computed<"asc" | "desc">(() => viewState.sort.value.startsWith("-") ? "desc" : "asc"); -const hasContent = computed(() => tab.value === "devices" ? devices.value.length > 0 : tab.value === "nodes" ? nodes.value.length > 0 : Boolean(hwpod.nodeUpdate)); +const hasContent = computed(() => tab.value === "devices" ? devices.value.length > 0 : tab.value === "nodes" ? nodes.value.length > 0 : Boolean(hwpod.nodeUpdate) || nodes.value.length > 0); const boundaryState = computed(() => { if (hwpod.consoleError) return hwpod.consoleStale && hasContent.value ? "partial" as const : "error" as const; if (hwpod.consoleLoading) return hasContent.value ? "refreshing" as const : "initial-loading" as const; @@ -99,6 +99,10 @@ function goTab(next: ConsoleTab): void { const query = collectionQuery(); delete query.cursor; delete query.filter; + if (next === "onboarding") { + delete query.q; + delete query.sort; + } void router.push({ path: `/hwpods/${next}`, query }); }