From e2248fa04f198c97c14a3cf2482f94b3a5b95160 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 19 May 2026 09:19:20 +0800 Subject: [PATCH] Add P9 segment monitor page for operation-system Replaces the ops UI placeholder with a single-panel monitor: fetches /api/runtime/overview to render one card per segment with state badge, current step, fault / block note, and per-card Start / Stop / Ack-Fault / Reset buttons plus batch start/stop. WebSocket subscriber routes app_event(app=operation-system, event_type=segment_runtime_changed) into in-place card updates with exponential reconnect. Note: UI not verified in-browser; the engine + WebSocket plumbing has unit + smoke test coverage but the page itself needs runtime validation by running app_operation_system and visiting /ui/. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/ops/html/segment-panel.html | 11 ++ web/ops/index.html | 5 +- web/ops/js/api.js | 33 ++++++ web/ops/js/app.js | 18 ++- web/ops/js/segments.js | 185 +++++++++++++++++++++++++++++ web/ops/js/ws.js | 58 +++++++++ web/ops/ops-styles.css | 204 ++++++++++++++++++++++++++++++++ 7 files changed, 510 insertions(+), 4 deletions(-) create mode 100644 web/ops/html/segment-panel.html create mode 100644 web/ops/js/api.js create mode 100644 web/ops/js/segments.js create mode 100644 web/ops/js/ws.js create mode 100644 web/ops/ops-styles.css diff --git a/web/ops/html/segment-panel.html b/web/ops/html/segment-panel.html new file mode 100644 index 0000000..62e1659 --- /dev/null +++ b/web/ops/html/segment-panel.html @@ -0,0 +1,11 @@ +
+
+

段运行态

+
+ + + +
+
+
+
diff --git a/web/ops/index.html b/web/ops/index.html index 852278d..05b15b4 100644 --- a/web/ops/index.html +++ b/web/ops/index.html @@ -5,12 +5,13 @@ 运转系统 +
-
-
运转系统页面开发中
+
+
diff --git a/web/ops/js/api.js b/web/ops/js/api.js new file mode 100644 index 0000000..987b8c4 --- /dev/null +++ b/web/ops/js/api.js @@ -0,0 +1,33 @@ +async function jsonOrThrow(response, fallbackMessage) { + if (response.ok) { + if (response.status === 204) return null; + return response.json(); + } + let detail = ""; + try { + const body = await response.json(); + detail = body?.message || body?.err_msg || JSON.stringify(body); + } catch { + detail = await response.text(); + } + throw new Error(`${fallbackMessage}: ${response.status} ${detail || response.statusText}`); +} + +export async function fetchOverview() { + const response = await fetch("/api/runtime/overview"); + return jsonOrThrow(response, "加载段运行态失败"); +} + +async function postControl(path, label) { + const response = await fetch(path, { method: "POST" }); + return jsonOrThrow(response, label); +} + +export const segmentControl = { + startAuto: (id) => postControl(`/api/control/segment/${id}/start-auto`, "启动自动控制失败"), + stopAuto: (id) => postControl(`/api/control/segment/${id}/stop-auto`, "停止自动控制失败"), + ackFault: (id) => postControl(`/api/control/segment/${id}/ack-fault`, "故障确认失败"), + reset: (id) => postControl(`/api/control/segment/${id}/reset`, "复位失败"), + batchStart: () => postControl(`/api/control/segment/batch-start-auto`, "批量启动失败"), + batchStop: () => postControl(`/api/control/segment/batch-stop-auto`, "批量停止失败"), +}; diff --git a/web/ops/js/app.js b/web/ops/js/app.js index 74140f4..587a0ac 100644 --- a/web/ops/js/app.js +++ b/web/ops/js/app.js @@ -1,5 +1,19 @@ -function bootstrap() { - console.log("Operation system app initialized"); +import { bindSegmentEvents, loadSegments } from "./segments.js"; +import { startOpsSocket } from "./ws.js"; + +async function bootstrap() { + bindSegmentEvents(); + startOpsSocket(); + try { + await loadSegments(); + } catch (err) { + const root = document.getElementById("segmentList"); + if (root) { + root.innerHTML = ``; + } + } } bootstrap(); diff --git a/web/ops/js/segments.js b/web/ops/js/segments.js new file mode 100644 index 0000000..f6a0174 --- /dev/null +++ b/web/ops/js/segments.js @@ -0,0 +1,185 @@ +import { fetchOverview, segmentControl } from "./api.js"; + +const STATE_LABEL = { + idle: "空闲", + checking: "校验", + executing: "执行", + confirming: "等待确认", + resetting: "复位", + completed: "完成", + blocked: "阻塞", + faulted: "故障", + manual_ack_required: "待人工确认", +}; + +const STATE_CLASS = { + idle: "state-idle", + checking: "state-active", + executing: "state-active", + confirming: "state-active", + resetting: "state-active", + completed: "state-active", + blocked: "state-warn", + faulted: "state-error", + manual_ack_required: "state-warn", +}; + +const segments = new Map(); + +function escapeHtml(text) { + if (text === null || text === undefined) return ""; + return String(text) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function renderState(runtime) { + const state = runtime?.state || "idle"; + const label = STATE_LABEL[state] || state; + const cls = STATE_CLASS[state] || "state-idle"; + return `${escapeHtml(label)}`; +} + +function renderActions(seg) { + const runtime = seg.runtime || {}; + const autoOn = runtime.auto_enabled === true; + const state = runtime.state || "idle"; + const canAck = state === "faulted" || state === "manual_ack_required"; + const canReset = canAck || state === "blocked"; + return ` +
+ + + + +
+ `; +} + +function renderCard(seg) { + const segment = seg.segment; + const runtime = seg.runtime || {}; + const note = runtime.fault_message || runtime.blocked_reason || ""; + const lineTag = segment.line_code ? `${escapeHtml(segment.line_code)}` : ""; + const modeTag = `${escapeHtml(segment.mode)}`; + const autoTag = runtime.auto_enabled + ? `AUTO` + : ""; + const stepText = runtime.current_step_no === null || runtime.current_step_no === undefined + ? "—" + : `Step ${runtime.current_step_no}`; + return ` +
+
+
+ ${escapeHtml(segment.code)} + ${escapeHtml(segment.name)} +
+
${lineTag}${modeTag}${autoTag}${renderState(runtime)}
+
+
+
当前步骤${escapeHtml(stepText)}
+ ${note ? `
${escapeHtml(note)}
` : ""} +
+ ${renderActions(seg)} +
+ `; +} + +function renderAll() { + const root = document.getElementById("segmentList"); + if (!root) return; + const items = Array.from(segments.values()); + items.sort((a, b) => a.segment.code.localeCompare(b.segment.code)); + if (items.length === 0) { + root.innerHTML = `
尚无段配置;执行种子或通过配置页新增段。
`; + return; + } + root.innerHTML = items.map(renderCard).join(""); +} + +function setBanner(message, level = "info") { + const root = document.getElementById("segmentList"); + if (!root) return; + const existing = root.querySelector(".ops-banner"); + if (existing) existing.remove(); + const div = document.createElement("div"); + div.className = `ops-banner banner-${level}`; + div.textContent = message; + root.prepend(div); + window.setTimeout(() => div.remove(), 4000); +} + +async function callAndRefresh(label, fn) { + try { + await fn(); + setBanner(`${label} 成功`, "info"); + } catch (err) { + setBanner(err.message || String(err), "error"); + } +} + +function handleAction(event) { + const button = event.target.closest("button[data-action]"); + if (!button) return; + const action = button.dataset.action; + const id = button.dataset.id; + switch (action) { + case "start-auto": + return callAndRefresh("启动自动控制", () => segmentControl.startAuto(id)); + case "stop-auto": + return callAndRefresh("停止自动控制", () => segmentControl.stopAuto(id)); + case "ack-fault": + return callAndRefresh("故障确认", () => segmentControl.ackFault(id)); + case "reset": + return callAndRefresh("复位", () => segmentControl.reset(id)); + default: + return undefined; + } +} + +export async function loadSegments() { + const data = await fetchOverview(); + segments.clear(); + (data?.segments || []).forEach((entry) => { + segments.set(entry.segment.id, entry); + }); + renderAll(); +} + +/// Apply a SegmentRuntime payload pushed via WebSocket app_event. +export function applyRuntimeUpdate(runtime) { + if (!runtime?.segment_id) return; + const entry = segments.get(runtime.segment_id); + if (!entry) { + // Unknown segment — refresh from overview so we pick it up. + void loadSegments(); + return; + } + entry.runtime = runtime; + renderAll(); +} + +export function bindSegmentEvents() { + const root = document.getElementById("segmentList"); + if (root) root.addEventListener("click", handleAction); + const refreshBtn = document.getElementById("refreshSegmentsBtn"); + if (refreshBtn) { + refreshBtn.addEventListener("click", () => callAndRefresh("刷新", loadSegments)); + } + const batchStart = document.getElementById("batchStartAutoBtn"); + if (batchStart) { + batchStart.addEventListener("click", async () => { + await callAndRefresh("批量启动", () => segmentControl.batchStart()); + await loadSegments(); + }); + } + const batchStop = document.getElementById("batchStopAutoBtn"); + if (batchStop) { + batchStop.addEventListener("click", async () => { + await callAndRefresh("批量停止", () => segmentControl.batchStop()); + await loadSegments(); + }); + } +} diff --git a/web/ops/js/ws.js b/web/ops/js/ws.js new file mode 100644 index 0000000..5fc6d23 --- /dev/null +++ b/web/ops/js/ws.js @@ -0,0 +1,58 @@ +import { applyRuntimeUpdate } from "./segments.js"; + +const RECONNECT_INITIAL_MS = 1_000; +const RECONNECT_MAX_MS = 30_000; +let socket = null; +let reconnectDelay = RECONNECT_INITIAL_MS; + +function setWsStatus(connected) { + const dot = document.getElementById("wsDot"); + const label = document.getElementById("wsLabel"); + if (dot) { + dot.classList.toggle("connected", connected); + dot.classList.toggle("disconnected", !connected); + } + if (label) { + label.textContent = connected ? "已连接" : "连接断开,重连中…"; + } +} + +function handleMessage(payload) { + if (!payload || typeof payload !== "object") return; + if (payload.type !== "app_event") return; + const event = payload.data; + if (!event || event.app !== "operation-system") return; + if (event.event_type === "segment_runtime_changed") { + applyRuntimeUpdate(event.data); + } +} + +export function startOpsSocket() { + const protocol = location.protocol === "https:" ? "wss" : "ws"; + const ws = new WebSocket(`${protocol}://${location.host}/ws/public`); + socket = ws; + + ws.onopen = () => { + setWsStatus(true); + reconnectDelay = RECONNECT_INITIAL_MS; + }; + + ws.onmessage = (event) => { + try { + const payload = JSON.parse(event.data); + handleMessage(payload); + } catch (err) { + // Tolerate non-JSON pings. + console.debug("ops ws non-json message", err); + } + }; + + ws.onclose = () => { + setWsStatus(false); + socket = null; + window.setTimeout(startOpsSocket, reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS); + }; + + ws.onerror = () => setWsStatus(false); +} diff --git a/web/ops/ops-styles.css b/web/ops/ops-styles.css new file mode 100644 index 0000000..163aabf --- /dev/null +++ b/web/ops/ops-styles.css @@ -0,0 +1,204 @@ +/* Operation-system specific styles. Loaded after /ui/styles.css. */ + +main.ops-main { + height: calc(100vh - var(--topbar-h)); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel.ops-segments { + flex: 1 1 auto; + min-height: 0; +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} + +.panel-head h2 { + font-size: 14px; + font-weight: 600; +} + +.toolbar { + display: flex; + gap: 6px; +} + +.toolbar button { + height: 26px; + padding: 0 10px; + font-size: 12px; + border: 1px solid var(--border); + background: var(--surface); + cursor: pointer; +} + +.toolbar button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.ops-segment-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 8px; + padding: 12px; + overflow-y: auto; + align-content: start; +} + +.ops-card { + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; +} + +.ops-card .card-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + flex-wrap: wrap; +} + +.ops-card .card-title { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 13px; +} + +.ops-card .card-title .muted { + font-size: 12px; +} + +.ops-card .card-tags { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.badge { + border: 1px solid var(--border); + background: var(--surface-2); + padding: 1px 6px; + font-size: 11px; + color: var(--text-2); + border-radius: 2px; + white-space: nowrap; +} + +.badge-accent { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-bg); +} + +.state-badge { + padding: 1px 6px; + font-size: 11px; + border-radius: 2px; + border: 1px solid var(--border); + background: var(--surface-2); +} + +.state-badge.state-idle { color: var(--text-2); } +.state-badge.state-active { + background: rgba(5, 150, 105, 0.08); + border-color: var(--success); + color: var(--success); +} +.state-badge.state-warn { + background: rgba(217, 119, 6, 0.08); + border-color: var(--warning); + color: var(--warning); +} +.state-badge.state-error { + background: rgba(239, 68, 68, 0.08); + border-color: var(--danger); + color: var(--danger); +} + +.card-body { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--text-2); +} + +.card-row { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.card-note { + border-left: 2px solid var(--warning); + padding: 4px 6px; + background: rgba(217, 119, 6, 0.06); + color: var(--text); + font-size: 11px; + white-space: pre-wrap; + word-break: break-word; +} + +.card-actions { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.card-actions button { + flex: 1 1 auto; + min-width: 60px; + height: 24px; + font-size: 11px; + border: 1px solid var(--border); + background: var(--surface); + cursor: pointer; +} + +.card-actions button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.card-actions button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.card-empty { + padding: 24px; + text-align: center; +} + +.ops-banner { + grid-column: 1 / -1; + border-radius: 2px; + padding: 6px 10px; + font-size: 12px; +} + +.ops-banner.banner-info { + background: rgba(37, 99, 235, 0.08); + border: 1px solid var(--accent); + color: var(--accent); +} + +.ops-banner.banner-error { + background: rgba(239, 68, 68, 0.08); + border: 1px solid var(--danger); + color: var(--danger); +}