feat(hwpod): 补齐拓扑与节点接入控制台

This commit is contained in:
root
2026-07-13 04:09:31 +02:00
parent 63a5cfff4a
commit 652f1f9c6b
14 changed files with 1369 additions and 122 deletions
+47 -3
View File
@@ -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({
+75 -2
View File
@@ -9,6 +9,13 @@ type HwpodNodeConnection = {
name: string | null;
capabilities: string[];
labels: Record<string, unknown>;
runtime: {
kind: string | null;
installed: boolean | null;
desktopVisible: boolean | null;
};
maxInFlight: number;
latestOperation: Record<string, unknown> | null;
diagnostics: any[];
firstSeenAt: string;
lastSeenAt: string;
@@ -17,6 +24,7 @@ type HwpodNodeConnection = {
type PendingDispatch = {
nodeId: string;
requestId: string;
connection: HwpodNodeConnection;
timer: ReturnType<typeof setTimeout>;
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<string, unknown>) {
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<string, unknown> | null, status: string, finishedAt: string, blockerCode: string | null) {
return {
...(operation ?? {}),
status,
finishedAt,
blockerCode
};
}
function inFlightCountForNode(nodeId: string | null, pending: Map<string, PendingDispatch>) {
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;
@@ -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" } }
}
}
};
}
+420
View File
@@ -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<string, any>();
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<string>();
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;
}
+71 -2
View File
@@ -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;
+11 -2
View File
@@ -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 "";
+24 -1
View File
@@ -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():
+30 -2
View File
@@ -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<ApiResult<HwpodSpecsResponse>> => fetchJson("/v1/hwpod/specs?probe=1", { timeoutMs: 12000, timeoutName: "HWPOD specs" }),
nodeOpsHealth: (): Promise<ApiResult<HwpodNodeOpsResponse>> => fetchJson("/v1/hwpod-node-ops", { timeoutMs: 12000, timeoutName: "HWPOD node-ops" }),
groups: (): Promise<ApiResult<HwpodNodeOpsResponse>> => fetchJson("/v1/hwpod-node-ops", { timeoutMs: 12000, timeoutName: "HWPOD groups" })
groups: (): Promise<ApiResult<HwpodNodeOpsResponse>> => fetchJson("/v1/hwpod-node-ops", { timeoutMs: 12000, timeoutName: "HWPOD groups" }),
devices: (query: TopologyQuery = {}): Promise<ApiResult<HwpodTopologyResponse<HwpodDeviceTopologyItem>>> => fetchJson(topologyPath("device", query), { timeoutMs: 20000, timeoutName: "HWPOD device topology" }),
nodes: (query: TopologyQuery = {}): Promise<ApiResult<HwpodTopologyResponse<HwpodNodeTopologyItem>>> => fetchJson(topologyPath("node", query), { timeoutMs: 20000, timeoutName: "HWPOD node topology" }),
nodeUpdate: (): Promise<ApiResult<HwlabNodeUpdateMetadata>> => fetchJson("/v1/hwlab-node/update?platform=windows&channel=stable", { timeoutMs: 12000, timeoutName: "HWLAB Node update metadata" })
};
@@ -0,0 +1,120 @@
<!--
SPEC: PJ2026-010405 云端控制台
Implementation reference: draft-2026-07-13-p0-cloud-console.
-->
<script setup lang="ts">
import { computed } from "vue";
import type { HwpodDeviceTopologyItem } from "@/types";
const props = defineProps<{ device: HwpodDeviceTopologyItem }>();
const emit = defineEmits<{ select: [id: string] }>();
const statusLabel = computed(() => ({
available: "可用",
busy: "执行中",
offline: "节点离线",
"capability-mismatch": "能力不匹配",
"workspace-blocked": "工作区阻塞",
unprobed: "待探测"
}[props.device.status] ?? props.device.status));
function linkState(kind: "target" | "workspace" | "debug" | "io"): string {
if (!props.device.online) return "offline";
if (kind === "workspace" && props.device.availability.ok === false) return "blocked";
const prefix = kind === "debug" ? "debug." : kind === "io" ? "io." : "";
if (prefix && props.device.missingCapabilities.some((capability) => capability.startsWith(prefix))) return "mismatch";
if (props.device.busy) return "active";
return props.device.available ? "ready" : "pending";
}
</script>
<template>
<article class="pcb-card" :data-status="device.status" :data-testid="`hwpod-device-${device.hwpodId}`">
<header class="pcb-card__header">
<div>
<span class="pcb-card__eyebrow">{{ device.nodeName }}</span>
<h2>{{ device.name }}</h2>
</div>
<span class="pcb-status" :data-status="device.status"><i aria-hidden="true" />{{ statusLabel }}</span>
</header>
<svg class="pcb-map" viewBox="0 0 520 220" role="img" :aria-label="`${device.name} HWPOD 四要素状态图`">
<defs>
<pattern :id="`grid-${device.hwpodId}`" width="16" height="16" patternUnits="userSpaceOnUse">
<path d="M 16 0 L 0 0 0 16" class="pcb-grid-line" />
</pattern>
</defs>
<rect x="8" y="8" width="504" height="204" rx="20" class="pcb-board" />
<rect x="8" y="8" width="504" height="204" rx="20" :fill="`url(#grid-${device.hwpodId})`" />
<path d="M260 110 H128 V56 H84" class="pcb-trace" :data-state="linkState('target')" />
<path d="M260 110 H128 V164 H84" class="pcb-trace" :data-state="linkState('workspace')" />
<path d="M260 110 H392 V56 H436" class="pcb-trace" :data-state="linkState('debug')" />
<path d="M260 110 H392 V164 H436" class="pcb-trace" :data-state="linkState('io')" />
<rect x="210" y="76" width="100" height="68" rx="12" class="pcb-chip" />
<text x="260" y="104" text-anchor="middle" class="pcb-chip-label">HWPOD</text>
<text x="260" y="124" text-anchor="middle" class="pcb-chip-id">{{ device.hwpodId.slice(0, 18) }}</text>
<g class="pcb-pad" :data-state="linkState('target')"><circle cx="66" cy="56" r="20" /><text x="66" y="61" text-anchor="middle">TGT</text></g>
<g class="pcb-pad" :data-state="linkState('workspace')"><circle cx="66" cy="164" r="20" /><text x="66" y="169" text-anchor="middle">WS</text></g>
<g class="pcb-pad" :data-state="linkState('debug')"><circle cx="454" cy="56" r="20" /><text x="454" y="61" text-anchor="middle">DBG</text></g>
<g class="pcb-pad" :data-state="linkState('io')"><circle cx="454" cy="164" r="20" /><text x="454" y="169" text-anchor="middle">IO</text></g>
</svg>
<dl class="element-grid">
<div><dt>目标板</dt><dd>{{ device.elements.target.label }}</dd><small>{{ device.elements.target.mcu || '未声明 MCU' }}</small></div>
<div><dt>工作区</dt><dd>{{ device.elements.workspace.label }}</dd><small>{{ device.elements.workspace.toolchain || '未声明工具链' }}</small></div>
<div><dt>调试探头</dt><dd>{{ device.elements.debugProbe.label }}</dd><small>{{ device.elements.debugProbe.type || '未声明类型' }}</small></div>
<div><dt>IO 探头</dt><dd>{{ device.elements.ioProbe.label }}</dd><small>{{ device.elements.ioProbe.kind }}</small></div>
</dl>
<p v-if="device.blockers[0]" class="pcb-blocker"><strong>{{ device.blockers[0].code }}</strong>{{ device.blockers[0].summary }}</p>
<footer>
<span>{{ device.declaredCapabilities.length }} 项声明能力</span>
<span v-if="device.operation">{{ device.operation.status }} · {{ device.operation.opCount }} ops</span>
<button type="button" @click="emit('select', device.hwpodId)">查看详情</button>
</footer>
</article>
</template>
<style scoped>
.pcb-card { min-width: 0; border: 1px solid #cbd2cf; border-top: 3px solid #2e6b67; border-radius: 12px; background: #fbfaf6; box-shadow: 0 12px 28px rgb(20 34 32 / 8%); overflow: hidden; }
.pcb-card[data-status="busy"] { border-top-color: #b86d1d; }
.pcb-card[data-status="offline"], .pcb-card[data-status="workspace-blocked"] { border-top-color: #8d453b; }
.pcb-card[data-status="capability-mismatch"] { border-top-color: #8a6b16; }
.pcb-card__header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; padding: 18px 20px 12px; }
.pcb-card__header h2 { margin: 3px 0 0; color: #152421; font-size: 1.08rem; letter-spacing: .01em; overflow-wrap: anywhere; }
.pcb-card__eyebrow { color: #687572; font: 600 .72rem/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.pcb-status { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; padding: 5px 9px; border: 1px solid #b7c6c1; border-radius: 999px; color: #244943; background: #eef7f2; font-size: .76rem; font-weight: 700; }
.pcb-status i { width: 7px; height: 7px; border-radius: 50%; background: currentcolor; }
.pcb-status[data-status="busy"] { color: #8b4c0a; background: #fff4df; border-color: #e6bd80; }
.pcb-status[data-status="offline"], .pcb-status[data-status="workspace-blocked"] { color: #7b332c; background: #fff0ed; border-color: #ddb2ac; }
.pcb-status[data-status="capability-mismatch"] { color: #6f560b; background: #fff8d8; border-color: #d8c376; }
.pcb-map { display: block; width: calc(100% - 28px); max-height: 220px; margin: 0 14px; }
.pcb-board { fill: #142c28; stroke: #31514a; stroke-width: 2; }
.pcb-grid-line { fill: none; stroke: rgb(137 184 168 / 9%); stroke-width: 1; }
.pcb-trace { fill: none; stroke: #789087; stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; }
.pcb-trace[data-state="ready"] { stroke: #50c5a4; }
.pcb-trace[data-state="active"] { stroke: #efad51; stroke-dasharray: 8 8; animation: trace-flow .75s linear infinite; }
.pcb-trace[data-state="mismatch"] { stroke: #e5ca54; stroke-dasharray: 5 6; }
.pcb-trace[data-state="blocked"], .pcb-trace[data-state="offline"] { stroke: #bf675d; }
.pcb-chip { fill: #0d1917; stroke: #7ea398; stroke-width: 2; }
.pcb-chip-label { fill: #eef9f4; font: 700 15px/1 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .12em; }
.pcb-chip-id { fill: #91aaa2; font: 10px/1 ui-monospace, SFMono-Regular, Consolas, monospace; }
.pcb-pad circle { fill: #213d37; stroke: #789087; stroke-width: 3; }
.pcb-pad text { fill: #d7e5df; font: 700 10px/1 ui-monospace, SFMono-Regular, Consolas, monospace; }
.pcb-pad[data-state="ready"] circle { stroke: #50c5a4; }
.pcb-pad[data-state="active"] circle { stroke: #efad51; }
.pcb-pad[data-state="mismatch"] circle { stroke: #e5ca54; }
.pcb-pad[data-state="blocked"] circle, .pcb-pad[data-state="offline"] circle { stroke: #bf675d; }
.element-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 12px 18px; border: 1px solid #d6dcda; background: #d6dcda; }
.element-grid div { min-width: 0; padding: 10px 12px; background: #f7f6f1; }
.element-grid dt { color: #73807c; font-size: .67rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.element-grid dd { margin: 4px 0 2px; color: #172b27; font-size: .84rem; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.element-grid small { display: block; color: #6b7673; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pcb-blocker { margin: 12px 18px; padding: 10px 12px; border-left: 3px solid #aa5d37; background: #fff4e9; color: #5f3a29; font-size: .78rem; line-height: 1.45; }
.pcb-blocker strong { display: block; margin-bottom: 3px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
footer { display: flex; align-items: center; gap: 12px; padding: 12px 18px 16px; color: #697571; font-size: .74rem; }
footer button { margin-left: auto; border: 1px solid #446c63; border-radius: 7px; background: #183d36; color: #f4fbf8; padding: 7px 11px; font-weight: 700; cursor: pointer; }
@keyframes trace-flow { to { stroke-dashoffset: -16; } }
@media (prefers-reduced-motion: reduce) { .pcb-trace { animation: none !important; } }
@media (max-width: 560px) { .element-grid { grid-template-columns: 1fr; } .pcb-card__header { padding-inline: 14px; } footer { align-items: flex-start; flex-wrap: wrap; } }
</style>
@@ -0,0 +1,39 @@
<!--
SPEC: PJ2026-010405 云端控制台
Implementation reference: draft-2026-07-13-p0-cloud-console.
-->
<script setup lang="ts">
import type { HwpodNodeReadinessStage } from "@/types";
defineProps<{ stages: HwpodNodeReadinessStage[] }>();
</script>
<template>
<ol class="readiness-rail" aria-label="HWPOD Node 接入 readiness">
<li v-for="(stage, index) in stages" :key="stage.id" :data-status="stage.status">
<span class="stage-index" aria-hidden="true">{{ String(index + 1).padStart(2, '0') }}</span>
<div>
<strong>{{ stage.label }}</strong>
<small>{{ stage.authority }}</small>
<p v-if="stage.blocker">{{ stage.blocker.summary }}</p>
</div>
<span class="stage-status">{{ stage.status === 'ready' ? '已就绪' : stage.status === 'blocked' ? '阻塞' : '待观测' }}</span>
</li>
</ol>
</template>
<style scoped>
.readiness-rail { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.readiness-rail li { position: relative; display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 12px; align-items: start; min-height: 66px; padding: 8px 0 14px; }
.readiness-rail li:not(:last-child)::after { content: ""; position: absolute; left: 17px; top: 40px; bottom: -1px; width: 2px; background: #ccd5d1; }
.stage-index { position: relative; z-index: 1; display: grid; place-items: center; width: 34px; height: 34px; border: 2px solid #aebbb7; border-radius: 50%; background: #f7f7f3; color: #66736f; font: 700 .66rem/1 ui-monospace, SFMono-Regular, Consolas, monospace; }
li[data-status="ready"] .stage-index { border-color: #3d907f; background: #e6f6ef; color: #24695b; }
li[data-status="blocked"] .stage-index { border-color: #b46b4b; background: #fff0e9; color: #8b4326; }
.readiness-rail strong { display: block; color: #1d302c; font-size: .86rem; }
.readiness-rail small { color: #71807b; font: .67rem/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
.readiness-rail p { margin: 4px 0 0; color: #79503b; font-size: .75rem; line-height: 1.4; }
.stage-status { margin-top: 4px; color: #5e6f69; font-size: .7rem; font-weight: 800; }
li[data-status="ready"] .stage-status { color: #2c7567; }
li[data-status="blocked"] .stage-status { color: #9b4f2d; }
@media (max-width: 560px) { .readiness-rail li { grid-template-columns: 32px minmax(0, 1fr); } .stage-status { grid-column: 2; margin-top: -8px; } }
</style>
+5 -2
View File
@@ -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" } },
+34 -2
View File
@@ -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<HwpodSpecsResponse | null>(null);
const nodeOps = ref<HwpodNodeOpsResponse | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const deviceTopology = ref<HwpodTopologyResponse<HwpodDeviceTopologyItem> | null>(null);
const nodeTopology = ref<HwpodTopologyResponse<HwpodNodeTopologyItem> | null>(null);
const nodeUpdate = ref<HwlabNodeUpdateMetadata | null>(null);
const consoleLoading = ref(false);
const consoleError = ref<string | null>(null);
async function refresh(): Promise<void> {
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<void> {
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 };
});
+125
View File
@@ -387,6 +387,131 @@ export type AgentChatResultResponse = AgentChatResponse & {
export interface HwpodSpecsResponse { specs?: Array<Record<string, unknown>>; [key: string]: unknown }
export interface HwpodNodeOpsResponse { ok?: boolean; status?: string; events?: TraceEvent[]; groups?: Array<Record<string, unknown>>; [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<T extends HwpodDeviceTopologyItem | HwpodNodeTopologyItem> {
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<string, unknown> | null; expected?: Record<string, unknown> | null; runtime?: Record<string, unknown> | null; [key: string]: unknown }
export interface CaseRunCasesResponse { ok?: boolean; contractVersion?: string; count?: number; cases?: CaseRunCaseSummary[]; caseAuthority?: Record<string, unknown>; [key: string]: unknown }
export interface CaseRunEvent { at?: string; status?: string; stage?: string; payload?: Record<string, unknown>; [key: string]: unknown }
@@ -1,144 +1,305 @@
<!--
SPEC: PJ2026-010405 云端控制台; PJ2026-010103 HWPOD服务
Implementation reference: draft-2026-07-13-p0-cloud-console.
-->
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue";
import PageHeader from "@/components/common/PageHeader.vue";
import HwpodDeviceCard from "@/components/hwpod/HwpodDeviceCard.vue";
import HwpodReadinessRail from "@/components/hwpod/HwpodReadinessRail.vue";
import { useHwpodStore } from "@/stores/hwpod";
import type { HwpodNodeReadinessStage } from "@/types";
type ConsoleTab = "devices" | "nodes" | "onboarding";
const route = useRoute();
const router = useRouter();
const hwpod = useHwpodStore();
const showRawJson = ref(false);
const searchText = ref(text(route.query.q));
const copied = ref(false);
const nodeOps = computed(() => asRecord(hwpod.nodeOps));
const specs = computed(() => asRecord(hwpod.specs));
const websocket = computed(() => asRecord(nodeOps.value.websocket));
const supportedOps = computed(() => stringArray(nodeOps.value.supportedOps ?? nodeOps.value.ops));
const nodeRows = computed(() => normalizeNodeRows(nodeOps.value, specs.value));
const blockerSummary = computed(() => collectBlockers([nodeOps.value, specs.value, websocket.value, ...nodeRows.value]).join(" / "));
const rawPayload = computed(() => JSON.stringify({ nodeOps: nodeOps.value, specs: specs.value }, null, 2));
const tab = computed<ConsoleTab>(() => {
const value = text(route.params.tab);
return value === "nodes" || value === "onboarding" ? value : "devices";
});
const view = computed(() => route.query.view === "list" ? "list" : "cards");
const devices = computed(() => hwpod.deviceTopology?.items ?? []);
const nodes = computed(() => hwpod.nodeTopology?.items ?? []);
const summary = computed(() => tab.value === "devices" ? hwpod.deviceTopology?.summary : hwpod.nodeTopology?.summary);
const invalidSpecs = computed(() => (tab.value === "devices" ? hwpod.deviceTopology : hwpod.nodeTopology)?.invalidSpecs ?? []);
const firstInvalidSpec = computed(() => invalidSpecs.value[0] ?? null);
const selectedDevice = computed(() => devices.value.find((device) => device.hwpodId === text(route.query.selected)) ?? null);
const selectedNode = computed(() => nodes.value.find((node) => node.nodeId === text(route.query.nodeId)) ?? nodes.value[0] ?? null);
const onboardingStages = computed<HwpodNodeReadinessStage[]>(() => selectedNode.value?.readiness ?? (hwpod.nodeUpdate?.readinessStages ?? []).map((stage) => ({ ...stage, status: "unknown", blocker: null })));
const artifact = computed(() => hwpod.nodeUpdate?.artifact ?? null);
onMounted(() => void hwpod.refresh());
watch(() => route.query.q, (value) => { searchText.value = text(value); });
watch([tab, () => route.query.q], ([currentTab, q]) => {
const resource = currentTab === "onboarding" ? "onboarding" : currentTab === "nodes" ? "node" : "device";
void hwpod.refreshConsole(resource, { q: text(q) });
}, { immediate: true });
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
function goTab(next: ConsoleTab): void {
void router.push({ path: `/hwpods/${next}`, query: next === "devices" ? { view: view.value } : {} });
}
function asRecordArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.map(asRecord).filter((item) => Object.keys(item).length > 0) : [];
function setView(next: "cards" | "list"): void {
void router.replace({ query: { ...route.query, view: next } });
}
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item ?? "").trim()).filter(Boolean) : [];
function applySearch(): void {
const query = { ...route.query };
if (searchText.value.trim()) query.q = searchText.value.trim();
else delete query.q;
void router.replace({ query });
}
function firstText(...values: unknown[]): string {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value.trim();
if (typeof value === "number" && Number.isFinite(value)) return String(value);
}
return "-";
function selectDevice(id: string): void {
void router.replace({ query: { ...route.query, selected: id } });
}
function countText(value: unknown): string {
const number = Number(value);
return Number.isFinite(number) ? String(number) : "0";
function closeInspector(): void {
const query = { ...route.query };
delete query.selected;
void router.replace({ query });
}
function normalizeNodeRows(ops: Record<string, unknown>, specPayload: Record<string, unknown>): Record<string, unknown>[] {
const wsNodes: Record<string, unknown>[] = asRecordArray(asRecord(ops.websocket).nodes);
const directNodes: Record<string, unknown>[] = asRecordArray(ops.nodes);
const groupNodes: Record<string, unknown>[] = asRecordArray(ops.groups).flatMap((group) => asRecordArray(group.nodes).map((node): Record<string, unknown> => ({ ...node, group: firstText(group.name, group.id) })));
const specNodes: Record<string, unknown>[] = asRecordArray(specPayload.specs).map((spec): Record<string, unknown> => ({
...spec,
name: firstText(spec.name, spec.hwpodId),
nodeId: firstText(spec.nodeId, spec.hwpodNodeId, spec.hwpodId),
status: firstText(asRecord(spec.availability).status, spec.status)
}));
const seen = new Set<string>();
return [...wsNodes, ...directNodes, ...groupNodes, ...specNodes].filter((node) => {
const key = firstText(node.nodeId, node.id, node.name, node.hwpodId);
if (key === "-" || seen.has(key)) return false;
seen.add(key);
return true;
});
function chooseNode(event: Event): void {
const nodeId = (event.target as HTMLSelectElement).value;
void router.replace({ query: { ...route.query, nodeId: nodeId || undefined } });
}
function nodeStatus(node: Record<string, unknown>): string {
return firstText(node.status, asRecord(node.availability).status, node.ready === true ? "ready" : null, node.connected === true ? "connected" : null);
async function copySha(): Promise<void> {
const sha = artifact.value?.sha256;
if (!sha) return;
await navigator.clipboard.writeText(sha);
copied.value = true;
window.setTimeout(() => { copied.value = false; }, 1600);
}
function collectBlockers(values: unknown[]): string[] {
const out: string[] = [];
const visit = (value: unknown): void => {
if (out.length >= 6 || value === null || value === undefined) return;
if (Array.isArray(value)) {
value.forEach(visit);
return;
}
if (typeof value !== "object") return;
const record = value as Record<string, unknown>;
const blocker = asRecord(record.blocker);
const summary = firstText(record.blockedReason, record.reason, record.error, blocker.summary, blocker.code);
if (summary !== "-" && !out.includes(summary)) out.push(summary);
visit(record.results);
visit(record.availability);
visit(record.events);
};
values.forEach(visit);
return out;
function statusLabel(status: string): string {
return ({ available: "可用", busy: "执行中", offline: "离线", "capability-mismatch": "能力不匹配", "workspace-blocked": "工作区阻塞", unprobed: "待探测", online: "在线", unconfigured: "无 Spec" }[status] ?? status);
}
function formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "-";
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KiB`;
}
function formatTime(value: string | null): string {
if (!value) return "未观测";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString("zh-CN", { hour12: false });
}
function text(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
</script>
<template>
<section class="route-stack hwpod-groups-page">
<PageHeader eyebrow="Admin" title="HWPOD 分组" description="HWPOD node-ops 合约、连接状态与节点可用性。" />
<LoadingState v-if="hwpod.loading" />
<EmptyState v-else-if="hwpod.error" title="HWPOD 加载失败" :description="hwpod.error" />
<section class="hwpod-console" data-testid="hwpod-console">
<PageHeader eyebrow="Hardware pool" title="HWPOD 控制台" description="设备资源、主动出站 Node 与 Python 单文件接入的同一事实视图。" />
<nav class="console-tabs" aria-label="HWPOD 子页面">
<button v-for="item in ([['devices', '设备资源'], ['nodes', 'HWPOD Node'], ['onboarding', '节点接入']] as const)" :key="item[0]" type="button" :data-active="tab === item[0]" :aria-current="tab === item[0] ? 'page' : undefined" @click="goTab(item[0])">
{{ item[1] }}
</button>
</nav>
<section class="console-command" aria-label="HWPOD 命令栏">
<form v-if="tab !== 'onboarding'" role="search" @submit.prevent="applySearch">
<label for="hwpod-search">搜索</label>
<input id="hwpod-search" v-model="searchText" type="search" placeholder="nodeId / hwpodId / 状态" />
<button type="submit">查询</button>
</form>
<div v-if="tab === 'devices'" class="view-switch" aria-label="显示模式">
<button type="button" :data-active="view === 'cards'" @click="setView('cards')">电路板卡片</button>
<button type="button" :data-active="view === 'list'" @click="setView('list')">列表</button>
</div>
<div class="command-facts" aria-live="polite">
<span>owner <strong>cloud-api-hwpod-domain</strong></span>
<span v-if="summary">{{ summary.onlineNodeCount }}/{{ summary.nodeCount }} nodes online</span>
<span v-if="summary">{{ summary.availableDeviceCount }}/{{ summary.deviceCount }} HWPOD available</span>
</div>
</section>
<LoadingState v-if="hwpod.consoleLoading" />
<EmptyState v-else-if="hwpod.consoleError" title="HWPOD 事实投影加载失败" :description="hwpod.consoleError" />
<template v-else>
<section class="system-summary-grid" aria-label="HWPOD node-ops summary">
<article class="metric-card"><span>Contract</span><strong>{{ firstText(nodeOps.contractVersion, specs.nodeOpsContractVersion, 'hwpod-node-ops-v1') }}</strong><small>{{ firstText(nodeOps.status, 'unknown') }}</small></article>
<article class="metric-card"><span>Route</span><strong>{{ firstText(nodeOps.route, '/v1/hwpod-node-ops') }}</strong><small>node-ops endpoint</small></article>
<article class="metric-card"><span>WebSocket</span><strong>{{ countText(websocket.connectedCount) }} / {{ countText(websocket.pendingCount) }}</strong><small>connected / pending</small></article>
<article class="metric-card"><span>Ops</span><strong>{{ supportedOps.length }}</strong><small>supported operations</small></article>
</section>
<aside v-if="firstInvalidSpec" class="spec-warning" role="status">
<strong>{{ invalidSpecs.length }} invalid spec 未进入可用资源</strong>
<span>{{ firstInvalidSpec.blocker.code }} · {{ firstInvalidSpec.blocker.summary }}</span>
</aside>
<section class="data-panel hwpod-groups-section" aria-label="HWPOD nodes">
<header class="panel-header table-page-header">
<div>
<h2>节点列表</h2>
<small>{{ nodeRows.length }} nodes / {{ countText(specs.availableCount) }} available specs</small>
</div>
</header>
<div v-if="nodeRows.length" class="hwpod-node-grid">
<article v-for="node in nodeRows" :key="firstText(node.nodeId, node.id, node.name, node.hwpodId)" class="hwpod-node-row">
<strong>{{ firstText(node.name, node.hwpodId, node.nodeId, node.id) }}</strong>
<span class="status-pill" :data-status="nodeStatus(node)">{{ nodeStatus(node) }}</span>
<small>{{ firstText(node.nodeId, node.id) }}</small>
<code>{{ firstText(node.workspacePath, node.workspace, node.group) }}</code>
</article>
<section v-if="tab === 'devices'" class="resource-stage">
<div v-if="devices.length && view === 'cards'" class="device-grid">
<HwpodDeviceCard v-for="device in devices" :key="device.hwpodId" :device="device" @select="selectDevice" />
</div>
<EmptyState v-else title="暂无在线 HWPOD node" :description="blockerSummary || 'node-ops 当前没有返回节点;连接数和 pending 状态见上方摘要。'" />
<p v-if="blockerSummary" class="muted-box hwpod-blocker">{{ blockerSummary }}</p>
</section>
<section class="data-panel hwpod-groups-section" aria-label="HWPOD supported operations">
<header class="panel-header table-page-header">
<div>
<h2>支持操作</h2>
<small>{{ supportedOps.length }} ops exposed by node-ops</small>
</div>
</header>
<div v-if="supportedOps.length" class="hwpod-op-list">
<code v-for="op in supportedOps" :key="op">{{ op }}</code>
<div v-else-if="devices.length" class="table-shell">
<table>
<thead><tr><th>HWPOD</th><th>Node</th><th>状态</th><th>目标 / 工作区</th><th>能力</th><th>主要 blocker</th><th /></tr></thead>
<tbody>
<tr v-for="device in devices" :key="device.hwpodId" :data-status="device.status">
<td><strong>{{ device.name }}</strong><code>{{ device.hwpodId }}</code></td>
<td><strong>{{ device.nodeName }}</strong><code>{{ device.nodeId }}</code></td>
<td><span class="status-chip" :data-status="device.status">{{ statusLabel(device.status) }}</span></td>
<td><strong>{{ device.elements.target.label }}</strong><small>{{ device.elements.workspace.label }}</small></td>
<td>{{ device.actualCapabilities.length }}/{{ device.declaredCapabilities.length }}<small v-if="device.missingCapabilities.length"> {{ device.missingCapabilities.join(', ') }}</small></td>
<td><strong>{{ device.blockers[0]?.code || '无' }}</strong><small>{{ device.blockers[0]?.summary || '当前可操作' }}</small></td>
<td><button class="row-action" type="button" @click="selectDevice(device.hwpodId)">详情</button></td>
</tr>
</tbody>
</table>
</div>
<EmptyState v-else title="暂无 supportedOps" description="后端尚未返回支持操作列表。" />
<EmptyState v-else title="没有 HWPOD 设备" description="owning registry 当前未返回符合查询的有效 HWPOD spec。" />
<aside v-if="selectedDevice" class="resource-inspector" aria-label="HWPOD 详情">
<header><div><span>HWPOD Inspector</span><h2>{{ selectedDevice.name }}</h2></div><button type="button" aria-label="关闭详情" @click="closeInspector">×</button></header>
<dl>
<div><dt>身份</dt><dd>{{ selectedDevice.hwpodId }}</dd></div>
<div><dt>Node</dt><dd>{{ selectedDevice.nodeId }}</dd></div>
<div><dt>Spec authority</dt><dd>{{ selectedDevice.source.authority }}</dd></div>
<div><dt>最近 operation</dt><dd>{{ selectedDevice.operation?.status || '无' }}</dd></div>
</dl>
<h3>缺失能力</h3>
<div class="capability-list"><code v-for="capability in selectedDevice.missingCapabilities" :key="capability">{{ capability }}</code><span v-if="!selectedDevice.missingCapabilities.length"></span></div>
<h3>诊断摘要</h3>
<p>{{ selectedDevice.diagnostic?.message || selectedDevice.blockers[0]?.summary || '无当前诊断' }}</p>
</aside>
</section>
<section class="data-panel hwpod-raw-panel" aria-label="HWPOD raw payload">
<button data-testid="hwpod-raw-json-toggle" class="table-action" type="button" :aria-expanded="showRawJson" @click="showRawJson = !showRawJson">
{{ showRawJson ? '隐藏原始 JSON' : '查看原始 JSON' }}
</button>
<pre v-if="showRawJson" data-testid="hwpod-raw-json" class="json-panel">{{ rawPayload }}</pre>
<section v-else-if="tab === 'nodes'" class="node-grid">
<article v-for="node in nodes" :key="node.nodeId" class="node-card" :data-status="node.status" :data-testid="`hwpod-node-${node.nodeId}`">
<header><div><span>{{ node.platform }} · {{ node.runtimeKind || '未识别运行时' }}</span><h2>{{ node.name }}</h2><code>{{ node.nodeId }}</code></div><span class="status-chip" :data-status="node.status">{{ statusLabel(node.status) }}</span></header>
<section class="node-gauges">
<div><small>HWPOD</small><strong>{{ node.deviceCount }}</strong></div>
<div><small>in-flight</small><strong>{{ node.inFlight.count }}/{{ node.inFlight.max }}</strong></div>
<div><small>capabilities</small><strong>{{ node.actualCapabilities.length }}/{{ node.declaredCapabilities.length }}</strong></div>
</section>
<section class="mounted-hwpods" aria-label="挂载 HWPOD">
<header><strong>挂载 HWPOD</strong><span>{{ node.hwpods.length }}</span></header>
<ul v-if="node.hwpods.length">
<li v-for="device in node.hwpods" :key="device.hwpodId">
<div><strong>{{ device.name }}</strong><code>{{ device.hwpodId }}</code></div>
<span class="status-chip" :data-status="device.status">{{ statusLabel(device.status) }}</span>
</li>
</ul>
<p v-else>节点在线 owning registry 尚未绑定 HWPOD spec</p>
</section>
<HwpodReadinessRail :stages="node.readiness" />
<footer><span>最后心跳 {{ formatTime(node.lastSeenAt) }}</span><strong v-if="node.blockers[0]">{{ node.blockers[0].code }}</strong></footer>
</article>
<EmptyState v-if="!nodes.length" title="没有 HWPOD Node" description="WebSocket registry 和 owning spec 当前都没有节点记录。" />
</section>
<section v-else class="onboarding-grid">
<article class="artifact-panel">
<span class="panel-kicker">Python single file</span>
<h2>{{ artifact?.fileName || 'hwlab-node.py' }}</h2>
<p>使用 Windows 交互用户的原生 Python/tkinter 运行并主动出站连接 Cloud API下载完成不等于 HWPOD ready</p>
<dl>
<div><dt>版本</dt><dd>{{ artifact?.version || '-' }}</dd></div>
<div><dt>大小</dt><dd>{{ formatSize(artifact?.sizeBytes || 0) }}</dd></div>
<div class="sha-row"><dt>SHA-256</dt><dd><code>{{ artifact?.sha256 || '未发布' }}</code><button type="button" :disabled="!artifact?.sha256" @click="copySha">{{ copied ? '已复制' : '复制' }}</button></dd></div>
</dl>
<a v-if="artifact?.downloadUrl" class="download-button" :href="artifact.downloadUrl" download="hwlab-node.py">下载 Python 单文件</a>
<p v-else class="download-blocked">发布 artifact 当前不可用</p>
<h3>发布说明</h3>
<ul><li v-for="note in hwpod.nodeUpdate?.releaseNotes || []" :key="note">{{ note }}</li></ul>
<h3>安装条件</h3>
<ul><li v-for="condition in hwpod.nodeUpdate?.installConditions || []" :key="condition.id">{{ condition.label }}</li></ul>
</article>
<article class="readiness-panel">
<header><div><span class="panel-kicker">Staged readiness</span><h2>接入进度</h2></div><label>Node<select :value="selectedNode?.nodeId || ''" @change="chooseNode"><option value="">未选择</option><option v-for="node in nodes" :key="node.nodeId" :value="node.nodeId">{{ node.name }} · {{ statusLabel(node.status) }}</option></select></label></header>
<HwpodReadinessRail :stages="onboardingStages" />
<aside class="outbound-note"><strong>网络边界</strong><span>节点只使用主动出站 WebSocket网页不生成凭据不要求用户 PC 开放入站端口</span></aside>
</article>
</section>
</template>
</section>
</template>
<style scoped>
.hwpod-console { min-width: 0; min-height: 100%; padding: 22px; color: #20312d; background: radial-gradient(circle at 90% 4%, rgb(56 128 115 / 8%), transparent 28%), #efefe9; }
.console-tabs { display: flex; gap: 4px; width: fit-content; max-width: 100%; margin: 16px 0 0; padding: 4px; border: 1px solid #bdc8c4; border-radius: 9px; background: #dfe4e1; overflow-x: auto; }
.console-tabs button, .view-switch button { border: 0; border-radius: 6px; background: transparent; color: #56645f; padding: 8px 13px; font-weight: 750; white-space: nowrap; cursor: pointer; }
.console-tabs button[data-active="true"], .view-switch button[data-active="true"] { color: #f4fbf8; background: #173d35; box-shadow: 0 3px 9px rgb(20 45 40 / 18%); }
.console-command { display: flex; align-items: center; gap: 14px; margin: 12px 0 16px; padding: 10px 12px; border: 1px solid #c9d0cd; border-radius: 9px; background: #faf9f5; }
.console-command form { display: flex; align-items: center; gap: 7px; min-width: min(420px, 100%); }
.console-command label { color: #67736f; font-size: .72rem; font-weight: 800; text-transform: uppercase; }
.console-command input { min-width: 0; flex: 1; border: 1px solid #b9c5c1; border-radius: 6px; background: #fff; padding: 8px 10px; }
.console-command form button, .row-action { border: 1px solid #486b63; border-radius: 6px; background: #f4f8f6; color: #254e45; padding: 7px 10px; font-weight: 750; cursor: pointer; }
.view-switch { display: flex; gap: 2px; padding: 3px; border: 1px solid #c4cdca; border-radius: 7px; background: #edf0ee; }
.view-switch button { padding: 6px 9px; font-size: .74rem; }
.command-facts { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px 14px; margin-left: auto; color: #66736f; font: .69rem/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
.command-facts strong { color: #2d524a; }
.spec-warning { display: flex; gap: 8px 20px; align-items: center; margin-bottom: 14px; padding: 10px 13px; border: 1px solid #dfc47d; border-left: 4px solid #aa7a18; border-radius: 7px; background: #fff8df; color: #6b5317; font-size: .78rem; }
.device-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(440px, 100%), 680px)); justify-content: start; align-items: start; gap: 16px; }
.table-shell { max-width: 100%; border: 1px solid #c5cecb; border-radius: 10px; background: #fbfaf7; overflow: auto; }
table { width: 100%; min-width: 980px; border-collapse: collapse; }
th { position: sticky; top: 0; z-index: 1; background: #e5e9e6; color: #53615d; font-size: .69rem; letter-spacing: .05em; text-align: left; text-transform: uppercase; }
th, td { padding: 11px 13px; border-bottom: 1px solid #dce1df; vertical-align: top; }
td { color: #263a35; font-size: .78rem; }
td strong, td small, td code { display: block; }
td small, td code { margin-top: 3px; color: #6b7773; font-size: .7rem; }
.status-chip { display: inline-flex; padding: 4px 8px; border: 1px solid #aebeb9; border-radius: 999px; background: #eaf5f0; color: #2c6b5d; font-size: .7rem; font-weight: 800; white-space: nowrap; }
.status-chip[data-status="busy"] { color: #8b4c0a; background: #fff4df; border-color: #e6bd80; }
.status-chip[data-status="offline"], .status-chip[data-status="workspace-blocked"] { color: #7b332c; background: #fff0ed; border-color: #ddb2ac; }
.status-chip[data-status="capability-mismatch"], .status-chip[data-status="unconfigured"] { color: #6f560b; background: #fff8d8; border-color: #d8c376; }
.resource-stage { position: relative; }
.resource-inspector { position: fixed; z-index: 20; top: 72px; right: 18px; bottom: 18px; width: min(420px, calc(100vw - 36px)); padding: 18px; border: 1px solid #aebdb8; border-radius: 12px; background: #fdfcf8; box-shadow: 0 24px 70px rgb(15 31 27 / 28%); overflow: auto; }
.resource-inspector header, .node-card > header, .readiness-panel > header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.resource-inspector header span, .node-card header span, .panel-kicker { color: #687671; font: 700 .68rem/1.3 ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.resource-inspector h2, .node-card h2, .artifact-panel h2, .readiness-panel h2 { margin: 4px 0; color: #172d27; }
.resource-inspector header button { border: 0; background: transparent; color: #51605b; font-size: 1.6rem; cursor: pointer; }
.resource-inspector dl, .artifact-panel dl { display: grid; gap: 1px; margin: 18px 0; border: 1px solid #d2d9d6; background: #d2d9d6; }
.resource-inspector dl div, .artifact-panel dl div { padding: 10px 11px; background: #f6f6f1; }
dt { color: #6d7a75; font-size: .68rem; font-weight: 800; text-transform: uppercase; }
dd { margin: 3px 0 0; overflow-wrap: anywhere; }
.capability-list { display: flex; flex-wrap: wrap; gap: 6px; }
.capability-list code { padding: 5px 7px; border-radius: 5px; background: #fff3dd; color: #754813; }
.node-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(380px, 100%), 1fr)); gap: 15px; }
.node-card, .artifact-panel, .readiness-panel { min-width: 0; padding: 18px; border: 1px solid #c1cbc7; border-top: 3px solid #3a766a; border-radius: 11px; background: #fbfaf6; box-shadow: 0 10px 26px rgb(19 42 36 / 7%); }
.node-card[data-status="offline"] { border-top-color: #9a5044; }
.node-card[data-status="busy"], .node-card[data-status="capability-mismatch"], .node-card[data-status="unconfigured"] { border-top-color: #aa751d; }
.node-card header code { color: #6e7975; font-size: .72rem; }
.node-gauges { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin: 16px 0; border: 1px solid #d2d9d6; background: #d2d9d6; }
.node-gauges div { padding: 10px; background: #f2f4f1; }
.node-gauges small, .node-gauges strong { display: block; }
.node-gauges small { color: #707d78; font-size: .67rem; text-transform: uppercase; }
.node-gauges strong { margin-top: 3px; color: #19332c; font: 800 1.05rem/1 ui-monospace, SFMono-Regular, Consolas, monospace; }
.mounted-hwpods { margin: 0 0 15px; border: 1px solid #d4dbd8; border-radius: 7px; background: #f5f6f2; overflow: hidden; }
.mounted-hwpods > header { display: flex; justify-content: space-between; padding: 8px 10px; border-bottom: 1px solid #d4dbd8; color: #53645e; font-size: .72rem; }
.mounted-hwpods ul { display: grid; gap: 1px; margin: 0; padding: 0; background: #dce2df; list-style: none; }
.mounted-hwpods li { display: flex; justify-content: space-between; gap: 10px; align-items: center; min-width: 0; padding: 9px 10px; background: #faf9f5; }
.mounted-hwpods li div { min-width: 0; }
.mounted-hwpods li strong, .mounted-hwpods li code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mounted-hwpods li strong { color: #2b403a; font-size: .76rem; }
.mounted-hwpods li code { margin-top: 2px; color: #71807b; font-size: .66rem; }
.mounted-hwpods p { margin: 0; padding: 10px; color: #765b25; font-size: .74rem; line-height: 1.4; }
.node-card footer { display: flex; justify-content: space-between; gap: 10px; margin-top: 10px; padding-top: 12px; border-top: 1px solid #d9dfdc; color: #687570; font-size: .72rem; }
.node-card footer strong { color: #8a4c2d; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.onboarding-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 16px; }
.artifact-panel > p { color: #61706b; line-height: 1.55; }
.artifact-panel h3 { margin: 20px 0 8px; color: #314a43; font-size: .83rem; }
.artifact-panel ul { margin: 0; padding-left: 20px; color: #5f6d68; font-size: .78rem; line-height: 1.65; }
.sha-row dd { display: flex; gap: 8px; align-items: center; }
.sha-row code { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sha-row button { margin-left: auto; flex: 0 0 auto; border: 1px solid #b8c5c0; border-radius: 5px; background: #fff; padding: 4px 7px; cursor: pointer; }
.download-button { display: flex; justify-content: center; padding: 11px 14px; border-radius: 7px; background: #173e35; color: #f5fcf9; font-weight: 800; text-decoration: none; }
.download-blocked { padding: 10px; border: 1px solid #ddafa7; background: #fff0ed; color: #7b332c !important; }
.readiness-panel > header { align-items: flex-start; margin-bottom: 14px; }
.readiness-panel label { display: grid; gap: 4px; color: #6b7873; font-size: .68rem; font-weight: 800; text-transform: uppercase; }
.readiness-panel select { max-width: 220px; border: 1px solid #b5c1bd; border-radius: 6px; background: #fff; padding: 7px 9px; color: #263c36; }
.outbound-note { display: grid; gap: 5px; margin-top: 14px; padding: 12px; border-left: 3px solid #398477; background: #eaf6f1; color: #365d54; font-size: .77rem; line-height: 1.45; }
@media (max-width: 1100px) { .console-command { align-items: stretch; flex-wrap: wrap; } .command-facts { width: 100%; justify-content: flex-start; margin-left: 0; } .onboarding-grid { grid-template-columns: 1fr; } }
@media (max-width: 680px) { .hwpod-console { padding: 14px; } .console-tabs { width: 100%; } .console-tabs button { flex: 1 0 auto; } .console-command form { min-width: 100%; } .spec-warning { align-items: flex-start; flex-direction: column; } .node-gauges { grid-template-columns: 1fr; } .readiness-panel > header { flex-direction: column; } .readiness-panel select { max-width: 100%; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } }
</style>