fix(hwpod): 收紧拓扑探测与深链状态

This commit is contained in:
root
2026-07-13 04:48:04 +02:00
parent 652f1f9c6b
commit 2ec436a89e
18 changed files with 563 additions and 227 deletions
+36
View File
@@ -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";
@@ -199,11 +205,16 @@ 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: [] }),
@@ -225,6 +236,11 @@ test("cloud-api serves the typed HWPOD topology read model from its existing own
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 });
@@ -417,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 () => {
+36 -10
View File
@@ -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;
@@ -16,6 +21,7 @@ type HwpodNodeConnection = {
};
maxInFlight: number;
latestOperation: Record<string, unknown> | null;
latestReadinessProbe: Record<string, unknown> | null;
diagnostics: any[];
firstSeenAt: string;
lastSeenAt: string;
@@ -25,6 +31,8 @@ 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;
};
@@ -48,6 +56,7 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
runtime: { kind: null, installed: null, desktopVisible: null },
maxInFlight: 1,
latestOperation: null,
latestReadinessProbe: null,
diagnostics: [],
firstSeenAt: now(),
lastSeenAt: now()
@@ -76,22 +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);
connection.latestOperation = operationStarted(plan, requestId, now());
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);
connection.latestOperation = operationFinished(connection.latestOperation, "timed-out", now(), "hwpod_node_unavailable");
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, connection, 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);
connection.latestOperation = operationFinished(connection.latestOperation, "failed", now(), "hwpod_node_unavailable");
finishPendingOperation(waiter, "failed", now(), "hwpod_node_unavailable");
resolve(blockedDispatch(plan, requestMeta, error instanceof Error ? error.message : String(error)));
}
});
@@ -119,6 +135,7 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
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),
@@ -186,8 +203,8 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
const result = message.result && typeof message.result === "object" && !Array.isArray(message.result)
? message.result
: { ok: false, status: "failed", results: [], blocker: { code: "hwpod_node_result_invalid", layer: "hwpod-node", retryable: true, summary: "hwpod-node WebSocket result payload is invalid" } };
waiter.connection.latestOperation = operationFinished(
waiter.connection.latestOperation,
finishPendingOperation(
waiter,
safeText(result.status) || (result.ok === false ? "failed" : "completed"),
now(),
safeText(result.blocker?.code) || null
@@ -201,7 +218,7 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
if (waiter.nodeId !== connection.nodeId) continue;
clearTimeout(waiter.timer);
pending.delete(requestId);
waiter.connection.latestOperation = operationFinished(waiter.connection.latestOperation, "disconnected", now(), "hwpod_node_unavailable");
finishPendingOperation(waiter, "disconnected", now(), "hwpod_node_unavailable");
waiter.resolve(blockedDispatch({ nodeId: waiter.nodeId, ops: [] }, { requestId }, `hwpod-node ${waiter.nodeId} WebSocket disconnected before returning result`));
}
}
@@ -213,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,
@@ -223,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
};
}
@@ -283,6 +300,15 @@ function operationFinished(operation: Record<string, unknown> | null, status: st
};
}
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;
@@ -1,3 +1,8 @@
/*
* 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";
@@ -73,6 +78,7 @@ test("typed topology keeps server-side status filtering and cursor bounded", ()
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) {
+6 -3
View File
@@ -1,7 +1,7 @@
/*
* SPEC: PJ2026-010405 云端控制台; PJ2026-010103 HWPOD服务.
* Implementation reference: draft-2026-07-13-p0-cloud-console.
* Responsibility: bounded typed HWPOD node/device topology owned by the Cloud API HWPOD domain.
* 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";
@@ -83,6 +83,7 @@ function normalizeOnlineNodes(snapshot: any) {
lastSeenAt: text(raw?.lastSeenAt) || null,
ageMs: integer(raw?.ageMs),
latestOperation: operationSummary(raw?.latestOperation),
latestReadinessProbe: operationSummary(raw?.latestReadinessProbe),
diagnostic: diagnosticSummary(raw?.lastDiagnostic)
});
}
@@ -172,6 +173,7 @@ function buildNode(nodeId: string, onlineNode: any, devices: any[], observedAt:
busy
},
operation: onlineNode?.latestOperation ?? null,
readinessProbe: onlineNode?.latestReadinessProbe ?? null,
diagnostic: onlineNode?.diagnostic ?? null,
readiness: nodeReadiness({ onlineNode, online, workspaceReady, hwpodReady, busy, blockers }),
blockers,
@@ -350,6 +352,7 @@ function paginate(items: any[], options: any) {
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
}
};
+12 -4
View File
@@ -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";
@@ -155,7 +157,7 @@ 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 }));
}
@@ -168,7 +170,7 @@ export async function handleHwpodTopologyHttp(request, response, url, options) {
const observedAt = new Date().toISOString();
let specs = await discoverHwpodSpecs(options);
if (truthyFlag(url.searchParams.get("probe"))) {
specs = await Promise.all(specs.map((spec) => probeDiscoveredHwpodSpec(spec, { request, options, observedAt })));
specs = await probeDiscoveredHwpodSpecs(specs, { request, options, observedAt });
}
const payload = buildHwpodTopologyReadModel(specs, options.hwpodNodeWsRegistry.describe(), {
observedAt,
@@ -277,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);
@@ -292,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 -1
View File
@@ -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 dispatcherWorkbench read model / compat wrapper dispatcher
*/
import { createServer } from "node:http";
+8 -2
View File
@@ -1,3 +1,7 @@
// 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,
@@ -9,17 +13,19 @@ import type {
HwlabNodeUpdateMetadata
} from "@/types";
interface TopologyQuery {
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, probe: "1", sort: query.sort ?? "name", direction: query.direction ?? "asc", limit: String(query.limit ?? 100) });
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());
@@ -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>
@@ -1,6 +1,7 @@
<!--
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";
@@ -1,6 +1,7 @@
<!--
SPEC: PJ2026-010405 云端控制台
Implementation reference: draft-2026-07-13-p0-cloud-console.
Responsibility: 展示后端提供的 Node 分阶段 readiness不在浏览器推断完成状态
-->
<script setup lang="ts">
import type { HwpodNodeReadinessStage } from "@/types";
@@ -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();
});
});
+4 -2
View File
@@ -1,6 +1,6 @@
// SPEC: PJ2026-010404 项目管理; PJ2026-010405 云端控制台。
// Implementation reference: draft-2026-07-13-p0-cloud-console.
// Responsibility: Cloud Web routes, including project-management pages under /projects.
// Responsibility: 维护 Cloud Web 路由;HWPOD 实体身份进入 path,query 只保存集合视图状态。
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
import { installRouterGuards } from "./guards";
@@ -36,7 +36,9 @@ const routes: RouteRecordRaw[] = [
{ 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", redirect: "/hwpods/devices" },
{ path: "/hwpods", redirect: "/hwpods/devices" },
{ path: "/hwpods/:tab(devices|nodes|onboarding)", name: "HwpodGroups", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.hwpodGroups", title: "HWPOD 控制台", section: "admin" } },
{ path: "/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" } },
+38 -16
View File
@@ -1,6 +1,11 @@
// 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 { TopologyQuery } from "@/api/hwpod";
import type {
HwpodDeviceTopologyItem,
HwpodNodeOpsResponse,
@@ -20,6 +25,7 @@ export const useHwpodStore = defineStore("hwpod", () => {
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;
@@ -31,25 +37,41 @@ export const useHwpodStore = defineStore("hwpod", () => {
loading.value = false;
}
async function refreshConsole(resource: "device" | "node" | "onboarding", query: { q?: string; status?: string } = {}): Promise<void> {
async function refreshConsole(resource: "device" | "node" | "onboarding", query: TopologyQuery = {}): Promise<void> {
consoleLoading.value = true;
consoleError.value = null;
if (resource === "device") {
const result = await hwpodAPI.devices(query);
deviceTopology.value = result.data;
consoleError.value = result.ok ? null : result.error ?? "HWPOD 设备拓扑不可用";
} else if (resource === "node") {
const result = await hwpodAPI.nodes(query);
nodeTopology.value = result.data;
consoleError.value = result.ok ? null : result.error ?? "HWPOD Node 拓扑不可用";
} else {
const [nodesResult, updateResult] = await Promise.all([hwpodAPI.nodes(query), hwpodAPI.nodeUpdate()]);
nodeTopology.value = nodesResult.data;
nodeUpdate.value = updateResult.data;
consoleError.value = nodesResult.ok && updateResult.ok ? null : nodesResult.error ?? updateResult.error ?? "节点接入元数据不可用";
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;
}
consoleLoading.value = false;
}
return { specs, nodeOps, loading, error, refresh, deviceTopology, nodeTopology, nodeUpdate, consoleLoading, consoleError, refreshConsole };
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;
+4 -3
View File
@@ -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 APIWorkbench 和 HWPOD typed topology 类型合同。
export type Tone = "ok" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run";
export type AuthState = "checking" | "login" | "authenticated";
@@ -475,6 +475,7 @@ export interface HwpodNodeTopologyItem {
capabilityMismatch: boolean;
inFlight: { count: number; max: number; busy: boolean };
operation: HwpodOperationSummary | null;
readinessProbe: HwpodOperationSummary | null;
diagnostic: HwpodDiagnosticSummary | null;
readiness: HwpodNodeReadinessStage[];
blockers: HwpodBlocker[];
@@ -491,7 +492,7 @@ export interface HwpodTopologyResponse<T extends HwpodDeviceTopologyItem | Hwpod
service: { ready: boolean; status: string; websocketMode: string; connectedNodeCount: number; pendingOperationCount: number; invalidSpecCount: number };
summary: { nodeCount: number; onlineNodeCount: number; offlineNodeCount: number; unconfiguredNodeCount: number; deviceCount: number; availableDeviceCount: number; busyDeviceCount: number; capabilityMismatchCount: number; invalidSpecCount: number };
items: T[];
page: { limit: number; returned: number; total: number; cursor: string | null; nextCursor: string | null };
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;
@@ -5,71 +5,181 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue";
import PageHeader from "@/components/common/PageHeader.vue";
import 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 { HwpodNodeReadinessStage } from "@/types";
import type { HwpodDeviceTopologyItem, HwpodNodeReadinessStage } from "@/types";
type ConsoleTab = "devices" | "nodes" | "onboarding";
const route = useRoute();
const router = useRouter();
const hwpod = useHwpodStore();
const searchText = ref(text(route.query.q));
const viewState = useConsoleViewState({ view: "cards", density: "comfortable", sort: "name" });
const searchText = ref(viewState.q.value);
const copied = ref(false);
const tab = computed<ConsoleTab>(() => {
const value = text(route.params.tab);
const value = route.path.split("/")[2] ?? "";
return value === "nodes" || value === "onboarding" ? value : "devices";
});
const view = computed(() => route.query.view === "list" ? "list" : "cards");
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.query.selected)) ?? null);
const selectedNode = computed(() => nodes.value.find((node) => node.nodeId === text(route.query.nodeId)) ?? nodes.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);
watch(() => route.query.q, (value) => { searchText.value = text(value); });
watch([tab, () => route.query.q], ([currentTab, q]) => {
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: text(q) });
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 {
void router.push({ path: `/hwpods/${next}`, query: next === "devices" ? { view: view.value } : {} });
const query = collectionQuery();
delete query.cursor;
delete query.filter;
void router.push({ path: `/hwpods/${next}`, query });
}
function setView(next: "cards" | "list"): void {
void router.replace({ query: { ...route.query, view: next } });
void viewState.update({ view: next });
}
function setDensity(next: "compact" | "comfortable"): void {
void viewState.update({ density: next });
}
function applySearch(): void {
const query = { ...route.query };
if (searchText.value.trim()) query.q = searchText.value.trim();
else delete query.q;
void router.replace({ query });
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({ query: { ...route.query, selected: id } });
void router.replace({ name: "HwpodDevices", params: { hwpodId: id }, query: collectionQuery() });
}
function closeInspector(): void {
const query = { ...route.query };
delete query.selected;
void router.replace({ query });
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({ query: { ...route.query, nodeId: nodeId || undefined } });
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> {
@@ -103,169 +213,257 @@ function text(value: unknown): string {
<template>
<section class="hwpod-console" data-testid="hwpod-console">
<PageHeader eyebrow="Hardware pool" title="HWPOD 控制台" description="设备资源、主动出站 Node 与 Python 单文件接入的同一事实视图。" />
<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>
<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>
<StatusStrip :items="statusItems" label="HWPOD typed topology 状态" live="polite" />
<section class="console-command" aria-label="HWPOD 命令栏">
<form v-if="tab !== 'onboarding'" role="search" @submit.prevent="applySearch">
<label for="hwpod-search">搜索</label>
<input id="hwpod-search" v-model="searchText" type="search" placeholder="nodeId / hwpodId / 状态" />
<button type="submit">查询</button>
</form>
<div v-if="tab === 'devices'" class="view-switch" aria-label="显示模式">
<button type="button" :data-active="view === 'cards'" @click="setView('cards')">电路板卡片</button>
<button type="button" :data-active="view === 'list'" @click="setView('list')">列表</button>
</div>
<div class="command-facts" aria-live="polite">
<span>owner <strong>cloud-api-hwpod-domain</strong></span>
<span v-if="summary">{{ summary.onlineNodeCount }}/{{ summary.nodeCount }} nodes online</span>
<span v-if="summary">{{ summary.availableDeviceCount }}/{{ summary.deviceCount }} HWPOD available</span>
</div>
</section>
<LoadingState v-if="hwpod.consoleLoading" />
<EmptyState v-else-if="hwpod.consoleError" title="HWPOD 事实投影加载失败" :description="hwpod.consoleError" />
<template v-else>
<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>
<section v-if="tab === 'devices'" class="resource-stage">
<div v-if="devices.length && view === 'cards'" class="device-grid">
<HwpodDeviceCard v-for="device in devices" :key="device.hwpodId" :device="device" @select="selectDevice" />
<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>
<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>
<div v-else-if="devices.length" class="table-shell">
<table>
<thead><tr><th>HWPOD</th><th>Node</th><th>状态</th><th>目标 / 工作区</th><th>能力</th><th>主要 blocker</th><th /></tr></thead>
<tbody>
<tr v-for="device in devices" :key="device.hwpodId" :data-status="device.status">
<td><strong>{{ device.name }}</strong><code>{{ device.hwpodId }}</code></td>
<td><strong>{{ device.nodeName }}</strong><code>{{ device.nodeId }}</code></td>
<td><span class="status-chip" :data-status="device.status">{{ statusLabel(device.status) }}</span></td>
<td><strong>{{ device.elements.target.label }}</strong><small>{{ device.elements.workspace.label }}</small></td>
<td>{{ device.actualCapabilities.length }}/{{ device.declaredCapabilities.length }}<small v-if="device.missingCapabilities.length"> {{ device.missingCapabilities.join(', ') }}</small></td>
<td><strong>{{ device.blockers[0]?.code || '无' }}</strong><small>{{ device.blockers[0]?.summary || '当前可操作' }}</small></td>
<td><button class="row-action" type="button" @click="selectDevice(device.hwpodId)">详情</button></td>
</tr>
</tbody>
</table>
</div>
<EmptyState v-else title="没有 HWPOD 设备" description="owning registry 当前未返回符合查询的有效 HWPOD spec。" />
</nav>
</AsyncBoundary>
<aside v-if="selectedDevice" class="resource-inspector" aria-label="HWPOD 详情">
<header><div><span>HWPOD Inspector</span><h2>{{ selectedDevice.name }}</h2></div><button type="button" aria-label="关闭详情" @click="closeInspector">×</button></header>
<dl>
<div><dt>身份</dt><dd>{{ selectedDevice.hwpodId }}</dd></div>
<div><dt>Node</dt><dd>{{ selectedDevice.nodeId }}</dd></div>
<div><dt>Spec authority</dt><dd>{{ selectedDevice.source.authority }}</dd></div>
<div><dt>最近 operation</dt><dd>{{ selectedDevice.operation?.status || '无' }}</dd></div>
</dl>
<h3>缺失能力</h3>
<div class="capability-list"><code v-for="capability in selectedDevice.missingCapabilities" :key="capability">{{ capability }}</code><span v-if="!selectedDevice.missingCapabilities.length"></span></div>
<h3>诊断摘要</h3>
<p>{{ selectedDevice.diagnostic?.message || selectedDevice.blockers[0]?.summary || '无当前诊断' }}</p>
</aside>
</section>
<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>
<section v-else-if="tab === 'nodes'" class="node-grid">
<article v-for="node in nodes" :key="node.nodeId" class="node-card" :data-status="node.status" :data-testid="`hwpod-node-${node.nodeId}`">
<header><div><span>{{ node.platform }} · {{ node.runtimeKind || '未识别运行时' }}</span><h2>{{ node.name }}</h2><code>{{ node.nodeId }}</code></div><span class="status-chip" :data-status="node.status">{{ statusLabel(node.status) }}</span></header>
<section class="node-gauges">
<div><small>HWPOD</small><strong>{{ node.deviceCount }}</strong></div>
<div><small>in-flight</small><strong>{{ node.inFlight.count }}/{{ node.inFlight.max }}</strong></div>
<div><small>capabilities</small><strong>{{ node.actualCapabilities.length }}/{{ node.declaredCapabilities.length }}</strong></div>
</section>
<section class="mounted-hwpods" aria-label="挂载 HWPOD">
<header><strong>挂载 HWPOD</strong><span>{{ node.hwpods.length }}</span></header>
<ul v-if="node.hwpods.length">
<li v-for="device in node.hwpods" :key="device.hwpodId">
<div><strong>{{ device.name }}</strong><code>{{ device.hwpodId }}</code></div>
<span class="status-chip" :data-status="device.status">{{ statusLabel(device.status) }}</span>
</li>
</ul>
<p v-else>节点在线 owning registry 尚未绑定 HWPOD spec</p>
</section>
<HwpodReadinessRail :stages="node.readiness" />
<footer><span>最后心跳 {{ formatTime(node.lastSeenAt) }}</span><strong v-if="node.blockers[0]">{{ node.blockers[0].code }}</strong></footer>
</article>
<EmptyState v-if="!nodes.length" title="没有 HWPOD Node" description="WebSocket registry 和 owning spec 当前都没有节点记录。" />
</section>
<section v-else class="onboarding-grid">
<article class="artifact-panel">
<span class="panel-kicker">Python single file</span>
<h2>{{ artifact?.fileName || 'hwlab-node.py' }}</h2>
<p>使用 Windows 交互用户的原生 Python/tkinter 运行并主动出站连接 Cloud API下载完成不等于 HWPOD ready</p>
<dl>
<div><dt>版本</dt><dd>{{ artifact?.version || '-' }}</dd></div>
<div><dt>大小</dt><dd>{{ formatSize(artifact?.sizeBytes || 0) }}</dd></div>
<div class="sha-row"><dt>SHA-256</dt><dd><code>{{ artifact?.sha256 || '未发布' }}</code><button type="button" :disabled="!artifact?.sha256" @click="copySha">{{ copied ? '已复制' : '复制' }}</button></dd></div>
</dl>
<a v-if="artifact?.downloadUrl" class="download-button" :href="artifact.downloadUrl" download="hwlab-node.py">下载 Python 单文件</a>
<p v-else class="download-blocked">发布 artifact 当前不可用</p>
<h3>发布说明</h3>
<ul><li v-for="note in hwpod.nodeUpdate?.releaseNotes || []" :key="note">{{ note }}</li></ul>
<h3>安装条件</h3>
<ul><li v-for="condition in hwpod.nodeUpdate?.installConditions || []" :key="condition.id">{{ condition.label }}</li></ul>
</article>
<article class="readiness-panel">
<header><div><span class="panel-kicker">Staged readiness</span><h2>接入进度</h2></div><label>Node<select :value="selectedNode?.nodeId || ''" @change="chooseNode"><option value="">未选择</option><option v-for="node in nodes" :key="node.nodeId" :value="node.nodeId">{{ node.name }} · {{ statusLabel(node.status) }}</option></select></label></header>
<HwpodReadinessRail :stages="onboardingStages" />
<aside class="outbound-note"><strong>网络边界</strong><span>节点只使用主动出站 WebSocket网页不生成凭据不要求用户 PC 开放入站端口</span></aside>
</article>
</section>
</template>
<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 { min-width: 0; min-height: 100%; padding: 22px; color: #20312d; background: radial-gradient(circle at 90% 4%, rgb(56 128 115 / 8%), transparent 28%), #efefe9; }
.console-tabs { display: flex; gap: 4px; width: fit-content; max-width: 100%; margin: 16px 0 0; padding: 4px; border: 1px solid #bdc8c4; border-radius: 9px; background: #dfe4e1; overflow-x: auto; }
.console-tabs button, .view-switch button { border: 0; border-radius: 6px; background: transparent; color: #56645f; padding: 8px 13px; font-weight: 750; white-space: nowrap; cursor: pointer; }
.console-tabs button[data-active="true"], .view-switch button[data-active="true"] { color: #f4fbf8; background: #173d35; box-shadow: 0 3px 9px rgb(20 45 40 / 18%); }
.console-command { display: flex; align-items: center; gap: 14px; margin: 12px 0 16px; padding: 10px 12px; border: 1px solid #c9d0cd; border-radius: 9px; background: #faf9f5; }
.console-command form { display: flex; align-items: center; gap: 7px; min-width: min(420px, 100%); }
.console-command label { color: #67736f; font-size: .72rem; font-weight: 800; text-transform: uppercase; }
.console-command input { min-width: 0; flex: 1; border: 1px solid #b9c5c1; border-radius: 6px; background: #fff; padding: 8px 10px; }
.console-command form button, .row-action { border: 1px solid #486b63; border-radius: 6px; background: #f4f8f6; color: #254e45; padding: 7px 10px; font-weight: 750; cursor: pointer; }
.view-switch { display: flex; gap: 2px; padding: 3px; border: 1px solid #c4cdca; border-radius: 7px; background: #edf0ee; }
.view-switch button { padding: 6px 9px; font-size: .74rem; }
.command-facts { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px 14px; margin-left: auto; color: #66736f; font: .69rem/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
.command-facts strong { color: #2d524a; }
.spec-warning { display: flex; gap: 8px 20px; align-items: center; margin-bottom: 14px; padding: 10px 13px; border: 1px solid #dfc47d; border-left: 4px solid #aa7a18; border-radius: 7px; background: #fff8df; color: #6b5317; font-size: .78rem; }
.device-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(440px, 100%), 680px)); justify-content: start; align-items: start; gap: 16px; }
.table-shell { max-width: 100%; border: 1px solid #c5cecb; border-radius: 10px; background: #fbfaf7; overflow: auto; }
table { width: 100%; min-width: 980px; border-collapse: collapse; }
th { position: sticky; top: 0; z-index: 1; background: #e5e9e6; color: #53615d; font-size: .69rem; letter-spacing: .05em; text-align: left; text-transform: uppercase; }
th, td { padding: 11px 13px; border-bottom: 1px solid #dce1df; vertical-align: top; }
td { color: #263a35; font-size: .78rem; }
td strong, td small, td code { display: block; }
td small, td code { margin-top: 3px; color: #6b7773; font-size: .7rem; }
.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; }
.resource-stage { position: relative; }
.resource-inspector { position: fixed; z-index: 20; top: 72px; right: 18px; bottom: 18px; width: min(420px, calc(100vw - 36px)); padding: 18px; border: 1px solid #aebdb8; border-radius: 12px; background: #fdfcf8; box-shadow: 0 24px 70px rgb(15 31 27 / 28%); overflow: auto; }
.resource-inspector header, .node-card > header, .readiness-panel > header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.resource-inspector header span, .node-card header span, .panel-kicker { color: #687671; font: 700 .68rem/1.3 ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.resource-inspector h2, .node-card h2, .artifact-panel h2, .readiness-panel h2 { margin: 4px 0; color: #172d27; }
.resource-inspector header button { border: 0; background: transparent; color: #51605b; font-size: 1.6rem; cursor: pointer; }
.resource-inspector dl, .artifact-panel dl { display: grid; gap: 1px; margin: 18px 0; border: 1px solid #d2d9d6; background: #d2d9d6; }
.resource-inspector dl div, .artifact-panel dl div { padding: 10px 11px; background: #f6f6f1; }
.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-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(380px, 100%), 1fr)); gap: 15px; }
.node-card, .artifact-panel, .readiness-panel { min-width: 0; padding: 18px; border: 1px solid #c1cbc7; border-top: 3px solid #3a766a; border-radius: 11px; background: #fbfaf6; box-shadow: 0 10px 26px rgb(19 42 36 / 7%); }
.node-card[data-status="offline"] { border-top-color: #9a5044; }
.node-card[data-status="busy"], .node-card[data-status="capability-mismatch"], .node-card[data-status="unconfigured"] { border-top-color: #aa751d; }
@@ -284,8 +482,13 @@ dd { margin: 3px 0 0; overflow-wrap: anywhere; }
.mounted-hwpods li strong { color: #2b403a; font-size: .76rem; }
.mounted-hwpods li code { margin-top: 2px; color: #71807b; font-size: .66rem; }
.mounted-hwpods p { margin: 0; padding: 10px; color: #765b25; font-size: .74rem; line-height: 1.4; }
.node-card footer { display: flex; justify-content: space-between; gap: 10px; margin-top: 10px; padding-top: 12px; border-top: 1px solid #d9dfdc; color: #687570; font-size: .72rem; }
.node-card footer { 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; }
@@ -299,7 +502,7 @@ dd { margin: 3px 0 0; overflow-wrap: anywhere; }
.readiness-panel label { display: grid; gap: 4px; color: #6b7873; font-size: .68rem; font-weight: 800; text-transform: uppercase; }
.readiness-panel select { max-width: 220px; border: 1px solid #b5c1bd; border-radius: 6px; background: #fff; padding: 7px 9px; color: #263c36; }
.outbound-note { display: grid; gap: 5px; margin-top: 14px; padding: 12px; border-left: 3px solid #398477; background: #eaf6f1; color: #365d54; font-size: .77rem; line-height: 1.45; }
@media (max-width: 1100px) { .console-command { align-items: stretch; flex-wrap: wrap; } .command-facts { width: 100%; justify-content: flex-start; margin-left: 0; } .onboarding-grid { grid-template-columns: 1fr; } }
@media (max-width: 680px) { .hwpod-console { padding: 14px; } .console-tabs { width: 100%; } .console-tabs button { flex: 1 0 auto; } .console-command form { min-width: 100%; } .spec-warning { align-items: flex-start; flex-direction: column; } .node-gauges { grid-template-columns: 1fr; } .readiness-panel > header { flex-direction: column; } .readiness-panel select { max-width: 100%; } }
@media (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>