feat: 拆分 HWPOD Temporal 服务与独立 Web

This commit is contained in:
root
2026-07-20 19:58:02 +02:00
parent d2e9e3593d
commit d7709f0a27
18 changed files with 520 additions and 1 deletions
+60
View File
@@ -0,0 +1,60 @@
const state = { resources: [], selected: null };
const resourcesEl = document.querySelector("[data-resources]");
const detailEl = document.querySelector("[data-detail]");
const healthEl = document.querySelector("[data-health]");
document.querySelector("[data-refresh]").addEventListener("click", refresh);
void refresh();
async function refresh() {
healthEl.dataset.health = "loading";
document.querySelector("[data-health-label]").textContent = "同步中";
try {
const [health, nodes, devices] = await Promise.all([getJson("/health/ready"), getJson("/v1/hwpod/topology?resource=node&limit=100"), getJson("/v1/hwpod/topology?resource=device&limit=100")]);
const nodeItems = Array.isArray(nodes.items) ? nodes.items : [];
const deviceItems = Array.isArray(devices.items) ? devices.items : [];
state.resources = [...nodeItems, ...deviceItems];
updateStats(nodes.summary || {}, devices.summary || {});
renderResources();
if (state.selected) selectResource(state.resources.find(item => resourceKey(item) === state.selected) || null);
healthEl.dataset.health = health.ok ? "ready" : "blocked";
document.querySelector("[data-health-label]").textContent = health.ok ? "服务就绪" : "依赖阻塞";
document.querySelector("[data-observed]").textContent = displayTime(devices.observedAt || nodes.observedAt);
} catch (error) {
healthEl.dataset.health = "blocked";
document.querySelector("[data-health-label]").textContent = "连接失败";
resourcesEl.innerHTML = `<div class="empty">${escapeHtml(error.message)}</div>`;
}
}
function renderResources() {
if (!state.resources.length) { resourcesEl.innerHTML = '<div class="empty">未发现 HWPOD 节点或设备。</div>'; return; }
resourcesEl.innerHTML = state.resources.map(item => {
const key = resourceKey(item);
const title = item.resourceType === "hwpod" ? item.name || item.hwpodId : item.name || item.nodeId;
const subtitle = item.resourceType === "hwpod" ? `${item.nodeId || "未绑定节点"} · ${item.elements?.workspace?.label || "未声明工作区"}` : `${item.platform || "unknown"} · ${item.deviceCount || 0} 个 HWPOD`;
return `<button class="resource" type="button" data-key="${escapeHtml(key)}" data-status="${escapeHtml(item.status || "unknown")}" aria-current="${state.selected === key}"><span class="rail"></span><span class="resource-copy"><strong>${escapeHtml(title)}</strong><span>${escapeHtml(subtitle)}</span></span><span class="badge">${escapeHtml(item.status || "unknown")}</span></button>`;
}).join("");
resourcesEl.querySelectorAll("[data-key]").forEach(button => button.addEventListener("click", () => selectResource(state.resources.find(item => resourceKey(item) === button.dataset.key))));
}
function selectResource(item) {
state.selected = item ? resourceKey(item) : null;
renderResources();
if (!item) { detailEl.innerHTML = '<div class="detail-empty"><strong>选择一个资源</strong><span>查看能力、占用状态和 blocker。</span></div>'; return; }
const identity = item.resourceType === "hwpod" ? { 类型: "HWPOD", ID: item.hwpodId, 节点: item.nodeId, 状态: item.status } : { 类型: "HWPOD Node", ID: item.nodeId, 平台: item.platform, 状态: item.status };
const capabilities = item.resourceType === "hwpod" ? item.declaredCapabilities : item.actualCapabilities;
detailEl.innerHTML = `${detailBlock("IDENTITY", kv(identity))}${detailBlock("CAPABILITIES", chips(capabilities))}${blockers(item.blockers)}${detailBlock("RAW STATUS", kv({ 在线: String(Boolean(item.online)), 占用: String(Boolean(item.busy || item.inFlight?.busy)), 最近观测: displayTime(item.observedAt) }))}`;
}
function updateStats(nodes, devices) {
setStat("nodes", nodes.nodeCount || 0); setStat("online", nodes.onlineNodeCount || 0); setStat("devices", devices.deviceCount || 0); setStat("available", devices.availableDeviceCount || 0); setStat("blocked", (devices.deviceCount || 0) - (devices.availableDeviceCount || 0));
}
function setStat(name, value) { document.querySelector(`[data-stat="${name}"]`).textContent = String(value); }
function detailBlock(title, body) { return `<section class="detail-block"><h3>${title}</h3>${body}</section>`; }
function kv(values) { return `<dl class="kv">${Object.entries(values).map(([key, value]) => `<dt>${escapeHtml(key)}</dt><dd>${escapeHtml(value ?? "-")}</dd>`).join("")}</dl>`; }
function chips(values) { const items = Array.isArray(values) ? values : []; return items.length ? `<div class="chips">${items.map(value => `<span class="chip">${escapeHtml(value)}</span>`).join("")}</div>` : '<span class="empty">未声明能力</span>'; }
function blockers(values) { const items = Array.isArray(values) ? values : []; return items.length ? detailBlock("BLOCKERS", items.map(value => `<div class="blocker"><strong>${escapeHtml(value.code || "blocked")}</strong><br>${escapeHtml(value.summary || value.message || "资源不可用")}</div>`).join("")) : ""; }
function resourceKey(item) { return `${item.resourceType}:${item.hwpodId || item.nodeId}`; }
function displayTime(value) { if (!value) return "尚未同步"; const date = new Date(value); return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("zh-CN", { hour12: false }); }
async function getJson(url) { const response = await fetch(url, { headers: { accept: "application/json" } }); const body = await response.json().catch(() => null); if (!response.ok || !body) throw new Error(body?.error?.message || `${url} 返回 HTTP ${response.status}`); return body; }
function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/gu, char => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char])); }
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111410" />
<title>HWPOD 控制台</title>
<link rel="stylesheet" href="/styles.css" />
<script type="module" src="/app.js"></script>
</head>
<body>
<div class="shell" data-testid="hwpod-console">
<header class="command-bar">
<div class="identity">
<span class="mark" aria-hidden="true">HP</span>
<div><strong>HWPOD</strong><small>硬件资源控制台</small></div>
</div>
<div class="actions">
<span class="connection" data-health="loading"><i></i><span data-health-label>连接中</span></span>
<button type="button" data-refresh>刷新</button>
</div>
</header>
<section class="status-strip" aria-label="HWPOD 状态摘要">
<div><span>节点</span><strong data-stat="nodes">-</strong></div>
<div><span>在线</span><strong data-stat="online">-</strong></div>
<div><span>设备</span><strong data-stat="devices">-</strong></div>
<div><span>可用</span><strong data-stat="available">-</strong></div>
<div><span>阻塞</span><strong data-stat="blocked">-</strong></div>
</section>
<main class="workspace">
<section class="resource-pane" aria-labelledby="resource-title">
<div class="pane-heading"><div><p>RESOURCE MAP</p><h1 id="resource-title">节点与设备</h1></div><time data-observed>尚未同步</time></div>
<div class="resource-list" data-resources><div class="empty">正在读取 HWPOD topology...</div></div>
</section>
<aside class="detail-pane" aria-labelledby="detail-title">
<div class="pane-heading"><div><p>INSPECTOR</p><h2 id="detail-title">资源详情</h2></div></div>
<div class="detail" data-detail>
<div class="detail-empty"><strong>选择一个资源</strong><span>查看能力、占用状态和 blocker。</span></div>
</div>
</aside>
</main>
</div>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
:root { color-scheme: dark; font-family: "IBM Plex Sans", "Noto Sans SC", sans-serif; background: #111410; color: #eef2e8; }
* { box-sizing: border-box; }
html, body { width: 100%; min-height: 100%; margin: 0; }
body { background: #111410; letter-spacing: 0; overflow: hidden; }
button { font: inherit; letter-spacing: 0; }
.shell { min-height: 100vh; display: grid; grid-template-rows: 58px 54px minmax(0, 1fr); background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px); background-size: 28px 28px; }
.command-bar { display: flex; align-items: center; justify-content: space-between; padding: 0 22px; border-bottom: 1px solid #30372e; background: rgba(17,20,16,.94); }
.identity, .actions, .connection { display: flex; align-items: center; }
.identity { gap: 12px; }
.identity .mark { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid #a7ff3f; color: #a7ff3f; font: 700 12px/1 "IBM Plex Mono", monospace; }
.identity div { display: grid; gap: 2px; }
.identity strong { font-size: 15px; }
.identity small { color: #8d9788; font-size: 11px; }
.actions { gap: 16px; }
.connection { gap: 8px; color: #aeb6a8; font-size: 12px; }
.connection i { width: 7px; height: 7px; border-radius: 50%; background: #e9bd54; box-shadow: 0 0 12px #e9bd54; }
.connection[data-health="ready"] i { background: #a7ff3f; box-shadow: 0 0 12px #a7ff3f; }
.connection[data-health="blocked"] i { background: #ff6b57; box-shadow: 0 0 12px #ff6b57; }
.actions button { min-width: 66px; height: 30px; border: 1px solid #495044; background: #191d18; color: #eef2e8; cursor: pointer; }
.actions button:hover { border-color: #a7ff3f; color: #a7ff3f; }
.status-strip { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border-bottom: 1px solid #30372e; background: #181c17; }
.status-strip div { min-width: 0; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 10px 18px; border-right: 1px solid #30372e; }
.status-strip span { color: #8d9788; font-size: 11px; }
.status-strip strong { font: 600 20px/1 "IBM Plex Mono", monospace; color: #f4f7f0; }
.workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(320px, .75fr); }
.resource-pane, .detail-pane { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); }
.resource-pane { border-right: 1px solid #30372e; }
.pane-heading { min-height: 68px; display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid #30372e; background: rgba(17,20,16,.84); }
.pane-heading p { margin: 0 0 4px; color: #a7ff3f; font: 600 10px/1 "IBM Plex Mono", monospace; }
.pane-heading h1, .pane-heading h2 { margin: 0; font-size: 17px; font-weight: 600; }
.pane-heading time { color: #717b6d; font: 11px/1 "IBM Plex Mono", monospace; }
.resource-list, .detail { min-height: 0; overflow: auto; padding: 14px; }
.resource-list { display: grid; align-content: start; gap: 8px; }
.resource { width: 100%; min-height: 76px; display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 12px 14px; border: 1px solid #333a30; background: #171b16; color: inherit; text-align: left; cursor: pointer; }
.resource:hover, .resource[aria-current="true"] { border-color: #829e65; background: #1c211a; }
.resource .rail { width: 3px; height: 44px; background: #596255; }
.resource[data-status="online"] .rail, .resource[data-status="available"] .rail { background: #a7ff3f; }
.resource[data-status="busy"] .rail { background: #e9bd54; }
.resource[data-status="offline"] .rail, .resource[data-status="blocked"] .rail { background: #ff6b57; }
.resource-copy { min-width: 0; display: grid; gap: 5px; }
.resource-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
.resource-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8d9788; font: 11px/1.35 "IBM Plex Mono", monospace; }
.badge { border: 1px solid #41483d; padding: 5px 8px; color: #b6beb1; font: 10px/1 "IBM Plex Mono", monospace; text-transform: uppercase; }
.detail { display: grid; align-content: start; gap: 14px; }
.detail-empty, .empty { min-height: 180px; display: grid; place-content: center; gap: 7px; color: #747d70; text-align: center; }
.detail-empty strong { color: #c8cec3; }
.detail-block { border-top: 1px solid #353c32; padding-top: 14px; }
.detail-block h3 { margin: 0 0 10px; color: #8d9788; font: 600 10px/1 "IBM Plex Mono", monospace; }
.kv { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 8px 14px; font-size: 12px; }
.kv dt { color: #7f897a; }
.kv dd { margin: 0; overflow-wrap: anywhere; color: #e5e9df; font-family: "IBM Plex Mono", monospace; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { border: 1px solid #3f493b; padding: 5px 7px; color: #c8d0c1; font: 10px/1 "IBM Plex Mono", monospace; }
.blocker { border-left: 3px solid #ff6b57; padding: 10px 12px; background: #271a17; color: #ffc1b8; font-size: 12px; }
@media (max-width: 760px) { body { overflow: auto; } .shell { min-height: 100svh; grid-template-rows: 58px auto auto; } .status-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); } .status-strip div:nth-child(n+4) { border-top: 1px solid #30372e; } .workspace { grid-template-columns: 1fr; } .resource-pane { border-right: 0; } .detail-pane { border-top: 1px solid #30372e; min-height: 420px; } .resource-list, .detail { overflow: visible; } }
+1
View File
@@ -7,6 +7,7 @@
"deps": "node ../../scripts/worktree-deps.mjs --web",
"dev": "bun run deps --quiet && vite",
"dev:native-caserun": "CHOKIDAR_USEPOLLING=1 HWLAB_CASERUN_NATIVE_TEST=1 bun run dev",
"dev:native-hwpod": "bun run deps --quiet && vite --config scripts/hwpod-native-vite.config.ts",
"caserun:native": "bun scripts/caserun-native-supervisor.ts",
"caserun:native:start": "bun run scripts/caserun-native-service.ts start",
"caserun:native:stop": "bun run scripts/caserun-native-service.ts stop",
@@ -0,0 +1,24 @@
/*
* SPEC: PJ2026-010103 HWPOD 服务。
* 实现引用: draft-2026-07-20-hwpod-temporal-split。
* 责任: 独立 HWPOD L1 Web 的固定端口、健康检查和 API proxy。
*/
import path from "node:path";
import { defineConfig, type Plugin } from "vite";
const apiUrl = process.env.HWPOD_NATIVE_API_URL;
if (!apiUrl) throw new Error("HWPOD_NATIVE_API_URL is required");
export default defineConfig({
root: path.resolve(import.meta.dirname, "../hwpod"),
server: {
host: process.env.HWPOD_WEB_HOST || "0.0.0.0",
port: requiredPort(process.env.HWPOD_WEB_PORT),
strictPort: true,
proxy: { "/v1": { target: apiUrl, changeOrigin: false }, "/health/ready": { target: apiUrl, changeOrigin: false } }
},
plugins: [healthPlugin()]
});
function healthPlugin(): Plugin { return { name: "hwpod-native-health", configureServer(server) { server.middlewares.use((request, response, next) => { if (request.url !== "/health/live") return next(); response.statusCode = 200; response.setHeader("content-type", "application/json"); response.end(JSON.stringify({ ok: true, serviceId: "hwlab-hwpod-web", status: "live", valuesPrinted: false })); }); } }; }
function requiredPort(value: string | undefined) { const port = Number.parseInt(String(value ?? ""), 10); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("HWPOD_WEB_PORT is required and must be valid"); return port; }