diff --git a/internal/cloud/hwpod-node-ops.test.ts b/internal/cloud/hwpod-node-ops.test.ts index 912e8380..b156e4ec 100644 --- a/internal/cloud/hwpod-node-ops.test.ts +++ b/internal/cloud/hwpod-node-ops.test.ts @@ -1,3 +1,8 @@ +/* + * SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。 + * Implementation reference: draft-2026-07-13-p0-cloud-console。 + * Responsibility: 验证 HWPOD typed topology、Node 更新元数据、主动出站调度与并发门禁。 + */ import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; @@ -5,6 +10,7 @@ import path from "node:path"; import { test } from "bun:test"; import { createCloudApiBunServer } from "./bun-server.ts"; +import { createHwpodNodeWsRegistry } from "./hwpod-node-ws-registry.ts"; import { createCloudApiServer } from "./server.ts"; import { connectHwpodNodeWs, createHwpodNodeServer } from "../../tools/src/hwpod-node-lib.ts"; @@ -33,6 +39,8 @@ test("cloud-api exposes hwpod-node-ops contract on /v1", async () => { assert.equal(payload.hwpod.contractVersion, "hwpod-node-ops-v1"); assert.equal(payload.hwpod.apiRole, "node-ops-forwarder"); assert.equal(payload.hwpod.specDiscoveryRoute, "/v1/hwpod/specs"); + assert.equal(payload.hwpod.topologyRoute, "/v1/hwpod/topology"); + assert.equal(payload.hwpod.topologyContractVersion, "hwpod-topology-v1"); assert.ok(payload.hwpod.supportedOps.includes("node.diagnostics")); assert.ok(payload.hwpod.supportedOps.includes("workspace.ls")); assert.ok(payload.hwpod.supportedOps.includes("workspace.insert-after")); @@ -66,6 +74,11 @@ test("cloud-api exposes hwlab-node Python update metadata", async () => { assert.equal(payload.latestVersion, "0.1.1"); assert.equal(payload.downloadUrl, "https://hwlab.pikapython.com/downloads/hwlab-node.py"); assert.equal(payload.sha256, "abc123"); + assert.equal(payload.artifact.fileName, "hwlab-node.py"); + assert.equal(payload.artifact.sha256, "abc123"); + assert.ok(payload.artifact.sizeBytes > 0); + assert.equal(payload.readinessStages.at(-1).id, "hwpod-ready"); + assert.ok(payload.installConditions.some((item: any) => item.id === "outbound-websocket")); const currentResponse = await fetch(`${serverUrl(server)}/v1/hwlab-node/update?current=0.1.1&channel=stable&platform=windows`); const currentPayload = await currentResponse.json(); @@ -85,15 +98,18 @@ test("cloud-api serves bundled hwlab-node Python updater artifact by default", a const payload = await response.json(); assert.equal(response.status, 200); assert.equal(payload.updateAvailable, true); - assert.equal(payload.latestVersion, "0.1.1"); + assert.equal(payload.latestVersion, "0.1.2"); assert.equal(payload.downloadUrl, "https://hwlab.pikapython.com/v1/hwlab-node/download/hwlab-node.py"); assert.match(payload.sha256, /^[a-f0-9]{64}$/u); + assert.equal(payload.artifact.version, "0.1.2"); + assert.equal(payload.artifact.fileName, "hwlab-node.py"); + assert.ok(payload.artifact.sizeBytes > 0); const download = await fetch(`${serverUrl(server)}/v1/hwlab-node/download/hwlab-node.py`); const text = await download.text(); assert.equal(download.status, 200); - assert.equal(download.headers.get("x-hwlab-node-version"), "0.1.1"); - assert.ok(text.includes('APP_VERSION = "0.1.1"')); + assert.equal(download.headers.get("x-hwlab-node-version"), "0.1.2"); + assert.ok(text.includes('APP_VERSION = "0.1.2"')); } finally { await close(server); } @@ -187,6 +203,50 @@ test("cloud-api probes discovered hwpod-spec availability through node-ops", asy } }); +test("cloud-api serves the typed HWPOD topology read model from its existing owner", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-topology-")); + const probes: any[] = []; + const specDir = path.join(root, ".hwlab"); + await mkdir(specDir, { recursive: true }); + await writeFile(path.join(specDir, "hwpod-spec.yaml"), sampleSpecYaml(), "utf8"); + const server = createHwpodTestCloudApiServer({ + env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: root }, + hwpodNodeOpsHandler: async (plan: any, context: any) => { + probes.push({ plan, context }); + return { ok: true, status: "completed", planId: plan.planId, results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed" })) }; + }, + hwpodNodeWsRegistry: { + hasNode: () => false, + dispatch: async () => ({ ok: false, status: "blocked", results: [] }), + describe: () => ({ + mode: "hwpod-node-outbound-native-ws", + connectedCount: 1, + pendingCount: 0, + nodes: [{ nodeId: "node-d601-f103-v2", name: "D601 Python Node", status: "online", platform: "win32", version: "0.1.2-python-gui", capabilities: ["workspace.ls"], inFlightCount: 0, maxInFlight: 1 }] + }) + } + }); + await listen(server); + try { + const response = await fetch(`${serverUrl(server)}/v1/hwpod/topology?resource=node`); + const payload = await response.json(); + assert.equal(response.status, 200); + assert.equal(payload.contractVersion, "hwpod-topology-v1"); + assert.equal(payload.owner, "cloud-api-hwpod-domain"); + assert.equal(payload.items[0].nodeId, "node-d601-f103-v2"); + assert.equal(payload.items[0].deviceCount, 1); + assert.equal(payload.items[0].capabilityMismatch, true); + assert.equal(probes.length, 0); + const probeResponse = await fetch(`${serverUrl(server)}/v1/hwpod/topology?resource=node&probe=1`); + assert.equal(probeResponse.status, 200); + assert.equal(probes.length, 1); + assert.equal(probes[0].context.requestMeta.operationKind, "readiness-probe"); + } finally { + await close(server); + await rm(root, { recursive: true, force: true }); + } +}); + test("cloud-api forwards valid hwpod-node-ops plans to injected node handler", async () => { const seen: any[] = []; const server = createHwpodTestCloudApiServer({ @@ -373,6 +433,26 @@ test("cloud-api dispatches hwpod-node-ops to outbound WebSocket hwpod-node by no } }); +test("WebSocket registry keeps readiness probes separate and rejects dispatch above maxInFlight", async () => { + const sent: any[] = []; + const socket = { send(value: string) { sent.push(JSON.parse(value)); }, close() {} }; + const registry = createHwpodNodeWsRegistry({ now: () => "2026-07-13T02:00:00.000Z" }); + registry.openBunSocket(socket); + registry.handleBunMessage(socket, JSON.stringify({ type: "register", nodeId: "pc-host-1", name: "PC Host", capabilities: ["workspace.ls"], maxInFlight: 1 })); + const plan = samplePlan(); + plan.nodeId = "pc-host-1"; + const probePromise = registry.dispatch(plan, { requestId: "req_probe", operationKind: "readiness-probe" }, { timeoutMs: 5000 }); + const busy = await registry.dispatch(plan, { requestId: "req_user" }, { timeoutMs: 5000 }); + assert.equal(busy.blocker.code, "hwpod_node_busy"); + assert.equal(registry.describe().nodes[0].latestOperation, null); + assert.equal(registry.describe().nodes[0].latestReadinessProbe.status, "running"); + registry.handleBunMessage(socket, JSON.stringify({ type: "hwpod-node-ops-result", requestId: "req_probe", nodeId: "pc-host-1", result: { ok: true, status: "completed", results: [] } })); + await probePromise; + assert.equal(registry.describe().nodes[0].latestReadinessProbe.status, "completed"); + assert.equal(sent.filter((message) => message.type === "hwpod-node-ops").length, 1); + registry.closeBunSocket(socket); +}); + test("cloud-api rejects unsupported hwpod-node ops before forwarding", async () => { const server = createHwpodTestCloudApiServer({ hwpodNodeOpsHandler: async () => { diff --git a/internal/cloud/hwpod-node-ws-registry.ts b/internal/cloud/hwpod-node-ws-registry.ts index 4b8cc4dd..89c4ea8c 100644 --- a/internal/cloud/hwpod-node-ws-registry.ts +++ b/internal/cloud/hwpod-node-ws-registry.ts @@ -1,3 +1,8 @@ +/* + * SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。 + * Implementation reference: draft-2026-07-13-p0-cloud-console。 + * Responsibility: 管理主动出站 HWPOD Node 连接、并发门禁,以及用户操作与 readiness 探测的独立摘要。 + */ import { randomUUID } from "node:crypto"; const DEFAULT_DISPATCH_TIMEOUT_MS = 30000; @@ -9,6 +14,14 @@ type HwpodNodeConnection = { name: string | null; capabilities: string[]; labels: Record; + runtime: { + kind: string | null; + installed: boolean | null; + desktopVisible: boolean | null; + }; + maxInFlight: number; + latestOperation: Record | null; + latestReadinessProbe: Record | null; diagnostics: any[]; firstSeenAt: string; lastSeenAt: string; @@ -17,6 +30,9 @@ type HwpodNodeConnection = { type PendingDispatch = { nodeId: string; requestId: string; + connection: HwpodNodeConnection; + operationKind: "user-operation" | "readiness-probe"; + operation: Record; timer: ReturnType; resolve: (value: any) => void; }; @@ -37,6 +53,10 @@ export function createHwpodNodeWsRegistry(options: any = {}) { name: null, capabilities: [], labels: {}, + runtime: { kind: null, installed: null, desktopVisible: null }, + maxInFlight: 1, + latestOperation: null, + latestReadinessProbe: null, diagnostics: [], firstSeenAt: now(), lastSeenAt: now() @@ -65,19 +85,29 @@ export function createHwpodNodeWsRegistry(options: any = {}) { if (!nodeId || !connection) { return Promise.resolve(blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId || ""} is not connected through outbound WebSocket`)); } + if (inFlightCountForNode(nodeId, pending) >= connection.maxInFlight) { + return Promise.resolve(blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId} reached its maxInFlight=${connection.maxInFlight} limit`, "hwpod_node_busy")); + } const requestId = safeText(requestMeta.requestId) || `req_hwpod_ws_${randomUUID()}`; const timeoutMs = positiveInteger(dispatchOptions.timeoutMs, DEFAULT_DISPATCH_TIMEOUT_MS); + const operationKind = safeText(requestMeta.operationKind) === "readiness-probe" ? "readiness-probe" : "user-operation"; + const operation = operationStarted(plan, requestId, now()); + if (operationKind === "readiness-probe") connection.latestReadinessProbe = operation; + else connection.latestOperation = operation; return new Promise((resolve) => { const timer = setTimeout(() => { pending.delete(requestId); + finishPendingOperation({ nodeId, requestId, connection, operationKind, operation, timer, resolve }, "timed-out", now(), "hwpod_node_unavailable"); resolve(blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId} WebSocket dispatch timed out after ${timeoutMs}ms`)); }, timeoutMs); - pending.set(requestId, { nodeId, requestId, timer, resolve }); + const waiter = { nodeId, requestId, connection, operationKind, operation, timer, resolve } satisfies PendingDispatch; + pending.set(requestId, waiter); try { sendJson(connection, { type: "hwpod-node-ops", requestId, plan, requestMeta }); } catch (error) { clearTimeout(timer); pending.delete(requestId); + finishPendingOperation(waiter, "failed", now(), "hwpod_node_unavailable"); resolve(blockedDispatch(plan, requestMeta, error instanceof Error ? error.message : String(error))); } }); @@ -96,7 +126,16 @@ export function createHwpodNodeWsRegistry(options: any = {}) { status: "online", capabilityCount: connection.capabilities.length, capabilities: connection.capabilities, - labels: connection.labels, + platform: safeText(connection.labels.platform) || "unknown", + version: safeText(connection.labels.version) || null, + runtimeKind: connection.runtime.kind, + installed: connection.runtime.installed, + desktopVisible: connection.runtime.desktopVisible, + maxInFlight: connection.maxInFlight, + inFlightCount: inFlightCountForNode(connection.nodeId, pending), + busy: inFlightCountForNode(connection.nodeId, pending) >= connection.maxInFlight, + latestOperation: connection.latestOperation, + latestReadinessProbe: connection.latestReadinessProbe, diagnosticCount: connection.diagnostics.length, lastDiagnostic: connection.diagnostics.at(-1) ?? null, diagnostics: connection.diagnostics.slice(-10), @@ -130,6 +169,8 @@ export function createHwpodNodeWsRegistry(options: any = {}) { connection.name = safeText(message.name) || nodeId; connection.capabilities = Array.isArray(message.capabilities) ? message.capabilities.map(String).filter(Boolean).slice(0, 64) : []; connection.labels = message.labels && typeof message.labels === "object" && !Array.isArray(message.labels) ? message.labels : {}; + connection.runtime = normalizeRuntime(message.runtime, connection.labels); + connection.maxInFlight = positiveInteger(message.maxInFlight ?? connection.labels.maxInFlight, 1); connection.lastSeenAt = now(); const previous = connections.get(nodeId); if (previous && previous !== connection) previous.socket.close(); @@ -162,6 +203,12 @@ export function createHwpodNodeWsRegistry(options: any = {}) { const result = message.result && typeof message.result === "object" && !Array.isArray(message.result) ? message.result : { ok: false, status: "failed", results: [], blocker: { code: "hwpod_node_result_invalid", layer: "hwpod-node", retryable: true, summary: "hwpod-node WebSocket result payload is invalid" } }; + finishPendingOperation( + waiter, + safeText(result.status) || (result.ok === false ? "failed" : "completed"), + now(), + safeText(result.blocker?.code) || null + ); waiter.resolve(result); } @@ -171,6 +218,7 @@ export function createHwpodNodeWsRegistry(options: any = {}) { if (waiter.nodeId !== connection.nodeId) continue; clearTimeout(waiter.timer); pending.delete(requestId); + finishPendingOperation(waiter, "disconnected", now(), "hwpod_node_unavailable"); waiter.resolve(blockedDispatch({ nodeId: waiter.nodeId, ops: [] }, { requestId }, `hwpod-node ${waiter.nodeId} WebSocket disconnected before returning result`)); } } @@ -182,7 +230,7 @@ function sendJson(connection: HwpodNodeConnection, value: any) { connection.socket.send(JSON.stringify(value)); } -function blockedDispatch(plan: any, requestMeta: any, summary: string) { +function blockedDispatch(plan: any, requestMeta: any, summary: string, code = "hwpod_node_unavailable") { const ops = Array.isArray(plan?.ops) ? plan.ops : []; return { ok: false, @@ -192,9 +240,9 @@ function blockedDispatch(plan: any, requestMeta: any, summary: string) { op: safeText(op?.op) || "unknown", ok: false, status: "blocked", - blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary } + blocker: { code, layer: "hwpod-node", retryable: true, summary } })), - blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary }, + blocker: { code, layer: "hwpod-node", retryable: true, summary }, requestMeta }; } @@ -221,6 +269,57 @@ function normalizeDiagnostic(value: any, observedAt: string) { }; } +function normalizeRuntime(value: any, labels: Record) { + const input = value && typeof value === "object" && !Array.isArray(value) ? value : {}; + return { + kind: safeText(input.kind ?? labels.runtimeKind) || null, + installed: booleanOrNull(input.installed ?? labels.installed), + desktopVisible: booleanOrNull(input.desktopVisible ?? labels.desktopVisible) + }; +} + +function operationStarted(plan: any, requestId: string, startedAt: string) { + return { + requestId, + planId: safeText(plan?.planId) || null, + hwpodId: safeText(plan?.hwpodId) || null, + status: "running", + opCount: Array.isArray(plan?.ops) ? plan.ops.length : 0, + startedAt, + finishedAt: null, + blockerCode: null + }; +} + +function operationFinished(operation: Record | null, status: string, finishedAt: string, blockerCode: string | null) { + return { + ...(operation ?? {}), + status, + finishedAt, + blockerCode + }; +} + +function finishPendingOperation(waiter: PendingDispatch, status: string, finishedAt: string, blockerCode: string | null) { + const finished = operationFinished(waiter.operation, status, finishedAt, blockerCode); + if (waiter.operationKind === "readiness-probe") { + if (waiter.connection.latestReadinessProbe?.requestId === waiter.requestId) waiter.connection.latestReadinessProbe = finished; + return; + } + if (waiter.connection.latestOperation?.requestId === waiter.requestId) waiter.connection.latestOperation = finished; +} + +function inFlightCountForNode(nodeId: string | null, pending: Map) { + if (!nodeId) return 0; + let count = 0; + for (const waiter of pending.values()) if (waiter.nodeId === nodeId) count += 1; + return count; +} + +function booleanOrNull(value: unknown) { + return typeof value === "boolean" ? value : null; +} + function positiveInteger(value: unknown, fallback: number) { const numberValue = Number(value); return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : fallback; diff --git a/internal/cloud/hwpod-topology-read-model.test.ts b/internal/cloud/hwpod-topology-read-model.test.ts new file mode 100644 index 00000000..16348dd1 --- /dev/null +++ b/internal/cloud/hwpod-topology-read-model.test.ts @@ -0,0 +1,107 @@ +/* + * SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。 + * Implementation reference: draft-2026-07-13-p0-cloud-console。 + * Responsibility: 验证 HWPOD typed topology 的状态、脱敏、筛选与双向 cursor。 + */ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { buildHwpodTopologyReadModel } from "./hwpod-topology-read-model.ts"; + +test("typed topology covers multi-device, offline, unconfigured, invalid, mismatch, and busy states", () => { + const specs = [ + spec("hwpod-a", "node-a", ["workspace.ls"], true), + spec("hwpod-b", "node-a", ["workspace.ls", "debug.download"], true), + spec("hwpod-c", "node-c", ["workspace.ls"], null), + { ok: false, status: "invalid", specPath: "/registry/broken.yaml", error: { code: "invalid_hwpod_spec", message: "missing targetDevice" } } + ]; + const registry = { + mode: "hwpod-node-outbound-native-ws", + pendingCount: 1, + nodes: [ + { + nodeId: "node-a", + name: "D601 Python Node", + status: "online", + platform: "win32", + version: "0.1.2-python-gui", + runtimeKind: "python-tkinter-gui", + installed: true, + desktopVisible: true, + capabilities: ["workspace.ls"], + inFlightCount: 1, + maxInFlight: 1, + lastDiagnostic: { level: "ERROR", source: "ws", message: "connect failed: Bearer private-token?token=still-secret" }, + latestOperation: { requestId: "req_busy", planId: "plan_busy", hwpodId: "hwpod-a", status: "running", opCount: 1, startedAt: "2026-07-13T00:00:00.000Z" } + }, + { nodeId: "node-b", name: "Online without spec", status: "online", capabilities: [], inFlightCount: 0, maxInFlight: 1 } + ] + }; + + const devicePayload = buildHwpodTopologyReadModel(specs, registry, { resource: "device", observedAt: "2026-07-13T01:00:00.000Z" }); + assert.equal(devicePayload.contractVersion, "hwpod-topology-v1"); + assert.equal(devicePayload.valuesRedacted, true); + 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"]); + 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.inFlight.busy, true); + 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); + assert.equal(nodeA.diagnostic.valuesRedacted, true); + assert.equal(nodeA.readiness.find((stage: any) => stage.id === "desktop-visible").status, "ready"); + 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"); +}); + +test("typed topology keeps server-side status filtering and cursor bounded", () => { + const specs = [spec("hwpod-a", "node-a", ["workspace.ls"], true), spec("hwpod-b", "node-b", ["workspace.ls"], true)]; + const registry = { + nodes: [ + { nodeId: "node-a", status: "online", capabilities: ["workspace.ls"], inFlightCount: 0, maxInFlight: 1 }, + { nodeId: "node-b", status: "online", capabilities: ["workspace.ls"], inFlightCount: 0, maxInFlight: 1 } + ] + }; + const first = buildHwpodTopologyReadModel(specs, registry, { resource: "device", status: "available", 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 }); + assert.equal(second.items.length, 1); + assert.notEqual(first.items[0].hwpodId, second.items[0].hwpodId); + assert.equal(second.page.previousCursor, "b2Zmc2V0OjA"); +}); + +function spec(hwpodId: string, nodeId: string, capabilities: string[], available: boolean | null) { + return { + ok: true, + hwpodId, + name: hwpodId, + uid: hwpodId.toUpperCase(), + nodeId, + authority: "test-registry", + specPath: `/registry/${hwpodId}.yaml`, + availability: { ok: available, status: available === true ? "available" : available === false ? "blocked" : "unprobed", checkedAt: null }, + document: { + kind: "Hwpod", + metadata: { name: hwpodId }, + spec: { + capabilities, + nodeBinding: { nodeId }, + targetDevice: { board: `${hwpodId} board`, mcu: "STM32" }, + workspace: { path: `F:\\Work\\${hwpodId}`, toolchain: "keil-mdk" }, + debugProbe: { type: "cmsis-dap", probeName: "MicroLink" }, + ioProbe: { uart: { id: "uart/1", port: "COM4" } } + } + } + }; +} diff --git a/internal/cloud/hwpod-topology-read-model.ts b/internal/cloud/hwpod-topology-read-model.ts new file mode 100644 index 00000000..ffa50071 --- /dev/null +++ b/internal/cloud/hwpod-topology-read-model.ts @@ -0,0 +1,423 @@ +/* + * SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。 + * Implementation reference: draft-2026-07-13-p0-cloud-console。 + * Responsibility: 由 Cloud API HWPOD domain 生成有界 typed Node/设备 topology,不让浏览器拼装业务真相。 + */ + +export const HWPOD_TOPOLOGY_CONTRACT_VERSION = "hwpod-topology-v1"; + +const DEFAULT_PAGE_LIMIT = 50; +const MAX_PAGE_LIMIT = 100; + +export function buildHwpodTopologyReadModel(specs: any[], registrySnapshot: any = {}, options: any = {}) { + const observedAt = text(options.observedAt) || new Date().toISOString(); + const onlineNodes = normalizeOnlineNodes(registrySnapshot); + const invalidSpecs = specs.filter((spec) => spec?.ok === false).map((spec) => invalidSpecSummary(spec)); + const validSpecs = specs.filter((spec) => spec?.ok !== false && text(spec?.nodeId) && text(spec?.hwpodId ?? spec?.name)); + const devices = validSpecs.map((spec) => buildDevice(spec, onlineNodes.get(text(spec.nodeId)), observedAt)); + const nodeIds = new Set([...onlineNodes.keys(), ...validSpecs.map((spec) => text(spec.nodeId)).filter(Boolean)]); + const nodes = [...nodeIds].map((nodeId) => buildNode(nodeId, onlineNodes.get(nodeId), devices.filter((device) => device.nodeId === nodeId), observedAt)); + const resource = text(options.resource) === "device" ? "device" : "node"; + const sourceItems = resource === "device" ? devices : nodes; + const filteredItems = filterItems(sourceItems, options); + const sortedItems = sortItems(filteredItems, options); + const page = paginate(sortedItems, options); + const partial = invalidSpecs.length > 0; + + return { + ok: true, + status: partial ? "partial" : "ready", + contractVersion: HWPOD_TOPOLOGY_CONTRACT_VERSION, + route: "/v1/hwpod/topology", + owner: "cloud-api-hwpod-domain", + resource, + service: { + ready: true, + status: partial ? "partial" : "ready", + websocketMode: text(registrySnapshot?.mode) || "hwpod-node-outbound-native-ws", + connectedNodeCount: onlineNodes.size, + pendingOperationCount: integer(registrySnapshot?.pendingCount), + invalidSpecCount: invalidSpecs.length + }, + summary: { + 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, + deviceCount: devices.length, + availableDeviceCount: devices.filter((device) => device.status === "available").length, + busyDeviceCount: devices.filter((device) => device.busy).length, + capabilityMismatchCount: devices.filter((device) => device.capabilityMismatch).length, + invalidSpecCount: invalidSpecs.length + }, + items: page.items, + page: page.page, + invalidSpecs, + valuesRedacted: true, + observedAt + }; +} + +function normalizeOnlineNodes(snapshot: any) { + const map = new Map(); + for (const raw of array(snapshot?.nodes)) { + const nodeId = text(raw?.nodeId); + if (!nodeId) continue; + const actualCapabilities = uniqueStrings(raw?.capabilities); + const inFlightCount = integer(raw?.inFlightCount ?? raw?.pendingCount); + const maxInFlight = positiveInteger(raw?.maxInFlight, 1); + map.set(nodeId, { + nodeId, + name: text(raw?.name) || nodeId, + online: raw?.status !== "offline", + platform: text(raw?.platform ?? raw?.labels?.platform) || "unknown", + version: text(raw?.version ?? raw?.labels?.version) || null, + runtimeKind: text(raw?.runtimeKind ?? raw?.runtime?.kind ?? raw?.labels?.runtimeKind) || null, + installed: booleanOrNull(raw?.installed ?? raw?.runtime?.installed), + desktopVisible: booleanOrNull(raw?.desktopVisible ?? raw?.runtime?.desktopVisible), + actualCapabilities, + inFlightCount, + maxInFlight, + busy: raw?.busy === true || inFlightCount >= maxInFlight, + firstSeenAt: text(raw?.firstSeenAt) || null, + lastSeenAt: text(raw?.lastSeenAt) || null, + ageMs: integer(raw?.ageMs), + latestOperation: operationSummary(raw?.latestOperation), + latestReadinessProbe: operationSummary(raw?.latestReadinessProbe), + diagnostic: diagnosticSummary(raw?.lastDiagnostic) + }); + } + return map; +} + +function buildDevice(spec: any, node: any, observedAt: string) { + const document = object(spec.document); + const body = object(document.spec); + const declaredCapabilities = declaredCapabilitiesFor(body); + const actualCapabilities = node?.actualCapabilities ?? []; + const missingCapabilities = node ? declaredCapabilities.filter((capability) => !actualCapabilities.includes(capability)) : declaredCapabilities; + 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 }); + return { + resourceType: "hwpod", + hwpodId: text(spec.hwpodId ?? spec.name), + uid: text(spec.uid) || null, + name: text(spec.name ?? spec.hwpodId), + nodeId: text(spec.nodeId), + nodeName: node?.name ?? text(spec.nodeId), + status, + online, + available: status === "available", + busy, + capabilityMismatch, + declaredCapabilities, + actualCapabilities, + missingCapabilities, + elements: hwpodElements(body), + availability, + operation: node?.latestOperation?.hwpodId === text(spec.hwpodId ?? spec.name) ? node.latestOperation : null, + diagnostic: node?.diagnostic ?? null, + blockers, + source: { + authority: text(spec.authority) || "workspace-or-registry", + kind: text(spec.source?.kind) || "workspace-or-registry", + specPath: text(spec.specPath) || null + }, + observedAt + }; +} + +function buildNode(nodeId: string, onlineNode: any, devices: any[], observedAt: string) { + const declaredCapabilities = uniqueStrings(devices.flatMap((device) => device.declaredCapabilities)); + const actualCapabilities = onlineNode?.actualCapabilities ?? []; + const missingCapabilities = onlineNode ? declaredCapabilities.filter((capability) => !actualCapabilities.includes(capability)) : declaredCapabilities; + 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 blockers = nodeBlockers({ online, hasSpec, busy, capabilityMismatch, missingCapabilities, devices }); + const workspaceReady = devices.some((device) => device.availability.ok === true); + const hwpodReady = devices.some((device) => device.available); + return { + resourceType: "hwpod-node", + nodeId, + name: onlineNode?.name ?? nodeId, + status, + online, + platform: onlineNode?.platform ?? "unknown", + version: onlineNode?.version ?? null, + runtimeKind: onlineNode?.runtimeKind ?? null, + firstSeenAt: onlineNode?.firstSeenAt ?? null, + lastSeenAt: onlineNode?.lastSeenAt ?? null, + ageMs: onlineNode?.ageMs ?? null, + deviceCount: devices.length, + hwpods: devices.map((device) => ({ + hwpodId: device.hwpodId, + name: device.name, + status: device.status, + busy: device.busy, + capabilityMismatch: device.capabilityMismatch + })), + declaredCapabilities, + actualCapabilities, + missingCapabilities, + capabilityMismatch, + inFlight: { + count: onlineNode?.inFlightCount ?? 0, + max: onlineNode?.maxInFlight ?? 1, + busy + }, + operation: onlineNode?.latestOperation ?? null, + readinessProbe: onlineNode?.latestReadinessProbe ?? null, + diagnostic: onlineNode?.diagnostic ?? null, + readiness: nodeReadiness({ onlineNode, online, workspaceReady, hwpodReady, busy, blockers }), + blockers, + observedAt + }; +} + +function declaredCapabilitiesFor(spec: any) { + const explicit = uniqueStrings([...array(spec?.capabilities), ...array(spec?.nodeBinding?.capabilities)]); + if (explicit.length > 0) return explicit; + const capabilities = ["node.health", "node.version", "node.inventory", "node.diagnostics"]; + if (Object.keys(object(spec?.workspace)).length > 0) { + capabilities.push("workspace.ls", "workspace.cat", "workspace.rg", "workspace.apply-patch", "workspace.write", "workspace.replace", "workspace.insert-after", "cmd.run"); + } + if (Object.keys(object(spec?.debugProbe)).length > 0) capabilities.push("debug.build", "debug.download", "debug.reset"); + if (Object.keys(object(spec?.ioProbe?.uart)).length > 0) capabilities.push("io.uart.read", "io.uart.write", "io.uart.jsonrpc"); + return uniqueStrings(capabilities); +} + +function hwpodElements(spec: any) { + const target = object(spec.targetDevice); + const workspace = object(spec.workspace); + const debugProbe = object(spec.debugProbe); + const ioProbe = object(spec.ioProbe); + const uart = object(ioProbe.uart); + return { + target: { + label: text(target.board ?? target.name ?? target.id) || "未声明目标板", + mcu: text(target.mcu) || null + }, + workspace: { + label: text(workspace.projectRoot ?? workspace.path) || "未声明工作区", + path: text(workspace.path) || null, + toolchain: text(workspace.toolchain) || null + }, + debugProbe: { + label: text(debugProbe.probeName ?? debugProbe.name ?? debugProbe.type ?? debugProbe.id) || "未声明调试探头", + type: text(debugProbe.type ?? debugProbe.kind) || null + }, + ioProbe: { + label: text(uart.id ?? uart.port ?? ioProbe.name ?? ioProbe.id) || "未声明 IO 探头", + kind: Object.keys(uart).length > 0 ? "uart" : text(ioProbe.kind) || "unknown" + } + }; +} + +function availabilitySummary(value: any) { + const availability = object(value); + const blocker = object(availability.blocker); + return { + ok: booleanOrNull(availability.ok), + status: text(availability.status) || "unprobed", + checkedAt: text(availability.checkedAt) || null, + blocker: Object.keys(blocker).length > 0 ? { + code: text(blocker.code) || "hwpod_unavailable", + summary: text(blocker.summary ?? blocker.message) || "HWPOD 不可用", + retryable: blocker.retryable !== false + } : null + }; +} + +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 deviceBlockers(input: any) { + const blockers: any[] = []; + if (!input.online) blockers.push(blocker("hwpod_node_offline", "声明的 HWPOD Node 当前离线。", true)); + if (input.capabilityMismatch) blockers.push(blocker("hwpod_capability_mismatch", `节点缺少 ${input.missingCapabilities.length} 项声明能力。`, false)); + if (input.busy) blockers.push(blocker("hwpod_node_busy", "节点已达 in-flight 并发上限。", true)); + if (input.availability.blocker) blockers.push(input.availability.blocker); + return dedupeBlockers(blockers); +} + +function nodeBlockers(input: any) { + const blockers: any[] = []; + if (!input.online) blockers.push(blocker("hwpod_node_offline", "Owning 配置已声明该节点,但未观测到主动出站 WebSocket。", true)); + if (input.online && !input.hasSpec) blockers.push(blocker("hwpod_node_spec_missing", "节点已在线,但没有任何 HWPOD spec 绑定。", false)); + if (input.capabilityMismatch) blockers.push(blocker("hwpod_capability_mismatch", `节点缺少 ${input.missingCapabilities.length} 项所属 HWPOD 声明能力。`, false)); + if (input.busy) blockers.push(blocker("hwpod_node_busy", "节点已达 in-flight 并发上限。", true)); + for (const device of input.devices) blockers.push(...device.blockers); + return dedupeBlockers(blockers); +} + +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) + ]; +} + +function readinessStage(id: string, label: string, status: string, authority: string, stageBlocker: any) { + return { id, label, status, authority, blocker: stageBlocker }; +} + +function invalidSpecSummary(spec: any) { + return { + specPath: text(spec?.specPath) || null, + status: "invalid", + blocker: blocker(text(spec?.error?.code) || "invalid_hwpod_spec", text(spec?.error?.message) || "HWPOD spec 无法解析。", false) + }; +} + +function operationSummary(value: any) { + const operation = object(value); + if (Object.keys(operation).length === 0) return null; + return { + requestId: text(operation.requestId) || null, + planId: text(operation.planId) || null, + hwpodId: text(operation.hwpodId) || null, + status: text(operation.status) || "unknown", + opCount: integer(operation.opCount), + startedAt: text(operation.startedAt) || null, + finishedAt: text(operation.finishedAt) || null, + blockerCode: text(operation.blockerCode) || null + }; +} + +function diagnosticSummary(value: any) { + const diagnostic = object(value); + if (Object.keys(diagnostic).length === 0) return null; + return { + level: text(diagnostic.level) || "ERROR", + source: text(diagnostic.source) || "unknown", + message: redactDiagnosticMessage(diagnostic.message), + observedAt: text(diagnostic.observedAt ?? diagnostic.receivedAt) || null, + valuesRedacted: true + }; +} + +function redactDiagnosticMessage(value: unknown) { + return text(value) + .replace(/\b(Bearer|Basic)\s+\S+/giu, "$1 [REDACTED]") + .replace(/([?&](?:api[_-]?key|token|secret|password|authorization)=)[^&\s]+/giu, "$1[REDACTED]") + .replace(/\b(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*\S+/giu, "$1=[REDACTED]") + .slice(0, 500); +} + +function filterItems(items: any[], options: any) { + const query = text(options.q).toLowerCase(); + const statuses = new Set(uniqueStrings(String(options.status ?? "").split(","))); + return items.filter((item) => { + if (statuses.size > 0 && !statuses.has(text(item.status))) return false; + if (!query) return true; + const haystack = [item.nodeId, item.name, item.hwpodId, item.status, ...array(item.missingCapabilities)].map(text).join(" ").toLowerCase(); + return haystack.includes(query); + }); +} + +function sortItems(items: any[], options: any) { + const sort = ["name", "status", "lastSeenAt", "deviceCount"].includes(text(options.sort)) ? text(options.sort) : "name"; + const direction = text(options.direction) === "desc" ? -1 : 1; + return [...items].sort((left, right) => compareValues(left?.[sort], right?.[sort]) * direction); +} + +function paginate(items: any[], options: any) { + const limit = Math.min(positiveInteger(options.limit, DEFAULT_PAGE_LIMIT), MAX_PAGE_LIMIT); + const offset = decodeCursor(options.cursor); + const pageItems = items.slice(offset, offset + limit); + const nextOffset = offset + pageItems.length; + return { + items: pageItems, + page: { + limit, + returned: pageItems.length, + total: items.length, + cursor: offset > 0 ? encodeCursor(offset) : null, + previousCursor: offset > 0 ? encodeCursor(Math.max(0, offset - limit)) : null, + nextCursor: nextOffset < items.length ? encodeCursor(nextOffset) : null + } + }; +} + +function encodeCursor(offset: number) { + return Buffer.from(`offset:${offset}`, "utf8").toString("base64url"); +} + +function decodeCursor(value: unknown) { + const cursor = text(value); + if (!cursor) return 0; + try { + const parsed = Buffer.from(cursor, "base64url").toString("utf8").match(/^offset:(\d+)$/u); + return parsed ? integer(parsed[1]) : 0; + } catch { + return 0; + } +} + +function blocker(code: string, summary: string, retryable: boolean) { + return { code, summary, retryable }; +} + +function dedupeBlockers(values: any[]) { + const seen = new Set(); + return values.filter((value) => { + const code = text(value?.code); + if (!code || seen.has(code)) return false; + seen.add(code); + return true; + }).slice(0, 8); +} + +function compareValues(left: unknown, right: unknown) { + if (typeof left === "number" || typeof right === "number") return Number(left ?? 0) - Number(right ?? 0); + return text(left).localeCompare(text(right), "zh-CN"); +} + +function uniqueStrings(values: unknown) { + return [...new Set(array(values).map(text).filter(Boolean))].sort(); +} + +function array(value: unknown): any[] { + return Array.isArray(value) ? value : []; +} + +function object(value: unknown): any { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function text(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : ""; +} + +function integer(value: unknown) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : 0; +} + +function positiveInteger(value: unknown, fallback: number) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function booleanOrNull(value: unknown) { + return typeof value === "boolean" ? value : null; +} diff --git a/internal/cloud/server-hwpod-http.ts b/internal/cloud/server-hwpod-http.ts index 8d4d7617..2719c3f3 100644 --- a/internal/cloud/server-hwpod-http.ts +++ b/internal/cloud/server-hwpod-http.ts @@ -1,5 +1,7 @@ /* - * Responsibility: Cloud API HTTP handlers for HWLAB node updates, HWPOD discovery, and node-ops dispatch. + * SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。 + * Implementation reference: draft-2026-07-13-p0-cloud-console。 + * Responsibility: 提供 HWLAB Node 更新、HWPOD typed topology、显式有界 readiness 探测与 node-ops 转发。 */ import { createHash, randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; @@ -12,6 +14,9 @@ import { hwpodSpecDiscoveryPayload, hwpodSpecWorkspaceProbePlan } from "./hwpod-spec-discovery.ts"; +import { + buildHwpodTopologyReadModel +} from "./hwpod-topology-read-model.ts"; import { getHeader, parsePositiveInteger, @@ -87,6 +92,8 @@ export function handleHwlabNodeUpdateHttp(request, response, url, options) { const releaseNotesUrl = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES_URL) || null; const downloadUrl = hwlabNodeDownloadUrl(env); const sha256 = cleanText(env.HWLAB_NODE_PY_SHA256) || bundled.sha256 || null; + const fileName = nodeArtifactFileName(downloadUrl) || "hwlab-node.py"; + const sizeBytes = parsePositiveInteger(env.HWLAB_NODE_PY_FILE_SIZE_BYTES, bundled.sizeBytes); const newer = currentVersion ? compareVersionStrings(latestVersion, currentVersion) > 0 : Boolean(latestVersion); const updateAvailable = Boolean(downloadUrl && newer); sendJson(response, 200, { @@ -101,7 +108,22 @@ export function handleHwlabNodeUpdateHttp(request, response, url, options) { updateAvailable, downloadUrl: updateAvailable ? downloadUrl : null, sha256: updateAvailable ? sha256 : null, + artifact: { + version: latestVersion, + fileName, + sizeBytes, + sha256, + downloadUrl + }, + releaseNotes: hwlabNodeReleaseNotes(env), releaseNotesUrl, + installConditions: [ + { id: "windows-python", label: "Windows 交互用户的原生 Python", required: true }, + { id: "tkinter", label: "Python tkinter 图形能力", required: true }, + { id: "yaml-managed-config", label: "owning YAML 声明 nodeId、工作区与凭据引用", required: true }, + { id: "outbound-websocket", label: "可主动出站连接 Cloud API WebSocket", required: true } + ], + readinessStages: hwlabNodeReadinessStages(), manualDefault: true, autoApplyDefault: false, checkIntervalSeconds: parsePositiveInteger(env.HWLAB_NODE_PY_UPDATE_INTERVAL_SECONDS, 300), @@ -135,11 +157,34 @@ export async function handleHwpodSpecDiscoveryHttp(request, response, url, optio const observedAt = new Date().toISOString(); let specs = await discoverHwpodSpecs(options); if (probe) { - specs = await Promise.all(specs.map((spec) => probeDiscoveredHwpodSpec(spec, { request, options, observedAt }))); + specs = await probeDiscoveredHwpodSpecs(specs, { request, options, observedAt }); } sendJson(response, 200, hwpodSpecDiscoveryPayload(specs, { observedAt })); } +export async function handleHwpodTopologyHttp(request, response, url, options) { + if (request.method !== "GET") { + sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "GET required." } }); + return; + } + const observedAt = new Date().toISOString(); + let specs = await discoverHwpodSpecs(options); + if (truthyFlag(url.searchParams.get("probe"))) { + specs = await probeDiscoveredHwpodSpecs(specs, { request, options, observedAt }); + } + const payload = buildHwpodTopologyReadModel(specs, options.hwpodNodeWsRegistry.describe(), { + observedAt, + resource: url.searchParams.get("resource"), + q: url.searchParams.get("q"), + status: url.searchParams.get("status"), + sort: url.searchParams.get("sort"), + direction: url.searchParams.get("direction"), + cursor: url.searchParams.get("cursor"), + limit: parsePositiveInteger(url.searchParams.get("limit"), 50) + }); + sendJson(response, 200, payload); +} + function runtimeEnvironment(env = process.env) { const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV; return typeof value === "string" && value.trim() ? value.trim() : ENVIRONMENT_DEV; @@ -164,12 +209,38 @@ function hwlabNodeBundledMetadata(env) { const content = readFileSync(bundlePath, "utf8"); const version = cleanText(content.match(/APP_VERSION\s*=\s*["']([^"']+)["']/u)?.[1]); const sha256 = createHash("sha256").update(content, "utf8").digest("hex"); - return { path: bundlePath, content, version, sha256 }; + return { path: bundlePath, content, version, sha256, sizeBytes: Buffer.byteLength(content, "utf8") }; } catch { - return { path: bundlePath, content: "", version: "", sha256: "" }; + return { path: bundlePath, content: "", version: "", sha256: "", sizeBytes: 0 }; } } +function nodeArtifactFileName(downloadUrl) { + try { + const name = path.posix.basename(new URL(downloadUrl).pathname); + return cleanText(name); + } catch { + return ""; + } +} + +function hwlabNodeReleaseNotes(env) { + const configured = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES); + if (configured) return configured.split(/\r?\n|\|/u).map((item) => item.trim()).filter(Boolean).slice(0, 12); + return ["单文件 Python/tkinter 节点,提供主动出站 WebSocket、有界诊断和 SHA-256 校验自更新。"]; +} + +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" } + ]; +} + function hwlabNodeDownloadUrl(env) { const explicit = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_URL); if (explicit) return explicit; @@ -208,6 +279,12 @@ function cleanText(value) { return text || ""; } +async function probeDiscoveredHwpodSpecs(specs, context) { + const probed = []; + for (const spec of specs) probed.push(await probeDiscoveredHwpodSpec(spec, context)); + return probed; +} + async function probeDiscoveredHwpodSpec(spec, { request, options, observedAt }) { if (spec.ok === false) return spec; const plan = hwpodSpecWorkspaceProbePlan(spec); @@ -223,7 +300,7 @@ async function probeDiscoveredHwpodSpec(spec, { request, options, observedAt }) } }; } - const requestMeta = hwpodNodeOpsRequestMeta(request, options, "hwpod_spec"); + const requestMeta = { ...hwpodNodeOpsRequestMeta(request, options, "hwpod_spec"), operationKind: "readiness-probe" }; const handled = await dispatchHwpodNodeOpsPlan(validation.plan, requestMeta, options, { request }); const payload = handled.payload; return { diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 56f3bc29..769f1d52 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -1,5 +1,5 @@ /* - * SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-20-p0-error-diagnostics; PJ2026-010401 Web工作台 draft-2026-06-20-p0-error-diagnostics; PJ2026-01060505 Workbench Performance draft-2026-06-20-p0-error-diagnostics + * SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-20-p0-error-diagnostics; PJ2026-010401 Web工作台 draft-2026-06-20-p0-error-diagnostics; PJ2026-010405 云端控制台 draft-2026-07-13-p0-cloud-console; PJ2026-01060505 Workbench Performance draft-2026-06-20-p0-error-diagnostics * 职责: Cloud API REST/SSE route dispatcher。Workbench 新资源路由应委托 read model / compat wrapper,不在 dispatcher 内写业务事实。 */ import { createServer } from "node:http"; @@ -88,8 +88,10 @@ import { handleHwlabNodeDownloadHttp, handleHwlabNodeUpdateHttp, handleHwpodNodeOpsHttp, - handleHwpodSpecDiscoveryHttp + handleHwpodSpecDiscoveryHttp, + handleHwpodTopologyHttp } from "./server-hwpod-http.ts"; +import { HWPOD_TOPOLOGY_CONTRACT_VERSION } from "./hwpod-topology-read-model.ts"; import { handleCaseRunHttp } from "./server-caserun-http.ts"; import { handleProjectManagementProxyHttp } from "./project-management-proxy.ts"; import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts"; @@ -651,6 +653,8 @@ async function handleRestAdapter(request, response, url, options) { hwpod: { route: "/v1/hwpod-node-ops", specDiscoveryRoute: "/v1/hwpod/specs", + topologyRoute: "/v1/hwpod/topology", + topologyContractVersion: HWPOD_TOPOLOGY_CONTRACT_VERSION, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, specAuthority: "workspace-or-registry", compiler: "hwpod-compiler-cli", @@ -813,6 +817,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/hwpod/topology") { + await handleHwpodTopologyHttp(request, response, url, options); + return; + } + if (request.method === "GET" && url.pathname === "/v1/hwpod-node/ws") { sendJson(response, 426, { ok: false, status: "upgrade_required", route: "/v1/hwpod-node/ws", websocket: options.hwpodNodeWsRegistry.describe() }); return; @@ -1003,7 +1012,7 @@ function navIdForRestPath(pathname, method = "GET") { if (pathname.startsWith("/v1/admin/access")) return "admin.access"; if (pathname.startsWith("/v1/admin/users")) return "admin.users"; if (pathname.startsWith("/v1/admin/")) return "admin.access"; - if (pathname === "/v1/hwpod-node-ops" || pathname === "/v1/caserun" || pathname.startsWith("/v1/caserun/") || pathname === "/v1/hwpod/specs") return "admin.hwpodGroups"; + if (pathname === "/v1/hwpod-node-ops" || pathname === "/v1/caserun" || pathname.startsWith("/v1/caserun/") || pathname === "/v1/hwpod/specs" || pathname === "/v1/hwpod/topology") return "admin.hwpodGroups"; if (pathname === "/v1/web-performance/metrics" || pathname === "/v1/web-performance/summary" || pathname === "/v1/live-builds") return "system.performance"; if (pathname === "/v1/diagnostics/gate") return "system.gate"; return ""; diff --git a/tools/hwlab-node.py b/tools/hwlab-node.py index b7e5d754..c4468911 100644 --- a/tools/hwlab-node.py +++ b/tools/hwlab-node.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +# SPEC: PJ2026-010104 AI网关; PJ2026-010405 云端控制台。 +# Implementation reference: draft-2026-07-13-p0-cloud-console. """面向 Windows HWPOD 的单文件 HWLAB 图形节点。 用户入口不接受命令行参数,也不依赖 HWLAB_* 环境变量。 @@ -742,7 +744,28 @@ class WebSocketNodeClient: self.sock = ssl.create_default_context().wrap_socket(raw, server_hostname=host) if parsed.scheme == "wss" else raw self._handshake(parsed, host, port) self.bus.emit("log", f"WebSocket 已打开: {ws_url}") - self._send_json({"type": "register", "nodeId": self.executor.node_id, "name": self.executor.node_id, "capabilities": self.executor.capabilities(), "labels": {"platform": sys.platform, "hostname": socket.gethostname(), "version": NODE_VERSION, "startedAt": utc_now(), "allowedWorkspaceRootCount": len(self.executor.allowed_workspace_roots()), "diagnostics": DIAGNOSTICS.summary()}}) + self._send_json({ + "type": "register", + "nodeId": self.executor.node_id, + "name": self.executor.node_id, + "capabilities": self.executor.capabilities(), + "maxInFlight": 1, + "runtime": { + "kind": "python-tkinter-gui", + "installed": True, + "desktopVisible": True, + "currentVersion": APP_VERSION, + "updateChannel": str(self.config.get("update", {}).get("channel") or "stable"), + }, + "labels": { + "platform": sys.platform, + "hostname": socket.gethostname(), + "version": NODE_VERSION, + "startedAt": utc_now(), + "allowedWorkspaceRootCount": len(self.executor.allowed_workspace_roots()), + "diagnostics": DIAGNOSTICS.summary(), + }, + }) last_heartbeat = 0.0 last_diagnostic_flush = 0.0 while not self.stop_event.is_set(): diff --git a/web/hwlab-cloud-web/src/api/hwpod.ts b/web/hwlab-cloud-web/src/api/hwpod.ts index b1669dc3..93aad1d3 100644 --- a/web/hwlab-cloud-web/src/api/hwpod.ts +++ b/web/hwlab-cloud-web/src/api/hwpod.ts @@ -1,8 +1,42 @@ +// SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。 +// Implementation reference: draft-2026-07-13-p0-cloud-console。 +// Responsibility: 调用 HWPOD typed topology、显式 readiness probe 与 Python Node 更新元数据接口。 + import { fetchJson } from "./client"; -import type { ApiResult, HwpodNodeOpsResponse, HwpodSpecsResponse } from "@/types"; +import type { + ApiResult, + HwpodDeviceTopologyItem, + HwpodNodeOpsResponse, + HwpodNodeTopologyItem, + HwpodSpecsResponse, + HwpodTopologyResponse, + HwlabNodeUpdateMetadata +} from "@/types"; + +export interface TopologyQuery { + q?: string; + status?: string; + sort?: "name" | "status" | "lastSeenAt" | "deviceCount"; + direction?: "asc" | "desc"; + cursor?: string; + limit?: number; + probe?: boolean; +} + +function topologyPath(resource: "device" | "node", query: TopologyQuery = {}): string { + const params = new URLSearchParams({ resource, sort: query.sort ?? "name", direction: query.direction ?? "asc", limit: String(query.limit ?? 50) }); + if (query.probe) params.set("probe", "1"); + if (query.q?.trim()) params.set("q", query.q.trim()); + if (query.status?.trim()) params.set("status", query.status.trim()); + if (query.cursor?.trim()) params.set("cursor", query.cursor.trim()); + return `/v1/hwpod/topology?${params.toString()}`; +} export const hwpodAPI = { specs: (): Promise> => fetchJson("/v1/hwpod/specs?probe=1", { timeoutMs: 12000, timeoutName: "HWPOD specs" }), nodeOpsHealth: (): Promise> => fetchJson("/v1/hwpod-node-ops", { timeoutMs: 12000, timeoutName: "HWPOD node-ops" }), - groups: (): Promise> => fetchJson("/v1/hwpod-node-ops", { timeoutMs: 12000, timeoutName: "HWPOD groups" }) + groups: (): Promise> => fetchJson("/v1/hwpod-node-ops", { timeoutMs: 12000, timeoutName: "HWPOD groups" }), + devices: (query: TopologyQuery = {}): Promise>> => fetchJson(topologyPath("device", query), { timeoutMs: 20000, timeoutName: "HWPOD device topology" }), + nodes: (query: TopologyQuery = {}): Promise>> => fetchJson(topologyPath("node", query), { timeoutMs: 20000, timeoutName: "HWPOD node topology" }), + nodeUpdate: (): Promise> => fetchJson("/v1/hwlab-node/update?platform=windows&channel=stable", { timeoutMs: 12000, timeoutName: "HWLAB Node update metadata" }) }; diff --git a/web/hwlab-cloud-web/src/components/common/EntityCard.vue b/web/hwlab-cloud-web/src/components/common/EntityCard.vue index 587095fc..d74fb9f2 100644 --- a/web/hwlab-cloud-web/src/components/common/EntityCard.vue +++ b/web/hwlab-cloud-web/src/components/common/EntityCard.vue @@ -1,5 +1,6 @@ + + + + + diff --git a/web/hwlab-cloud-web/src/components/hwpod/HwpodReadinessRail.vue b/web/hwlab-cloud-web/src/components/hwpod/HwpodReadinessRail.vue new file mode 100644 index 00000000..b96a5515 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/hwpod/HwpodReadinessRail.vue @@ -0,0 +1,40 @@ + + + + + + diff --git a/web/hwlab-cloud-web/src/composables/useConsoleViewState.test.ts b/web/hwlab-cloud-web/src/composables/useConsoleViewState.test.ts index 93e418fb..cee6277c 100644 --- a/web/hwlab-cloud-web/src/composables/useConsoleViewState.test.ts +++ b/web/hwlab-cloud-web/src/composables/useConsoleViewState.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom // SPEC: PJ2026-010405 云端控制台. // Implementation reference: draft-2026-07-13-p0-cloud-console. +// Responsibility: 验证集合视图 query 的恢复、清理与 cursor 重置行为。 import { mount } from "@vue/test-utils"; import { describe, expect, test } from "vitest"; @@ -17,7 +18,7 @@ describe("useConsoleViewState", () => { await router.push("/resources?view=list&density=compact&q=node&filter=online&sort=-updatedAt&cursor=next&tab=nodes"); await router.isReady(); - let state: ConsoleViewState | null = null; + let state!: ConsoleViewState; const Host = defineComponent({ setup() { state = useConsoleViewState(); @@ -26,12 +27,12 @@ describe("useConsoleViewState", () => { template: "
" }); const wrapper = mount(Host, { global: { plugins: [router] } }); - expect(state?.view.value).toBe("list"); - expect(state?.density.value).toBe("compact"); - expect(state?.cursor.value).toBe("next"); - expect(state?.tab.value).toBe("nodes"); + expect(state.view.value).toBe("list"); + expect(state.density.value).toBe("compact"); + expect(state.cursor.value).toBe("next"); + expect(state.tab.value).toBe("nodes"); - await state?.update({ q: "device", view: "cards" }); + await state.update({ q: "device", view: "cards" }); await nextTick(); expect(router.currentRoute.value.query.q).toBe("device"); expect(router.currentRoute.value.query.view).toBe("cards"); @@ -47,11 +48,11 @@ describe("useConsoleViewState", () => { await router.push("/resources?view=tiles&density=huge"); await router.isReady(); - let state: ConsoleViewState | null = null; + let state!: ConsoleViewState; const Host = defineComponent({ setup() { state = useConsoleViewState(); return {}; }, template: "
" }); const wrapper = mount(Host, { global: { plugins: [router] } }); - expect(state?.view.value).toBe("cards"); - expect(state?.density.value).toBe("comfortable"); + expect(state.view.value).toBe("cards"); + expect(state.density.value).toBe("comfortable"); wrapper.unmount(); }); }); diff --git a/web/hwlab-cloud-web/src/router/index.ts b/web/hwlab-cloud-web/src/router/index.ts index 91072f49..39770c53 100644 --- a/web/hwlab-cloud-web/src/router/index.ts +++ b/web/hwlab-cloud-web/src/router/index.ts @@ -1,5 +1,6 @@ -// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo. -// Responsibility: Cloud Web routes, including project-management pages under /projects. +// SPEC: PJ2026-010404 项目管理; PJ2026-010405 云端控制台。 +// Implementation reference: draft-2026-07-13-p0-cloud-console. +// Responsibility: 维护 Cloud Web 路由;HWPOD 实体身份进入 path,query 只保存集合视图状态。 import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router"; import { installRouterGuards } from "./guards"; @@ -33,7 +34,11 @@ const routes: RouteRecordRaw[] = [ { path: "/admin/secrets/external-secrets/:namespace/:name", name: "AdminExternalSecretDetail", component: () => import("@/views/admin/ExternalSecretDetailView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.secrets", title: "ExternalSecret 详情", section: "admin" } }, { path: "/admin/users", name: "Users", component: () => import("@/views/admin/UsersView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.users", title: "用户管理", section: "admin" } }, { path: "/admin/billing", name: "AdminBilling", component: () => import("@/views/admin/BillingView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.billing", title: "兑换与订阅", section: "admin" } }, - { path: "/admin/hwpod-groups", name: "HwpodGroups", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.hwpodGroups", title: "HWPOD 分组", section: "admin" } }, + { path: "/admin/hwpod-groups", redirect: "/hwpods/devices" }, + { path: "/hwpods", redirect: "/hwpods/devices" }, + { path: "/hwpods/devices/:hwpodId?", name: "HwpodDevices", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.hwpodGroups", navParent: "HwpodGroups", title: "HWPOD 设备资源", section: "admin" } }, + { path: "/hwpods/nodes/:nodeId?", name: "HwpodNodes", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.hwpodGroups", navParent: "HwpodGroups", title: "HWPOD Node", section: "admin" } }, + { path: "/hwpods/onboarding/:nodeId?", name: "HwpodOnboarding", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.hwpodGroups", navParent: "HwpodGroups", title: "HWPOD 节点接入", section: "admin" } }, { path: "/admin/provider-profiles", name: "ProviderProfiles", component: () => import("@/views/admin/ProviderProfilesView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.providerProfiles", title: "Provider Profiles", section: "admin" } }, { path: "/skills", name: "Skills", component: () => import("@/views/SkillsView.vue"), meta: { requiresAuth: true, navId: "system.skills", title: "技能包", section: "system" } }, { path: "/settings", name: "Settings", component: () => import("@/views/SettingsView.vue"), meta: { requiresAuth: true, navId: "system.settings", title: "设置", section: "system" } }, diff --git a/web/hwlab-cloud-web/src/stores/hwpod.ts b/web/hwlab-cloud-web/src/stores/hwpod.ts index 246caa0e..d0b3cc92 100644 --- a/web/hwlab-cloud-web/src/stores/hwpod.ts +++ b/web/hwlab-cloud-web/src/stores/hwpod.ts @@ -1,13 +1,31 @@ +// SPEC: PJ2026-010405 云端控制台。 +// Implementation reference: draft-2026-07-13-p0-cloud-console。 +// Responsibility: 保存 HWPOD typed topology 的最近成功快照,并投影刷新、部分失败与 stale 状态。 + import { ref } from "vue"; import { defineStore } from "pinia"; import { hwpodAPI } from "@/api"; -import type { HwpodNodeOpsResponse, HwpodSpecsResponse } from "@/types"; +import type { TopologyQuery } from "@/api/hwpod"; +import type { + HwpodDeviceTopologyItem, + HwpodNodeOpsResponse, + HwpodNodeTopologyItem, + HwpodSpecsResponse, + HwpodTopologyResponse, + HwlabNodeUpdateMetadata +} from "@/types"; export const useHwpodStore = defineStore("hwpod", () => { const specs = ref(null); const nodeOps = ref(null); const loading = ref(false); const error = ref(null); + const deviceTopology = ref | null>(null); + const nodeTopology = ref | null>(null); + const nodeUpdate = ref(null); + const consoleLoading = ref(false); + const consoleError = ref(null); + const consoleStale = ref(false); async function refresh(): Promise { loading.value = true; @@ -19,5 +37,41 @@ export const useHwpodStore = defineStore("hwpod", () => { loading.value = false; } - return { specs, nodeOps, loading, error, refresh }; + async function refreshConsole(resource: "device" | "node" | "onboarding", query: TopologyQuery = {}): Promise { + consoleLoading.value = true; + consoleError.value = null; + consoleStale.value = false; + try { + if (resource === "device") { + const result = await hwpodAPI.devices(query); + if (result.ok && result.data) deviceTopology.value = result.data; + else { + consoleError.value = result.error ?? "HWPOD 设备拓扑不可用"; + consoleStale.value = Boolean(deviceTopology.value); + } + } else if (resource === "node") { + const result = await hwpodAPI.nodes(query); + if (result.ok && result.data) nodeTopology.value = result.data; + else { + consoleError.value = result.error ?? "HWPOD Node 拓扑不可用"; + consoleStale.value = Boolean(nodeTopology.value); + } + } else { + const [nodesResult, updateResult] = await Promise.all([hwpodAPI.nodes(query), hwpodAPI.nodeUpdate()]); + if (nodesResult.ok && nodesResult.data) nodeTopology.value = nodesResult.data; + if (updateResult.ok && updateResult.data) nodeUpdate.value = updateResult.data; + if (!nodesResult.ok || !updateResult.ok) { + consoleError.value = nodesResult.error ?? updateResult.error ?? "节点接入元数据不可用"; + consoleStale.value = Boolean(nodeTopology.value || nodeUpdate.value); + } + } + } catch (cause) { + 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; + } + } + + return { specs, nodeOps, loading, error, refresh, deviceTopology, nodeTopology, nodeUpdate, consoleLoading, consoleError, consoleStale, refreshConsole }; }); diff --git a/web/hwlab-cloud-web/src/styles/console-foundation.css b/web/hwlab-cloud-web/src/styles/console-foundation.css index 7826c5d6..1184f728 100644 --- a/web/hwlab-cloud-web/src/styles/console-foundation.css +++ b/web/hwlab-cloud-web/src/styles/console-foundation.css @@ -290,6 +290,16 @@ .resource-collection-items[data-density="compact"] { gap: var(--console-space-2); } +.resource-collection-item { + min-width: 0; + min-height: 0; +} + +.resource-collection-item > .entity-card { + width: 100%; + height: 100%; +} + .entity-card { position: relative; display: grid; diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index bd2922d2..8e6be40a 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -1,5 +1,5 @@ -// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. -// Responsibility: Cloud Web API and Workbench state type contracts shared by stores and components. +// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-010405 云端控制台 draft-2026-07-13-p0-cloud-console; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. +// Responsibility: 保存 store 与组件共享的 Cloud Web API、Workbench 和 HWPOD typed topology 类型合同。 export type Tone = "ok" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run"; export type AuthState = "checking" | "login" | "authenticated"; @@ -387,6 +387,132 @@ export type AgentChatResultResponse = AgentChatResponse & { export interface HwpodSpecsResponse { specs?: Array>; [key: string]: unknown } export interface HwpodNodeOpsResponse { ok?: boolean; status?: string; events?: TraceEvent[]; groups?: Array>; [key: string]: unknown } + +export type HwpodResourceStatus = "available" | "busy" | "offline" | "workspace-blocked" | "capability-mismatch" | "unprobed" | "online" | "unconfigured" | string; +export type HwpodReadinessStatus = "ready" | "blocked" | "unknown" | string; + +export interface HwpodBlocker { + code: string; + summary: string; + retryable: boolean; +} + +export interface HwpodOperationSummary { + requestId: string | null; + planId: string | null; + hwpodId: string | null; + status: string; + opCount: number; + startedAt: string | null; + finishedAt: string | null; + blockerCode: string | null; +} + +export interface HwpodDiagnosticSummary { + level: string; + source: string; + message: string; + observedAt: string | null; + valuesRedacted: true; +} + +export interface HwpodElements { + target: { label: string; mcu: string | null }; + workspace: { label: string; path: string | null; toolchain: string | null }; + debugProbe: { label: string; type: string | null }; + ioProbe: { label: string; kind: string }; +} + +export interface HwpodDeviceTopologyItem { + resourceType: "hwpod"; + hwpodId: string; + uid: string | null; + name: string; + nodeId: string; + nodeName: string; + status: HwpodResourceStatus; + online: boolean; + available: boolean; + busy: boolean; + capabilityMismatch: boolean; + declaredCapabilities: string[]; + actualCapabilities: string[]; + missingCapabilities: string[]; + elements: HwpodElements; + availability: { ok: boolean | null; status: string; checkedAt: string | null; blocker: HwpodBlocker | null }; + operation: HwpodOperationSummary | null; + diagnostic: HwpodDiagnosticSummary | null; + blockers: HwpodBlocker[]; + source: { authority: string; kind: string; specPath: string | null }; + observedAt: string; +} + +export interface HwpodNodeReadinessStage { + id: "downloaded" | "installed" | "desktop-visible" | "registered" | "workspace-ready" | "hwpod-ready" | string; + label: string; + status: HwpodReadinessStatus; + authority: string; + blocker: HwpodBlocker | null; +} + +export interface HwpodNodeTopologyItem { + resourceType: "hwpod-node"; + nodeId: string; + name: string; + status: HwpodResourceStatus; + online: boolean; + platform: string; + version: string | null; + runtimeKind: string | null; + firstSeenAt: string | null; + lastSeenAt: string | null; + ageMs: number | null; + deviceCount: number; + hwpods: Array<{ hwpodId: string; name: string; status: HwpodResourceStatus; busy: boolean; capabilityMismatch: boolean }>; + declaredCapabilities: string[]; + actualCapabilities: string[]; + missingCapabilities: string[]; + capabilityMismatch: boolean; + inFlight: { count: number; max: number; busy: boolean }; + operation: HwpodOperationSummary | null; + readinessProbe: HwpodOperationSummary | null; + diagnostic: HwpodDiagnosticSummary | null; + readiness: HwpodNodeReadinessStage[]; + blockers: HwpodBlocker[]; + observedAt: string; +} + +export interface HwpodTopologyResponse { + ok: boolean; + status: "ready" | "partial" | string; + contractVersion: "hwpod-topology-v1" | string; + route: string; + owner: "cloud-api-hwpod-domain" | string; + resource: "device" | "node"; + service: { ready: boolean; status: string; websocketMode: string; connectedNodeCount: number; pendingOperationCount: number; invalidSpecCount: number }; + summary: { nodeCount: number; onlineNodeCount: number; offlineNodeCount: number; unconfiguredNodeCount: number; deviceCount: number; availableDeviceCount: number; busyDeviceCount: number; capabilityMismatchCount: number; invalidSpecCount: number }; + items: T[]; + page: { limit: number; returned: number; total: number; cursor: string | null; previousCursor: string | null; nextCursor: string | null }; + invalidSpecs: Array<{ specPath: string | null; status: "invalid"; blocker: HwpodBlocker }>; + valuesRedacted: true; + observedAt: string; +} + +export interface HwlabNodeUpdateMetadata { + ok: boolean; + contractVersion: "hwlab-node-update-v1" | string; + channel: string; + platform: string; + currentVersion: string | null; + latestVersion: string; + updateAvailable: boolean; + artifact: { version: string; fileName: string; sizeBytes: number; sha256: string | null; downloadUrl: string | null }; + releaseNotes: string[]; + releaseNotesUrl: string | null; + installConditions: Array<{ id: string; label: string; required: boolean }>; + readinessStages: Array<{ id: string; label: string; authority: 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 } export interface CaseRunCasesResponse { ok?: boolean; contractVersion?: string; count?: number; cases?: CaseRunCaseSummary[]; caseAuthority?: Record; [key: string]: unknown } export interface CaseRunEvent { at?: string; status?: string; stage?: string; payload?: Record; [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 a92960b7..b4257598 100644 --- a/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue +++ b/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue @@ -1,144 +1,508 @@ + + +