diff --git a/internal/cloud/hwpod-topology-read-model.test.ts b/internal/cloud/hwpod-topology-read-model.test.ts index a2a59b5a..bcc8dbbb 100644 --- a/internal/cloud/hwpod-topology-read-model.test.ts +++ b/internal/cloud/hwpod-topology-read-model.test.ts @@ -28,7 +28,7 @@ test("typed topology covers multi-device, offline, unconfigured, invalid, mismat runtimeKind: "python-tkinter-gui", installed: true, desktopVisible: true, - capabilities: ["workspace.ls"], + capabilities: ["workspace.ls", "node.health"], inFlightCount: 1, maxInFlight: 1, lastDiagnostic: { level: "ERROR", source: "ws", message: "connect failed: Bearer private-token?token=still-secret" }, @@ -44,15 +44,24 @@ test("typed topology covers multi-device, offline, unconfigured, invalid, mismat assert.equal(devicePayload.summary.nodeCount, 3); assert.equal(devicePayload.summary.deviceCount, 3); assert.equal(devicePayload.summary.invalidSpecCount, 1); - assert.equal(devicePayload.items.find((item: any) => item.hwpodId === "hwpod-a").status, "busy"); - assert.equal(devicePayload.items.find((item: any) => item.hwpodId === "hwpod-b").status, "capability-mismatch"); - assert.deepEqual(devicePayload.items.find((item: any) => item.hwpodId === "hwpod-b").missingCapabilities, ["debug.download"]); + const deviceA = devicePayload.items.find((item: any) => item.hwpodId === "hwpod-a"); + const deviceB = devicePayload.items.find((item: any) => item.hwpodId === "hwpod-b"); + assert.equal(deviceA.status, "online"); + assert.equal(deviceA.busy, true); + assert.equal(deviceA.available, false); + assert.equal(deviceB.status, "online"); + assert.equal(deviceB.capabilityMismatch, true); + assert.deepEqual(deviceB.missingCapabilities, ["debug.download"]); + assert.deepEqual(deviceB.capabilityCoverage, { matched: 1, declared: 2, actual: 2, missing: 1, extra: 1 }); + assert.equal(deviceB.blockers.some((item: any) => item.code === "hwpod_capability_mismatch"), true); assert.equal(devicePayload.items.find((item: any) => item.hwpodId === "hwpod-c").status, "offline"); const nodePayload = buildHwpodTopologyReadModel(specs, registry, { resource: "node", observedAt: "2026-07-13T01:00:00.000Z" }); const nodeA = nodePayload.items.find((item: any) => item.nodeId === "node-a"); assert.equal(nodeA.deviceCount, 2); + assert.equal(nodeA.status, "online"); assert.equal(nodeA.inFlight.busy, true); + assert.deepEqual(nodeA.capabilityCoverage, { matched: 1, declared: 2, actual: 2, missing: 1, extra: 1 }); assert.equal(nodeA.diagnostic.message.includes("private-token"), false); assert.equal(nodeA.diagnostic.message.includes("still-secret"), false); assert.equal(nodeA.diagnostic.message.includes("[REDACTED]"), true); @@ -61,7 +70,8 @@ test("typed topology covers multi-device, offline, unconfigured, invalid, mismat 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-b").status, "online"); + assert.equal(nodePayload.summary.unconfiguredNodeCount, 1); assert.equal(nodePayload.items.find((item: any) => item.nodeId === "node-c").status, "offline"); assert.equal(nodePayload.invalidSpecs[0].blocker.code, "invalid_hwpod_spec"); }); @@ -74,11 +84,11 @@ test("typed topology keeps server-side status filtering and cursor bounded", () { nodeId: "node-b", status: "online", capabilities: ["workspace.ls"], inFlightCount: 0, maxInFlight: 1 } ] }; - const first = buildHwpodTopologyReadModel(specs, registry, { resource: "device", status: "available", limit: 1 }); + const first = buildHwpodTopologyReadModel(specs, registry, { resource: "device", status: "online", limit: 1 }); assert.equal(first.items.length, 1); assert.equal(first.page.total, 2); assert.ok(first.page.nextCursor); - const second = buildHwpodTopologyReadModel(specs, registry, { resource: "device", status: "available", limit: 1, cursor: first.page.nextCursor }); + const second = buildHwpodTopologyReadModel(specs, registry, { resource: "device", status: "online", limit: 1, cursor: first.page.nextCursor }); assert.equal(second.items.length, 1); assert.notEqual(first.items[0].hwpodId, second.items[0].hwpodId); assert.equal(second.page.previousCursor, "b2Zmc2V0OjA"); diff --git a/internal/cloud/hwpod-topology-read-model.ts b/internal/cloud/hwpod-topology-read-model.ts index 272abab4..619d50be 100644 --- a/internal/cloud/hwpod-topology-read-model.ts +++ b/internal/cloud/hwpod-topology-read-model.ts @@ -52,9 +52,9 @@ export function buildHwpodTopologyReadModel(specs: any[], registrySnapshot: any nodeCount: nodes.length, onlineNodeCount: nodes.filter((node) => node.online).length, offlineNodeCount: nodes.filter((node) => !node.online).length, - unconfiguredNodeCount: nodes.filter((node) => node.status === "unconfigured").length, + unconfiguredNodeCount: nodes.filter((node) => node.deviceCount === 0).length, deviceCount: devices.length, - availableDeviceCount: devices.filter((device) => device.status === "available").length, + availableDeviceCount: devices.filter((device) => device.available).length, busyDeviceCount: devices.filter((device) => device.busy).length, capabilityMismatchCount: devices.filter((device) => device.capabilityMismatch).length, invalidSpecCount: invalidSpecs.length @@ -105,12 +105,14 @@ function buildDevice(spec: any, node: any, observedAt: string) { const declaredCapabilities = declaredCapabilitiesFor(body); const actualCapabilities = node?.actualCapabilities ?? []; const missingCapabilities = node ? declaredCapabilities.filter((capability) => !actualCapabilities.includes(capability)) : declaredCapabilities; + const capabilityCoverage = buildCapabilityCoverage(declaredCapabilities, actualCapabilities); const availability = availabilitySummary(spec.availability); const online = Boolean(node?.online); const busy = Boolean(node?.busy); const capabilityMismatch = online && missingCapabilities.length > 0; const blockers = deviceBlockers({ online, busy, capabilityMismatch, missingCapabilities, availability }); - const status = deviceStatus({ online, busy, capabilityMismatch, availability }); + const status = online ? "online" : "offline"; + const available = online && !busy && !capabilityMismatch && availability.ok === true; return { resourceType: "hwpod", hwpodId: text(spec.hwpodId ?? spec.name), @@ -120,12 +122,13 @@ function buildDevice(spec: any, node: any, observedAt: string) { nodeName: node?.name ?? text(spec.nodeId), status, online, - available: status === "available", + available, busy, capabilityMismatch, declaredCapabilities, actualCapabilities, missingCapabilities, + capabilityCoverage, elements: hwpodElements(body), availability, operation: node?.latestOperation?.hwpodId === text(spec.hwpodId ?? spec.name) ? node.latestOperation : null, @@ -144,11 +147,12 @@ function buildNode(nodeId: string, onlineNode: any, devices: any[], observedAt: const declaredCapabilities = uniqueStrings(devices.flatMap((device) => device.declaredCapabilities)); const actualCapabilities = onlineNode?.actualCapabilities ?? []; const missingCapabilities = onlineNode ? declaredCapabilities.filter((capability) => !actualCapabilities.includes(capability)) : declaredCapabilities; + const capabilityCoverage = buildCapabilityCoverage(declaredCapabilities, actualCapabilities); const busy = Boolean(onlineNode?.busy); const online = Boolean(onlineNode?.online); const capabilityMismatch = online && missingCapabilities.length > 0; const hasSpec = devices.length > 0; - const status = !online ? "offline" : !hasSpec ? "unconfigured" : capabilityMismatch ? "capability-mismatch" : busy ? "busy" : "online"; + const status = online ? "online" : "offline"; const blockers = nodeBlockers({ online, hasSpec, busy, capabilityMismatch, missingCapabilities, devices }); const workspaceReady = devices.some((device) => device.availability.ok === true); const hwpodReady = devices.some((device) => device.available); @@ -175,6 +179,7 @@ function buildNode(nodeId: string, onlineNode: any, devices: any[], observedAt: declaredCapabilities, actualCapabilities, missingCapabilities, + capabilityCoverage, capabilityMismatch, inFlight: { count: onlineNode?.inFlightCount ?? 0, @@ -244,13 +249,16 @@ function availabilitySummary(value: any) { }; } -function deviceStatus(input: any) { - if (!input.online) return "offline"; - if (input.capabilityMismatch) return "capability-mismatch"; - if (input.busy) return "busy"; - if (input.availability.ok === false) return "workspace-blocked"; - if (input.availability.ok === true) return "available"; - return "unprobed"; +function buildCapabilityCoverage(declaredCapabilities: string[], actualCapabilities: string[]) { + const matched = declaredCapabilities.filter((capability) => actualCapabilities.includes(capability)).length; + const extra = actualCapabilities.filter((capability) => !declaredCapabilities.includes(capability)).length; + return { + matched, + declared: declaredCapabilities.length, + actual: actualCapabilities.length, + missing: declaredCapabilities.length - matched, + extra + }; } function deviceBlockers(input: any) { diff --git a/web/hwlab-cloud-web/scripts/r6-architecture-parity.test.ts b/web/hwlab-cloud-web/scripts/r6-architecture-parity.test.ts index bad7304f..a860765c 100644 --- a/web/hwlab-cloud-web/scripts/r6-architecture-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/r6-architecture-parity.test.ts @@ -351,6 +351,24 @@ test("R2 visual constraints keep industrial controls bounded and component-owned assert.match(read(relativePath), /ViewModeSwitch/u, `${relativePath} should use the shared view switch`); }); +test("HWPOD topology keeps connection state separate and owns bounded scrolling", () => { + const routes = read("src/router/index.ts"); + for (const path of ["/hwpods/devices/:hwpodId?", "/hwpods/nodes/:nodeId?", "/hwpods/onboarding/:nodeId?"]) { + const route = routes.split(`{ path: "${path}"`)[1]?.split("},")[0] ?? ""; + assert.match(route, /boundedLayout: true/u, `${path} should use bounded AppShell layout`); + } + + const view = read("src/views/admin/HwpodGroupsView.vue"); + assert.match(view, /\.hwpod-console \{[^}]*height: 100%;[^}]*overflow: hidden;/u); + assert.match(view, /\.hwpod-workspace \{[^}]*flex: 1 1 auto;/u); + assert.doesNotMatch(view, /\.hwpod-workspace \{[^}]*height: clamp\(/u); + + const deviceWorkspace = read("src/components/hwpod/HwpodDevicesWorkspace.vue"); + assert.match(deviceWorkspace, /capabilityCoverage\.matched/u); + assert.match(deviceWorkspace, /capabilityCoverage\.declared/u); + assert.doesNotMatch(deviceWorkspace, /actualCapabilities\.length\s*\}\}\/\{\{\s*row\.declaredCapabilities\.length/u); +}); + test("R6 Sub2API source comparison is recorded in parent/child issue scope", () => { const issueBody = readIssueBodyFixture(); assert.match(issueBody, /Wei-Shaw\/sub2api@v0\.1\.136=a2f76e4/u); diff --git a/web/hwlab-cloud-web/src/components/hwpod/HwpodDeviceCard.vue b/web/hwlab-cloud-web/src/components/hwpod/HwpodDeviceCard.vue index 52ce1de4..32665346 100644 --- a/web/hwlab-cloud-web/src/components/hwpod/HwpodDeviceCard.vue +++ b/web/hwlab-cloud-web/src/components/hwpod/HwpodDeviceCard.vue @@ -14,12 +14,8 @@ const props = withDefaults( const emit = defineEmits<{ select: [id: string] }>(); const statusLabel = computed(() => ({ - available: "可用", - busy: "执行中", + online: "在线", offline: "节点离线", - "capability-mismatch": "能力不匹配", - "workspace-blocked": "工作区阻塞", - unprobed: "待探测" }[props.device.status] ?? props.device.status)); function linkState(kind: "target" | "workspace" | "debug" | "io"): string { @@ -80,7 +76,7 @@ function linkState(kind: "target" | "workspace" | "debug" | "io"): string {

{{ device.blockers[0].code }}{{ device.blockers[0].summary }}