f3ae03202e
feat: enrich cloud workbench hardware status
1450 lines
50 KiB
JavaScript
1450 lines
50 KiB
JavaScript
import { gateSummary } from "./gate-summary.mjs";
|
||
import { runtime } from "./runtime.mjs";
|
||
import { marked } from "./third_party/marked/marked.esm.js";
|
||
|
||
const API_TIMEOUT_MS = 4500;
|
||
const rpcReadMethods = Object.freeze([
|
||
"system.health",
|
||
"cloud.adapter.describe",
|
||
"audit.event.query",
|
||
"evidence.record.query"
|
||
]);
|
||
|
||
const STATUS_LABELS = Object.freeze({
|
||
active: "活动",
|
||
available: "可用",
|
||
blocked: "阻塞 BLOCKED",
|
||
completed: "完成",
|
||
connected: "已连接",
|
||
degraded: "降级",
|
||
disabled: "禁用",
|
||
entry: "入口",
|
||
failed: "失败",
|
||
healthy: "健康",
|
||
live: "实况",
|
||
ok: "正常",
|
||
open: "未关闭",
|
||
pass: "通过",
|
||
pending: "等待",
|
||
running: "处理中",
|
||
probing: "探测中",
|
||
ready: "就绪",
|
||
recorded: "已记录",
|
||
sent: "已发送",
|
||
requires: "依赖",
|
||
source: "来源 SOURCE",
|
||
"dry-run": "演练 DRY-RUN",
|
||
"dev-live": "开发实况 DEV-LIVE"
|
||
});
|
||
|
||
const M3_TRUSTED_ROUTE = Object.freeze({
|
||
fromResourceId: "res_boxsimu_1",
|
||
fromPort: "DO1",
|
||
patchPanelServiceId: "hwlab-patch-panel",
|
||
toResourceId: "res_boxsimu_2",
|
||
toPort: "DI1"
|
||
});
|
||
|
||
const LIVE_M3_ID_FIELDS = Object.freeze(["operationId", "traceId", "auditId", "evidenceId"]);
|
||
const LIVE_M3_PASS_STATUSES = Object.freeze(["pass", "passed", "verified", "succeeded", "trusted", "completed"]);
|
||
const NON_LIVE_ID_VALUES = Object.freeze(["", "n/a", "none", "null", "undefined", "not_observed", "not-observed"]);
|
||
|
||
const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view));
|
||
let rpcSequence = 0;
|
||
|
||
const el = {
|
||
shell: query("[data-app-shell]"),
|
||
explorerToggle: byId("explorer-toggle"),
|
||
explorerStatus: byId("explorer-status"),
|
||
treeCount: byId("tree-count"),
|
||
resourceTree: byId("resource-tree"),
|
||
capabilityCount: byId("capability-count"),
|
||
explorerCapabilities: byId("explorer-capabilities"),
|
||
routePath: byId("route-path"),
|
||
liveStatus: byId("live-status"),
|
||
liveDetail: byId("live-detail"),
|
||
agentChatStatus: byId("agent-chat-status"),
|
||
conversationList: byId("conversation-list"),
|
||
taskList: byId("task-list"),
|
||
traceList: byId("trace-list"),
|
||
unblockList: byId("unblock-list"),
|
||
gateMetadata: byId("gate-metadata"),
|
||
milestoneGrid: byId("milestone-grid"),
|
||
healthBody: byId("health-body"),
|
||
gateBlockers: byId("gate-blockers"),
|
||
validationList: byId("validation-list"),
|
||
commandForm: byId("command-form"),
|
||
commandInput: byId("command-input"),
|
||
commandSend: byId("command-send"),
|
||
commandClear: byId("command-clear"),
|
||
hardwareSourceStatus: byId("hardware-source-status"),
|
||
hardwareList: byId("hardware-list"),
|
||
controlList: byId("control-list"),
|
||
wiringBody: byId("wiring-body"),
|
||
recordsList: byId("records-list"),
|
||
diagnosticsList: byId("diagnostics-list"),
|
||
methodList: byId("method-list"),
|
||
helpContent: byId("help-content"),
|
||
helpStatus: byId("help-status")
|
||
};
|
||
|
||
const state = {
|
||
conversationId: null,
|
||
codeAgentAvailability: null,
|
||
chatMessages: [],
|
||
chatPending: false
|
||
};
|
||
|
||
initRoutes();
|
||
initExplorerToggle();
|
||
initSideTabs();
|
||
initQuickActions();
|
||
initCommandBar();
|
||
renderStaticWorkbench();
|
||
renderProbePending();
|
||
loadHelpSurface();
|
||
loadLiveSurface().then(renderLiveSurface);
|
||
|
||
function byId(id) {
|
||
const element = document.getElementById(id);
|
||
if (!element) {
|
||
throw new Error(`missing required element #${id}`);
|
||
}
|
||
return element;
|
||
}
|
||
|
||
function query(selector) {
|
||
const element = document.querySelector(selector);
|
||
if (!element) {
|
||
throw new Error(`missing required element ${selector}`);
|
||
}
|
||
return element;
|
||
}
|
||
|
||
function initRoutes() {
|
||
window.addEventListener("hashchange", () => showView(routeFromLocation()));
|
||
for (const button of document.querySelectorAll("[data-route]")) {
|
||
button.addEventListener("click", () => {
|
||
window.location.hash = button.dataset.route;
|
||
});
|
||
}
|
||
showView(routeFromLocation());
|
||
}
|
||
|
||
function routeFromLocation() {
|
||
const hashRoute = window.location.hash.replace(/^#/, "");
|
||
if (viewIds.has(hashRoute)) return hashRoute;
|
||
if (internalGatePathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "gate";
|
||
return "workspace";
|
||
}
|
||
|
||
function showView(route) {
|
||
const activeRoute = viewIds.has(route) ? route : "workspace";
|
||
for (const view of document.querySelectorAll("[data-view]")) {
|
||
view.hidden = view.dataset.view !== activeRoute;
|
||
}
|
||
for (const button of document.querySelectorAll("[data-route]")) {
|
||
const active = button.dataset.route === activeRoute;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-current", active ? "page" : "false");
|
||
}
|
||
}
|
||
|
||
function initExplorerToggle() {
|
||
el.explorerToggle.addEventListener("click", () => {
|
||
const collapsed = !el.shell.classList.contains("explorer-collapsed");
|
||
el.shell.classList.toggle("explorer-collapsed", collapsed);
|
||
el.explorerToggle.setAttribute("aria-pressed", collapsed ? "true" : "false");
|
||
});
|
||
}
|
||
|
||
async function loadHelpSurface() {
|
||
el.helpStatus.textContent = "加载中";
|
||
el.helpStatus.className = "state-tag tone-dry-run";
|
||
el.helpContent.dataset.helpState = "loading";
|
||
|
||
try {
|
||
const response = await fetch("/help.md", { cache: "no-store" });
|
||
if (!response.ok) {
|
||
throw new Error(`help.md HTTP ${response.status}`);
|
||
}
|
||
const markdown = await response.text();
|
||
el.helpContent.innerHTML = marked.parse(markdown, { gfm: true, breaks: false });
|
||
el.helpContent.dataset.helpState = "ready";
|
||
el.helpStatus.textContent = "已就绪";
|
||
el.helpStatus.className = "state-tag tone-source";
|
||
} catch (error) {
|
||
el.helpContent.dataset.helpState = "error";
|
||
el.helpContent.replaceChildren();
|
||
const notice = document.createElement("div");
|
||
notice.className = "help-error";
|
||
notice.textContent = `帮助内容加载失败:${error.message}。默认工作台仍可继续使用。`;
|
||
el.helpContent.append(notice);
|
||
el.helpStatus.textContent = "加载失败";
|
||
el.helpStatus.className = "state-tag tone-blocked";
|
||
}
|
||
}
|
||
|
||
function initSideTabs() {
|
||
for (const tab of document.querySelectorAll("[data-side-tab]")) {
|
||
tab.addEventListener("click", () => selectSideTab(tab.dataset.sideTab));
|
||
}
|
||
for (const jump of document.querySelectorAll("[data-side-tab-jump]")) {
|
||
jump.addEventListener("click", () => selectSideTab(jump.dataset.sideTabJump));
|
||
}
|
||
}
|
||
|
||
function initQuickActions() {
|
||
for (const button of document.querySelectorAll("[data-focus-command]")) {
|
||
button.addEventListener("click", () => {
|
||
el.commandInput.focus();
|
||
});
|
||
}
|
||
}
|
||
|
||
function selectSideTab(tabId) {
|
||
for (const tab of document.querySelectorAll("[data-side-tab]")) {
|
||
const active = tab.dataset.sideTab === tabId;
|
||
tab.classList.toggle("active", active);
|
||
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||
}
|
||
for (const panel of document.querySelectorAll("[data-side-panel]")) {
|
||
const active = panel.dataset.sidePanel === tabId;
|
||
panel.classList.toggle("active", active);
|
||
panel.hidden = !active;
|
||
}
|
||
}
|
||
|
||
function initCommandBar() {
|
||
el.commandForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const value = el.commandInput.value.trim();
|
||
if (!value || state.chatPending) return;
|
||
const userMessage = {
|
||
id: nextProtocolId("msg"),
|
||
role: "user",
|
||
title: `用户 ${shortTime(new Date().toISOString())}`,
|
||
text: value,
|
||
status: "sent",
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
const pendingMessage = {
|
||
id: nextProtocolId("msg"),
|
||
role: "agent",
|
||
title: "Code Agent 处理中",
|
||
text: "正在调用真实 Code Agent / Codex 后端,请稍候。",
|
||
status: "running",
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
state.chatMessages.push(userMessage, pendingMessage);
|
||
state.chatPending = true;
|
||
el.commandInput.value = "";
|
||
renderAgentChatStatus("running");
|
||
renderConversation();
|
||
renderDrafts();
|
||
|
||
try {
|
||
const result = await sendAgentMessage(value, state.conversationId);
|
||
state.conversationId = result.conversationId || result.sessionId || state.conversationId;
|
||
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
|
||
const status = result.status === "completed" ? "completed" : "failed";
|
||
state.chatMessages[index] = {
|
||
...pendingMessage,
|
||
title: result.status === "completed" ? sourceTitle(result) : "Code Agent 返回失败",
|
||
text: result.status === "completed"
|
||
? result.reply?.content || "Code Agent 没有返回文本。"
|
||
: failureMessage(result),
|
||
status,
|
||
traceId: result.traceId,
|
||
messageId: result.messageId,
|
||
provider: result.provider,
|
||
model: result.model,
|
||
backend: result.backend,
|
||
updatedAt: result.updatedAt,
|
||
error: result.error,
|
||
availability: result.availability
|
||
};
|
||
if (result.availability) {
|
||
state.codeAgentAvailability = result.availability;
|
||
}
|
||
if (result.status !== "completed") {
|
||
el.commandInput.value = value;
|
||
}
|
||
renderAgentChatStatus(status, result);
|
||
} catch (error) {
|
||
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
|
||
state.chatMessages[index] = {
|
||
...pendingMessage,
|
||
title: "发送失败",
|
||
text: `发送失败:${error.message}`,
|
||
status: "failed",
|
||
updatedAt: new Date().toISOString(),
|
||
error: {
|
||
message: error.message
|
||
}
|
||
};
|
||
el.commandInput.value = value;
|
||
renderAgentChatStatus("failed");
|
||
} finally {
|
||
state.chatPending = false;
|
||
renderConversation();
|
||
renderDrafts();
|
||
}
|
||
});
|
||
|
||
el.commandClear.addEventListener("click", () => {
|
||
state.chatMessages = [];
|
||
state.conversationId = null;
|
||
state.chatPending = false;
|
||
el.commandInput.value = "";
|
||
renderAgentChatStatus("idle");
|
||
renderConversation();
|
||
renderDrafts();
|
||
});
|
||
}
|
||
|
||
function renderStaticWorkbench() {
|
||
el.explorerStatus.textContent = "只读工作区";
|
||
el.explorerStatus.className = "status-dot tone-source";
|
||
el.routePath.textContent = "当前浏览器会话只保存任务草稿,硬件动作需通过受控后端流程。";
|
||
|
||
renderResourceTree();
|
||
renderCapabilityList();
|
||
renderConversation();
|
||
renderTaskList();
|
||
renderTrace();
|
||
renderUnblockList();
|
||
renderHardwareStatus(null);
|
||
renderDrafts();
|
||
renderWiringList(null);
|
||
renderRecords(null);
|
||
renderDiagnostics(null);
|
||
renderGateDiagnostics();
|
||
}
|
||
|
||
function renderProbePending() {
|
||
el.liveStatus.textContent = "只读模式";
|
||
el.liveStatus.className = "tone-source";
|
||
el.liveDetail.textContent = "可整理任务、查看资源、核对接线和可信记录;不会触发硬件变更。";
|
||
renderMethodList(rpcReadMethods, "pending");
|
||
}
|
||
|
||
async function sendAgentMessage(message, conversationId) {
|
||
const traceId = nextProtocolId("trc");
|
||
const response = await fetchJson("/v1/agent/chat", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-Trace-Id": traceId
|
||
},
|
||
body: JSON.stringify({
|
||
message,
|
||
conversationId,
|
||
traceId,
|
||
projectId: gateSummary.topology.projectId
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(response.error || "Code Agent 请求失败");
|
||
}
|
||
return response.data;
|
||
}
|
||
|
||
async function loadLiveSurface() {
|
||
const projectId = gateSummary.topology.projectId;
|
||
const [healthLive, restIndex, health, adapter, audit, evidence] = await Promise.all([
|
||
fetchJson("/health/live"),
|
||
fetchJson("/v1"),
|
||
callRpc("system.health"),
|
||
callRpc("cloud.adapter.describe"),
|
||
callRpc("audit.event.query", { projectId, limit: 6 }),
|
||
callRpc("evidence.record.query", { projectId, limit: 6 })
|
||
]);
|
||
|
||
return {
|
||
observedAt: new Date().toISOString(),
|
||
healthLive,
|
||
restIndex,
|
||
health,
|
||
adapter,
|
||
audit,
|
||
evidence
|
||
};
|
||
}
|
||
|
||
async function fetchJson(path, options = {}) {
|
||
const controller = new AbortController();
|
||
const timer = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||
try {
|
||
const response = await fetch(path, {
|
||
...options,
|
||
headers: {
|
||
Accept: "application/json",
|
||
...(options.headers ?? {})
|
||
},
|
||
signal: controller.signal
|
||
});
|
||
const text = await response.text();
|
||
const data = text ? JSON.parse(text) : null;
|
||
if (!response.ok) {
|
||
throw new Error(`${path} HTTP ${response.status}:${messageFromPayload(data)}`);
|
||
}
|
||
return { ok: true, path, status: response.status, data };
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
path,
|
||
error: error.name === "AbortError" ? `${path} 请求超时 ${API_TIMEOUT_MS}ms` : `请求失败:${error.message}`
|
||
};
|
||
} finally {
|
||
window.clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function callRpc(method, params = {}) {
|
||
const id = nextProtocolId("req");
|
||
const traceId = nextProtocolId("trc");
|
||
const response = await fetchJson("/json-rpc", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json"
|
||
},
|
||
body: JSON.stringify({
|
||
jsonrpc: "2.0",
|
||
id,
|
||
method,
|
||
params,
|
||
meta: {
|
||
traceId,
|
||
serviceId: "hwlab-cloud-web",
|
||
environment: "dev"
|
||
}
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
return { ...response, method };
|
||
}
|
||
if (response.data?.error) {
|
||
return {
|
||
ok: false,
|
||
path: response.path,
|
||
status: response.status,
|
||
method,
|
||
error: `RPC 错误 ${method}:${response.data.error.message}`,
|
||
envelope: response.data
|
||
};
|
||
}
|
||
return {
|
||
ok: true,
|
||
path: response.path,
|
||
status: response.status,
|
||
method,
|
||
data: response.data?.result,
|
||
envelope: response.data
|
||
};
|
||
}
|
||
|
||
function nextProtocolId(prefix) {
|
||
rpcSequence += 1;
|
||
return `${prefix}_cloud-web-workbench-${Date.now()}-${rpcSequence}`;
|
||
}
|
||
|
||
function renderLiveSurface(live) {
|
||
const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter];
|
||
const reachable = coreProbes.some((probe) => probe.ok);
|
||
const runtimeSummary = runtimeSummaryFrom(live);
|
||
const methods = methodsFrom(live);
|
||
|
||
el.liveStatus.textContent = reachable ? "只读连接可用" : "离线查看";
|
||
el.liveStatus.className = `tone-${toneClass(reachable ? "dev-live" : "source")}`;
|
||
state.codeAgentAvailability = codeAgentAvailabilityFrom(live);
|
||
el.liveDetail.textContent = reachable
|
||
? codeAgentAvailabilitySummary()
|
||
: "同源 API 暂不可用,当前仍可使用本地草稿和已加载资源。";
|
||
|
||
renderAgentChatStatus(deriveAgentChatStatus());
|
||
renderHardwareStatus(runtimeSummary);
|
||
renderWiringList(runtimeSummary);
|
||
renderRecords(live);
|
||
renderDiagnostics(live);
|
||
renderMethodList(methods.length > 0 ? methods.filter((method) => rpcReadMethods.includes(method)) : rpcReadMethods, reachable ? "live" : "degraded");
|
||
renderConversation();
|
||
renderDrafts();
|
||
}
|
||
|
||
function renderResourceTree() {
|
||
const items = [
|
||
{
|
||
label: gateSummary.topology.projectId,
|
||
meta: "当前项目",
|
||
tone: "source",
|
||
children: [
|
||
...gateSummary.topology.gateways.map((gateway) => ({
|
||
label: gateway.gatewaySessionId,
|
||
meta: `${gateway.serviceId} / ${statusLabel(gateway.status)}`,
|
||
tone: gateway.status
|
||
})),
|
||
...gateSummary.topology.boxResources.map((resource) => ({
|
||
label: resource.resourceId,
|
||
meta: `${resourceTypeLabel(resource.resourceType)} / ${statusLabel(resource.state)}`,
|
||
tone: resource.state
|
||
})),
|
||
{
|
||
label: gateSummary.topology.patchPanel.patchPanelStatusId,
|
||
meta: `${gateSummary.topology.patchPanel.serviceId} / ${statusLabel(gateSummary.topology.patchPanel.state)}`,
|
||
tone: gateSummary.topology.patchPanel.state
|
||
}
|
||
]
|
||
},
|
||
{
|
||
label: gateSummary.agent.agentSessionId,
|
||
meta: `${gateSummary.agent.agentServiceId} / Agent 会话`,
|
||
tone: gateSummary.agent.agentStatus,
|
||
children: [
|
||
{
|
||
label: gateSummary.agent.workerSessionId,
|
||
meta: `${gateSummary.agent.workerServiceId} / ${statusLabel(gateSummary.agent.workerStatus)}`,
|
||
tone: gateSummary.agent.workerStatus
|
||
}
|
||
]
|
||
}
|
||
];
|
||
|
||
el.treeCount.textContent = String(items.reduce((count, item) => count + 1 + (item.children?.length ?? 0), 0));
|
||
replaceChildren(el.resourceTree, ...items.map(treeGroup));
|
||
}
|
||
|
||
function treeGroup(item) {
|
||
const details = document.createElement("details");
|
||
details.open = true;
|
||
const summary = document.createElement("summary");
|
||
summary.append(statusLight(item.tone), textSpan(item.label, "tree-label"), textSpan(item.meta, "tree-meta"));
|
||
details.append(summary);
|
||
for (const child of item.children ?? []) {
|
||
const row = document.createElement("button");
|
||
row.type = "button";
|
||
row.className = "tree-row";
|
||
row.append(statusLight(child.tone), textSpan(child.label, "tree-label"), textSpan(child.meta, "tree-meta"));
|
||
details.append(row);
|
||
}
|
||
return details;
|
||
}
|
||
|
||
function renderCapabilityList() {
|
||
const capabilities = [
|
||
{
|
||
title: "任务草稿",
|
||
detail: "在底部输入区记录要交给 Agent 的任务,不会直接发送硬件请求。",
|
||
tone: "source"
|
||
},
|
||
{
|
||
title: "资源查看",
|
||
detail: "查看 Gateway-SIMU、BOX-SIMU 与 hwlab-patch-panel 的只读资源状态。",
|
||
tone: "source"
|
||
},
|
||
{
|
||
title: "接线核对",
|
||
detail: "在右侧接线表核对源端口、接线盘、目标端口与证据记录。",
|
||
tone: "source"
|
||
}
|
||
];
|
||
el.capabilityCount.textContent = String(capabilities.length);
|
||
replaceChildren(el.explorerCapabilities, ...capabilities.map(infoCard));
|
||
replaceChildren(el.gateBlockers, ...gateSummary.blockers.map(blockerCard));
|
||
}
|
||
|
||
function renderConversation() {
|
||
const introMessages = [
|
||
{
|
||
role: "system",
|
||
title: "界面模式",
|
||
text: "这里是用户工作台:可以整理任务、查看资源、核对接线和可信记录。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。",
|
||
status: "source"
|
||
},
|
||
codeAgentStatusMessage(state.codeAgentAvailability)
|
||
];
|
||
replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard));
|
||
}
|
||
|
||
function renderTaskList() {
|
||
const rows = [
|
||
{
|
||
title: "1. 描述目标",
|
||
detail: "例如要检查哪个 BOX-SIMU、哪个端口、期望看到什么信号。",
|
||
tone: "source"
|
||
},
|
||
{
|
||
title: "2. 核对接线",
|
||
detail: "打开右侧“接线”表,确认源设备、接线盘、目标设备和 trace/evidence 是否匹配。",
|
||
tone: "source"
|
||
},
|
||
{
|
||
title: "3. 留存记录",
|
||
detail: "在“可信记录”面板查看已有证据;新增硬件动作不从前端直接发起。",
|
||
tone: "source"
|
||
}
|
||
];
|
||
replaceChildren(el.taskList, ...rows.map(infoCard));
|
||
}
|
||
|
||
function renderTrace() {
|
||
replaceChildren(
|
||
el.traceList,
|
||
...gateSummary.steps.map((step) => {
|
||
const article = document.createElement("article");
|
||
article.className = "trace-item";
|
||
article.append(textSpan(String(step.order).padStart(2, "0"), "trace-order"));
|
||
const body = document.createElement("div");
|
||
body.append(textSpan(stepTitle(step.title), "trace-title"));
|
||
body.append(textSpan(`${stepKindLabel(step.kind)} / ${step.outputs.length} 个输出`, "trace-meta"));
|
||
article.append(body, badge(step.requires.length === 0 ? "入口" : "依赖前序", step.requires.length === 0 ? "source" : "dry-run"));
|
||
return article;
|
||
})
|
||
);
|
||
}
|
||
|
||
function renderUnblockList() {
|
||
const rows = [
|
||
{
|
||
title: "DB live readiness",
|
||
detail: "先让 cloud-api /health/live 返回 db.ready=true、db.connected=true,再进入 Agent runtime 调度。",
|
||
tone: "blocked"
|
||
},
|
||
{
|
||
title: "patch-panel DO1 -> DI1",
|
||
detail: "再由 hwlab-patch-panel 建立 res_boxsimu_1:DO1 -> res_boxsimu_2:DI1 的活动链路,并附带可信记录。",
|
||
tone: "blocked"
|
||
},
|
||
{
|
||
title: "证据升级规则",
|
||
detail: "SOURCE、LOCAL、DRY-RUN 与 fixture 只能作为解释材料,不能升级为 DEV-LIVE 验收证据。",
|
||
tone: "dry-run"
|
||
}
|
||
];
|
||
replaceChildren(el.unblockList, ...rows.map(infoCard));
|
||
}
|
||
|
||
function renderHardwareStatus(summary) {
|
||
const counts = summary?.counts ?? null;
|
||
const patchPanel = gateSummary.topology.patchPanel;
|
||
const sourceLink = trustedM3Link(patchPanel.activeConnections);
|
||
const liveM3Evidence = trustedM3LiveEvidenceFrom(summary);
|
||
const m3Live = hasTrustedM3LiveEvidence(liveM3Evidence);
|
||
const runtimeCountsSourceKind = "SOURCE";
|
||
const runtimeCountsTone = "source";
|
||
const patchTone = m3Live ? "live" : patchPanel.state === "active" ? "source" : patchPanel.state;
|
||
const linkTone = m3Live ? "live" : sourceLink || patchPanel.activeConnectionCount > 0 ? "degraded" : "blocked";
|
||
|
||
el.hardwareSourceStatus.textContent = m3Live ? "DEV-LIVE" : "SOURCE";
|
||
el.hardwareSourceStatus.className = `state-tag tone-${toneClass(m3Live ? "dev-live" : "source")}`;
|
||
|
||
replaceChildren(
|
||
el.hardwareList,
|
||
hardwareGroup({
|
||
title: "Gateway/Box 在线",
|
||
rows: [
|
||
{
|
||
label: "Gateway-SIMU",
|
||
value: counts ? `${counts.gatewaySessions ?? 0} 会话` : "离线查看",
|
||
detail: counts
|
||
? `${counts.gatewaySessions ?? 0} 个 Gateway 会话来自只读 runtime counts;不是 M3 DEV-LIVE 证据。`
|
||
: `${gateSummary.gatewaySessionCount} 个 Gateway 会话来自 SOURCE fixture。`,
|
||
sourceKind: runtimeCountsSourceKind,
|
||
tone: runtimeCountsTone
|
||
},
|
||
{
|
||
label: "BOX-SIMU",
|
||
value: counts ? `${counts.boxResources ?? 0} 资源` : "离线查看",
|
||
detail: counts
|
||
? `${counts.boxResources ?? 0} 个 BOX 资源来自只读 runtime counts;不是 M3 DEV-LIVE 证据。`
|
||
: `${gateSummary.boxResourceCount} 个 BOX 资源来自 SOURCE fixture。`,
|
||
sourceKind: runtimeCountsSourceKind,
|
||
tone: runtimeCountsTone
|
||
}
|
||
]
|
||
}),
|
||
hardwareGroup({
|
||
title: "BOX-SIMU 端口",
|
||
rows: [
|
||
{
|
||
label: "DO1",
|
||
value: m3Live ? "verified" : "not wired",
|
||
detail: m3Live ? m3LiveDetail(liveM3Evidence) : "BLOCKED:没有 DO1 电平的 live M3 operation/trace/audit/evidence 证据。",
|
||
sourceKind: m3Live ? "DEV-LIVE" : "BLOCKED",
|
||
tone: m3Live ? "live" : "blocked"
|
||
},
|
||
{
|
||
label: "DI1",
|
||
value: m3Live ? "verified" : "not wired",
|
||
detail: m3Live ? m3LiveDetail(liveM3Evidence) : "BLOCKED:没有 DI1 回读的 live M3 operation/trace/audit/evidence 证据。",
|
||
sourceKind: m3Live ? "DEV-LIVE" : "BLOCKED",
|
||
tone: m3Live ? "live" : "blocked"
|
||
},
|
||
{
|
||
label: "AI/AO/FREQ",
|
||
value: "future capability",
|
||
detail: "当前未声明模拟量/频率闭环,按 future capability 处理。",
|
||
sourceKind: "SOURCE",
|
||
tone: "disabled"
|
||
}
|
||
]
|
||
}),
|
||
hardwareGroup({
|
||
title: "Patch Panel 连线",
|
||
rows: [
|
||
{
|
||
label: "hwlab-patch-panel",
|
||
value: statusLabel(patchPanel.state),
|
||
detail: m3Live
|
||
? `DEV-LIVE patch-panel live report ${liveM3Evidence.patchPanelLiveReport.reportId ?? liveM3Evidence.patchPanelLiveReport.patchPanelStatusId} 已绑定 M3 route。`
|
||
: `${patchPanel.activeConnectionCount} 条 SOURCE fixture 接线;工作台只展示 required M3 route,未升级为 DEV-LIVE。`,
|
||
sourceKind: m3Live ? "DEV-LIVE" : "SOURCE",
|
||
tone: patchTone
|
||
},
|
||
{
|
||
label: "DO1 -> patch-panel -> DI1",
|
||
value: linkStatusLabel(linkTone),
|
||
detail: m3LinkDetail({ liveM3Evidence, sourceLink, patchPanel, counts }),
|
||
sourceKind: m3Live ? "DEV-LIVE" : "BLOCKED",
|
||
tone: linkTone
|
||
}
|
||
]
|
||
}),
|
||
hardwareGroup({
|
||
title: "写入边界",
|
||
rows: [
|
||
{
|
||
label: "硬件控制",
|
||
value: "已禁用",
|
||
detail: "Web 不提供硬件写 RPC;不会新增伪硬件写操作。",
|
||
sourceKind: "SOURCE",
|
||
tone: "source"
|
||
}
|
||
]
|
||
})
|
||
);
|
||
}
|
||
|
||
function hardwareGroup(group) {
|
||
const article = document.createElement("article");
|
||
article.className = "hardware-section";
|
||
article.append(textSpan(group.title, "hardware-section-title"));
|
||
const rows = document.createElement("div");
|
||
rows.className = "hardware-rows";
|
||
rows.append(...group.rows.map(hardwareRow));
|
||
article.append(rows);
|
||
return article;
|
||
}
|
||
|
||
function hardwareRow(item) {
|
||
const row = document.createElement("div");
|
||
row.className = `hardware-row tone-border-${toneClass(item.tone)}`;
|
||
row.dataset.sourceKind = item.sourceKind;
|
||
const head = document.createElement("div");
|
||
head.className = "hardware-row-head";
|
||
head.append(textSpan(item.label, "hardware-row-label"), badge(item.sourceKind, item.sourceKind));
|
||
row.append(head);
|
||
row.append(textSpan(item.value, `hardware-row-value tone-${toneClass(item.tone)}`));
|
||
row.append(textSpan(item.detail, "hardware-row-detail"));
|
||
return row;
|
||
}
|
||
|
||
function trustedM3Link(connections) {
|
||
return (connections ?? []).find(
|
||
(link) =>
|
||
String(link.fromResourceId ?? "") === M3_TRUSTED_ROUTE.fromResourceId &&
|
||
normalizePort(link.fromPort) === "DO1" &&
|
||
String(link.toResourceId ?? "") === M3_TRUSTED_ROUTE.toResourceId &&
|
||
normalizePort(link.toPort) === "DI1" &&
|
||
connectionPatchPanelServiceId(link) === M3_TRUSTED_ROUTE.patchPanelServiceId
|
||
);
|
||
}
|
||
|
||
function m3LinkDetail({ liveM3Evidence, sourceLink, patchPanel, counts }) {
|
||
if (hasTrustedM3LiveEvidence(liveM3Evidence)) {
|
||
return `DEV-LIVE M3:${M3_TRUSTED_ROUTE.fromResourceId}:${M3_TRUSTED_ROUTE.fromPort} -> ${M3_TRUSTED_ROUTE.patchPanelServiceId} -> ${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort};${m3LiveDetail(liveM3Evidence)}。`;
|
||
}
|
||
if (sourceLink) {
|
||
return `BLOCKED:SOURCE fixture 包含目标接线 ${sourceLink.fromResourceId}:${sourceLink.fromPort} -> ${patchPanel.serviceId} -> ${sourceLink.toResourceId}:${sourceLink.toPort},但缺少 patch-panel live report 与 operation/trace/audit/evidence IDs。`;
|
||
}
|
||
if (counts) {
|
||
return "BLOCKED:runtime counts 已返回,但 counts 不是 patch-panel live report,不能证明 M3 DEV-LIVE。";
|
||
}
|
||
if (patchPanel.activeConnectionCount > 0) {
|
||
return `SOURCE 仅看到非 M3 patch-panel fixture 接线;required M3 route ${M3_TRUSTED_ROUTE.fromResourceId}:${M3_TRUSTED_ROUTE.fromPort} -> ${M3_TRUSTED_ROUTE.patchPanelServiceId} -> ${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort} 仍 BLOCKED,未冒充 live。`;
|
||
}
|
||
return "BLOCKED:当前没有 DO1 -> patch-panel -> DI1 的可信链路证据。";
|
||
}
|
||
|
||
function linkStatusLabel(status) {
|
||
return (
|
||
{
|
||
live: "live",
|
||
degraded: "degraded",
|
||
blocked: "blocked"
|
||
}[status] ?? statusLabel(status)
|
||
);
|
||
}
|
||
|
||
function normalizePort(port) {
|
||
return String(port ?? "").trim().toUpperCase();
|
||
}
|
||
|
||
function trustedM3LiveEvidenceFrom(summary) {
|
||
const candidates = [
|
||
summary?.m3TrustedLoop,
|
||
summary?.m3HardwareLoop,
|
||
summary?.hardware?.m3TrustedLoop,
|
||
summary?.patchPanel?.m3TrustedLoop,
|
||
summary?.liveM3Evidence
|
||
].filter(Boolean);
|
||
|
||
return candidates.find(hasTrustedM3LiveEvidence) ?? null;
|
||
}
|
||
|
||
function hasTrustedM3LiveEvidence(candidate) {
|
||
if (!candidate || typeof candidate !== "object") return false;
|
||
if (candidate.dryRun === true || candidate.fixture === true || candidate.sourceKind === "SOURCE" || candidate.sourceKind === "DRY-RUN") return false;
|
||
if (!hasLiveSourceKind(candidate)) return false;
|
||
if (!LIVE_M3_PASS_STATUSES.includes(String(candidate.status ?? candidate.outcome ?? "verified").toLowerCase())) return false;
|
||
if (!LIVE_M3_ID_FIELDS.every((field) => isLiveIdentifier(candidate[field] ?? candidate.operation?.[field] ?? candidate.identifiers?.[field]))) return false;
|
||
const patchPanelLiveReport = candidate.patchPanelLiveReport ?? candidate.patchPanelReport;
|
||
return hasTrustedPatchPanelLiveReport(patchPanelLiveReport);
|
||
}
|
||
|
||
function hasTrustedPatchPanelLiveReport(report) {
|
||
if (!report || typeof report !== "object") return false;
|
||
if (report.dryRun === true || report.fixture === true || report.sourceKind === "SOURCE" || report.sourceKind === "DRY-RUN") return false;
|
||
if (!hasLiveSourceKind(report)) return false;
|
||
if (report.serviceId !== M3_TRUSTED_ROUTE.patchPanelServiceId) return false;
|
||
if (!isLiveIdentifier(report.reportId ?? report.patchPanelStatusId ?? report.statusId ?? report.evidenceId)) return false;
|
||
return (report.activeConnections ?? report.connections ?? []).some((link) => trustedM3Link([link]));
|
||
}
|
||
|
||
function hasLiveSourceKind(value) {
|
||
return value.live === true || value.sourceKind === "DEV-LIVE" || value.evidenceLevel === "DEV-LIVE";
|
||
}
|
||
|
||
function isLiveIdentifier(value) {
|
||
const text = String(value ?? "").trim();
|
||
return text !== "" && !NON_LIVE_ID_VALUES.includes(text.toLowerCase());
|
||
}
|
||
|
||
function connectionPatchPanelServiceId(link) {
|
||
return String(link.patchPanelServiceId ?? link.patchPanel?.serviceId ?? link.serviceId ?? M3_TRUSTED_ROUTE.patchPanelServiceId);
|
||
}
|
||
|
||
function m3LiveDetail(evidence) {
|
||
return `operation=${evidence.operationId} / trace=${evidence.traceId} / audit=${evidence.auditId} / evidence=${evidence.evidenceId}`;
|
||
}
|
||
|
||
function controlRows() {
|
||
return [
|
||
{
|
||
title: "硬件操作",
|
||
detail: "此 Web 工作台不开放硬件写入;需要硬件动作时交由受控后端流程处理。",
|
||
tone: "source"
|
||
},
|
||
{
|
||
title: "Code Agent 对话",
|
||
detail: codeAgentControlSummary(state.codeAgentAvailability),
|
||
tone: currentConversationTone()
|
||
},
|
||
{
|
||
title: "只读探测",
|
||
detail: "技术复核探测仍保持 same-origin read-only,并放在二级页面。",
|
||
tone: "source"
|
||
}
|
||
];
|
||
}
|
||
|
||
function renderDrafts() {
|
||
const rows = [
|
||
...state.chatMessages.slice(-3).reverse().map((message) => ({
|
||
title: `${roleLabel(message.role)} ${statusLabel(message.status)}`,
|
||
detail: message.text,
|
||
tone: message.status || "source"
|
||
})),
|
||
...(state.chatMessages.length === 0
|
||
? [
|
||
{
|
||
title: "对话状态",
|
||
detail: codeAgentPromptText(state.codeAgentAvailability),
|
||
tone: state.codeAgentAvailability?.status === "blocked" ? "blocked" : "source"
|
||
}
|
||
]
|
||
: []),
|
||
...controlRows()
|
||
];
|
||
replaceChildren(el.controlList, ...rows.map(infoCard));
|
||
}
|
||
|
||
function renderWiringList(summary = null) {
|
||
const patchPanel = gateSummary.topology.patchPanel;
|
||
const liveM3Evidence = trustedM3LiveEvidenceFrom(summary);
|
||
const m3Live = hasTrustedM3LiveEvidence(liveM3Evidence);
|
||
const traceEvidence = m3Live
|
||
? [liveM3Evidence.operationId, liveM3Evidence.traceId, liveM3Evidence.auditId, liveM3Evidence.evidenceId].join(" / ")
|
||
: "m3-route-required / waiting-for-dev-live";
|
||
const tr = document.createElement("tr");
|
||
tr.append(cell(M3_TRUSTED_ROUTE.fromResourceId, "mono wrap"));
|
||
tr.append(cell(M3_TRUSTED_ROUTE.fromPort, "mono"));
|
||
tr.append(cell(M3_TRUSTED_ROUTE.patchPanelServiceId, "mono wrap"));
|
||
tr.append(cell(M3_TRUSTED_ROUTE.toResourceId, "mono wrap"));
|
||
tr.append(cell(M3_TRUSTED_ROUTE.toPort, "mono"));
|
||
const status = document.createElement("td");
|
||
status.append(badge(m3Live ? "开发实况 DEV-LIVE" : "阻塞 BLOCKED", m3Live ? "dev-live" : "blocked"));
|
||
tr.append(status);
|
||
tr.append(cell(m3Live ? "DEV-LIVE" : "SOURCE / required M3 route", "wrap"));
|
||
tr.append(cell(traceEvidence, "mono wrap"));
|
||
|
||
replaceChildren(el.wiringBody, tr);
|
||
}
|
||
|
||
function renderRecords(live) {
|
||
const sourceCards = gateSummary.evidenceRecords.map((record) =>
|
||
infoCard({
|
||
title: record.evidenceId,
|
||
detail: `${recordKindLabel(record.kind)} / ${record.operationId} / ${record.dryRun ? "演练 DRY-RUN" : "未知来源"}`,
|
||
tone: record.dryRun ? "dry-run" : "source"
|
||
})
|
||
);
|
||
|
||
const liveRecords = live?.evidence.ok ? live.evidence.data?.records ?? [] : [];
|
||
const liveCards = liveRecords.map((record) => {
|
||
const m3Trusted = hasTrustedM3LiveEvidence(record);
|
||
return infoCard({
|
||
title: record.evidenceId,
|
||
detail: [
|
||
m3Trusted ? "开发实况 DEV-LIVE M3" : "阻塞 BLOCKED:只读 evidence 查询不是 M3 trusted loop",
|
||
recordKindLabel(record.kind ?? "record"),
|
||
record.operationId ?? "n/a",
|
||
record.serviceId ?? "未知服务"
|
||
].join(" / "),
|
||
tone: m3Trusted ? "dev-live" : "blocked"
|
||
});
|
||
});
|
||
if (live && !live.evidence.ok) {
|
||
liveCards.push(infoCard({ title: "evidence.record.query", detail: live.evidence.error, tone: "blocked" }));
|
||
}
|
||
|
||
replaceChildren(el.recordsList, ...liveCards, ...sourceCards);
|
||
}
|
||
|
||
function renderDiagnostics(live) {
|
||
const diagnostics = [
|
||
{
|
||
title: "前端",
|
||
detail: runtime.endpoints.frontend,
|
||
tone: "source"
|
||
},
|
||
{
|
||
title: "API 边界",
|
||
detail: runtime.endpoints.api,
|
||
tone: "source"
|
||
},
|
||
{
|
||
title: "运行路由",
|
||
detail: runtime.serviceRoute.join(" -> "),
|
||
tone: "source"
|
||
}
|
||
];
|
||
|
||
if (live) {
|
||
diagnostics.push(
|
||
{
|
||
title: "/health/live",
|
||
detail: live.healthLive.ok ? `HTTP ${live.healthLive.status} ${live.healthLive.data?.status ?? "ok"}` : live.healthLive.error,
|
||
tone: live.healthLive.ok ? live.healthLive.data?.status ?? "dev-live" : "blocked"
|
||
},
|
||
{
|
||
title: "/v1",
|
||
detail: live.restIndex.ok ? `HTTP ${live.restIndex.status} ${live.restIndex.data?.status ?? "ok"}` : live.restIndex.error,
|
||
tone: live.restIndex.ok ? live.restIndex.data?.status ?? "dev-live" : "blocked"
|
||
},
|
||
{
|
||
title: "system.health",
|
||
detail: live.health.ok ? `RPC ${live.health.data?.status ?? "ok"}` : live.health.error,
|
||
tone: live.health.ok ? live.health.data?.status ?? "dev-live" : "blocked"
|
||
},
|
||
{
|
||
title: "audit.event.query",
|
||
detail: live.audit.ok ? `${live.audit.data?.count ?? live.audit.data?.events?.length ?? 0} 条记录` : live.audit.error,
|
||
tone: live.audit.ok ? "dev-live" : "blocked"
|
||
}
|
||
);
|
||
} else {
|
||
diagnostics.push(
|
||
{
|
||
title: "/health/live",
|
||
detail: "正在探测同源 Web/API 健康状态",
|
||
tone: "dev-live"
|
||
},
|
||
{
|
||
title: "/v1 + /json-rpc",
|
||
detail: "只读诊断等待中",
|
||
tone: "source"
|
||
}
|
||
);
|
||
}
|
||
|
||
replaceChildren(el.diagnosticsList, ...diagnostics.map(infoCard));
|
||
}
|
||
|
||
function renderGateDiagnostics() {
|
||
appendMetricGrid(el.gateMetadata, [
|
||
["环境", gateSummary.environment, "source"],
|
||
["前端", runtime.endpoints.frontend, "source"],
|
||
["API", runtime.endpoints.api, "source"],
|
||
["Gate", statusLabel(gateSummary.gateStatus), gateSummary.gateStatus],
|
||
["演练 DRY-RUN", statusLabel(gateSummary.dryRun.status), "dry-run"],
|
||
["报告", shortHash(gateSummary.reportCommitId), "source"]
|
||
]);
|
||
|
||
replaceChildren(
|
||
el.milestoneGrid,
|
||
...gateSummary.milestones.map((milestone) => {
|
||
const item = document.createElement("article");
|
||
item.className = "milestone-card";
|
||
const header = document.createElement("div");
|
||
header.className = "panel-title-row";
|
||
header.append(textSpan(milestone.id, "milestone-id"), badge(statusLabel(milestone.status), milestone.status));
|
||
item.append(header);
|
||
item.append(textSpan(userFacingSummary(milestone.summary), "milestone-summary"));
|
||
item.append(textSpan(`${milestone.commandCount} 条命令 / ${milestone.evidenceCount} 条证据`, "milestone-meta"));
|
||
return item;
|
||
})
|
||
);
|
||
|
||
el.healthBody.replaceChildren();
|
||
for (const row of gateSummary.health) {
|
||
const tr = document.createElement("tr");
|
||
tr.append(cell(componentLabel(row.component)));
|
||
tr.append(cell(row.serviceId, "mono"));
|
||
const status = document.createElement("td");
|
||
status.append(badge(statusLabel(row.status), row.status));
|
||
tr.append(status);
|
||
tr.append(cell(observedByLabel(row.observedBy)));
|
||
tr.append(cell(row.endpoint ?? gateSummary.endpoint, "mono wrap"));
|
||
el.healthBody.append(tr);
|
||
}
|
||
|
||
replaceChildren(
|
||
el.validationList,
|
||
...[
|
||
"node --check web/hwlab-cloud-web/app.mjs",
|
||
"node --check web/hwlab-cloud-web/scripts/check.mjs",
|
||
"node web/hwlab-cloud-web/scripts/check.mjs",
|
||
"node scripts/l6-cli-web-smoke.mjs",
|
||
"git diff --check"
|
||
].map((command) => {
|
||
const li = document.createElement("li");
|
||
li.textContent = command;
|
||
return li;
|
||
})
|
||
);
|
||
}
|
||
|
||
function renderMethodList(methods, tone) {
|
||
replaceChildren(el.methodList, ...methods.map((method) => badge(method, tone === "live" ? "dev-live" : tone)));
|
||
}
|
||
|
||
function renderAgentChatStatus(status, result = null) {
|
||
const labels = {
|
||
idle: "等待输入",
|
||
running: "处理中",
|
||
completed: "已回复",
|
||
failed: "发送失败",
|
||
blocked: "BLOCKED 凭证缺口"
|
||
};
|
||
el.agentChatStatus.textContent = labels[status] ?? statusLabel(status);
|
||
el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : status === "failed" || status === "blocked" ? "blocked" : "source")}`;
|
||
el.commandInput.disabled = status === "running";
|
||
el.commandSend.disabled = status === "running";
|
||
el.commandSend.textContent = status === "running" ? "发送中" : "发送";
|
||
if (result?.conversationId) {
|
||
el.agentChatStatus.title = `${result.provider ?? "provider"} / ${result.model ?? "model"} / ${result.backend ?? "backend"}`;
|
||
} else {
|
||
el.agentChatStatus.removeAttribute("title");
|
||
}
|
||
}
|
||
|
||
function sourceTitle(result) {
|
||
return `Code Agent 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`;
|
||
}
|
||
|
||
function failureMessage(result) {
|
||
if (result?.error?.code === "provider_unavailable" || result?.availability?.status === "blocked") {
|
||
const availability = result.availability ?? codeAgentAvailabilityFromResult(result);
|
||
const missing = Array.isArray(result.error?.missingEnv) && result.error.missingEnv.length > 0
|
||
? ` 当前缺口:${result.error.missingEnv.join("、")}。`
|
||
: "";
|
||
return `${availability.summary}${missing}`;
|
||
}
|
||
const missing = [
|
||
...(result.error?.missingCommands ?? []),
|
||
...(result.error?.missingEnv ?? [])
|
||
].filter(Boolean);
|
||
const suffix = missing.length ? ` 缺口:${missing.join("、")}。` : "";
|
||
return `Code Agent 调用失败:${result.error?.message ?? "未知错误"}。${suffix}请稍后重试或联系维护者补齐后端 provider。`;
|
||
}
|
||
|
||
function blockerCard(blocker) {
|
||
return infoCard({
|
||
title: blockerTitle(blocker),
|
||
detail: userFacingSummary(blocker.summary),
|
||
tone: blocker.status === "open" ? "blocked" : blocker.status
|
||
});
|
||
}
|
||
|
||
function messageCard(message) {
|
||
const article = document.createElement("article");
|
||
article.className = `message-card message-${toneClass(message.role)} status-${toneClass(message.status)}`;
|
||
article.append(textSpan(roleLabel(message.role), "message-role"));
|
||
article.append(textSpan(message.title, "message-title"));
|
||
article.append(textSpan(message.text, "message-copy"));
|
||
const meta = [message.provider, message.model, message.backend, message.traceId].filter(Boolean).join(" / ");
|
||
const messageMeta = [message.messageId, meta].filter(Boolean).join(" / ");
|
||
if (messageMeta) {
|
||
article.append(textSpan(messageMeta, "message-meta"));
|
||
}
|
||
return article;
|
||
}
|
||
|
||
function statusCard(item) {
|
||
const article = document.createElement("article");
|
||
article.className = "status-card";
|
||
article.append(textSpan(item.title, "metric-label"));
|
||
article.append(textSpan(String(item.value), `status-value tone-${toneClass(item.tone)}`));
|
||
article.append(textSpan(item.meta, "status-meta"));
|
||
return article;
|
||
}
|
||
|
||
function infoCard(item) {
|
||
const article = document.createElement("article");
|
||
article.className = "info-card";
|
||
article.append(badge(statusLabel(item.tone), item.tone));
|
||
const body = document.createElement("div");
|
||
body.append(textSpan(item.title, "info-title"));
|
||
body.append(textSpan(item.detail, "info-detail"));
|
||
article.append(body);
|
||
return article;
|
||
}
|
||
|
||
function statusLight(tone) {
|
||
const span = document.createElement("span");
|
||
span.className = `tree-light tone-bg-${toneClass(tone)}`;
|
||
return span;
|
||
}
|
||
|
||
function badge(text, tone = text) {
|
||
const span = document.createElement("span");
|
||
span.className = `badge tone-${toneClass(tone)}`;
|
||
span.textContent = text ?? "无";
|
||
return span;
|
||
}
|
||
|
||
function appendMetricGrid(parent, entries) {
|
||
replaceChildren(
|
||
parent,
|
||
...entries.map(([label, value, tone]) => {
|
||
const item = document.createElement("div");
|
||
item.className = "metric";
|
||
item.append(textSpan(label, "metric-label"), textSpan(String(value ?? "无"), `metric-value tone-${toneClass(tone ?? "source")}`));
|
||
return item;
|
||
})
|
||
);
|
||
}
|
||
|
||
function textSpan(text, className) {
|
||
const span = document.createElement("span");
|
||
span.textContent = text ?? "无";
|
||
if (className) span.className = className;
|
||
return span;
|
||
}
|
||
|
||
function cell(text, className) {
|
||
const td = document.createElement("td");
|
||
td.textContent = text ?? "无";
|
||
if (className) td.className = className;
|
||
return td;
|
||
}
|
||
|
||
function runtimeSummaryFrom(live) {
|
||
return live.health.data?.runtime ?? live.healthLive.data?.runtime ?? live.adapter.data?.runtime ?? live.restIndex.data?.runtime ?? null;
|
||
}
|
||
|
||
function methodsFrom(live) {
|
||
const restMethods = live.restIndex.data?.methods;
|
||
const adapterMethods = live.adapter.data?.methods;
|
||
if (Array.isArray(restMethods)) return restMethods;
|
||
if (Array.isArray(adapterMethods)) return adapterMethods;
|
||
return [];
|
||
}
|
||
|
||
function messageFromPayload(payload) {
|
||
if (!payload || typeof payload !== "object") return "非 JSON 或空响应";
|
||
return payload.error?.message || payload.error?.code || payload.message || JSON.stringify(payload).slice(0, 160);
|
||
}
|
||
|
||
function codeAgentAvailabilityFrom(live) {
|
||
return live?.restIndex?.data?.codeAgent ?? live?.healthLive?.data?.codeAgent ?? null;
|
||
}
|
||
|
||
function codeAgentAvailabilityFromResult(result) {
|
||
return {
|
||
status: "blocked",
|
||
blocker: "凭证缺口",
|
||
reason: result?.error?.code ?? "provider_unavailable",
|
||
summary: "真实后端已接入,但当前 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入,因此不能返回真实回复。",
|
||
missingEnv: result?.error?.missingEnv ?? [],
|
||
secretRefs: [
|
||
{
|
||
env: "OPENAI_API_KEY",
|
||
secretName: "hwlab-code-agent-provider",
|
||
secretKey: "openai-api-key",
|
||
redacted: true
|
||
}
|
||
]
|
||
};
|
||
}
|
||
|
||
function codeAgentStatusMessage(availability) {
|
||
if (availability?.status === "blocked") {
|
||
return {
|
||
role: "system",
|
||
title: "Code Agent 状态:BLOCKED 凭证缺口",
|
||
text: codeAgentBlockedSummary(availability),
|
||
status: "blocked"
|
||
};
|
||
}
|
||
if (availability?.status === "available") {
|
||
return {
|
||
role: "system",
|
||
title: "Code Agent 状态:真实通道已接入",
|
||
text: "真实 /v1/agent/chat 后端已接入;发送后只有实际 completed 回复才会标记为 DEV-LIVE。",
|
||
status: "source"
|
||
};
|
||
}
|
||
return {
|
||
role: "system",
|
||
title: "Code Agent 状态:等待只读探测",
|
||
text: "正在读取 same-origin /v1 与 /health/live;首屏不会宣称 Code Agent 可用,也不会把失败或静态内容标成 DEV-LIVE。",
|
||
status: "source"
|
||
};
|
||
}
|
||
|
||
function codeAgentPromptText(availability) {
|
||
if (availability?.status === "blocked") {
|
||
return "可以继续输入并保留草稿;发送会走真实 /v1/agent/chat,但当前 DEV provider 凭证缺口会返回结构化失败,不会冒充真实 Codex 回复。";
|
||
}
|
||
return "请在底部输入中文消息并点击发送;完成态只来自真实 /v1/agent/chat completed 回复。";
|
||
}
|
||
|
||
function codeAgentControlSummary(availability) {
|
||
if (latestCompletedAgentMessage()) {
|
||
return state.conversationId
|
||
? `当前会话 ${state.conversationId};最近一次真实 completed 回复来自 cloud-api 受控 Code Agent 接口。`
|
||
: "最近一次真实 completed 回复来自 cloud-api 受控 Code Agent 接口。";
|
||
}
|
||
if (availability?.status === "blocked") {
|
||
return codeAgentBlockedSummary(availability);
|
||
}
|
||
return "输入区会调用 cloud-api 受控 Code Agent 接口;只有真实 completed 回复才标 DEV-LIVE,不能因为只有 conversationId 标为开发实况。";
|
||
}
|
||
|
||
function codeAgentBlockedSummary(availability) {
|
||
return availability?.summary ?? "真实后端已接入,但当前 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入,因此不能返回真实回复。";
|
||
}
|
||
|
||
function codeAgentAvailabilitySummary() {
|
||
if (state.codeAgentAvailability?.status === "blocked") {
|
||
return "同源 API 可响应;Code Agent 为 BLOCKED/凭证缺口,真实后端已接入但 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入。";
|
||
}
|
||
if (state.codeAgentAvailability?.status === "available") {
|
||
return "同源 API 可响应;Code Agent 真实通道已接入,只有 completed 回复才标 DEV-LIVE。";
|
||
}
|
||
return "同源只读接口可响应。技术复核详情已放在二级页面。";
|
||
}
|
||
|
||
function latestCompletedAgentMessage() {
|
||
return [...state.chatMessages].reverse().find((message) =>
|
||
message.role === "agent" &&
|
||
message.status === "completed" &&
|
||
!message.error &&
|
||
Boolean(message.provider) &&
|
||
Boolean(message.model) &&
|
||
Boolean(message.backend)
|
||
) ?? null;
|
||
}
|
||
|
||
function latestChatResult() {
|
||
return [...state.chatMessages].reverse().find((message) => message.role === "agent") ?? null;
|
||
}
|
||
|
||
function deriveAgentChatStatus() {
|
||
if (state.chatPending) return "running";
|
||
const latest = latestChatResult();
|
||
if (latest?.status === "completed") return "completed";
|
||
if (latest?.status === "failed") return latest.error?.code === "provider_unavailable" ? "blocked" : "failed";
|
||
if (state.codeAgentAvailability?.status === "blocked") return "blocked";
|
||
return "idle";
|
||
}
|
||
|
||
function currentConversationTone() {
|
||
if (latestCompletedAgentMessage()) return "dev-live";
|
||
if (state.codeAgentAvailability?.status === "blocked" || latestChatResult()?.status === "failed") return "blocked";
|
||
return "source";
|
||
}
|
||
|
||
function statusLabel(value) {
|
||
const key = String(value ?? "unknown").trim();
|
||
const normalized = key.toLowerCase();
|
||
return STATUS_LABELS[normalized] ?? key;
|
||
}
|
||
|
||
function roleLabel(role) {
|
||
return (
|
||
{
|
||
agent: "Agent",
|
||
blocker: "阻塞",
|
||
system: "系统",
|
||
user: "用户"
|
||
}[String(role ?? "").toLowerCase()] ?? statusLabel(role)
|
||
);
|
||
}
|
||
|
||
function resourceTypeLabel(type) {
|
||
return (
|
||
{
|
||
board: "板卡",
|
||
relay: "继电器"
|
||
}[String(type ?? "").toLowerCase()] ?? statusLabel(type)
|
||
);
|
||
}
|
||
|
||
function stepKindLabel(kind) {
|
||
return (
|
||
{
|
||
agent: "Agent 会话",
|
||
audit: "审计",
|
||
cleanup: "清理",
|
||
evidence: "证据",
|
||
hardware_operation: "硬件操作",
|
||
health: "健康检查",
|
||
status: "状态检查",
|
||
wiring: "接线"
|
||
}[String(kind ?? "").toLowerCase()] ?? statusLabel(kind)
|
||
);
|
||
}
|
||
|
||
function stepTitle(title) {
|
||
return (
|
||
{
|
||
audit: "审计",
|
||
cleanup: "清理",
|
||
"agent hardware call": "Agent 硬件调用",
|
||
"agent session": "Agent 会话",
|
||
"DEV health": "DEV 健康",
|
||
"direct hardware call": "直接硬件调用",
|
||
evidence: "证据",
|
||
"gateway/box status": "Gateway/Box 状态",
|
||
wiring: "接线"
|
||
}[String(title ?? "")] ?? statusLabel(title)
|
||
);
|
||
}
|
||
|
||
function recordKindLabel(kind) {
|
||
return (
|
||
{
|
||
audit: "审计",
|
||
evidence: "证据",
|
||
operation: "操作",
|
||
record: "记录",
|
||
report: "报告",
|
||
trace: "轨迹"
|
||
}[String(kind ?? "").toLowerCase()] ?? statusLabel(kind)
|
||
);
|
||
}
|
||
|
||
function componentLabel(component) {
|
||
return (
|
||
{
|
||
"Agent manager": "Agent 管理器",
|
||
"Agent skills": "Agent 技能",
|
||
"Agent worker": "Agent Worker",
|
||
"Box simulator": "Box 模拟器",
|
||
CLI: "CLI",
|
||
"Cloud API": "Cloud API",
|
||
"Cloud Web": "Cloud Web",
|
||
"D601 router": "D601 路由器",
|
||
"DEV ingress": "DEV 入口",
|
||
Gateway: "Gateway",
|
||
"Gateway simulator": "Gateway 模拟器",
|
||
"Patch panel": "Patch Panel",
|
||
frp: "frp",
|
||
"master edge proxy": "master 边界代理"
|
||
}[String(component ?? "")] ?? statusLabel(component)
|
||
);
|
||
}
|
||
|
||
function observedByLabel(value) {
|
||
return (
|
||
{
|
||
fixture: "fixture 来源 SOURCE"
|
||
}[String(value ?? "").toLowerCase()] ?? statusLabel(value)
|
||
);
|
||
}
|
||
|
||
function blockerTitle(blocker) {
|
||
const scope = blocker?.scope ?? "unknown";
|
||
return `阻塞:${scope}`;
|
||
}
|
||
|
||
function internalGatePathnames() {
|
||
return new Set(["/gate", "/diagnostics/gate"]);
|
||
}
|
||
|
||
function userFacingSummary(text) {
|
||
return String(text ?? "")
|
||
.replaceAll("Public DEV route/frp", "公共 DEV 路由/frp")
|
||
.replaceAll("live MVP remains blocked", "实况 MVP 仍被阻塞")
|
||
.replaceAll("DB live readiness", "DB 实况就绪")
|
||
.replaceAll("M3 hardware loop topology", "M3 硬件闭环拓扑")
|
||
.replaceAll("no historical 6667 failure is counted as active evidence", "历史 6667 失败不计为当前有效证据")
|
||
.replaceAll("cloud-api /health/live reports DB degraded", "cloud-api /health/live 报告 DB 降级")
|
||
.replaceAll("live DB evidence is not established", "尚未建立实况 DB 证据")
|
||
.replaceAll("Live M3 smoke reached DEV simulators", "实况 M3 smoke 已触达 DEV 模拟器")
|
||
.replaceAll("patch-panel active DO1 -> DI1 connection is missing", "缺少 patch-panel 上的活动 DO1 -> DI1 连接")
|
||
.replaceAll("Frozen contract, artifact, boundary, drift, and evidence fixtures are parseable and aligned to the DEV boundary.", "冻结的契约、产物、边界、漂移与证据 fixture 可解析,并已对齐 DEV 边界。")
|
||
.replaceAll("The local M1 contract smoke passes.", "本地 M1 契约 smoke 已通过。")
|
||
.replaceAll("The deploy smoke remains fixture-only and validates the frozen route phases and artifact metadata.", "部署 smoke 仍仅使用 fixture,用于验证冻结路由阶段与产物元数据。")
|
||
.replaceAll("Local M3 remains green, but active DEV-LIVE M3 is blocked by hardware loop topology.", "本地 M3 仍为通过,但活动 DEV-LIVE M3 受硬件闭环拓扑阻塞。")
|
||
.replaceAll("Local M4 remains green, but active DEV-LIVE M4 is blocked at DB live readiness before agent runtime scheduling.", "本地 M4 仍为通过,但活动 DEV-LIVE M4 在 Agent 运行时调度前受 DB 实况就绪阻塞。")
|
||
.replaceAll("The M5 orchestration is green in dry-run mode, but active DEV-LIVE acceptance is blocked by DB live and hardware loop evidence.", "M5 编排在 DRY-RUN 模式下通过,但活动 DEV-LIVE 验收受 DB 实况与硬件闭环证据阻塞。");
|
||
}
|
||
|
||
function valueOrUnavailable(liveValue, sourceValue) {
|
||
if (liveValue !== undefined && liveValue !== null) return liveValue;
|
||
if (sourceValue !== undefined && sourceValue !== null) return `${sourceValue} 来源 SOURCE`;
|
||
return "不可用";
|
||
}
|
||
|
||
function shortTime(value) {
|
||
return new Date(value).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "Z");
|
||
}
|
||
|
||
function shortHash(value) {
|
||
return String(value ?? "unknown").slice(0, 7);
|
||
}
|
||
|
||
function toneClass(tone) {
|
||
return String(tone ?? "unknown").replace(/[^a-z0-9_-]/gi, "-").toLowerCase();
|
||
}
|
||
|
||
function replaceChildren(parent, ...children) {
|
||
parent.replaceChildren(...children.filter((child) => child && typeof child !== "string"));
|
||
}
|