Merge pull request #2519 from pikasTech/feat/2514-hwpod-console
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
feat(hwpod): 补齐拓扑与节点接入控制台
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。
|
||||
* Implementation reference: draft-2026-07-13-p0-cloud-console。
|
||||
* Responsibility: 验证 HWPOD typed topology、Node 更新元数据、主动出站调度与并发门禁。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
@@ -5,6 +10,7 @@ import path from "node:path";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { createCloudApiBunServer } from "./bun-server.ts";
|
||||
import { createHwpodNodeWsRegistry } from "./hwpod-node-ws-registry.ts";
|
||||
import { createCloudApiServer } from "./server.ts";
|
||||
import { connectHwpodNodeWs, createHwpodNodeServer } from "../../tools/src/hwpod-node-lib.ts";
|
||||
|
||||
@@ -33,6 +39,8 @@ test("cloud-api exposes hwpod-node-ops contract on /v1", async () => {
|
||||
assert.equal(payload.hwpod.contractVersion, "hwpod-node-ops-v1");
|
||||
assert.equal(payload.hwpod.apiRole, "node-ops-forwarder");
|
||||
assert.equal(payload.hwpod.specDiscoveryRoute, "/v1/hwpod/specs");
|
||||
assert.equal(payload.hwpod.topologyRoute, "/v1/hwpod/topology");
|
||||
assert.equal(payload.hwpod.topologyContractVersion, "hwpod-topology-v1");
|
||||
assert.ok(payload.hwpod.supportedOps.includes("node.diagnostics"));
|
||||
assert.ok(payload.hwpod.supportedOps.includes("workspace.ls"));
|
||||
assert.ok(payload.hwpod.supportedOps.includes("workspace.insert-after"));
|
||||
@@ -66,6 +74,11 @@ test("cloud-api exposes hwlab-node Python update metadata", async () => {
|
||||
assert.equal(payload.latestVersion, "0.1.1");
|
||||
assert.equal(payload.downloadUrl, "https://hwlab.pikapython.com/downloads/hwlab-node.py");
|
||||
assert.equal(payload.sha256, "abc123");
|
||||
assert.equal(payload.artifact.fileName, "hwlab-node.py");
|
||||
assert.equal(payload.artifact.sha256, "abc123");
|
||||
assert.ok(payload.artifact.sizeBytes > 0);
|
||||
assert.equal(payload.readinessStages.at(-1).id, "hwpod-ready");
|
||||
assert.ok(payload.installConditions.some((item: any) => item.id === "outbound-websocket"));
|
||||
|
||||
const currentResponse = await fetch(`${serverUrl(server)}/v1/hwlab-node/update?current=0.1.1&channel=stable&platform=windows`);
|
||||
const currentPayload = await currentResponse.json();
|
||||
@@ -85,15 +98,18 @@ test("cloud-api serves bundled hwlab-node Python updater artifact by default", a
|
||||
const payload = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.updateAvailable, true);
|
||||
assert.equal(payload.latestVersion, "0.1.1");
|
||||
assert.equal(payload.latestVersion, "0.1.2");
|
||||
assert.equal(payload.downloadUrl, "https://hwlab.pikapython.com/v1/hwlab-node/download/hwlab-node.py");
|
||||
assert.match(payload.sha256, /^[a-f0-9]{64}$/u);
|
||||
assert.equal(payload.artifact.version, "0.1.2");
|
||||
assert.equal(payload.artifact.fileName, "hwlab-node.py");
|
||||
assert.ok(payload.artifact.sizeBytes > 0);
|
||||
|
||||
const download = await fetch(`${serverUrl(server)}/v1/hwlab-node/download/hwlab-node.py`);
|
||||
const text = await download.text();
|
||||
assert.equal(download.status, 200);
|
||||
assert.equal(download.headers.get("x-hwlab-node-version"), "0.1.1");
|
||||
assert.ok(text.includes('APP_VERSION = "0.1.1"'));
|
||||
assert.equal(download.headers.get("x-hwlab-node-version"), "0.1.2");
|
||||
assert.ok(text.includes('APP_VERSION = "0.1.2"'));
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
@@ -187,6 +203,50 @@ test("cloud-api probes discovered hwpod-spec availability through node-ops", asy
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud-api serves the typed HWPOD topology read model from its existing owner", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-topology-"));
|
||||
const probes: any[] = [];
|
||||
const specDir = path.join(root, ".hwlab");
|
||||
await mkdir(specDir, { recursive: true });
|
||||
await writeFile(path.join(specDir, "hwpod-spec.yaml"), sampleSpecYaml(), "utf8");
|
||||
const server = createHwpodTestCloudApiServer({
|
||||
env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: root },
|
||||
hwpodNodeOpsHandler: async (plan: any, context: any) => {
|
||||
probes.push({ plan, context });
|
||||
return { ok: true, status: "completed", planId: plan.planId, results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed" })) };
|
||||
},
|
||||
hwpodNodeWsRegistry: {
|
||||
hasNode: () => false,
|
||||
dispatch: async () => ({ ok: false, status: "blocked", results: [] }),
|
||||
describe: () => ({
|
||||
mode: "hwpod-node-outbound-native-ws",
|
||||
connectedCount: 1,
|
||||
pendingCount: 0,
|
||||
nodes: [{ nodeId: "node-d601-f103-v2", name: "D601 Python Node", status: "online", platform: "win32", version: "0.1.2-python-gui", capabilities: ["workspace.ls"], inFlightCount: 0, maxInFlight: 1 }]
|
||||
})
|
||||
}
|
||||
});
|
||||
await listen(server);
|
||||
try {
|
||||
const response = await fetch(`${serverUrl(server)}/v1/hwpod/topology?resource=node`);
|
||||
const payload = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.contractVersion, "hwpod-topology-v1");
|
||||
assert.equal(payload.owner, "cloud-api-hwpod-domain");
|
||||
assert.equal(payload.items[0].nodeId, "node-d601-f103-v2");
|
||||
assert.equal(payload.items[0].deviceCount, 1);
|
||||
assert.equal(payload.items[0].capabilityMismatch, true);
|
||||
assert.equal(probes.length, 0);
|
||||
const probeResponse = await fetch(`${serverUrl(server)}/v1/hwpod/topology?resource=node&probe=1`);
|
||||
assert.equal(probeResponse.status, 200);
|
||||
assert.equal(probes.length, 1);
|
||||
assert.equal(probes[0].context.requestMeta.operationKind, "readiness-probe");
|
||||
} finally {
|
||||
await close(server);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud-api forwards valid hwpod-node-ops plans to injected node handler", async () => {
|
||||
const seen: any[] = [];
|
||||
const server = createHwpodTestCloudApiServer({
|
||||
@@ -373,6 +433,26 @@ test("cloud-api dispatches hwpod-node-ops to outbound WebSocket hwpod-node by no
|
||||
}
|
||||
});
|
||||
|
||||
test("WebSocket registry keeps readiness probes separate and rejects dispatch above maxInFlight", async () => {
|
||||
const sent: any[] = [];
|
||||
const socket = { send(value: string) { sent.push(JSON.parse(value)); }, close() {} };
|
||||
const registry = createHwpodNodeWsRegistry({ now: () => "2026-07-13T02:00:00.000Z" });
|
||||
registry.openBunSocket(socket);
|
||||
registry.handleBunMessage(socket, JSON.stringify({ type: "register", nodeId: "pc-host-1", name: "PC Host", capabilities: ["workspace.ls"], maxInFlight: 1 }));
|
||||
const plan = samplePlan();
|
||||
plan.nodeId = "pc-host-1";
|
||||
const probePromise = registry.dispatch(plan, { requestId: "req_probe", operationKind: "readiness-probe" }, { timeoutMs: 5000 });
|
||||
const busy = await registry.dispatch(plan, { requestId: "req_user" }, { timeoutMs: 5000 });
|
||||
assert.equal(busy.blocker.code, "hwpod_node_busy");
|
||||
assert.equal(registry.describe().nodes[0].latestOperation, null);
|
||||
assert.equal(registry.describe().nodes[0].latestReadinessProbe.status, "running");
|
||||
registry.handleBunMessage(socket, JSON.stringify({ type: "hwpod-node-ops-result", requestId: "req_probe", nodeId: "pc-host-1", result: { ok: true, status: "completed", results: [] } }));
|
||||
await probePromise;
|
||||
assert.equal(registry.describe().nodes[0].latestReadinessProbe.status, "completed");
|
||||
assert.equal(sent.filter((message) => message.type === "hwpod-node-ops").length, 1);
|
||||
registry.closeBunSocket(socket);
|
||||
});
|
||||
|
||||
test("cloud-api rejects unsupported hwpod-node ops before forwarding", async () => {
|
||||
const server = createHwpodTestCloudApiServer({
|
||||
hwpodNodeOpsHandler: async () => {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。
|
||||
* Implementation reference: draft-2026-07-13-p0-cloud-console。
|
||||
* Responsibility: 管理主动出站 HWPOD Node 连接、并发门禁,以及用户操作与 readiness 探测的独立摘要。
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const DEFAULT_DISPATCH_TIMEOUT_MS = 30000;
|
||||
@@ -9,6 +14,14 @@ type HwpodNodeConnection = {
|
||||
name: string | null;
|
||||
capabilities: string[];
|
||||
labels: Record<string, unknown>;
|
||||
runtime: {
|
||||
kind: string | null;
|
||||
installed: boolean | null;
|
||||
desktopVisible: boolean | null;
|
||||
};
|
||||
maxInFlight: number;
|
||||
latestOperation: Record<string, unknown> | null;
|
||||
latestReadinessProbe: Record<string, unknown> | null;
|
||||
diagnostics: any[];
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
@@ -17,6 +30,9 @@ type HwpodNodeConnection = {
|
||||
type PendingDispatch = {
|
||||
nodeId: string;
|
||||
requestId: string;
|
||||
connection: HwpodNodeConnection;
|
||||
operationKind: "user-operation" | "readiness-probe";
|
||||
operation: Record<string, unknown>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
resolve: (value: any) => void;
|
||||
};
|
||||
@@ -37,6 +53,10 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
|
||||
name: null,
|
||||
capabilities: [],
|
||||
labels: {},
|
||||
runtime: { kind: null, installed: null, desktopVisible: null },
|
||||
maxInFlight: 1,
|
||||
latestOperation: null,
|
||||
latestReadinessProbe: null,
|
||||
diagnostics: [],
|
||||
firstSeenAt: now(),
|
||||
lastSeenAt: now()
|
||||
@@ -65,19 +85,29 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
|
||||
if (!nodeId || !connection) {
|
||||
return Promise.resolve(blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId || "<missing>"} is not connected through outbound WebSocket`));
|
||||
}
|
||||
if (inFlightCountForNode(nodeId, pending) >= connection.maxInFlight) {
|
||||
return Promise.resolve(blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId} reached its maxInFlight=${connection.maxInFlight} limit`, "hwpod_node_busy"));
|
||||
}
|
||||
const requestId = safeText(requestMeta.requestId) || `req_hwpod_ws_${randomUUID()}`;
|
||||
const timeoutMs = positiveInteger(dispatchOptions.timeoutMs, DEFAULT_DISPATCH_TIMEOUT_MS);
|
||||
const operationKind = safeText(requestMeta.operationKind) === "readiness-probe" ? "readiness-probe" : "user-operation";
|
||||
const operation = operationStarted(plan, requestId, now());
|
||||
if (operationKind === "readiness-probe") connection.latestReadinessProbe = operation;
|
||||
else connection.latestOperation = operation;
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(requestId);
|
||||
finishPendingOperation({ nodeId, requestId, connection, operationKind, operation, timer, resolve }, "timed-out", now(), "hwpod_node_unavailable");
|
||||
resolve(blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId} WebSocket dispatch timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
pending.set(requestId, { nodeId, requestId, timer, resolve });
|
||||
const waiter = { nodeId, requestId, connection, operationKind, operation, timer, resolve } satisfies PendingDispatch;
|
||||
pending.set(requestId, waiter);
|
||||
try {
|
||||
sendJson(connection, { type: "hwpod-node-ops", requestId, plan, requestMeta });
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
pending.delete(requestId);
|
||||
finishPendingOperation(waiter, "failed", now(), "hwpod_node_unavailable");
|
||||
resolve(blockedDispatch(plan, requestMeta, error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
});
|
||||
@@ -96,7 +126,16 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
|
||||
status: "online",
|
||||
capabilityCount: connection.capabilities.length,
|
||||
capabilities: connection.capabilities,
|
||||
labels: connection.labels,
|
||||
platform: safeText(connection.labels.platform) || "unknown",
|
||||
version: safeText(connection.labels.version) || null,
|
||||
runtimeKind: connection.runtime.kind,
|
||||
installed: connection.runtime.installed,
|
||||
desktopVisible: connection.runtime.desktopVisible,
|
||||
maxInFlight: connection.maxInFlight,
|
||||
inFlightCount: inFlightCountForNode(connection.nodeId, pending),
|
||||
busy: inFlightCountForNode(connection.nodeId, pending) >= connection.maxInFlight,
|
||||
latestOperation: connection.latestOperation,
|
||||
latestReadinessProbe: connection.latestReadinessProbe,
|
||||
diagnosticCount: connection.diagnostics.length,
|
||||
lastDiagnostic: connection.diagnostics.at(-1) ?? null,
|
||||
diagnostics: connection.diagnostics.slice(-10),
|
||||
@@ -130,6 +169,8 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
|
||||
connection.name = safeText(message.name) || nodeId;
|
||||
connection.capabilities = Array.isArray(message.capabilities) ? message.capabilities.map(String).filter(Boolean).slice(0, 64) : [];
|
||||
connection.labels = message.labels && typeof message.labels === "object" && !Array.isArray(message.labels) ? message.labels : {};
|
||||
connection.runtime = normalizeRuntime(message.runtime, connection.labels);
|
||||
connection.maxInFlight = positiveInteger(message.maxInFlight ?? connection.labels.maxInFlight, 1);
|
||||
connection.lastSeenAt = now();
|
||||
const previous = connections.get(nodeId);
|
||||
if (previous && previous !== connection) previous.socket.close();
|
||||
@@ -162,6 +203,12 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
|
||||
const result = message.result && typeof message.result === "object" && !Array.isArray(message.result)
|
||||
? message.result
|
||||
: { ok: false, status: "failed", results: [], blocker: { code: "hwpod_node_result_invalid", layer: "hwpod-node", retryable: true, summary: "hwpod-node WebSocket result payload is invalid" } };
|
||||
finishPendingOperation(
|
||||
waiter,
|
||||
safeText(result.status) || (result.ok === false ? "failed" : "completed"),
|
||||
now(),
|
||||
safeText(result.blocker?.code) || null
|
||||
);
|
||||
waiter.resolve(result);
|
||||
}
|
||||
|
||||
@@ -171,6 +218,7 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
|
||||
if (waiter.nodeId !== connection.nodeId) continue;
|
||||
clearTimeout(waiter.timer);
|
||||
pending.delete(requestId);
|
||||
finishPendingOperation(waiter, "disconnected", now(), "hwpod_node_unavailable");
|
||||
waiter.resolve(blockedDispatch({ nodeId: waiter.nodeId, ops: [] }, { requestId }, `hwpod-node ${waiter.nodeId} WebSocket disconnected before returning result`));
|
||||
}
|
||||
}
|
||||
@@ -182,7 +230,7 @@ function sendJson(connection: HwpodNodeConnection, value: any) {
|
||||
connection.socket.send(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function blockedDispatch(plan: any, requestMeta: any, summary: string) {
|
||||
function blockedDispatch(plan: any, requestMeta: any, summary: string, code = "hwpod_node_unavailable") {
|
||||
const ops = Array.isArray(plan?.ops) ? plan.ops : [];
|
||||
return {
|
||||
ok: false,
|
||||
@@ -192,9 +240,9 @@ function blockedDispatch(plan: any, requestMeta: any, summary: string) {
|
||||
op: safeText(op?.op) || "unknown",
|
||||
ok: false,
|
||||
status: "blocked",
|
||||
blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary }
|
||||
blocker: { code, layer: "hwpod-node", retryable: true, summary }
|
||||
})),
|
||||
blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary },
|
||||
blocker: { code, layer: "hwpod-node", retryable: true, summary },
|
||||
requestMeta
|
||||
};
|
||||
}
|
||||
@@ -221,6 +269,57 @@ function normalizeDiagnostic(value: any, observedAt: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntime(value: any, labels: Record<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 finishPendingOperation(waiter: PendingDispatch, status: string, finishedAt: string, blockerCode: string | null) {
|
||||
const finished = operationFinished(waiter.operation, status, finishedAt, blockerCode);
|
||||
if (waiter.operationKind === "readiness-probe") {
|
||||
if (waiter.connection.latestReadinessProbe?.requestId === waiter.requestId) waiter.connection.latestReadinessProbe = finished;
|
||||
return;
|
||||
}
|
||||
if (waiter.connection.latestOperation?.requestId === waiter.requestId) waiter.connection.latestOperation = finished;
|
||||
}
|
||||
|
||||
function inFlightCountForNode(nodeId: string | null, pending: Map<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,107 @@
|
||||
/*
|
||||
* SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。
|
||||
* Implementation reference: draft-2026-07-13-p0-cloud-console。
|
||||
* Responsibility: 验证 HWPOD typed topology 的状态、脱敏、筛选与双向 cursor。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { buildHwpodTopologyReadModel } from "./hwpod-topology-read-model.ts";
|
||||
|
||||
test("typed topology covers multi-device, offline, unconfigured, invalid, mismatch, and busy states", () => {
|
||||
const specs = [
|
||||
spec("hwpod-a", "node-a", ["workspace.ls"], true),
|
||||
spec("hwpod-b", "node-a", ["workspace.ls", "debug.download"], true),
|
||||
spec("hwpod-c", "node-c", ["workspace.ls"], null),
|
||||
{ ok: false, status: "invalid", specPath: "/registry/broken.yaml", error: { code: "invalid_hwpod_spec", message: "missing targetDevice" } }
|
||||
];
|
||||
const registry = {
|
||||
mode: "hwpod-node-outbound-native-ws",
|
||||
pendingCount: 1,
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "node-a",
|
||||
name: "D601 Python Node",
|
||||
status: "online",
|
||||
platform: "win32",
|
||||
version: "0.1.2-python-gui",
|
||||
runtimeKind: "python-tkinter-gui",
|
||||
installed: true,
|
||||
desktopVisible: true,
|
||||
capabilities: ["workspace.ls"],
|
||||
inFlightCount: 1,
|
||||
maxInFlight: 1,
|
||||
lastDiagnostic: { level: "ERROR", source: "ws", message: "connect failed: Bearer private-token?token=still-secret" },
|
||||
latestOperation: { requestId: "req_busy", planId: "plan_busy", hwpodId: "hwpod-a", status: "running", opCount: 1, startedAt: "2026-07-13T00:00:00.000Z" }
|
||||
},
|
||||
{ nodeId: "node-b", name: "Online without spec", status: "online", capabilities: [], inFlightCount: 0, maxInFlight: 1 }
|
||||
]
|
||||
};
|
||||
|
||||
const devicePayload = buildHwpodTopologyReadModel(specs, registry, { resource: "device", observedAt: "2026-07-13T01:00:00.000Z" });
|
||||
assert.equal(devicePayload.contractVersion, "hwpod-topology-v1");
|
||||
assert.equal(devicePayload.valuesRedacted, true);
|
||||
assert.equal(devicePayload.summary.nodeCount, 3);
|
||||
assert.equal(devicePayload.summary.deviceCount, 3);
|
||||
assert.equal(devicePayload.summary.invalidSpecCount, 1);
|
||||
assert.equal(devicePayload.items.find((item: any) => item.hwpodId === "hwpod-a").status, "busy");
|
||||
assert.equal(devicePayload.items.find((item: any) => item.hwpodId === "hwpod-b").status, "capability-mismatch");
|
||||
assert.deepEqual(devicePayload.items.find((item: any) => item.hwpodId === "hwpod-b").missingCapabilities, ["debug.download"]);
|
||||
assert.equal(devicePayload.items.find((item: any) => item.hwpodId === "hwpod-c").status, "offline");
|
||||
|
||||
const nodePayload = buildHwpodTopologyReadModel(specs, registry, { resource: "node", observedAt: "2026-07-13T01:00:00.000Z" });
|
||||
const nodeA = nodePayload.items.find((item: any) => item.nodeId === "node-a");
|
||||
assert.equal(nodeA.deviceCount, 2);
|
||||
assert.equal(nodeA.inFlight.busy, true);
|
||||
assert.equal(nodeA.diagnostic.message.includes("private-token"), false);
|
||||
assert.equal(nodeA.diagnostic.message.includes("still-secret"), false);
|
||||
assert.equal(nodeA.diagnostic.message.includes("[REDACTED]"), true);
|
||||
assert.equal(nodeA.diagnostic.valuesRedacted, true);
|
||||
assert.equal(nodeA.readiness.find((stage: any) => stage.id === "desktop-visible").status, "ready");
|
||||
assert.equal(nodePayload.items.find((item: any) => item.nodeId === "node-b").status, "unconfigured");
|
||||
assert.equal(nodePayload.items.find((item: any) => item.nodeId === "node-c").status, "offline");
|
||||
assert.equal(nodePayload.invalidSpecs[0].blocker.code, "invalid_hwpod_spec");
|
||||
});
|
||||
|
||||
test("typed topology keeps server-side status filtering and cursor bounded", () => {
|
||||
const specs = [spec("hwpod-a", "node-a", ["workspace.ls"], true), spec("hwpod-b", "node-b", ["workspace.ls"], true)];
|
||||
const registry = {
|
||||
nodes: [
|
||||
{ nodeId: "node-a", status: "online", capabilities: ["workspace.ls"], inFlightCount: 0, maxInFlight: 1 },
|
||||
{ nodeId: "node-b", status: "online", capabilities: ["workspace.ls"], inFlightCount: 0, maxInFlight: 1 }
|
||||
]
|
||||
};
|
||||
const first = buildHwpodTopologyReadModel(specs, registry, { resource: "device", status: "available", limit: 1 });
|
||||
assert.equal(first.items.length, 1);
|
||||
assert.equal(first.page.total, 2);
|
||||
assert.ok(first.page.nextCursor);
|
||||
const second = buildHwpodTopologyReadModel(specs, registry, { resource: "device", status: "available", limit: 1, cursor: first.page.nextCursor });
|
||||
assert.equal(second.items.length, 1);
|
||||
assert.notEqual(first.items[0].hwpodId, second.items[0].hwpodId);
|
||||
assert.equal(second.page.previousCursor, "b2Zmc2V0OjA");
|
||||
});
|
||||
|
||||
function spec(hwpodId: string, nodeId: string, capabilities: string[], available: boolean | null) {
|
||||
return {
|
||||
ok: true,
|
||||
hwpodId,
|
||||
name: hwpodId,
|
||||
uid: hwpodId.toUpperCase(),
|
||||
nodeId,
|
||||
authority: "test-registry",
|
||||
specPath: `/registry/${hwpodId}.yaml`,
|
||||
availability: { ok: available, status: available === true ? "available" : available === false ? "blocked" : "unprobed", checkedAt: null },
|
||||
document: {
|
||||
kind: "Hwpod",
|
||||
metadata: { name: hwpodId },
|
||||
spec: {
|
||||
capabilities,
|
||||
nodeBinding: { nodeId },
|
||||
targetDevice: { board: `${hwpodId} board`, mcu: "STM32" },
|
||||
workspace: { path: `F:\\Work\\${hwpodId}`, toolchain: "keil-mdk" },
|
||||
debugProbe: { type: "cmsis-dap", probeName: "MicroLink" },
|
||||
ioProbe: { uart: { id: "uart/1", port: "COM4" } }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
/*
|
||||
* SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。
|
||||
* Implementation reference: draft-2026-07-13-p0-cloud-console。
|
||||
* Responsibility: 由 Cloud API HWPOD domain 生成有界 typed Node/设备 topology,不让浏览器拼装业务真相。
|
||||
*/
|
||||
|
||||
export const HWPOD_TOPOLOGY_CONTRACT_VERSION = "hwpod-topology-v1";
|
||||
|
||||
const DEFAULT_PAGE_LIMIT = 50;
|
||||
const MAX_PAGE_LIMIT = 100;
|
||||
|
||||
export function buildHwpodTopologyReadModel(specs: any[], registrySnapshot: any = {}, options: any = {}) {
|
||||
const observedAt = text(options.observedAt) || new Date().toISOString();
|
||||
const onlineNodes = normalizeOnlineNodes(registrySnapshot);
|
||||
const invalidSpecs = specs.filter((spec) => spec?.ok === false).map((spec) => invalidSpecSummary(spec));
|
||||
const validSpecs = specs.filter((spec) => spec?.ok !== false && text(spec?.nodeId) && text(spec?.hwpodId ?? spec?.name));
|
||||
const devices = validSpecs.map((spec) => buildDevice(spec, onlineNodes.get(text(spec.nodeId)), observedAt));
|
||||
const nodeIds = new Set([...onlineNodes.keys(), ...validSpecs.map((spec) => text(spec.nodeId)).filter(Boolean)]);
|
||||
const nodes = [...nodeIds].map((nodeId) => buildNode(nodeId, onlineNodes.get(nodeId), devices.filter((device) => device.nodeId === nodeId), observedAt));
|
||||
const resource = text(options.resource) === "device" ? "device" : "node";
|
||||
const sourceItems = resource === "device" ? devices : nodes;
|
||||
const filteredItems = filterItems(sourceItems, options);
|
||||
const sortedItems = sortItems(filteredItems, options);
|
||||
const page = paginate(sortedItems, options);
|
||||
const partial = invalidSpecs.length > 0;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: partial ? "partial" : "ready",
|
||||
contractVersion: HWPOD_TOPOLOGY_CONTRACT_VERSION,
|
||||
route: "/v1/hwpod/topology",
|
||||
owner: "cloud-api-hwpod-domain",
|
||||
resource,
|
||||
service: {
|
||||
ready: true,
|
||||
status: partial ? "partial" : "ready",
|
||||
websocketMode: text(registrySnapshot?.mode) || "hwpod-node-outbound-native-ws",
|
||||
connectedNodeCount: onlineNodes.size,
|
||||
pendingOperationCount: integer(registrySnapshot?.pendingCount),
|
||||
invalidSpecCount: invalidSpecs.length
|
||||
},
|
||||
summary: {
|
||||
nodeCount: nodes.length,
|
||||
onlineNodeCount: nodes.filter((node) => node.online).length,
|
||||
offlineNodeCount: nodes.filter((node) => !node.online).length,
|
||||
unconfiguredNodeCount: nodes.filter((node) => node.status === "unconfigured").length,
|
||||
deviceCount: devices.length,
|
||||
availableDeviceCount: devices.filter((device) => device.status === "available").length,
|
||||
busyDeviceCount: devices.filter((device) => device.busy).length,
|
||||
capabilityMismatchCount: devices.filter((device) => device.capabilityMismatch).length,
|
||||
invalidSpecCount: invalidSpecs.length
|
||||
},
|
||||
items: page.items,
|
||||
page: page.page,
|
||||
invalidSpecs,
|
||||
valuesRedacted: true,
|
||||
observedAt
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOnlineNodes(snapshot: any) {
|
||||
const map = new Map<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),
|
||||
latestReadinessProbe: operationSummary(raw?.latestReadinessProbe),
|
||||
diagnostic: diagnosticSummary(raw?.lastDiagnostic)
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildDevice(spec: any, node: any, observedAt: string) {
|
||||
const document = object(spec.document);
|
||||
const body = object(document.spec);
|
||||
const declaredCapabilities = declaredCapabilitiesFor(body);
|
||||
const actualCapabilities = node?.actualCapabilities ?? [];
|
||||
const missingCapabilities = node ? declaredCapabilities.filter((capability) => !actualCapabilities.includes(capability)) : declaredCapabilities;
|
||||
const availability = availabilitySummary(spec.availability);
|
||||
const online = Boolean(node?.online);
|
||||
const busy = Boolean(node?.busy);
|
||||
const capabilityMismatch = online && missingCapabilities.length > 0;
|
||||
const blockers = deviceBlockers({ online, busy, capabilityMismatch, missingCapabilities, availability });
|
||||
const status = deviceStatus({ online, busy, capabilityMismatch, availability });
|
||||
return {
|
||||
resourceType: "hwpod",
|
||||
hwpodId: text(spec.hwpodId ?? spec.name),
|
||||
uid: text(spec.uid) || null,
|
||||
name: text(spec.name ?? spec.hwpodId),
|
||||
nodeId: text(spec.nodeId),
|
||||
nodeName: node?.name ?? text(spec.nodeId),
|
||||
status,
|
||||
online,
|
||||
available: status === "available",
|
||||
busy,
|
||||
capabilityMismatch,
|
||||
declaredCapabilities,
|
||||
actualCapabilities,
|
||||
missingCapabilities,
|
||||
elements: hwpodElements(body),
|
||||
availability,
|
||||
operation: node?.latestOperation?.hwpodId === text(spec.hwpodId ?? spec.name) ? node.latestOperation : null,
|
||||
diagnostic: node?.diagnostic ?? null,
|
||||
blockers,
|
||||
source: {
|
||||
authority: text(spec.authority) || "workspace-or-registry",
|
||||
kind: text(spec.source?.kind) || "workspace-or-registry",
|
||||
specPath: text(spec.specPath) || null
|
||||
},
|
||||
observedAt
|
||||
};
|
||||
}
|
||||
|
||||
function buildNode(nodeId: string, onlineNode: any, devices: any[], observedAt: string) {
|
||||
const declaredCapabilities = uniqueStrings(devices.flatMap((device) => device.declaredCapabilities));
|
||||
const actualCapabilities = onlineNode?.actualCapabilities ?? [];
|
||||
const missingCapabilities = onlineNode ? declaredCapabilities.filter((capability) => !actualCapabilities.includes(capability)) : declaredCapabilities;
|
||||
const busy = Boolean(onlineNode?.busy);
|
||||
const online = Boolean(onlineNode?.online);
|
||||
const capabilityMismatch = online && missingCapabilities.length > 0;
|
||||
const hasSpec = devices.length > 0;
|
||||
const status = !online ? "offline" : !hasSpec ? "unconfigured" : capabilityMismatch ? "capability-mismatch" : busy ? "busy" : "online";
|
||||
const blockers = nodeBlockers({ online, hasSpec, busy, capabilityMismatch, missingCapabilities, devices });
|
||||
const workspaceReady = devices.some((device) => device.availability.ok === true);
|
||||
const hwpodReady = devices.some((device) => device.available);
|
||||
return {
|
||||
resourceType: "hwpod-node",
|
||||
nodeId,
|
||||
name: onlineNode?.name ?? nodeId,
|
||||
status,
|
||||
online,
|
||||
platform: onlineNode?.platform ?? "unknown",
|
||||
version: onlineNode?.version ?? null,
|
||||
runtimeKind: onlineNode?.runtimeKind ?? null,
|
||||
firstSeenAt: onlineNode?.firstSeenAt ?? null,
|
||||
lastSeenAt: onlineNode?.lastSeenAt ?? null,
|
||||
ageMs: onlineNode?.ageMs ?? null,
|
||||
deviceCount: devices.length,
|
||||
hwpods: devices.map((device) => ({
|
||||
hwpodId: device.hwpodId,
|
||||
name: device.name,
|
||||
status: device.status,
|
||||
busy: device.busy,
|
||||
capabilityMismatch: device.capabilityMismatch
|
||||
})),
|
||||
declaredCapabilities,
|
||||
actualCapabilities,
|
||||
missingCapabilities,
|
||||
capabilityMismatch,
|
||||
inFlight: {
|
||||
count: onlineNode?.inFlightCount ?? 0,
|
||||
max: onlineNode?.maxInFlight ?? 1,
|
||||
busy
|
||||
},
|
||||
operation: onlineNode?.latestOperation ?? null,
|
||||
readinessProbe: onlineNode?.latestReadinessProbe ?? null,
|
||||
diagnostic: onlineNode?.diagnostic ?? null,
|
||||
readiness: nodeReadiness({ onlineNode, online, workspaceReady, hwpodReady, busy, blockers }),
|
||||
blockers,
|
||||
observedAt
|
||||
};
|
||||
}
|
||||
|
||||
function declaredCapabilitiesFor(spec: any) {
|
||||
const explicit = uniqueStrings([...array(spec?.capabilities), ...array(spec?.nodeBinding?.capabilities)]);
|
||||
if (explicit.length > 0) return explicit;
|
||||
const capabilities = ["node.health", "node.version", "node.inventory", "node.diagnostics"];
|
||||
if (Object.keys(object(spec?.workspace)).length > 0) {
|
||||
capabilities.push("workspace.ls", "workspace.cat", "workspace.rg", "workspace.apply-patch", "workspace.write", "workspace.replace", "workspace.insert-after", "cmd.run");
|
||||
}
|
||||
if (Object.keys(object(spec?.debugProbe)).length > 0) capabilities.push("debug.build", "debug.download", "debug.reset");
|
||||
if (Object.keys(object(spec?.ioProbe?.uart)).length > 0) capabilities.push("io.uart.read", "io.uart.write", "io.uart.jsonrpc");
|
||||
return uniqueStrings(capabilities);
|
||||
}
|
||||
|
||||
function hwpodElements(spec: any) {
|
||||
const target = object(spec.targetDevice);
|
||||
const workspace = object(spec.workspace);
|
||||
const debugProbe = object(spec.debugProbe);
|
||||
const ioProbe = object(spec.ioProbe);
|
||||
const uart = object(ioProbe.uart);
|
||||
return {
|
||||
target: {
|
||||
label: text(target.board ?? target.name ?? target.id) || "未声明目标板",
|
||||
mcu: text(target.mcu) || null
|
||||
},
|
||||
workspace: {
|
||||
label: text(workspace.projectRoot ?? workspace.path) || "未声明工作区",
|
||||
path: text(workspace.path) || null,
|
||||
toolchain: text(workspace.toolchain) || null
|
||||
},
|
||||
debugProbe: {
|
||||
label: text(debugProbe.probeName ?? debugProbe.name ?? debugProbe.type ?? debugProbe.id) || "未声明调试探头",
|
||||
type: text(debugProbe.type ?? debugProbe.kind) || null
|
||||
},
|
||||
ioProbe: {
|
||||
label: text(uart.id ?? uart.port ?? ioProbe.name ?? ioProbe.id) || "未声明 IO 探头",
|
||||
kind: Object.keys(uart).length > 0 ? "uart" : text(ioProbe.kind) || "unknown"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function availabilitySummary(value: any) {
|
||||
const availability = object(value);
|
||||
const blocker = object(availability.blocker);
|
||||
return {
|
||||
ok: booleanOrNull(availability.ok),
|
||||
status: text(availability.status) || "unprobed",
|
||||
checkedAt: text(availability.checkedAt) || null,
|
||||
blocker: Object.keys(blocker).length > 0 ? {
|
||||
code: text(blocker.code) || "hwpod_unavailable",
|
||||
summary: text(blocker.summary ?? blocker.message) || "HWPOD 不可用",
|
||||
retryable: blocker.retryable !== false
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
function deviceStatus(input: any) {
|
||||
if (!input.online) return "offline";
|
||||
if (input.capabilityMismatch) return "capability-mismatch";
|
||||
if (input.busy) return "busy";
|
||||
if (input.availability.ok === false) return "workspace-blocked";
|
||||
if (input.availability.ok === true) return "available";
|
||||
return "unprobed";
|
||||
}
|
||||
|
||||
function deviceBlockers(input: any) {
|
||||
const blockers: any[] = [];
|
||||
if (!input.online) blockers.push(blocker("hwpod_node_offline", "声明的 HWPOD Node 当前离线。", true));
|
||||
if (input.capabilityMismatch) blockers.push(blocker("hwpod_capability_mismatch", `节点缺少 ${input.missingCapabilities.length} 项声明能力。`, false));
|
||||
if (input.busy) blockers.push(blocker("hwpod_node_busy", "节点已达 in-flight 并发上限。", true));
|
||||
if (input.availability.blocker) blockers.push(input.availability.blocker);
|
||||
return dedupeBlockers(blockers);
|
||||
}
|
||||
|
||||
function nodeBlockers(input: any) {
|
||||
const blockers: any[] = [];
|
||||
if (!input.online) blockers.push(blocker("hwpod_node_offline", "Owning 配置已声明该节点,但未观测到主动出站 WebSocket。", true));
|
||||
if (input.online && !input.hasSpec) blockers.push(blocker("hwpod_node_spec_missing", "节点已在线,但没有任何 HWPOD spec 绑定。", false));
|
||||
if (input.capabilityMismatch) blockers.push(blocker("hwpod_capability_mismatch", `节点缺少 ${input.missingCapabilities.length} 项所属 HWPOD 声明能力。`, false));
|
||||
if (input.busy) blockers.push(blocker("hwpod_node_busy", "节点已达 in-flight 并发上限。", true));
|
||||
for (const device of input.devices) blockers.push(...device.blockers);
|
||||
return dedupeBlockers(blockers);
|
||||
}
|
||||
|
||||
function nodeReadiness(input: any) {
|
||||
const localObserved = Boolean(input.onlineNode);
|
||||
const stageBlocker = input.blockers[0] ?? null;
|
||||
return [
|
||||
readinessStage("downloaded", "已下载", input.onlineNode?.installed === true ? "ready" : "unknown", "python-node-runtime", input.onlineNode?.installed === true ? null : blocker("hwpod_download_not_observed", "云端不以浏览器下载动作作为安装事实。", false)),
|
||||
readinessStage("installed", "已安装", input.onlineNode?.installed === true ? "ready" : "unknown", "python-node-runtime", input.onlineNode?.installed === true ? null : blocker("hwpod_install_not_observed", "等待 Python 节点报告安装运行时。", true)),
|
||||
readinessStage("desktop-visible", "桌面可见", input.onlineNode?.desktopVisible === true ? "ready" : localObserved ? "blocked" : "unknown", "python-node-runtime", input.onlineNode?.desktopVisible === true ? null : blocker("hwpod_desktop_not_visible", "尚未证明图形界面与托盘属于交互登录会话。", true)),
|
||||
readinessStage("registered", "已注册", input.online ? "ready" : "blocked", "websocket-registry", input.online ? null : blocker("hwpod_node_offline", "未观测到已认证的主动出站 WebSocket。", true)),
|
||||
readinessStage("workspace-ready", "工作区就绪", input.workspaceReady ? "ready" : "blocked", "hwpod-spec-probe", input.workspaceReady ? null : stageBlocker),
|
||||
readinessStage("hwpod-ready", "HWPOD 就绪", input.hwpodReady && !input.busy ? "ready" : "blocked", "hwpod-topology", input.hwpodReady && !input.busy ? null : stageBlocker)
|
||||
];
|
||||
}
|
||||
|
||||
function readinessStage(id: string, label: string, status: string, authority: string, stageBlocker: any) {
|
||||
return { id, label, status, authority, blocker: stageBlocker };
|
||||
}
|
||||
|
||||
function invalidSpecSummary(spec: any) {
|
||||
return {
|
||||
specPath: text(spec?.specPath) || null,
|
||||
status: "invalid",
|
||||
blocker: blocker(text(spec?.error?.code) || "invalid_hwpod_spec", text(spec?.error?.message) || "HWPOD spec 无法解析。", false)
|
||||
};
|
||||
}
|
||||
|
||||
function operationSummary(value: any) {
|
||||
const operation = object(value);
|
||||
if (Object.keys(operation).length === 0) return null;
|
||||
return {
|
||||
requestId: text(operation.requestId) || null,
|
||||
planId: text(operation.planId) || null,
|
||||
hwpodId: text(operation.hwpodId) || null,
|
||||
status: text(operation.status) || "unknown",
|
||||
opCount: integer(operation.opCount),
|
||||
startedAt: text(operation.startedAt) || null,
|
||||
finishedAt: text(operation.finishedAt) || null,
|
||||
blockerCode: text(operation.blockerCode) || null
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticSummary(value: any) {
|
||||
const diagnostic = object(value);
|
||||
if (Object.keys(diagnostic).length === 0) return null;
|
||||
return {
|
||||
level: text(diagnostic.level) || "ERROR",
|
||||
source: text(diagnostic.source) || "unknown",
|
||||
message: redactDiagnosticMessage(diagnostic.message),
|
||||
observedAt: text(diagnostic.observedAt ?? diagnostic.receivedAt) || null,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
function redactDiagnosticMessage(value: unknown) {
|
||||
return text(value)
|
||||
.replace(/\b(Bearer|Basic)\s+\S+/giu, "$1 [REDACTED]")
|
||||
.replace(/([?&](?:api[_-]?key|token|secret|password|authorization)=)[^&\s]+/giu, "$1[REDACTED]")
|
||||
.replace(/\b(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*\S+/giu, "$1=[REDACTED]")
|
||||
.slice(0, 500);
|
||||
}
|
||||
|
||||
function filterItems(items: any[], options: any) {
|
||||
const query = text(options.q).toLowerCase();
|
||||
const statuses = new Set(uniqueStrings(String(options.status ?? "").split(",")));
|
||||
return items.filter((item) => {
|
||||
if (statuses.size > 0 && !statuses.has(text(item.status))) return false;
|
||||
if (!query) return true;
|
||||
const haystack = [item.nodeId, item.name, item.hwpodId, item.status, ...array(item.missingCapabilities)].map(text).join(" ").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
function sortItems(items: any[], options: any) {
|
||||
const sort = ["name", "status", "lastSeenAt", "deviceCount"].includes(text(options.sort)) ? text(options.sort) : "name";
|
||||
const direction = text(options.direction) === "desc" ? -1 : 1;
|
||||
return [...items].sort((left, right) => compareValues(left?.[sort], right?.[sort]) * direction);
|
||||
}
|
||||
|
||||
function paginate(items: any[], options: any) {
|
||||
const limit = Math.min(positiveInteger(options.limit, DEFAULT_PAGE_LIMIT), MAX_PAGE_LIMIT);
|
||||
const offset = decodeCursor(options.cursor);
|
||||
const pageItems = items.slice(offset, offset + limit);
|
||||
const nextOffset = offset + pageItems.length;
|
||||
return {
|
||||
items: pageItems,
|
||||
page: {
|
||||
limit,
|
||||
returned: pageItems.length,
|
||||
total: items.length,
|
||||
cursor: offset > 0 ? encodeCursor(offset) : null,
|
||||
previousCursor: offset > 0 ? encodeCursor(Math.max(0, offset - limit)) : null,
|
||||
nextCursor: nextOffset < items.length ? encodeCursor(nextOffset) : null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function encodeCursor(offset: number) {
|
||||
return Buffer.from(`offset:${offset}`, "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodeCursor(value: unknown) {
|
||||
const cursor = text(value);
|
||||
if (!cursor) return 0;
|
||||
try {
|
||||
const parsed = Buffer.from(cursor, "base64url").toString("utf8").match(/^offset:(\d+)$/u);
|
||||
return parsed ? integer(parsed[1]) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function blocker(code: string, summary: string, retryable: boolean) {
|
||||
return { code, summary, retryable };
|
||||
}
|
||||
|
||||
function dedupeBlockers(values: any[]) {
|
||||
const seen = new Set<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;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
/*
|
||||
* Responsibility: Cloud API HTTP handlers for HWLAB node updates, HWPOD discovery, and node-ops dispatch.
|
||||
* SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。
|
||||
* Implementation reference: draft-2026-07-13-p0-cloud-console。
|
||||
* Responsibility: 提供 HWLAB Node 更新、HWPOD typed topology、显式有界 readiness 探测与 node-ops 转发。
|
||||
*/
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -12,6 +14,9 @@ import {
|
||||
hwpodSpecDiscoveryPayload,
|
||||
hwpodSpecWorkspaceProbePlan
|
||||
} from "./hwpod-spec-discovery.ts";
|
||||
import {
|
||||
buildHwpodTopologyReadModel
|
||||
} from "./hwpod-topology-read-model.ts";
|
||||
import {
|
||||
getHeader,
|
||||
parsePositiveInteger,
|
||||
@@ -87,6 +92,8 @@ export function handleHwlabNodeUpdateHttp(request, response, url, options) {
|
||||
const releaseNotesUrl = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES_URL) || null;
|
||||
const downloadUrl = hwlabNodeDownloadUrl(env);
|
||||
const sha256 = cleanText(env.HWLAB_NODE_PY_SHA256) || bundled.sha256 || null;
|
||||
const fileName = nodeArtifactFileName(downloadUrl) || "hwlab-node.py";
|
||||
const sizeBytes = parsePositiveInteger(env.HWLAB_NODE_PY_FILE_SIZE_BYTES, bundled.sizeBytes);
|
||||
const newer = currentVersion ? compareVersionStrings(latestVersion, currentVersion) > 0 : Boolean(latestVersion);
|
||||
const updateAvailable = Boolean(downloadUrl && newer);
|
||||
sendJson(response, 200, {
|
||||
@@ -101,7 +108,22 @@ export function handleHwlabNodeUpdateHttp(request, response, url, options) {
|
||||
updateAvailable,
|
||||
downloadUrl: updateAvailable ? downloadUrl : null,
|
||||
sha256: updateAvailable ? sha256 : null,
|
||||
artifact: {
|
||||
version: latestVersion,
|
||||
fileName,
|
||||
sizeBytes,
|
||||
sha256,
|
||||
downloadUrl
|
||||
},
|
||||
releaseNotes: hwlabNodeReleaseNotes(env),
|
||||
releaseNotesUrl,
|
||||
installConditions: [
|
||||
{ id: "windows-python", label: "Windows 交互用户的原生 Python", required: true },
|
||||
{ id: "tkinter", label: "Python tkinter 图形能力", required: true },
|
||||
{ id: "yaml-managed-config", label: "owning YAML 声明 nodeId、工作区与凭据引用", required: true },
|
||||
{ id: "outbound-websocket", label: "可主动出站连接 Cloud API WebSocket", required: true }
|
||||
],
|
||||
readinessStages: hwlabNodeReadinessStages(),
|
||||
manualDefault: true,
|
||||
autoApplyDefault: false,
|
||||
checkIntervalSeconds: parsePositiveInteger(env.HWLAB_NODE_PY_UPDATE_INTERVAL_SECONDS, 300),
|
||||
@@ -135,11 +157,34 @@ export async function handleHwpodSpecDiscoveryHttp(request, response, url, optio
|
||||
const observedAt = new Date().toISOString();
|
||||
let specs = await discoverHwpodSpecs(options);
|
||||
if (probe) {
|
||||
specs = await Promise.all(specs.map((spec) => probeDiscoveredHwpodSpec(spec, { request, options, observedAt })));
|
||||
specs = await probeDiscoveredHwpodSpecs(specs, { request, options, observedAt });
|
||||
}
|
||||
sendJson(response, 200, hwpodSpecDiscoveryPayload(specs, { observedAt }));
|
||||
}
|
||||
|
||||
export async function handleHwpodTopologyHttp(request, response, url, options) {
|
||||
if (request.method !== "GET") {
|
||||
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "GET required." } });
|
||||
return;
|
||||
}
|
||||
const observedAt = new Date().toISOString();
|
||||
let specs = await discoverHwpodSpecs(options);
|
||||
if (truthyFlag(url.searchParams.get("probe"))) {
|
||||
specs = await probeDiscoveredHwpodSpecs(specs, { request, options, observedAt });
|
||||
}
|
||||
const payload = buildHwpodTopologyReadModel(specs, options.hwpodNodeWsRegistry.describe(), {
|
||||
observedAt,
|
||||
resource: url.searchParams.get("resource"),
|
||||
q: url.searchParams.get("q"),
|
||||
status: url.searchParams.get("status"),
|
||||
sort: url.searchParams.get("sort"),
|
||||
direction: url.searchParams.get("direction"),
|
||||
cursor: url.searchParams.get("cursor"),
|
||||
limit: parsePositiveInteger(url.searchParams.get("limit"), 50)
|
||||
});
|
||||
sendJson(response, 200, payload);
|
||||
}
|
||||
|
||||
function runtimeEnvironment(env = process.env) {
|
||||
const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : ENVIRONMENT_DEV;
|
||||
@@ -164,12 +209,38 @@ function hwlabNodeBundledMetadata(env) {
|
||||
const content = readFileSync(bundlePath, "utf8");
|
||||
const version = cleanText(content.match(/APP_VERSION\s*=\s*["']([^"']+)["']/u)?.[1]);
|
||||
const sha256 = createHash("sha256").update(content, "utf8").digest("hex");
|
||||
return { path: bundlePath, content, version, sha256 };
|
||||
return { path: bundlePath, content, version, sha256, sizeBytes: Buffer.byteLength(content, "utf8") };
|
||||
} catch {
|
||||
return { path: bundlePath, content: "", version: "", sha256: "" };
|
||||
return { path: bundlePath, content: "", version: "", sha256: "", sizeBytes: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function nodeArtifactFileName(downloadUrl) {
|
||||
try {
|
||||
const name = path.posix.basename(new URL(downloadUrl).pathname);
|
||||
return cleanText(name);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function hwlabNodeReleaseNotes(env) {
|
||||
const configured = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES);
|
||||
if (configured) return configured.split(/\r?\n|\|/u).map((item) => item.trim()).filter(Boolean).slice(0, 12);
|
||||
return ["单文件 Python/tkinter 节点,提供主动出站 WebSocket、有界诊断和 SHA-256 校验自更新。"];
|
||||
}
|
||||
|
||||
function hwlabNodeReadinessStages() {
|
||||
return [
|
||||
{ id: "downloaded", label: "已下载", authority: "user-device" },
|
||||
{ id: "installed", label: "已安装", authority: "python-node-runtime" },
|
||||
{ id: "desktop-visible", label: "桌面可见", authority: "python-node-runtime" },
|
||||
{ id: "registered", label: "已注册", authority: "websocket-registry" },
|
||||
{ id: "workspace-ready", label: "工作区就绪", authority: "hwpod-spec-probe" },
|
||||
{ id: "hwpod-ready", label: "HWPOD 就绪", authority: "hwpod-topology" }
|
||||
];
|
||||
}
|
||||
|
||||
function hwlabNodeDownloadUrl(env) {
|
||||
const explicit = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_URL);
|
||||
if (explicit) return explicit;
|
||||
@@ -208,6 +279,12 @@ function cleanText(value) {
|
||||
return text || "";
|
||||
}
|
||||
|
||||
async function probeDiscoveredHwpodSpecs(specs, context) {
|
||||
const probed = [];
|
||||
for (const spec of specs) probed.push(await probeDiscoveredHwpodSpec(spec, context));
|
||||
return probed;
|
||||
}
|
||||
|
||||
async function probeDiscoveredHwpodSpec(spec, { request, options, observedAt }) {
|
||||
if (spec.ok === false) return spec;
|
||||
const plan = hwpodSpecWorkspaceProbePlan(spec);
|
||||
@@ -223,7 +300,7 @@ async function probeDiscoveredHwpodSpec(spec, { request, options, observedAt })
|
||||
}
|
||||
};
|
||||
}
|
||||
const requestMeta = hwpodNodeOpsRequestMeta(request, options, "hwpod_spec");
|
||||
const requestMeta = { ...hwpodNodeOpsRequestMeta(request, options, "hwpod_spec"), operationKind: "readiness-probe" };
|
||||
const handled = await dispatchHwpodNodeOpsPlan(validation.plan, requestMeta, options, { request });
|
||||
const payload = handled.payload;
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-20-p0-error-diagnostics; PJ2026-010401 Web工作台 draft-2026-06-20-p0-error-diagnostics; PJ2026-01060505 Workbench Performance draft-2026-06-20-p0-error-diagnostics
|
||||
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-20-p0-error-diagnostics; PJ2026-010401 Web工作台 draft-2026-06-20-p0-error-diagnostics; PJ2026-010405 云端控制台 draft-2026-07-13-p0-cloud-console; PJ2026-01060505 Workbench Performance draft-2026-06-20-p0-error-diagnostics
|
||||
* 职责: Cloud API REST/SSE route dispatcher。Workbench 新资源路由应委托 read model / compat wrapper,不在 dispatcher 内写业务事实。
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
@@ -88,8 +88,10 @@ import {
|
||||
handleHwlabNodeDownloadHttp,
|
||||
handleHwlabNodeUpdateHttp,
|
||||
handleHwpodNodeOpsHttp,
|
||||
handleHwpodSpecDiscoveryHttp
|
||||
handleHwpodSpecDiscoveryHttp,
|
||||
handleHwpodTopologyHttp
|
||||
} from "./server-hwpod-http.ts";
|
||||
import { HWPOD_TOPOLOGY_CONTRACT_VERSION } from "./hwpod-topology-read-model.ts";
|
||||
import { handleCaseRunHttp } from "./server-caserun-http.ts";
|
||||
import { handleProjectManagementProxyHttp } from "./project-management-proxy.ts";
|
||||
import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts";
|
||||
@@ -651,6 +653,8 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
hwpod: {
|
||||
route: "/v1/hwpod-node-ops",
|
||||
specDiscoveryRoute: "/v1/hwpod/specs",
|
||||
topologyRoute: "/v1/hwpod/topology",
|
||||
topologyContractVersion: HWPOD_TOPOLOGY_CONTRACT_VERSION,
|
||||
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||
specAuthority: "workspace-or-registry",
|
||||
compiler: "hwpod-compiler-cli",
|
||||
@@ -813,6 +817,11 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/v1/hwpod/topology") {
|
||||
await handleHwpodTopologyHttp(request, response, url, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/v1/hwpod-node/ws") {
|
||||
sendJson(response, 426, { ok: false, status: "upgrade_required", route: "/v1/hwpod-node/ws", websocket: options.hwpodNodeWsRegistry.describe() });
|
||||
return;
|
||||
@@ -1003,7 +1012,7 @@ function navIdForRestPath(pathname, method = "GET") {
|
||||
if (pathname.startsWith("/v1/admin/access")) return "admin.access";
|
||||
if (pathname.startsWith("/v1/admin/users")) return "admin.users";
|
||||
if (pathname.startsWith("/v1/admin/")) return "admin.access";
|
||||
if (pathname === "/v1/hwpod-node-ops" || pathname === "/v1/caserun" || pathname.startsWith("/v1/caserun/") || pathname === "/v1/hwpod/specs") return "admin.hwpodGroups";
|
||||
if (pathname === "/v1/hwpod-node-ops" || pathname === "/v1/caserun" || pathname.startsWith("/v1/caserun/") || pathname === "/v1/hwpod/specs" || pathname === "/v1/hwpod/topology") return "admin.hwpodGroups";
|
||||
if (pathname === "/v1/web-performance/metrics" || pathname === "/v1/web-performance/summary" || pathname === "/v1/live-builds") return "system.performance";
|
||||
if (pathname === "/v1/diagnostics/gate") return "system.gate";
|
||||
return "";
|
||||
|
||||
+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,42 @@
|
||||
// SPEC: PJ2026-010405 云端控制台;PJ2026-010103 HWPOD 服务。
|
||||
// Implementation reference: draft-2026-07-13-p0-cloud-console。
|
||||
// Responsibility: 调用 HWPOD typed topology、显式 readiness probe 与 Python Node 更新元数据接口。
|
||||
|
||||
import { fetchJson } from "./client";
|
||||
import type { ApiResult, HwpodNodeOpsResponse, HwpodSpecsResponse } from "@/types";
|
||||
import type {
|
||||
ApiResult,
|
||||
HwpodDeviceTopologyItem,
|
||||
HwpodNodeOpsResponse,
|
||||
HwpodNodeTopologyItem,
|
||||
HwpodSpecsResponse,
|
||||
HwpodTopologyResponse,
|
||||
HwlabNodeUpdateMetadata
|
||||
} from "@/types";
|
||||
|
||||
export interface TopologyQuery {
|
||||
q?: string;
|
||||
status?: string;
|
||||
sort?: "name" | "status" | "lastSeenAt" | "deviceCount";
|
||||
direction?: "asc" | "desc";
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
probe?: boolean;
|
||||
}
|
||||
|
||||
function topologyPath(resource: "device" | "node", query: TopologyQuery = {}): string {
|
||||
const params = new URLSearchParams({ resource, sort: query.sort ?? "name", direction: query.direction ?? "asc", limit: String(query.limit ?? 50) });
|
||||
if (query.probe) params.set("probe", "1");
|
||||
if (query.q?.trim()) params.set("q", query.q.trim());
|
||||
if (query.status?.trim()) params.set("status", query.status.trim());
|
||||
if (query.cursor?.trim()) params.set("cursor", query.cursor.trim());
|
||||
return `/v1/hwpod/topology?${params.toString()}`;
|
||||
}
|
||||
|
||||
export const hwpodAPI = {
|
||||
specs: (): Promise<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" })
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- SPEC: PJ2026-010405 云端控制台. -->
|
||||
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
|
||||
<!-- Responsibility: 提供键盘可激活且用 aria-pressed 表达选中状态的资源卡片。 -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import StatusBadge from "./StatusBadge.vue";
|
||||
@@ -41,8 +42,9 @@ function onKeydown(event: KeyboardEvent): void {
|
||||
:data-selected="selected ? 'true' : 'false'"
|
||||
:data-disabled="disabled ? 'true' : 'false'"
|
||||
:data-interactive="interactive ? 'true' : 'false'"
|
||||
:role="interactive ? 'button' : undefined"
|
||||
:tabindex="interactive && !disabled ? 0 : undefined"
|
||||
:aria-selected="interactive ? selected : undefined"
|
||||
:aria-pressed="interactive ? selected : undefined"
|
||||
:aria-disabled="disabled ? 'true' : undefined"
|
||||
@click="emitSelect"
|
||||
@keydown="onKeydown"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// SPEC: PJ2026-010405 云端控制台.
|
||||
// Implementation reference: draft-2026-07-13-p0-cloud-console.
|
||||
// Responsibility: 验证资源集合的统一 item 合同、激活事件与 list/listitem 无障碍语义。
|
||||
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { h } from "vue";
|
||||
@@ -12,6 +13,8 @@ describe("ResourceCollection", () => {
|
||||
test("forwards EntityCard activation", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const card = mount(EntityCard, { props: { title: "Node A", onSelect } });
|
||||
expect(card.get(".entity-card").attributes("role")).toBe("button");
|
||||
expect(card.get(".entity-card").attributes("aria-pressed")).toBe("false");
|
||||
await card.get(".entity-card").trigger("click");
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -25,9 +28,9 @@ describe("ResourceCollection", () => {
|
||||
const wrapper = mount(ResourceCollection, {
|
||||
props: {
|
||||
items,
|
||||
itemKey: (item: { id: string }) => item.id,
|
||||
itemTitle: (item: { title: string }) => item.title,
|
||||
itemStatus: (item: { status: string }) => item.status,
|
||||
itemKey: (item: object) => (item as { id: string }).id,
|
||||
itemTitle: (item: object) => (item as { title: string }).title,
|
||||
itemStatus: (item: object) => (item as { status: string }).status,
|
||||
selectedId: "node-a",
|
||||
"onUpdate:view": onView
|
||||
}
|
||||
@@ -35,6 +38,10 @@ describe("ResourceCollection", () => {
|
||||
|
||||
expect(wrapper.findAll(".entity-card")).toHaveLength(2);
|
||||
expect(wrapper.get(".entity-card").attributes("data-selected")).toBe("true");
|
||||
expect(wrapper.get(".resource-collection-items").attributes("role")).toBe("list");
|
||||
expect(wrapper.findAll(".resource-collection-item[role='listitem']")).toHaveLength(2);
|
||||
expect(wrapper.get(".entity-card").attributes("role")).toBe("button");
|
||||
expect(wrapper.get(".entity-card").attributes("aria-pressed")).toBe("true");
|
||||
|
||||
const listButton = wrapper.findAll(".segmented-control button").find((button) => button.text() === "列表");
|
||||
expect(listButton).toBeDefined();
|
||||
@@ -48,12 +55,12 @@ describe("ResourceCollection", () => {
|
||||
const wrapper = mount(ResourceCollection, {
|
||||
props: {
|
||||
items,
|
||||
itemKey: (item: { id: string }) => item.id,
|
||||
itemKey: (item: object) => (item as { id: string }).id,
|
||||
onSelect
|
||||
},
|
||||
slots: {
|
||||
card: ({ item, select }: { item: { title: string }; select: () => void }) =>
|
||||
h("button", { class: "select-item", onClick: select }, item.title)
|
||||
card: ({ item, select }: { item: object; selected: boolean; select: () => void }) =>
|
||||
h("button", { class: "select-item", onClick: select }, (item as { title: string }).title)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- SPEC: PJ2026-010405 云端控制台. -->
|
||||
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
|
||||
<!-- Responsibility: 以 list/listitem 语义承载同一资源合同的卡片与列表视图。 -->
|
||||
|
||||
<script setup lang="ts" generic="T extends object">
|
||||
import EntityCard from "./EntityCard.vue";
|
||||
@@ -54,8 +55,8 @@ function selectItem(item: T): void {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="items.length" class="resource-collection-items" :data-view="view" :data-density="density" role="listbox" :aria-label="ariaLabel">
|
||||
<template v-for="item in items" :key="itemKey(item)">
|
||||
<div v-if="items.length" class="resource-collection-items" :data-view="view" :data-density="density" role="list" :aria-label="ariaLabel">
|
||||
<div v-for="item in items" :key="itemKey(item)" class="resource-collection-item" role="listitem">
|
||||
<slot
|
||||
v-if="view === 'list' && $slots.list"
|
||||
name="list"
|
||||
@@ -86,7 +87,7 @@ function selectItem(item: T): void {
|
||||
:selected="itemKey(item) === selectedId"
|
||||
@select="selectItem(item)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-else name="empty"><p class="muted-box">{{ emptyText }}</p></slot>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<!--
|
||||
SPEC: PJ2026-010405 云端控制台。
|
||||
Implementation reference: draft-2026-07-13-p0-cloud-console.
|
||||
Responsibility: 将 typed HWPOD 四要素状态投影为有界 PCB 卡片,并遵守 reduced-motion。
|
||||
-->
|
||||
<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,40 @@
|
||||
<!--
|
||||
SPEC: PJ2026-010405 云端控制台。
|
||||
Implementation reference: draft-2026-07-13-p0-cloud-console.
|
||||
Responsibility: 展示后端提供的 Node 分阶段 readiness,不在浏览器推断完成状态。
|
||||
-->
|
||||
<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,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// SPEC: PJ2026-010405 云端控制台.
|
||||
// Implementation reference: draft-2026-07-13-p0-cloud-console.
|
||||
// Responsibility: 验证集合视图 query 的恢复、清理与 cursor 重置行为。
|
||||
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, test } from "vitest";
|
||||
@@ -17,7 +18,7 @@ describe("useConsoleViewState", () => {
|
||||
await router.push("/resources?view=list&density=compact&q=node&filter=online&sort=-updatedAt&cursor=next&tab=nodes");
|
||||
await router.isReady();
|
||||
|
||||
let state: ConsoleViewState | null = null;
|
||||
let state!: ConsoleViewState;
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
state = useConsoleViewState();
|
||||
@@ -26,12 +27,12 @@ describe("useConsoleViewState", () => {
|
||||
template: "<div />"
|
||||
});
|
||||
const wrapper = mount(Host, { global: { plugins: [router] } });
|
||||
expect(state?.view.value).toBe("list");
|
||||
expect(state?.density.value).toBe("compact");
|
||||
expect(state?.cursor.value).toBe("next");
|
||||
expect(state?.tab.value).toBe("nodes");
|
||||
expect(state.view.value).toBe("list");
|
||||
expect(state.density.value).toBe("compact");
|
||||
expect(state.cursor.value).toBe("next");
|
||||
expect(state.tab.value).toBe("nodes");
|
||||
|
||||
await state?.update({ q: "device", view: "cards" });
|
||||
await state.update({ q: "device", view: "cards" });
|
||||
await nextTick();
|
||||
expect(router.currentRoute.value.query.q).toBe("device");
|
||||
expect(router.currentRoute.value.query.view).toBe("cards");
|
||||
@@ -47,11 +48,11 @@ describe("useConsoleViewState", () => {
|
||||
await router.push("/resources?view=tiles&density=huge");
|
||||
await router.isReady();
|
||||
|
||||
let state: ConsoleViewState | null = null;
|
||||
let state!: ConsoleViewState;
|
||||
const Host = defineComponent({ setup() { state = useConsoleViewState(); return {}; }, template: "<div />" });
|
||||
const wrapper = mount(Host, { global: { plugins: [router] } });
|
||||
expect(state?.view.value).toBe("cards");
|
||||
expect(state?.density.value).toBe("comfortable");
|
||||
expect(state.view.value).toBe("cards");
|
||||
expect(state.density.value).toBe("comfortable");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
|
||||
// Responsibility: Cloud Web routes, including project-management pages under /projects.
|
||||
// SPEC: PJ2026-010404 项目管理; PJ2026-010405 云端控制台。
|
||||
// Implementation reference: draft-2026-07-13-p0-cloud-console.
|
||||
// Responsibility: 维护 Cloud Web 路由;HWPOD 实体身份进入 path,query 只保存集合视图状态。
|
||||
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
|
||||
import { installRouterGuards } from "./guards";
|
||||
@@ -33,7 +34,11 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: "/admin/secrets/external-secrets/:namespace/:name", name: "AdminExternalSecretDetail", component: () => import("@/views/admin/ExternalSecretDetailView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.secrets", title: "ExternalSecret 详情", section: "admin" } },
|
||||
{ path: "/admin/users", name: "Users", component: () => import("@/views/admin/UsersView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.users", title: "用户管理", section: "admin" } },
|
||||
{ path: "/admin/billing", name: "AdminBilling", component: () => import("@/views/admin/BillingView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.billing", title: "兑换与订阅", section: "admin" } },
|
||||
{ path: "/admin/hwpod-groups", name: "HwpodGroups", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.hwpodGroups", title: "HWPOD 分组", section: "admin" } },
|
||||
{ path: "/admin/hwpod-groups", redirect: "/hwpods/devices" },
|
||||
{ path: "/hwpods", redirect: "/hwpods/devices" },
|
||||
{ path: "/hwpods/devices/:hwpodId?", name: "HwpodDevices", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.hwpodGroups", navParent: "HwpodGroups", title: "HWPOD 设备资源", section: "admin" } },
|
||||
{ path: "/hwpods/nodes/:nodeId?", name: "HwpodNodes", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.hwpodGroups", navParent: "HwpodGroups", title: "HWPOD Node", section: "admin" } },
|
||||
{ path: "/hwpods/onboarding/:nodeId?", name: "HwpodOnboarding", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.hwpodGroups", navParent: "HwpodGroups", title: "HWPOD 节点接入", section: "admin" } },
|
||||
{ path: "/admin/provider-profiles", name: "ProviderProfiles", component: () => import("@/views/admin/ProviderProfilesView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.providerProfiles", title: "Provider Profiles", section: "admin" } },
|
||||
{ path: "/skills", name: "Skills", component: () => import("@/views/SkillsView.vue"), meta: { requiresAuth: true, navId: "system.skills", title: "技能包", section: "system" } },
|
||||
{ path: "/settings", name: "Settings", component: () => import("@/views/SettingsView.vue"), meta: { requiresAuth: true, navId: "system.settings", title: "设置", section: "system" } },
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
// SPEC: PJ2026-010405 云端控制台。
|
||||
// Implementation reference: draft-2026-07-13-p0-cloud-console。
|
||||
// Responsibility: 保存 HWPOD typed topology 的最近成功快照,并投影刷新、部分失败与 stale 状态。
|
||||
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { hwpodAPI } from "@/api";
|
||||
import type { HwpodNodeOpsResponse, HwpodSpecsResponse } from "@/types";
|
||||
import type { TopologyQuery } from "@/api/hwpod";
|
||||
import type {
|
||||
HwpodDeviceTopologyItem,
|
||||
HwpodNodeOpsResponse,
|
||||
HwpodNodeTopologyItem,
|
||||
HwpodSpecsResponse,
|
||||
HwpodTopologyResponse,
|
||||
HwlabNodeUpdateMetadata
|
||||
} from "@/types";
|
||||
|
||||
export const useHwpodStore = defineStore("hwpod", () => {
|
||||
const specs = ref<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);
|
||||
const consoleStale = ref(false);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
loading.value = true;
|
||||
@@ -19,5 +37,41 @@ export const useHwpodStore = defineStore("hwpod", () => {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
return { specs, nodeOps, loading, error, refresh };
|
||||
async function refreshConsole(resource: "device" | "node" | "onboarding", query: TopologyQuery = {}): Promise<void> {
|
||||
consoleLoading.value = true;
|
||||
consoleError.value = null;
|
||||
consoleStale.value = false;
|
||||
try {
|
||||
if (resource === "device") {
|
||||
const result = await hwpodAPI.devices(query);
|
||||
if (result.ok && result.data) deviceTopology.value = result.data;
|
||||
else {
|
||||
consoleError.value = result.error ?? "HWPOD 设备拓扑不可用";
|
||||
consoleStale.value = Boolean(deviceTopology.value);
|
||||
}
|
||||
} else if (resource === "node") {
|
||||
const result = await hwpodAPI.nodes(query);
|
||||
if (result.ok && result.data) nodeTopology.value = result.data;
|
||||
else {
|
||||
consoleError.value = result.error ?? "HWPOD Node 拓扑不可用";
|
||||
consoleStale.value = Boolean(nodeTopology.value);
|
||||
}
|
||||
} else {
|
||||
const [nodesResult, updateResult] = await Promise.all([hwpodAPI.nodes(query), hwpodAPI.nodeUpdate()]);
|
||||
if (nodesResult.ok && nodesResult.data) nodeTopology.value = nodesResult.data;
|
||||
if (updateResult.ok && updateResult.data) nodeUpdate.value = updateResult.data;
|
||||
if (!nodesResult.ok || !updateResult.ok) {
|
||||
consoleError.value = nodesResult.error ?? updateResult.error ?? "节点接入元数据不可用";
|
||||
consoleStale.value = Boolean(nodeTopology.value || nodeUpdate.value);
|
||||
}
|
||||
}
|
||||
} catch (cause) {
|
||||
consoleError.value = cause instanceof Error ? cause.message : "HWPOD 控制台刷新失败";
|
||||
consoleStale.value = resource === "device" ? Boolean(deviceTopology.value) : resource === "node" ? Boolean(nodeTopology.value) : Boolean(nodeTopology.value || nodeUpdate.value);
|
||||
} finally {
|
||||
consoleLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { specs, nodeOps, loading, error, refresh, deviceTopology, nodeTopology, nodeUpdate, consoleLoading, consoleError, consoleStale, refreshConsole };
|
||||
});
|
||||
|
||||
@@ -290,6 +290,16 @@
|
||||
|
||||
.resource-collection-items[data-density="compact"] { gap: var(--console-space-2); }
|
||||
|
||||
.resource-collection-item {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.resource-collection-item > .entity-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.entity-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
|
||||
// Responsibility: Cloud Web API and Workbench state type contracts shared by stores and components.
|
||||
// SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-010405 云端控制台 draft-2026-07-13-p0-cloud-console; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
|
||||
// Responsibility: 保存 store 与组件共享的 Cloud Web API、Workbench 和 HWPOD typed topology 类型合同。
|
||||
|
||||
export type Tone = "ok" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run";
|
||||
export type AuthState = "checking" | "login" | "authenticated";
|
||||
@@ -387,6 +387,132 @@ export type AgentChatResultResponse = AgentChatResponse & {
|
||||
|
||||
export interface HwpodSpecsResponse { specs?: Array<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;
|
||||
readinessProbe: 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; previousCursor: string | null; nextCursor: string | null };
|
||||
invalidSpecs: Array<{ specPath: string | null; status: "invalid"; blocker: HwpodBlocker }>;
|
||||
valuesRedacted: true;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
export interface HwlabNodeUpdateMetadata {
|
||||
ok: boolean;
|
||||
contractVersion: "hwlab-node-update-v1" | string;
|
||||
channel: string;
|
||||
platform: string;
|
||||
currentVersion: string | null;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
artifact: { version: string; fileName: string; sizeBytes: number; sha256: string | null; downloadUrl: string | null };
|
||||
releaseNotes: string[];
|
||||
releaseNotesUrl: string | null;
|
||||
installConditions: Array<{ id: string; label: string; required: boolean }>;
|
||||
readinessStages: Array<{ id: string; label: string; authority: string }>;
|
||||
observedAt: string;
|
||||
}
|
||||
export interface CaseRunCaseSummary { caseId: string; title?: string; mode?: string; available?: boolean; hwpodSpec?: string; subject?: Record<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,508 @@
|
||||
<!--
|
||||
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 EmptyState from "@/components/common/EmptyState.vue";
|
||||
import LoadingState from "@/components/common/LoadingState.vue";
|
||||
import PageHeader from "@/components/common/PageHeader.vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
||||
import BaseDrawer from "@/components/common/BaseDrawer.vue";
|
||||
import DataGrid from "@/components/common/DataGrid.vue";
|
||||
import ResourceCollection from "@/components/common/ResourceCollection.vue";
|
||||
import HwpodDeviceCard from "@/components/hwpod/HwpodDeviceCard.vue";
|
||||
import HwpodReadinessRail from "@/components/hwpod/HwpodReadinessRail.vue";
|
||||
import BoundedWorkspace from "@/components/layout/BoundedWorkspace.vue";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
import StatusStrip from "@/components/layout/StatusStrip.vue";
|
||||
import { useConsoleViewState } from "@/composables/useConsoleViewState";
|
||||
import { useHwpodStore } from "@/stores/hwpod";
|
||||
import type { HwpodDeviceTopologyItem, HwpodNodeReadinessStage } from "@/types";
|
||||
|
||||
type ConsoleTab = "devices" | "nodes" | "onboarding";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const hwpod = useHwpodStore();
|
||||
const showRawJson = ref(false);
|
||||
const viewState = useConsoleViewState({ view: "cards", density: "comfortable", sort: "name" });
|
||||
const searchText = ref(viewState.q.value);
|
||||
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 = route.path.split("/")[2] ?? "";
|
||||
return value === "nodes" || value === "onboarding" ? value : "devices";
|
||||
});
|
||||
const pathEntityId = computed(() => tab.value === "devices" ? text(route.params.hwpodId) : text(route.params.nodeId));
|
||||
const effectiveQuery = computed(() => viewState.q.value || pathEntityId.value);
|
||||
const effectiveCursor = computed(() => pathEntityId.value ? "" : viewState.cursor.value);
|
||||
const view = viewState.view;
|
||||
const density = viewState.density;
|
||||
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.params.hwpodId)) ?? null);
|
||||
const selectedNodeByPath = computed(() => nodes.value.find((node) => node.nodeId === text(route.params.nodeId)) ?? null);
|
||||
const selectedNode = computed(() => selectedNodeByPath.value);
|
||||
const selectedNodeDetail = computed(() => tab.value === "nodes" ? selectedNodeByPath.value : 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);
|
||||
const sortField = computed(() => viewState.sort.value.replace(/^-/, "") || "name");
|
||||
const sortDirection = computed<"asc" | "desc">(() => viewState.sort.value.startsWith("-") ? "desc" : "asc");
|
||||
const hasContent = computed(() => tab.value === "devices" ? devices.value.length > 0 : tab.value === "nodes" ? nodes.value.length > 0 : Boolean(hwpod.nodeUpdate));
|
||||
const boundaryState = computed(() => {
|
||||
if (hwpod.consoleError) return hwpod.consoleStale && hasContent.value ? "partial" as const : "error" as const;
|
||||
if (hwpod.consoleLoading) return hasContent.value ? "refreshing" as const : "initial-loading" as const;
|
||||
if (invalidSpecs.value.length > 0) return "partial" as const;
|
||||
if (!hasContent.value) return "empty" as const;
|
||||
return "ready" as const;
|
||||
});
|
||||
const statusItems = computed(() => [
|
||||
{ id: "owner", label: "owner", value: "cloud-api-hwpod-domain", tone: "info" as const },
|
||||
{ id: "nodes", label: "nodes online", value: `${summary.value?.onlineNodeCount ?? 0}/${summary.value?.nodeCount ?? 0}`, tone: (summary.value?.offlineNodeCount ?? 0) > 0 ? "warn" as const : "ok" as const },
|
||||
{ id: "hwpods", label: "HWPOD available", value: `${summary.value?.availableDeviceCount ?? 0}/${summary.value?.deviceCount ?? 0}`, tone: (summary.value?.busyDeviceCount ?? 0) > 0 ? "warn" as const : "neutral" as const },
|
||||
{ id: "invalid", label: "invalid specs", value: summary.value?.invalidSpecCount ?? 0, tone: (summary.value?.invalidSpecCount ?? 0) > 0 ? "error" as const : "neutral" as const }
|
||||
]);
|
||||
const deviceColumns = [
|
||||
{ key: "name", label: "HWPOD", sortable: true, width: "190px" },
|
||||
{ key: "nodeName", label: "Node", width: "170px" },
|
||||
{ key: "status", label: "状态", sortable: true, width: "100px" },
|
||||
{ key: "elements", label: "目标 / 工作区", width: "230px" },
|
||||
{ key: "capabilities", label: "能力", width: "140px" },
|
||||
{ key: "blocker", label: "主要 blocker", width: "250px" }
|
||||
];
|
||||
const nodeColumns = [
|
||||
{ key: "name", label: "Node", sortable: true, width: "220px" },
|
||||
{ key: "status", label: "状态", sortable: true, width: "110px" },
|
||||
{ key: "deviceCount", label: "HWPOD", sortable: true, width: "90px" },
|
||||
{ key: "inFlight", label: "in-flight", width: "100px" },
|
||||
{ key: "capabilities", label: "能力", width: "150px" },
|
||||
{ key: "blocker", label: "主要 blocker", width: "250px" }
|
||||
];
|
||||
const statusFilterOptions = computed(() => tab.value === "nodes"
|
||||
? [
|
||||
{ value: "online", label: "在线" },
|
||||
{ value: "busy", label: "执行中" },
|
||||
{ value: "offline", label: "离线" },
|
||||
{ value: "capability-mismatch", label: "能力不匹配" },
|
||||
{ value: "unconfigured", label: "无 Spec" }
|
||||
]
|
||||
: [
|
||||
{ value: "available", label: "可用" },
|
||||
{ value: "busy", label: "执行中" },
|
||||
{ value: "offline", label: "离线" },
|
||||
{ value: "capability-mismatch", label: "能力不匹配" },
|
||||
{ value: "workspace-blocked", label: "工作区阻塞" },
|
||||
{ value: "unprobed", label: "待探测" }
|
||||
]);
|
||||
const collectionPage = computed(() => tab.value === "devices" ? hwpod.deviceTopology?.page : tab.value === "nodes" ? hwpod.nodeTopology?.page : null);
|
||||
|
||||
onMounted(() => void hwpod.refresh());
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function asRecordArray(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.map(asRecord).filter((item) => Object.keys(item).length > 0) : [];
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((item) => String(item ?? "").trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
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 countText(value: unknown): string {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? String(number) : "0";
|
||||
}
|
||||
|
||||
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;
|
||||
watch(viewState.q, (value) => { searchText.value = value; });
|
||||
watch([tab, effectiveQuery, viewState.filter, viewState.sort, effectiveCursor], ([currentTab, q, filter, sort, cursor]) => {
|
||||
const resource = currentTab === "onboarding" ? "onboarding" : currentTab === "nodes" ? "node" : "device";
|
||||
void hwpod.refreshConsole(resource, {
|
||||
q,
|
||||
status: filter,
|
||||
sort: (sort.replace(/^-/, "") || "name") as "name" | "status" | "lastSeenAt" | "deviceCount",
|
||||
direction: sort.startsWith("-") ? "desc" : "asc",
|
||||
cursor
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
function goTab(next: ConsoleTab): void {
|
||||
const query = collectionQuery();
|
||||
delete query.cursor;
|
||||
delete query.filter;
|
||||
void router.push({ path: `/hwpods/${next}`, query });
|
||||
}
|
||||
|
||||
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);
|
||||
function setView(next: "cards" | "list"): void {
|
||||
void viewState.update({ view: next });
|
||||
}
|
||||
|
||||
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 setDensity(next: "compact" | "comfortable"): void {
|
||||
void viewState.update({ density: next });
|
||||
}
|
||||
|
||||
function applySearch(): void {
|
||||
void viewState.update({ q: searchText.value.trim() });
|
||||
}
|
||||
|
||||
function setStatusFilter(event: Event): void {
|
||||
void viewState.update({ filter: (event.target as HTMLSelectElement).value });
|
||||
}
|
||||
|
||||
function toggleSort(key: string): void {
|
||||
const next = sortField.value === key && sortDirection.value === "asc" ? `-${key}` : key;
|
||||
void viewState.update({ sort: next });
|
||||
}
|
||||
|
||||
function selectDevice(id: string): void {
|
||||
void router.replace({ name: "HwpodDevices", params: { hwpodId: id }, query: collectionQuery() });
|
||||
}
|
||||
|
||||
function closeInspector(): void {
|
||||
void router.replace({ name: "HwpodDevices", query: collectionQuery() });
|
||||
}
|
||||
|
||||
function selectDeviceItem(device: HwpodDeviceTopologyItem): void {
|
||||
selectDevice(device.hwpodId);
|
||||
}
|
||||
|
||||
function selectNodeItem(node: { nodeId: string }): void {
|
||||
void router.replace({ name: "HwpodNodes", params: { nodeId: node.nodeId }, query: collectionQuery() });
|
||||
}
|
||||
|
||||
function closeNodeInspector(): void {
|
||||
void router.replace({ name: "HwpodNodes", query: collectionQuery() });
|
||||
}
|
||||
|
||||
function setCursor(cursor: string | null): void {
|
||||
void viewState.update({ cursor: cursor || "" });
|
||||
}
|
||||
|
||||
function refreshCurrent(): void {
|
||||
const resource = tab.value === "onboarding" ? "onboarding" : tab.value === "nodes" ? "node" : "device";
|
||||
void hwpod.refreshConsole(resource, { q: effectiveQuery.value, status: viewState.filter.value, sort: sortField.value as "name" | "status" | "lastSeenAt" | "deviceCount", direction: sortDirection.value, cursor: effectiveCursor.value });
|
||||
}
|
||||
|
||||
function probeCurrent(): void {
|
||||
const resource = tab.value === "nodes" ? "node" : "device";
|
||||
void hwpod.refreshConsole(resource, { q: effectiveQuery.value, status: viewState.filter.value, sort: sortField.value as "name" | "status" | "lastSeenAt" | "deviceCount", direction: sortDirection.value, cursor: effectiveCursor.value, probe: true });
|
||||
}
|
||||
|
||||
function chooseNode(event: Event): void {
|
||||
const nodeId = (event.target as HTMLSelectElement).value;
|
||||
void router.replace({ name: "HwpodOnboarding", params: nodeId ? { nodeId } : {}, query: collectionQuery() });
|
||||
}
|
||||
|
||||
function collectionQuery() {
|
||||
const query = { ...route.query };
|
||||
for (const key of Object.keys(query)) if (!["view", "density", "q", "filter", "sort", "cursor"].includes(key)) delete query[key];
|
||||
return query;
|
||||
}
|
||||
|
||||
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 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" />
|
||||
<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>
|
||||
<section class="hwpod-console" data-testid="hwpod-console">
|
||||
<PageCommandBar eyebrow="Hardware pool" title="HWPOD 控制台" description="设备资源、主动出站 Node 与 Python 单文件接入的同一事实视图。">
|
||||
<template #actions>
|
||||
<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>
|
||||
</template>
|
||||
<template v-if="tab !== 'onboarding'" #filters>
|
||||
<form class="console-search" 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>
|
||||
<label class="status-filter">状态
|
||||
<select :value="viewState.filter.value" @change="setStatusFilter">
|
||||
<option value="">全部</option>
|
||||
<option v-for="option in statusFilterOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="probe-button" type="button" title="显式执行 workspace readiness 探测;普通页面读取不会 dispatch operation。" @click="probeCurrent">重新探测</button>
|
||||
<div class="segmented-control" aria-label="显示模式">
|
||||
<button type="button" :aria-pressed="view === 'cards'" @click="setView('cards')">{{ tab === 'devices' ? '电路板卡片' : '卡片' }}</button>
|
||||
<button type="button" :aria-pressed="view === 'list'" @click="setView('list')">列表</button>
|
||||
</div>
|
||||
<div class="segmented-control" aria-label="显示密度">
|
||||
<button type="button" :aria-pressed="density === 'comfortable'" @click="setDensity('comfortable')">舒适</button>
|
||||
<button type="button" :aria-pressed="density === 'compact'" @click="setDensity('compact')">紧凑</button>
|
||||
</div>
|
||||
</template>
|
||||
</PageCommandBar>
|
||||
|
||||
<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>
|
||||
<StatusStrip :items="statusItems" label="HWPOD typed topology 状态" live="polite" />
|
||||
|
||||
<AsyncBoundary
|
||||
:state="boundaryState"
|
||||
:has-content="hasContent"
|
||||
:error="hwpod.consoleError || 'HWPOD typed topology 当前不可用。'"
|
||||
empty-title="没有 HWPOD 资源"
|
||||
empty-description="owning registry 当前没有返回符合查询的 HWPOD 资源。"
|
||||
@retry="refreshCurrent"
|
||||
>
|
||||
<aside v-if="hwpod.consoleError && hwpod.consoleStale" class="stale-warning" role="status">
|
||||
<strong>显示最近成功快照</strong>
|
||||
<span>{{ hwpod.consoleError }}</span>
|
||||
</aside>
|
||||
<aside v-if="firstInvalidSpec" class="spec-warning" role="status">
|
||||
<strong>{{ invalidSpecs.length }} 份 invalid spec 未进入可用资源</strong>
|
||||
<span>{{ firstInvalidSpec.blocker.code }} · {{ firstInvalidSpec.blocker.summary }}</span>
|
||||
</aside>
|
||||
|
||||
<BoundedWorkspace class="hwpod-workspace" label="HWPOD 资源工作区">
|
||||
<section v-if="tab === 'devices'" class="resource-stage">
|
||||
<ResourceCollection
|
||||
v-if="view === 'cards'"
|
||||
class="device-collection"
|
||||
:items="devices"
|
||||
:item-key="(device) => device.hwpodId"
|
||||
:view="view"
|
||||
:density="density"
|
||||
:selected-id="selectedDevice?.hwpodId"
|
||||
:show-controls="false"
|
||||
aria-label="HWPOD 设备卡片"
|
||||
@select="selectDeviceItem"
|
||||
>
|
||||
<template #card="{ item, select }"><HwpodDeviceCard :device="item" @select="select" /></template>
|
||||
</ResourceCollection>
|
||||
<DataGrid
|
||||
v-else
|
||||
:columns="deviceColumns"
|
||||
:rows="devices"
|
||||
:row-key="(device) => device.hwpodId"
|
||||
:selected-id="selectedDevice?.hwpodId"
|
||||
:density="density"
|
||||
:sort="viewState.sort.value"
|
||||
aria-label="HWPOD 设备列表"
|
||||
@select="selectDeviceItem"
|
||||
@sort="toggleSort"
|
||||
>
|
||||
<template #cell-name="{ row }"><span class="grid-stack"><strong>{{ row.name }}</strong><code>{{ row.hwpodId }}</code></span></template>
|
||||
<template #cell-nodeName="{ row }"><span class="grid-stack"><strong>{{ row.nodeName }}</strong><code>{{ row.nodeId }}</code></span></template>
|
||||
<template #cell-status="{ row }"><span class="status-chip" :data-status="row.status">{{ statusLabel(row.status) }}</span></template>
|
||||
<template #cell-elements="{ row }"><span class="grid-stack"><strong>{{ row.elements.target.label }}</strong><small>{{ row.elements.workspace.label }}</small></span></template>
|
||||
<template #cell-capabilities="{ row }"><span class="grid-stack"><strong>{{ row.actualCapabilities.length }}/{{ row.declaredCapabilities.length }}</strong><small v-if="row.missingCapabilities.length">缺 {{ row.missingCapabilities.join(', ') }}</small></span></template>
|
||||
<template #cell-blocker="{ row }"><span class="grid-stack"><strong>{{ row.blockers[0]?.code || '无' }}</strong><small>{{ row.blockers[0]?.summary || '当前可操作' }}</small></span></template>
|
||||
</DataGrid>
|
||||
</section>
|
||||
|
||||
<section v-else-if="tab === 'nodes'" class="resource-stage">
|
||||
<ResourceCollection
|
||||
v-if="view === 'cards'"
|
||||
class="node-collection"
|
||||
:items="nodes"
|
||||
:item-key="(node) => node.nodeId"
|
||||
:view="view"
|
||||
:density="density"
|
||||
:selected-id="selectedNodeDetail?.nodeId"
|
||||
:show-controls="false"
|
||||
aria-label="HWPOD Node 卡片"
|
||||
@select="selectNodeItem"
|
||||
>
|
||||
<template #card="{ item: node, select }">
|
||||
<article 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><button type="button" @click="select">查看详情</button></footer>
|
||||
</article>
|
||||
</template>
|
||||
</ResourceCollection>
|
||||
<DataGrid
|
||||
v-else
|
||||
:columns="nodeColumns"
|
||||
:rows="nodes"
|
||||
:row-key="(node) => node.nodeId"
|
||||
:selected-id="selectedNodeDetail?.nodeId"
|
||||
:density="density"
|
||||
:sort="viewState.sort.value"
|
||||
aria-label="HWPOD Node 列表"
|
||||
@select="selectNodeItem"
|
||||
@sort="toggleSort"
|
||||
>
|
||||
<template #cell-name="{ row }"><span class="grid-stack"><strong>{{ row.name }}</strong><code>{{ row.nodeId }}</code><small>{{ row.platform }} · {{ row.runtimeKind || '未识别运行时' }}</small></span></template>
|
||||
<template #cell-status="{ row }"><span class="status-chip" :data-status="row.status">{{ statusLabel(row.status) }}</span></template>
|
||||
<template #cell-deviceCount="{ row }"><strong>{{ row.deviceCount }}</strong></template>
|
||||
<template #cell-inFlight="{ row }"><strong>{{ row.inFlight.count }}/{{ row.inFlight.max }}</strong></template>
|
||||
<template #cell-capabilities="{ row }"><span class="grid-stack"><strong>{{ row.actualCapabilities.length }}/{{ row.declaredCapabilities.length }}</strong><small v-if="row.missingCapabilities.length">缺 {{ row.missingCapabilities.join(', ') }}</small></span></template>
|
||||
<template #cell-blocker="{ row }"><span class="grid-stack"><strong>{{ row.blockers[0]?.code || '无' }}</strong><small>{{ row.blockers[0]?.summary || '当前可操作' }}</small></span></template>
|
||||
</DataGrid>
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
</BoundedWorkspace>
|
||||
<nav v-if="collectionPage && tab !== 'onboarding'" class="collection-pagination" aria-label="HWPOD 资源分页">
|
||||
<span>本页 {{ collectionPage.returned }} / 共 {{ collectionPage.total }}</span>
|
||||
<div>
|
||||
<button type="button" :disabled="!collectionPage.previousCursor" @click="setCursor(collectionPage.previousCursor)">上一页</button>
|
||||
<button type="button" :disabled="!collectionPage.nextCursor" @click="setCursor(collectionPage.nextCursor)">下一页</button>
|
||||
</div>
|
||||
<EmptyState v-else title="暂无 supportedOps" description="后端尚未返回支持操作列表。" />
|
||||
</section>
|
||||
</nav>
|
||||
</AsyncBoundary>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
<BaseDrawer :open="Boolean(selectedDevice)" :title="selectedDevice?.name || 'HWPOD 详情'" description="Cloud API HWPOD owner 返回的 typed DTO。" @close="closeInspector">
|
||||
<template v-if="selectedDevice">
|
||||
<dl class="resource-details">
|
||||
<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>
|
||||
<div><dt>目标板</dt><dd>{{ selectedDevice.elements.target.label }}</dd></div>
|
||||
<div><dt>工作区</dt><dd>{{ selectedDevice.elements.workspace.label }}</dd></div>
|
||||
<div><dt>调试探头</dt><dd>{{ selectedDevice.elements.debugProbe.label }}</dd></div>
|
||||
<div><dt>IO 探头</dt><dd>{{ selectedDevice.elements.ioProbe.label }}</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>
|
||||
</template>
|
||||
</BaseDrawer>
|
||||
|
||||
<BaseDrawer :open="Boolean(selectedNodeDetail)" :title="selectedNodeDetail?.name || 'HWPOD Node 详情'" description="Node 连接、挂载 HWPOD、并发与 readiness 的 typed DTO。" @close="closeNodeInspector">
|
||||
<template v-if="selectedNodeDetail">
|
||||
<dl class="resource-details">
|
||||
<div><dt>Node ID</dt><dd>{{ selectedNodeDetail.nodeId }}</dd></div>
|
||||
<div><dt>运行时</dt><dd>{{ selectedNodeDetail.runtimeKind || '未识别' }} · {{ selectedNodeDetail.version || '未知版本' }}</dd></div>
|
||||
<div><dt>in-flight</dt><dd>{{ selectedNodeDetail.inFlight.count }}/{{ selectedNodeDetail.inFlight.max }}</dd></div>
|
||||
<div><dt>最近用户 operation</dt><dd>{{ selectedNodeDetail.operation?.status || '无' }}</dd></div>
|
||||
<div><dt>最近 readiness probe</dt><dd>{{ selectedNodeDetail.readinessProbe?.status || '未探测' }}</dd></div>
|
||||
</dl>
|
||||
<h3>挂载 HWPOD</h3>
|
||||
<div class="capability-list"><code v-for="device in selectedNodeDetail.hwpods" :key="device.hwpodId">{{ device.hwpodId }} · {{ statusLabel(device.status) }}</code><span v-if="!selectedNodeDetail.hwpods.length">无</span></div>
|
||||
<h3>接入 readiness</h3>
|
||||
<HwpodReadinessRail :stages="selectedNodeDetail.readiness" />
|
||||
</template>
|
||||
</BaseDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hwpod-console { display: grid; min-width: 0; min-height: 100%; align-content: start; gap: 12px; padding: 8px; color: var(--console-graphite-900); }
|
||||
.console-tabs { display: flex; gap: 2px; width: fit-content; max-width: 100%; padding: 3px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-border); overflow-x: auto; }
|
||||
.console-tabs button { min-height: 32px; border: 0; border-radius: 5px; background: var(--console-surface-raised); color: var(--console-graphite-600); padding: 0 12px; font-size: 12px; font-weight: 750; white-space: nowrap; cursor: pointer; }
|
||||
.console-tabs button[data-active="true"] { color: #fff; background: var(--console-graphite-900); }
|
||||
.console-search { display: flex; min-width: min(420px, 100%); align-items: center; gap: 7px; }
|
||||
.console-search label, .status-filter { color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 10px; font-weight: 760; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.console-search input { min-width: 0; flex: 1; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 8px 10px; }
|
||||
.console-search button { border: 1px solid var(--console-cyan-700); border-radius: var(--console-radius-sm); background: var(--console-cyan-100); color: var(--console-cyan-700); padding: 7px 10px; font-weight: 750; cursor: pointer; }
|
||||
.status-filter { display: flex; align-items: center; gap: 7px; }
|
||||
.status-filter select { border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 7px 9px; color: var(--console-graphite-800); }
|
||||
.probe-button { border: 1px solid var(--console-amber-500); border-radius: var(--console-radius-sm); background: var(--console-amber-100); color: var(--console-amber-700); padding: 7px 10px; font-size: 11px; font-weight: 750; cursor: pointer; }
|
||||
.spec-warning { display: flex; gap: 8px 20px; align-items: center; margin-bottom: 10px; padding: 10px 13px; border: 1px solid #dfc47d; border-left: 4px solid #aa7a18; border-radius: 7px; background: #fff8df; color: #6b5317; font-size: .78rem; }
|
||||
.stale-warning { display: flex; gap: 8px 20px; align-items: center; margin-bottom: 10px; padding: 10px 13px; border: 1px solid #e4a8a2; border-left: 4px solid var(--console-red-700); border-radius: var(--console-radius-sm); background: var(--console-red-100); color: var(--console-red-700); font-size: .78rem; }
|
||||
.hwpod-workspace { height: clamp(420px, calc(100dvh - 300px), 760px); }
|
||||
.hwpod-workspace :deep(.bounded-workspace-main) { padding: 12px; }
|
||||
.resource-stage { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: minmax(0, 1fr); }
|
||||
.device-collection :deep(.resource-collection-items[data-view="cards"]) { grid-template-columns: repeat(auto-fill, minmax(min(440px, 100%), 680px)); justify-content: start; }
|
||||
.node-collection :deep(.resource-collection-items[data-view="cards"]) { grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 680px)); justify-content: start; }
|
||||
.grid-stack { display: grid; min-width: 0; gap: 3px; }
|
||||
.grid-stack strong, .grid-stack code, .grid-stack small { min-width: 0; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.grid-stack code, .grid-stack small { color: var(--console-graphite-600); font-size: 11px; }
|
||||
.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; }
|
||||
.node-card > header, .readiness-panel > header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
.node-card header span, .panel-kicker { color: #687671; font: 700 .68rem/1.3 ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
|
||||
.node-card h2, .artifact-panel h2, .readiness-panel h2 { margin: 4px 0; color: #172d27; }
|
||||
.resource-details, .artifact-panel dl { display: grid; gap: 1px; margin: 0 0 18px; border: 1px solid #d2d9d6; background: #d2d9d6; }
|
||||
.resource-details 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-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; align-items: center; 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; }
|
||||
.node-card footer button { margin-left: auto; border: 1px solid var(--console-cyan-700); border-radius: var(--console-radius-sm); background: var(--console-cyan-100); color: var(--console-cyan-700); padding: 6px 9px; font-size: 11px; font-weight: 750; cursor: pointer; }
|
||||
.collection-pagination { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 11px; }
|
||||
.collection-pagination div { display: flex; gap: 6px; }
|
||||
.collection-pagination button { border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); color: var(--console-graphite-800); padding: 6px 10px; font-weight: 700; cursor: pointer; }
|
||||
.collection-pagination button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.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) { .onboarding-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 680px) { .hwpod-console { padding: 2px; } .console-tabs { width: 100%; } .console-tabs button { flex: 1 0 auto; } .console-search { min-width: 100%; } .status-filter { width: 100%; justify-content: space-between; } .spec-warning, .stale-warning { align-items: flex-start; flex-direction: column; } .hwpod-workspace { height: clamp(520px, calc(100dvh - 260px), 680px); } .hwpod-workspace :deep(.bounded-workspace-main) { padding: 8px; } .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