fix: 修正 HWPOD 控制台状态与有界布局

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-21 13:23:55 +02:00
parent d1020aaab8
commit ccf10af5bd
9 changed files with 97 additions and 62 deletions
@@ -28,7 +28,7 @@ test("typed topology covers multi-device, offline, unconfigured, invalid, mismat
runtimeKind: "python-tkinter-gui",
installed: true,
desktopVisible: true,
capabilities: ["workspace.ls"],
capabilities: ["workspace.ls", "node.health"],
inFlightCount: 1,
maxInFlight: 1,
lastDiagnostic: { level: "ERROR", source: "ws", message: "connect failed: Bearer private-token?token=still-secret" },
@@ -44,15 +44,24 @@ test("typed topology covers multi-device, offline, unconfigured, invalid, mismat
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"]);
const deviceA = devicePayload.items.find((item: any) => item.hwpodId === "hwpod-a");
const deviceB = devicePayload.items.find((item: any) => item.hwpodId === "hwpod-b");
assert.equal(deviceA.status, "online");
assert.equal(deviceA.busy, true);
assert.equal(deviceA.available, false);
assert.equal(deviceB.status, "online");
assert.equal(deviceB.capabilityMismatch, true);
assert.deepEqual(deviceB.missingCapabilities, ["debug.download"]);
assert.deepEqual(deviceB.capabilityCoverage, { matched: 1, declared: 2, actual: 2, missing: 1, extra: 1 });
assert.equal(deviceB.blockers.some((item: any) => item.code === "hwpod_capability_mismatch"), true);
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.status, "online");
assert.equal(nodeA.inFlight.busy, true);
assert.deepEqual(nodeA.capabilityCoverage, { matched: 1, declared: 2, actual: 2, missing: 1, extra: 1 });
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);
@@ -61,7 +70,8 @@ test("typed topology covers multi-device, offline, unconfigured, invalid, mismat
assert.equal(nodeA.readiness.find((stage: any) => stage.id === "desktop-visible").nextAction, null);
assert.equal(nodeA.readiness.find((stage: any) => stage.id === "hwpod-ready").nextAction.entrypoint, "hwpod-topology");
assert.equal(nodePayload.items.find((item: any) => item.nodeId === "node-b").readiness.find((stage: any) => stage.id === "workspace-ready").blocker.code, "hwpod_node_spec_missing");
assert.equal(nodePayload.items.find((item: any) => item.nodeId === "node-b").status, "unconfigured");
assert.equal(nodePayload.items.find((item: any) => item.nodeId === "node-b").status, "online");
assert.equal(nodePayload.summary.unconfiguredNodeCount, 1);
assert.equal(nodePayload.items.find((item: any) => item.nodeId === "node-c").status, "offline");
assert.equal(nodePayload.invalidSpecs[0].blocker.code, "invalid_hwpod_spec");
});
@@ -74,11 +84,11 @@ test("typed topology keeps server-side status filtering and cursor bounded", ()
{ nodeId: "node-b", status: "online", capabilities: ["workspace.ls"], inFlightCount: 0, maxInFlight: 1 }
]
};
const first = buildHwpodTopologyReadModel(specs, registry, { resource: "device", status: "available", limit: 1 });
const first = buildHwpodTopologyReadModel(specs, registry, { resource: "device", status: "online", 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 });
const second = buildHwpodTopologyReadModel(specs, registry, { resource: "device", status: "online", 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");
+20 -12
View File
@@ -52,9 +52,9 @@ export function buildHwpodTopologyReadModel(specs: any[], registrySnapshot: any
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,
unconfiguredNodeCount: nodes.filter((node) => node.deviceCount === 0).length,
deviceCount: devices.length,
availableDeviceCount: devices.filter((device) => device.status === "available").length,
availableDeviceCount: devices.filter((device) => device.available).length,
busyDeviceCount: devices.filter((device) => device.busy).length,
capabilityMismatchCount: devices.filter((device) => device.capabilityMismatch).length,
invalidSpecCount: invalidSpecs.length
@@ -105,12 +105,14 @@ function buildDevice(spec: any, node: any, observedAt: string) {
const declaredCapabilities = declaredCapabilitiesFor(body);
const actualCapabilities = node?.actualCapabilities ?? [];
const missingCapabilities = node ? declaredCapabilities.filter((capability) => !actualCapabilities.includes(capability)) : declaredCapabilities;
const capabilityCoverage = buildCapabilityCoverage(declaredCapabilities, actualCapabilities);
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 });
const status = online ? "online" : "offline";
const available = online && !busy && !capabilityMismatch && availability.ok === true;
return {
resourceType: "hwpod",
hwpodId: text(spec.hwpodId ?? spec.name),
@@ -120,12 +122,13 @@ function buildDevice(spec: any, node: any, observedAt: string) {
nodeName: node?.name ?? text(spec.nodeId),
status,
online,
available: status === "available",
available,
busy,
capabilityMismatch,
declaredCapabilities,
actualCapabilities,
missingCapabilities,
capabilityCoverage,
elements: hwpodElements(body),
availability,
operation: node?.latestOperation?.hwpodId === text(spec.hwpodId ?? spec.name) ? node.latestOperation : null,
@@ -144,11 +147,12 @@ function buildNode(nodeId: string, onlineNode: any, devices: any[], observedAt:
const declaredCapabilities = uniqueStrings(devices.flatMap((device) => device.declaredCapabilities));
const actualCapabilities = onlineNode?.actualCapabilities ?? [];
const missingCapabilities = onlineNode ? declaredCapabilities.filter((capability) => !actualCapabilities.includes(capability)) : declaredCapabilities;
const capabilityCoverage = buildCapabilityCoverage(declaredCapabilities, actualCapabilities);
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 status = online ? "online" : "offline";
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);
@@ -175,6 +179,7 @@ function buildNode(nodeId: string, onlineNode: any, devices: any[], observedAt:
declaredCapabilities,
actualCapabilities,
missingCapabilities,
capabilityCoverage,
capabilityMismatch,
inFlight: {
count: onlineNode?.inFlightCount ?? 0,
@@ -244,13 +249,16 @@ function availabilitySummary(value: any) {
};
}
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 buildCapabilityCoverage(declaredCapabilities: string[], actualCapabilities: string[]) {
const matched = declaredCapabilities.filter((capability) => actualCapabilities.includes(capability)).length;
const extra = actualCapabilities.filter((capability) => !declaredCapabilities.includes(capability)).length;
return {
matched,
declared: declaredCapabilities.length,
actual: actualCapabilities.length,
missing: declaredCapabilities.length - matched,
extra
};
}
function deviceBlockers(input: any) {
@@ -351,6 +351,24 @@ test("R2 visual constraints keep industrial controls bounded and component-owned
assert.match(read(relativePath), /ViewModeSwitch/u, `${relativePath} should use the shared view switch`);
});
test("HWPOD topology keeps connection state separate and owns bounded scrolling", () => {
const routes = read("src/router/index.ts");
for (const path of ["/hwpods/devices/:hwpodId?", "/hwpods/nodes/:nodeId?", "/hwpods/onboarding/:nodeId?"]) {
const route = routes.split(`{ path: "${path}"`)[1]?.split("},")[0] ?? "";
assert.match(route, /boundedLayout: true/u, `${path} should use bounded AppShell layout`);
}
const view = read("src/views/admin/HwpodGroupsView.vue");
assert.match(view, /\.hwpod-console \{[^}]*height: 100%;[^}]*overflow: hidden;/u);
assert.match(view, /\.hwpod-workspace \{[^}]*flex: 1 1 auto;/u);
assert.doesNotMatch(view, /\.hwpod-workspace \{[^}]*height: clamp\(/u);
const deviceWorkspace = read("src/components/hwpod/HwpodDevicesWorkspace.vue");
assert.match(deviceWorkspace, /capabilityCoverage\.matched/u);
assert.match(deviceWorkspace, /capabilityCoverage\.declared/u);
assert.doesNotMatch(deviceWorkspace, /actualCapabilities\.length\s*\}\}\/\{\{\s*row\.declaredCapabilities\.length/u);
});
test("R6 Sub2API source comparison is recorded in parent/child issue scope", () => {
const issueBody = readIssueBodyFixture();
assert.match(issueBody, /Wei-Shaw\/sub2api@v0\.1\.136=a2f76e4/u);
@@ -14,12 +14,8 @@ const props = withDefaults(
const emit = defineEmits<{ select: [id: string] }>();
const statusLabel = computed(() => ({
available: "可用",
busy: "执行中",
online: "在线",
offline: "节点离线",
"capability-mismatch": "能力不匹配",
"workspace-blocked": "工作区阻塞",
unprobed: "待探测"
}[props.device.status] ?? props.device.status));
function linkState(kind: "target" | "workspace" | "debug" | "io"): string {
@@ -80,7 +76,7 @@ function linkState(kind: "target" | "workspace" | "debug" | "io"): string {
<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>能力覆盖 {{ device.capabilityCoverage.matched }}/{{ device.capabilityCoverage.declared }} · 上报 {{ device.capabilityCoverage.actual }}</span>
<span v-if="device.operation">{{ device.operation.status }} · {{ device.operation.opCount }} ops</span>
<button
type="button"
@@ -95,17 +91,13 @@ function linkState(kind: "target" | "workspace" | "debug" | "io"): string {
.pcb-card { position: relative; min-width: 0; border: 1px solid #cbd2cf; border-top: 3px solid #2e6b67; border-radius: 8px; background: #fbfaf6; box-shadow: 0 12px 28px rgb(20 34 32 / 8%); overflow: hidden; transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; }
.pcb-card[data-selected="true"] { border-color: #1f7469; border-top-color: #145f57; box-shadow: 0 0 0 3px rgb(31 116 105 / 18%), 0 16px 34px rgb(20 55 48 / 14%); transform: translateY(-1px); }
.pcb-card[data-selected="true"] footer button { border-color: #145f57; background: #dff2ed; color: #145f57; }
.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[data-status="offline"] { border-top-color: #8d453b; }
.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: 0; 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-status[data-status="offline"] { color: #7b332c; background: #fff0ed; border-color: #ddb2ac; }
.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; }
@@ -11,7 +11,7 @@ import HwpodDeviceCard from "./HwpodDeviceCard.vue";
defineProps<{ devices: HwpodDeviceTopologyItem[]; selectedId: string | null; view: "cards" | "list"; density: "compact" | "comfortable"; sort: string }>();
const emit = defineEmits<{ select: [device: HwpodDeviceTopologyItem]; sort: [key: string] }>();
const columns: DataGridColumn[] = [{ 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: "debugIo", label: "调试 / IO", width: "210px" }, { key: "capabilities", label: "能力", width: "140px" }, { key: "blocker", label: "主要 blocker", width: "220px" }, { key: "operation", label: "用户操作", width: "160px" }];
const statusLabel = (status: string): string => ({ online: "在线", offline: "离线", busy: "忙碌", available: "可用", error: "异常", mismatch: "能力不匹配", "capability-mismatch": "能力不匹配" }[status] || status || "unknown");
const statusLabel = (status: string): string => ({ online: "在线", offline: "离线" }[status] || status || "unknown");
</script>
<template>
@@ -49,7 +49,7 @@ const statusLabel = (status: string): string => ({ online: "在线", offline: "
<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-debugIo="{ row }"><span class="grid-stack"><strong>{{ row.elements.debugProbe.label }}</strong><small>{{ row.elements.ioProbe.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-capabilities="{ row }"><span class="grid-stack"><strong>覆盖 {{ row.capabilityCoverage.matched }}/{{ row.capabilityCoverage.declared }}</strong><small>上报 {{ row.capabilityCoverage.actual }} · 缺 {{ row.capabilityCoverage.missing }} · 额外 {{ row.capabilityCoverage.extra }}</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>
<template #cell-operation="{ row }"><span class="grid-stack"><strong>{{ row.operation?.status || '无' }}</strong><small>{{ row.operation ? `${row.operation.opCount} ops` : '尚无用户操作' }}</small></span></template>
</DataGrid>
@@ -11,7 +11,7 @@ import HwpodReadinessRail from "./HwpodReadinessRail.vue";
defineProps<{ nodes: HwpodNodeTopologyItem[]; selectedId: string | null; view: "cards" | "list"; density: "compact" | "comfortable"; sort: string }>();
const emit = defineEmits<{ select: [node: HwpodNodeTopologyItem]; sort: [key: string] }>();
const columns: DataGridColumn[] = [{ key: "name", label: "Node", sortable: true, width: "210px" }, { key: "status", label: "连接", sortable: true, width: "100px" }, { key: "deviceCount", label: "HWPOD", align: "right", width: "80px" }, { key: "inFlight", label: "In-flight", width: "100px" }, { key: "capabilities", label: "能力", width: "130px" }, { key: "mounted", label: "挂载 HWPOD", width: "220px" }, { key: "readiness", label: "接入阶段", width: "180px" }, { key: "lastSeen", label: "最后心跳", width: "150px" }, { key: "blocker", label: "Blocker", width: "220px" }];
const statusLabel = (status: string): string => ({ online: "在线", offline: "离线", busy: "忙碌", available: "可用", error: "异常", mismatch: "能力不匹配", "capability-mismatch": "能力不匹配" }[status] || status || "unknown");
const statusLabel = (status: string): string => ({ online: "在线", offline: "离线" }[status] || status || "unknown");
function formatTime(value: string | null): string { if (!value) return "-"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); }
</script>
@@ -46,7 +46,7 @@ function formatTime(value: string | null): string { if (!value) return "-"; cons
<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>
<div><small>capability coverage</small><strong>{{ node.capabilityCoverage.matched }}/{{ node.capabilityCoverage.declared }}</strong></div>
</section>
<section class="mounted-hwpods" aria-label="挂载 HWPOD">
<header><strong>挂载 HWPOD</strong><span>{{ node.hwpods.length }}</span></header>
@@ -85,7 +85,7 @@ function formatTime(value: string | null): string { if (!value) return "-"; cons
<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-capabilities="{ row }"><span class="grid-stack"><strong>覆盖 {{ row.capabilityCoverage.matched }}/{{ row.capabilityCoverage.declared }}</strong><small>上报 {{ row.capabilityCoverage.actual }} · 缺 {{ row.capabilityCoverage.missing }} · 额外 {{ row.capabilityCoverage.extra }}</small></span></template>
<template #cell-mounted="{ row }"><span class="grid-stack"><strong>{{ row.hwpods.map((item) => item.name).join(', ') || '无' }}</strong><small>{{ row.hwpods.map((item) => item.hwpodId).join(', ') || '未绑定 spec' }}</small></span></template>
<template #cell-readiness="{ row }"><span class="grid-stack"><strong>{{ row.readiness.filter((stage: HwpodNodeReadinessStage) => stage.status === 'ready').length }}/{{ row.readiness.length }} ready</strong><small>{{ row.readiness.find((stage: HwpodNodeReadinessStage) => stage.status !== 'ready')?.label || '全部阶段 ready' }}</small></span></template>
<template #cell-lastSeen="{ row }">{{ formatTime(row.lastSeenAt) }}</template>
+3 -3
View File
@@ -49,9 +49,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/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: "/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", boundedLayout: true } },
{ 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", boundedLayout: true } },
{ 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", boundedLayout: true } },
{ 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: "/admin/provider-profiles/:profile", name: "ProviderProfileDetail", component: () => import("@/views/admin/ProviderProfilesView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.providerProfiles", navParent: "ProviderProfiles", title: "Provider Profile 详情", section: "admin" } },
{ path: "/skills", name: "Skills", component: () => import("@/views/SkillsView.vue"), meta: { requiresAuth: true, navId: "system.skills", title: "技能包", section: "system" } },
+14 -4
View File
@@ -389,9 +389,17 @@ 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 HwpodConnectionStatus = "online" | "offline";
export type HwpodReadinessStatus = "ready" | "blocked" | "unknown" | string;
export interface HwpodCapabilityCoverage {
matched: number;
declared: number;
actual: number;
missing: number;
extra: number;
}
export interface HwpodBlocker {
code: string;
summary: string;
@@ -431,7 +439,7 @@ export interface HwpodDeviceTopologyItem {
name: string;
nodeId: string;
nodeName: string;
status: HwpodResourceStatus;
status: HwpodConnectionStatus;
online: boolean;
available: boolean;
busy: boolean;
@@ -439,6 +447,7 @@ export interface HwpodDeviceTopologyItem {
declaredCapabilities: string[];
actualCapabilities: string[];
missingCapabilities: string[];
capabilityCoverage: HwpodCapabilityCoverage;
elements: HwpodElements;
availability: { ok: boolean | null; status: string; checkedAt: string | null; blocker: HwpodBlocker | null };
operation: HwpodOperationSummary | null;
@@ -461,7 +470,7 @@ export interface HwpodNodeTopologyItem {
resourceType: "hwpod-node";
nodeId: string;
name: string;
status: HwpodResourceStatus;
status: HwpodConnectionStatus;
online: boolean;
platform: string;
version: string | null;
@@ -470,10 +479,11 @@ export interface HwpodNodeTopologyItem {
lastSeenAt: string | null;
ageMs: number | null;
deviceCount: number;
hwpods: Array<{ hwpodId: string; name: string; status: HwpodResourceStatus; busy: boolean; capabilityMismatch: boolean }>;
hwpods: Array<{ hwpodId: string; name: string; status: HwpodConnectionStatus; busy: boolean; capabilityMismatch: boolean }>;
declaredCapabilities: string[];
actualCapabilities: string[];
missingCapabilities: string[];
capabilityCoverage: HwpodCapabilityCoverage;
capabilityMismatch: boolean;
inFlight: { count: number; max: number; busy: boolean };
operation: HwpodOperationSummary | null;
@@ -70,18 +70,11 @@ const statusItems = computed(() => [
const statusFilterOptions = computed(() => tab.value === "nodes"
? [
{ value: "online", label: "在线" },
{ value: "busy", label: "执行中" },
{ value: "offline", label: "离线" },
{ value: "capability-mismatch", label: "能力不匹配" },
{ value: "unconfigured", label: "无 Spec" }
{ value: "offline", label: "离线" }
]
: [
{ value: "available", label: "可用" },
{ value: "busy", label: "执行中" },
{ value: "offline", label: "离线" },
{ value: "capability-mismatch", label: "能力不匹配" },
{ value: "workspace-blocked", label: "工作区阻塞" },
{ value: "unprobed", label: "待探测" }
{ value: "online", label: "在线" },
{ value: "offline", label: "离线" }
]);
const collectionPage = computed(() => tab.value === "devices" ? hwpod.deviceTopology?.page : tab.value === "nodes" ? hwpod.nodeTopology?.page : null);
@@ -195,7 +188,7 @@ async function copySha(): Promise<void> {
}
function statusLabel(status: string): string {
return ({ available: "可用", busy: "执行中", offline: "离线", "capability-mismatch": "能力不匹配", "workspace-blocked": "工作区阻塞", unprobed: "待探测", online: "在线", unconfigured: "无 Spec" }[status] ?? status);
return ({ online: "在线", offline: "离线" }[status] ?? status);
}
function formatTime(value: string | null): string {
@@ -310,20 +303,24 @@ function text(value: unknown): string {
</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); }
.hwpod-console { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr); align-content: stretch; gap: 12px; padding: 8px; color: var(--console-graphite-900); overflow: hidden; }
.hwpod-console :deep(.console-async-boundary) { display: flex; min-height: 0; flex-direction: column; overflow: hidden; }
.hwpod-console :deep(.page-command-filters) { flex-wrap: nowrap; align-items: center; gap: 6px; padding-bottom: 1px; overflow-x: auto; overscroll-behavior-inline: contain; }
.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 { display: flex; width: min(340px, 100%); min-width: min(340px, 100%); align-items: center; gap: 6px; }
.console-search label, .status-filter { color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 10px; font-weight: 760; letter-spacing: 0; 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; }
.console-search input { height: 32px; min-width: 0; flex: 1; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 0 9px; font-size: 11px; }
.console-search button { height: 32px; border: 1px solid var(--console-cyan-700); border-radius: var(--console-radius-sm); background: var(--console-cyan-100); color: var(--console-cyan-700); padding: 0 9px; font-size: 11px; 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; }
.status-filter select { height: 32px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 0 8px; color: var(--console-graphite-800); font-size: 11px; }
.probe-button { height: 32px; border: 1px solid var(--console-amber-500); border-radius: var(--console-radius-sm); background: var(--console-amber-100); color: var(--console-amber-700); padding: 0 9px; font-size: 11px; font-weight: 750; cursor: pointer; white-space: nowrap; }
.hwpod-console :deep(.view-mode-switch) { height: 32px; flex: 0 0 auto; }
.hwpod-console :deep(.view-mode-switch button) { height: 28px; min-height: 28px; padding-block: 0; font-size: 11px; }
.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 { min-height: 0; flex: 1 1 auto; }
.hwpod-workspace :deep(.bounded-workspace-main) { padding: 12px; }
.hwpod-workspace :deep(.resource-stage) { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: minmax(0, 1fr); }
.hwpod-workspace :deep(.grid-stack) { display: grid; min-width: 0; gap: 3px; }
@@ -346,6 +343,6 @@ dd { margin: 3px 0 0; overflow-wrap: anywhere; }
.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; }
@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; } }
@media (max-width: 680px) { .hwpod-console { padding: 2px; } .console-tabs { width: 100%; } .console-tabs button { flex: 1 0 auto; } .console-search { width: 100%; min-width: 100%; } .status-filter { flex: 0 0 auto; } .spec-warning, .stale-warning { align-items: flex-start; flex-direction: column; } .hwpod-workspace :deep(.bounded-workspace-main) { padding: 8px; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } }
</style>