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) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-19 09:19:20 +08:00
parent 972938a8e6
commit e2248fa04f
7 changed files with 510 additions and 4 deletions

View File

@ -0,0 +1,11 @@
<section class="panel ops-segments">
<div class="panel-head">
<h2>段运行态</h2>
<div class="toolbar">
<button type="button" class="secondary" id="refreshSegmentsBtn">刷新</button>
<button type="button" class="secondary" id="batchStartAutoBtn">全部启动</button>
<button type="button" class="secondary" id="batchStopAutoBtn">全部停止</button>
</div>
</div>
<div class="ops-segment-list" id="segmentList"></div>
</section>

View File

@ -5,12 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>运转系统</title> <title>运转系统</title>
<link rel="stylesheet" href="/ui/styles.css" /> <link rel="stylesheet" href="/ui/styles.css" />
<link rel="stylesheet" href="/ui/ops-styles.css" />
</head> </head>
<body> <body>
<div data-partial="/ui/html/topbar.html"></div> <div data-partial="/ui/html/topbar.html"></div>
<main> <main class="ops-main">
<div class="muted" style="padding:2rem;text-align:center">运转系统页面开发中</div> <div data-partial="/ui/html/segment-panel.html"></div>
</main> </main>
<script type="module" src="/ui/js/index.js"></script> <script type="module" src="/ui/js/index.js"></script>

33
web/ops/js/api.js Normal file
View File

@ -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`, "批量停止失败"),
};

View File

@ -1,5 +1,19 @@
function bootstrap() { import { bindSegmentEvents, loadSegments } from "./segments.js";
console.log("Operation system app initialized"); import { startOpsSocket } from "./ws.js";
async function bootstrap() {
bindSegmentEvents();
startOpsSocket();
try {
await loadSegments();
} catch (err) {
const root = document.getElementById("segmentList");
if (root) {
root.innerHTML = `<div class="ops-banner banner-error">${
err.message || String(err)
}</div>`;
}
}
} }
bootstrap(); bootstrap();

185
web/ops/js/segments.js Normal file
View File

@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function renderState(runtime) {
const state = runtime?.state || "idle";
const label = STATE_LABEL[state] || state;
const cls = STATE_CLASS[state] || "state-idle";
return `<span class="state-badge ${cls}">${escapeHtml(label)}</span>`;
}
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 `
<div class="card-actions">
<button data-action="start-auto" data-id="${seg.segment.id}" ${autoOn ? "disabled" : ""}>启动</button>
<button data-action="stop-auto" data-id="${seg.segment.id}" ${autoOn ? "" : "disabled"}>停止</button>
<button data-action="ack-fault" data-id="${seg.segment.id}" ${canAck ? "" : "disabled"}>故障确认</button>
<button data-action="reset" data-id="${seg.segment.id}" ${canReset ? "" : "disabled"}>复位</button>
</div>
`;
}
function renderCard(seg) {
const segment = seg.segment;
const runtime = seg.runtime || {};
const note = runtime.fault_message || runtime.blocked_reason || "";
const lineTag = segment.line_code ? `<span class="badge">${escapeHtml(segment.line_code)}</span>` : "";
const modeTag = `<span class="badge">${escapeHtml(segment.mode)}</span>`;
const autoTag = runtime.auto_enabled
? `<span class="badge badge-accent">AUTO</span>`
: "";
const stepText = runtime.current_step_no === null || runtime.current_step_no === undefined
? "—"
: `Step ${runtime.current_step_no}`;
return `
<article class="ops-card" data-segment-id="${segment.id}">
<header class="card-head">
<div class="card-title">
<strong>${escapeHtml(segment.code)}</strong>
<span class="muted">${escapeHtml(segment.name)}</span>
</div>
<div class="card-tags">${lineTag}${modeTag}${autoTag}${renderState(runtime)}</div>
</header>
<div class="card-body">
<div class="card-row"><span class="muted">当前步骤</span><span>${escapeHtml(stepText)}</span></div>
${note ? `<div class="card-note">${escapeHtml(note)}</div>` : ""}
</div>
${renderActions(seg)}
</article>
`;
}
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 = `<div class="muted card-empty">尚无段配置;执行种子或通过配置页新增段。</div>`;
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();
});
}
}

58
web/ops/js/ws.js Normal file
View File

@ -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);
}

204
web/ops/ops-styles.css Normal file
View File

@ -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);
}