diff --git a/internal/cloud/hwpod-node-ops.test.ts b/internal/cloud/hwpod-node-ops.test.ts index 912e8380..ee904f88 100644 --- a/internal/cloud/hwpod-node-ops.test.ts +++ b/internal/cloud/hwpod-node-ops.test.ts @@ -33,6 +33,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 +68,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 +92,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 +197,40 @@ 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 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 }, + 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); + } 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({ diff --git a/internal/cloud/hwpod-node-ws-registry.ts b/internal/cloud/hwpod-node-ws-registry.ts index 4b8cc4dd..37d1150f 100644 --- a/internal/cloud/hwpod-node-ws-registry.ts +++ b/internal/cloud/hwpod-node-ws-registry.ts @@ -9,6 +9,13 @@ type HwpodNodeConnection = { name: string | null; capabilities: string[]; labels: Record; + runtime: { + kind: string | null; + installed: boolean | null; + desktopVisible: boolean | null; + }; + maxInFlight: number; + latestOperation: Record | null; diagnostics: any[]; firstSeenAt: string; lastSeenAt: string; @@ -17,6 +24,7 @@ type HwpodNodeConnection = { type PendingDispatch = { nodeId: string; requestId: string; + connection: HwpodNodeConnection; timer: ReturnType; resolve: (value: any) => void; }; @@ -37,6 +45,9 @@ export function createHwpodNodeWsRegistry(options: any = {}) { name: null, capabilities: [], labels: {}, + runtime: { kind: null, installed: null, desktopVisible: null }, + maxInFlight: 1, + latestOperation: null, diagnostics: [], firstSeenAt: now(), lastSeenAt: now() @@ -67,17 +78,20 @@ export function createHwpodNodeWsRegistry(options: any = {}) { } const requestId = safeText(requestMeta.requestId) || `req_hwpod_ws_${randomUUID()}`; const timeoutMs = positiveInteger(dispatchOptions.timeoutMs, DEFAULT_DISPATCH_TIMEOUT_MS); + connection.latestOperation = operationStarted(plan, requestId, now()); return new Promise((resolve) => { const timer = setTimeout(() => { pending.delete(requestId); + connection.latestOperation = operationFinished(connection.latestOperation, "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 }); + pending.set(requestId, { nodeId, requestId, connection, timer, resolve }); try { sendJson(connection, { type: "hwpod-node-ops", requestId, plan, requestMeta }); } catch (error) { clearTimeout(timer); pending.delete(requestId); + connection.latestOperation = operationFinished(connection.latestOperation, "failed", now(), "hwpod_node_unavailable"); resolve(blockedDispatch(plan, requestMeta, error instanceof Error ? error.message : String(error))); } }); @@ -96,7 +110,15 @@ 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, diagnosticCount: connection.diagnostics.length, lastDiagnostic: connection.diagnostics.at(-1) ?? null, diagnostics: connection.diagnostics.slice(-10), @@ -130,6 +152,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 +186,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" } }; + waiter.connection.latestOperation = operationFinished( + waiter.connection.latestOperation, + safeText(result.status) || (result.ok === false ? "failed" : "completed"), + now(), + safeText(result.blocker?.code) || null + ); waiter.resolve(result); } @@ -171,6 +201,7 @@ export function createHwpodNodeWsRegistry(options: any = {}) { if (waiter.nodeId !== connection.nodeId) continue; clearTimeout(waiter.timer); pending.delete(requestId); + waiter.connection.latestOperation = operationFinished(waiter.connection.latestOperation, "disconnected", now(), "hwpod_node_unavailable"); waiter.resolve(blockedDispatch({ nodeId: waiter.nodeId, ops: [] }, { requestId }, `hwpod-node ${waiter.nodeId} WebSocket disconnected before returning result`)); } } @@ -221,6 +252,48 @@ 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 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..9b8ba75f --- /dev/null +++ b/internal/cloud/hwpod-topology-read-model.test.ts @@ -0,0 +1,101 @@ +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); +}); + +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..68a1a2be --- /dev/null +++ b/internal/cloud/hwpod-topology-read-model.ts @@ -0,0 +1,420 @@ +/* + * SPEC: PJ2026-010405 云端控制台; PJ2026-010103 HWPOD服务. + * Implementation reference: draft-2026-07-13-p0-cloud-console. + * Responsibility: bounded typed HWPOD node/device topology owned by the Cloud API HWPOD domain. + */ + +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), + 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, + 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, + 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..378d0a22 100644 --- a/internal/cloud/server-hwpod-http.ts +++ b/internal/cloud/server-hwpod-http.ts @@ -12,6 +12,9 @@ import { hwpodSpecDiscoveryPayload, hwpodSpecWorkspaceProbePlan } from "./hwpod-spec-discovery.ts"; +import { + buildHwpodTopologyReadModel +} from "./hwpod-topology-read-model.ts"; import { getHeader, parsePositiveInteger, @@ -87,6 +90,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 +106,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), @@ -140,6 +160,29 @@ export async function handleHwpodSpecDiscoveryHttp(request, response, url, optio 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 Promise.all(specs.map((spec) => probeDiscoveredHwpodSpec(spec, { 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 +207,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; diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 56f3bc29..5962eeb8 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -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..e0109c7c 100644 --- a/web/hwlab-cloud-web/src/api/hwpod.ts +++ b/web/hwlab-cloud-web/src/api/hwpod.ts @@ -1,8 +1,36 @@ import { fetchJson } from "./client"; -import type { ApiResult, HwpodNodeOpsResponse, HwpodSpecsResponse } from "@/types"; +import type { + ApiResult, + HwpodDeviceTopologyItem, + HwpodNodeOpsResponse, + HwpodNodeTopologyItem, + HwpodSpecsResponse, + HwpodTopologyResponse, + HwlabNodeUpdateMetadata +} from "@/types"; + +interface TopologyQuery { + q?: string; + status?: string; + sort?: "name" | "status" | "lastSeenAt" | "deviceCount"; + direction?: "asc" | "desc"; + cursor?: string; + limit?: number; +} + +function topologyPath(resource: "device" | "node", query: TopologyQuery = {}): string { + const params = new URLSearchParams({ resource, probe: "1", sort: query.sort ?? "name", direction: query.direction ?? "asc", limit: String(query.limit ?? 100) }); + 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/hwpod/HwpodDeviceCard.vue b/web/hwlab-cloud-web/src/components/hwpod/HwpodDeviceCard.vue new file mode 100644 index 00000000..af939fcd --- /dev/null +++ b/web/hwlab-cloud-web/src/components/hwpod/HwpodDeviceCard.vue @@ -0,0 +1,120 @@ + + + + + + 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..2a301957 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/hwpod/HwpodReadinessRail.vue @@ -0,0 +1,39 @@ + + + + + + diff --git a/web/hwlab-cloud-web/src/router/index.ts b/web/hwlab-cloud-web/src/router/index.ts index 91072f49..49c7d086 100644 --- a/web/hwlab-cloud-web/src/router/index.ts +++ b/web/hwlab-cloud-web/src/router/index.ts @@ -1,4 +1,5 @@ -// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo. +// SPEC: PJ2026-010404 项目管理; PJ2026-010405 云端控制台。 +// Implementation reference: draft-2026-07-13-p0-cloud-console. // Responsibility: Cloud Web routes, including project-management pages under /projects. import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router"; @@ -33,7 +34,9 @@ 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/:tab(devices|nodes|onboarding)", name: "HwpodGroups", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.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..9eb244d4 100644 --- a/web/hwlab-cloud-web/src/stores/hwpod.ts +++ b/web/hwlab-cloud-web/src/stores/hwpod.ts @@ -1,13 +1,25 @@ import { ref } from "vue"; import { defineStore } from "pinia"; import { hwpodAPI } from "@/api"; -import type { HwpodNodeOpsResponse, HwpodSpecsResponse } from "@/types"; +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); async function refresh(): Promise { loading.value = true; @@ -19,5 +31,25 @@ export const useHwpodStore = defineStore("hwpod", () => { loading.value = false; } - return { specs, nodeOps, loading, error, refresh }; + async function refreshConsole(resource: "device" | "node" | "onboarding", query: { q?: string; status?: string } = {}): Promise { + consoleLoading.value = true; + consoleError.value = null; + if (resource === "device") { + const result = await hwpodAPI.devices(query); + deviceTopology.value = result.data; + consoleError.value = result.ok ? null : result.error ?? "HWPOD 设备拓扑不可用"; + } else if (resource === "node") { + const result = await hwpodAPI.nodes(query); + nodeTopology.value = result.data; + consoleError.value = result.ok ? null : result.error ?? "HWPOD Node 拓扑不可用"; + } else { + const [nodesResult, updateResult] = await Promise.all([hwpodAPI.nodes(query), hwpodAPI.nodeUpdate()]); + nodeTopology.value = nodesResult.data; + nodeUpdate.value = updateResult.data; + consoleError.value = nodesResult.ok && updateResult.ok ? null : nodesResult.error ?? updateResult.error ?? "节点接入元数据不可用"; + } + consoleLoading.value = false; + } + + return { specs, nodeOps, loading, error, refresh, deviceTopology, nodeTopology, nodeUpdate, consoleLoading, consoleError, refreshConsole }; }); diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index bd2922d2..40c62efe 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -387,6 +387,131 @@ 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; + 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; 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..406b7c2e 100644 --- a/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue +++ b/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue @@ -1,144 +1,305 @@ + + +