feat(hwpod): 补齐拓扑与节点接入控制台
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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" } }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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>
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user