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:
parent
972938a8e6
commit
e2248fa04f
|
|
@ -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>
|
||||
|
|
@ -5,12 +5,13 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>运转系统</title>
|
||||
<link rel="stylesheet" href="/ui/styles.css" />
|
||||
<link rel="stylesheet" href="/ui/ops-styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div data-partial="/ui/html/topbar.html"></div>
|
||||
|
||||
<main>
|
||||
<div class="muted" style="padding:2rem;text-align:center">运转系统页面开发中</div>
|
||||
<main class="ops-main">
|
||||
<div data-partial="/ui/html/segment-panel.html"></div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/ui/js/index.js"></script>
|
||||
|
|
|
|||
|
|
@ -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`, "批量停止失败"),
|
||||
};
|
||||
|
|
@ -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 = `<div class="ops-banner banner-error">${
|
||||
err.message || String(err)
|
||||
}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
|
|||
|
|
@ -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 `<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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue