diff --git a/src/handler/control.rs b/src/handler/control.rs index 4edfb50..7963f12 100644 --- a/src/handler/control.rs +++ b/src/handler/control.rs @@ -114,11 +114,18 @@ pub async fn get_unit( } } +#[derive(serde::Serialize)] +pub struct PointDetail { + #[serde(flatten)] + pub point: crate::model::Point, + pub point_monitor: Option, +} + #[derive(serde::Serialize)] pub struct EquipmentDetail { #[serde(flatten)] pub equipment: crate::model::Equipment, - pub points: Vec, + pub points: Vec, } #[derive(serde::Serialize)] @@ -140,13 +147,21 @@ pub async fn get_unit_detail( let equipment_ids: Vec = equipments.iter().map(|e| e.id).collect(); let all_points = crate::service::get_points_by_equipment_ids(&state.pool, &equipment_ids).await?; + let monitor_guard = state + .connection_manager + .get_point_monitor_data_read_guard() + .await; + let equipments = equipments .into_iter() .map(|eq| { let points = all_points .iter() .filter(|p| p.equipment_id == Some(eq.id)) - .cloned() + .map(|p| PointDetail { + point_monitor: monitor_guard.get(&p.id).cloned(), + point: p.clone(), + }) .collect(); EquipmentDetail { equipment: eq, points } }) diff --git a/web/html/log-stream-panel.html b/web/html/log-stream-panel.html new file mode 100644 index 0000000..6a2e8a8 --- /dev/null +++ b/web/html/log-stream-panel.html @@ -0,0 +1,6 @@ +
+
+

实时日志

+
+
+
diff --git a/web/html/logs-panel.html b/web/html/logs-panel.html index 82af729..2d6d5f3 100644 --- a/web/html/logs-panel.html +++ b/web/html/logs-panel.html @@ -1,4 +1,4 @@ -
+

系统事件

diff --git a/web/html/ops-panel.html b/web/html/ops-panel.html new file mode 100644 index 0000000..2106894 --- /dev/null +++ b/web/html/ops-panel.html @@ -0,0 +1,13 @@ +
+
+ +
+
← 选择控制单元
+
+
+
diff --git a/web/html/topbar.html b/web/html/topbar.html index 8ace3f9..2902f70 100644 --- a/web/html/topbar.html +++ b/web/html/topbar.html @@ -1,5 +1,9 @@
PLC Control
+
+ + +
diff --git a/web/index.html b/web/index.html index 9f6dc8a..4d2c78d 100644 --- a/web/index.html +++ b/web/index.html @@ -4,15 +4,17 @@ PLC Control - +
-
+
+
+
@@ -20,6 +22,6 @@
- + diff --git a/web/js/app.js b/web/js/app.js index 84b3d0f..cbdc1f2 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -14,7 +14,8 @@ import { resetEquipmentForm, saveEquipment, } from "./equipment.js"; -import { startPointSocket } from "./logs.js"; +import { startPointSocket, startLogs, stopLogs } from "./logs.js"; +import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js"; import { clearBatchBinding, browseAndLoadTree, @@ -34,6 +35,36 @@ import { state } from "./state.js"; import { loadSources, saveSource } from "./sources.js"; import { closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js"; +function switchView(view) { + state.activeView = view; + const main = document.querySelector("main"); + main.className = view === "ops" ? "grid-ops" : "grid-config"; + + dom.tabOps.classList.toggle("active", view === "ops"); + dom.tabConfig.classList.toggle("active", view === "config"); + + // config-only panels + ["top-left", "top-right", "bottom-left", "bottom-right"].forEach((cls) => { + const el = main.querySelector(`.panel.${cls}`); + if (el) el.classList.toggle("hidden", view === "ops"); + }); + // bottom-mid is log-stream in config, hidden in ops + const logStreamPanel = main.querySelector(".panel.bottom-mid"); + if (logStreamPanel) logStreamPanel.classList.toggle("hidden", view === "ops"); + + // ops-only panels + const opsMain = main.querySelector(".panel.ops-main"); + const opsBottom = main.querySelector(".panel.ops-bottom"); + if (opsMain) opsMain.classList.toggle("hidden", view === "config"); + if (opsBottom) opsBottom.classList.toggle("hidden", view === "config"); + + if (view === "config") { + startLogs(); + } else { + stopLogs(); + } +} + function bindEvents() { dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event))); dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event))); @@ -125,13 +156,23 @@ function bindEvents() { } }); + dom.tabOps.addEventListener("click", () => switchView("ops")); + dom.tabConfig.addEventListener("click", () => switchView("config")); + document.addEventListener("equipments-updated", () => { renderUnits(); + renderOpsUnits(); + }); + + document.addEventListener("units-loaded", () => { + renderOpsUnits(); + if (!state.selectedOpsUnitId) loadAllEquipmentCards(); }); } async function bootstrap() { bindEvents(); + switchView("ops"); renderSelectedNodes(); updateSelectedPointSummary(); updatePointFilterSummary(); @@ -139,6 +180,7 @@ async function bootstrap() { startPointSocket(); await withStatus(loadUnits()); + startOps(); await withStatus(loadSources()); await withStatus(loadEquipments()); await withStatus(loadEvents()); diff --git a/web/js/dom.js b/web/js/dom.js index c55418f..413edc5 100644 --- a/web/js/dom.js +++ b/web/js/dom.js @@ -2,6 +2,11 @@ const byId = (id) => document.getElementById(id); export const dom = { statusText: byId("statusText"), + tabOps: byId("tabOps"), + tabConfig: byId("tabConfig"), + opsUnitList: byId("opsUnitList"), + opsEquipmentArea: byId("opsEquipmentArea"), + logView: byId("logView"), sourceList: byId("sourceList"), unitList: byId("unitList"), eventList: byId("eventList"), diff --git a/web/js/logs.js b/web/js/logs.js index f2caeea..26122da 100644 --- a/web/js/logs.js +++ b/web/js/logs.js @@ -1,9 +1,60 @@ import { appendChartPoint } from "./chart.js"; +import { dom } from "./dom.js"; import { prependEvent } from "./events.js"; import { formatValue } from "./points.js"; import { state } from "./state.js"; import { renderUnits } from "./units.js"; +function escapeHtml(text) { + return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +function parseLogLine(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null; + try { return JSON.parse(trimmed); } catch { return null; } +} + +export function appendLog(line) { + if (!dom.logView) return; + const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10; + const div = document.createElement("div"); + const parsed = parseLogLine(line); + if (!parsed) { + div.className = "log-line"; + div.textContent = line; + } else { + const levelRaw = (parsed.level || "").toString(); + const level = levelRaw.toLowerCase(); + div.className = `log-line${level ? ` level-${level}` : ""}`; + div.innerHTML = [ + `${escapeHtml(levelRaw || "LOG")}`, + parsed.timestamp ? ` ${escapeHtml(parsed.timestamp)}` : "", + parsed.target ? ` ${escapeHtml(parsed.target)}` : "", + `${escapeHtml(parsed.fields?.message || parsed.message || parsed.msg || line)}`, + ].join(""); + } + dom.logView.appendChild(div); + if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight; +} + +export function startLogs() { + if (state.logSource) return; + state.logSource = new EventSource("/api/logs/stream"); + state.logSource.addEventListener("log", (event) => { + const data = JSON.parse(event.data); + (data.lines || []).forEach(appendLog); + }); + state.logSource.addEventListener("error", () => appendLog("[log stream error]")); +} + +export function stopLogs() { + if (state.logSource) { + state.logSource.close(); + state.logSource = null; + } +} + export function startPointSocket() { const protocol = location.protocol === "https:" ? "wss" : "ws"; const ws = new WebSocket(`${protocol}://${location.host}/ws/public`); @@ -14,6 +65,8 @@ export function startPointSocket() { const payload = JSON.parse(event.data); if (payload.type === "PointNewValue" || payload.type === "point_new_value") { const data = payload.data; + + // config view point table const entry = state.pointEls.get(data.point_id); if (entry) { entry.value.textContent = formatValue(data); @@ -22,6 +75,14 @@ export function startPointSocket() { entry.time.textContent = data.timestamp || "--"; } + // ops view signal cell + const opsEntry = state.opsPointEls.get(data.point_id); + if (opsEntry) { + opsEntry.valueEl.textContent = formatValue(data); + opsEntry.qualityEl.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`; + opsEntry.qualityEl.textContent = (data.quality || "unknown").toUpperCase(); + } + if (state.chartPointId === data.point_id) { appendChartPoint(data); } @@ -36,6 +97,8 @@ export function startPointSocket() { const runtime = payload.data; state.runtimes.set(runtime.unit_id, runtime); renderUnits(); + // lazy import to avoid circular dep (ops.js -> logs.js -> ops.js) + import("./ops.js").then(({ renderOpsUnits }) => renderOpsUnits()); return; } } catch { diff --git a/web/js/ops.js b/web/js/ops.js new file mode 100644 index 0000000..1a166d3 --- /dev/null +++ b/web/js/ops.js @@ -0,0 +1,148 @@ +import { apiFetch } from "./api.js"; +import { dom } from "./dom.js"; +import { formatValue } from "./points.js"; +import { state } from "./state.js"; + +const SIGNAL_ROLES = ["rem", "run", "flt"]; +const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" }; + +export function renderOpsUnits() { + if (!dom.opsUnitList) return; + dom.opsUnitList.innerHTML = ""; + + if (!state.units.length) { + dom.opsUnitList.innerHTML = '
暂无控制单元
'; + return; + } + + state.units.forEach((unit) => { + const runtime = state.runtimes.get(unit.id); + const item = document.createElement("div"); + item.className = `ops-unit-item${state.selectedOpsUnitId === unit.id ? " selected" : ""}`; + item.innerHTML = ` +
${unit.code} / ${unit.name}
+
+ ${unit.enabled ? "EN" : "DIS"} + ${runtime ? `Acc ${Math.floor(runtime.accumulated_run_sec / 1000)}s` : ""} +
+ `; + item.addEventListener("click", () => selectOpsUnit(unit.id)); + dom.opsUnitList.appendChild(item); + }); +} + +async function selectOpsUnit(unitId) { + state.selectedOpsUnitId = unitId === state.selectedOpsUnitId ? null : unitId; + renderOpsUnits(); + + if (!state.selectedOpsUnitId) { + await loadAllEquipmentCards(); + return; + } + + dom.opsEquipmentArea.innerHTML = '
加载中...
'; + state.opsPointEls.clear(); + + const detail = await apiFetch(`/api/unit/${state.selectedOpsUnitId}/detail`); + renderOpsEquipments(detail.equipments || []); +} + +export async function loadAllEquipmentCards() { + if (!dom.opsEquipmentArea) return; + if (!state.units.length) { + dom.opsEquipmentArea.innerHTML = '
暂无控制单元
'; + return; + } + + dom.opsEquipmentArea.innerHTML = '
加载中...
'; + state.opsPointEls.clear(); + + const details = await Promise.all( + state.units.map((u) => apiFetch(`/api/unit/${u.id}/detail`).catch(() => ({ equipments: [] }))) + ); + const allEquipments = details.flatMap((d) => d.equipments || []); + renderOpsEquipments(allEquipments); +} + +function renderOpsEquipments(equipments) { + dom.opsEquipmentArea.innerHTML = ""; + if (!equipments.length) { + dom.opsEquipmentArea.innerHTML = '
该单元下暂无设备
'; + return; + } + + equipments.forEach((eq) => { + const card = document.createElement("div"); + card.className = "ops-eq-card"; + + // Build role → point map + const roleMap = {}; + (eq.points || []).forEach((p) => { + if (p.signal_role) roleMap[p.signal_role] = p; + }); + + // Signal rows HTML (placeholders; WS will fill values) + const signalRowsHtml = SIGNAL_ROLES.map((role) => { + const point = roleMap[role]; + if (!point) return ""; + return ` +
+ ${ROLE_LABELS[role] || role} + ? + -- +
`; + }).join(""); + + const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor"; + + card.innerHTML = ` +
+ ${eq.code} + ${eq.kind || "--"} +
+
${signalRowsHtml || '无绑定信号'}
+ ${canControl ? '
' : ""} + `; + + if (canControl) { + const actions = card.querySelector(".ops-eq-card-actions"); + const startBtn = document.createElement("button"); + startBtn.className = "secondary"; + startBtn.textContent = "Start"; + startBtn.addEventListener("click", () => + apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {}) + ); + const stopBtn = document.createElement("button"); + stopBtn.className = "danger"; + stopBtn.textContent = "Stop"; + stopBtn.addEventListener("click", () => + apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {}) + ); + actions.append(startBtn, stopBtn); + } + + dom.opsEquipmentArea.appendChild(card); + + // Register DOM elements for WS updates, then seed from cached monitor data + SIGNAL_ROLES.forEach((role) => { + const point = roleMap[role]; + if (!point) return; + const valueEl = card.querySelector(`[data-ops-value="${point.id}"]`); + const qualityEl = card.querySelector(`[data-ops-quality="${point.id}"]`); + if (valueEl && qualityEl) { + state.opsPointEls.set(point.id, { valueEl, qualityEl }); + if (point.point_monitor) { + const m = point.point_monitor; + valueEl.textContent = formatValue(m); + qualityEl.className = `badge quality-${(m.quality || "unknown").toLowerCase()}`; + qualityEl.textContent = (m.quality || "unknown").toUpperCase(); + } + } + }); + }); +} + +export function startOps() { + renderOpsUnits(); + loadAllEquipmentCards(); +} diff --git a/web/js/state.js b/web/js/state.js index ae317e7..a36d6c3 100644 --- a/web/js/state.js +++ b/web/js/state.js @@ -21,4 +21,8 @@ export const state = { pointSocket: null, apiDocLoaded: false, runtimes: new Map(), // unit_id -> UnitRuntime + activeView: "ops", // "ops" | "config" + opsPointEls: new Map(), // point_id -> { valueEl, qualityEl } + logSource: null, + selectedOpsUnitId: null, }; diff --git a/web/js/units.js b/web/js/units.js index 831ab93..ef6f686 100644 --- a/web/js/units.js +++ b/web/js/units.js @@ -191,6 +191,7 @@ export async function loadUnits() { renderUnits(); renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId); renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId); + document.dispatchEvent(new Event("units-loaded")); } export async function saveUnit(event) { diff --git a/web/styles.css b/web/styles.css index b6d4ed7..6f6ba89 100644 --- a/web/styles.css +++ b/web/styles.css @@ -59,6 +59,30 @@ body { gap: 10px; } +/* ── Tabs ───────────────────────────────────────── */ + +.tab-bar { + display: flex; + gap: 2px; +} + +.tab-btn { + padding: 0 16px; + height: 28px; + font-size: 13px; + font-weight: 500; + background: transparent; + border: 1px solid var(--border); + color: var(--text-2); + cursor: pointer; +} + +.tab-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + .link-button { display: inline-flex; align-items: center; @@ -79,19 +103,33 @@ body { /* ── Grid Layout ────────────────────────────────── */ -.grid { +.grid-ops, +.grid-config { display: grid; - grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr); - grid-template-rows: 1fr 380px; gap: 1px; height: calc(100vh - var(--topbar-h)); } -.panel.top-left { grid-column: 1; grid-row: 1; } -.panel.top-right { grid-column: 2 / 4; grid-row: 1; } -.panel.bottom-left { grid-column: 1; grid-row: 2; min-height: 0; } -.panel.bottom-middle { grid-column: 2; grid-row: 2; min-height: 0; } -.panel.bottom-right { grid-column: 3; grid-row: 2; min-height: 0; } +.grid-config { + grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr); + grid-template-rows: 1fr 380px; +} + +.grid-ops { + grid-template-columns: 260px minmax(0, 1fr); + grid-template-rows: 1fr 260px; +} + +/* config view slot assignments */ +.grid-config .panel.top-left { grid-column: 1; grid-row: 1; } +.grid-config .panel.top-right { grid-column: 2 / 4; grid-row: 1; } +.grid-config .panel.bottom-left { grid-column: 1; grid-row: 2; } +.grid-config .panel.bottom-mid { grid-column: 2; grid-row: 2; } +.grid-config .panel.bottom-right{ grid-column: 3; grid-row: 2; } + +/* ops view slot assignments */ +.grid-ops .panel.ops-main { grid-column: 1 / 3; grid-row: 1; } +.grid-ops .panel.ops-bottom { grid-column: 1 / 3; grid-row: 2; } .panel { background: var(--surface); @@ -119,6 +157,138 @@ body { border-top: 1px solid var(--border-light); } +/* ── Ops View ───────────────────────────────────── */ + +.ops-layout { + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; +} + +.ops-unit-sidebar { + width: 260px; + flex-shrink: 0; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.ops-unit-list { + flex: 1 1 auto; + overflow-y: auto; +} + +.ops-equipment-area { + flex: 1 1 auto; + overflow: auto; + padding: 12px; + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 12px; +} + +.ops-placeholder { + padding: 20px; +} + +/* Equipment ops card */ +.ops-eq-card { + width: 220px; + border: 1px solid var(--border); + background: var(--surface); + display: flex; + flex-direction: column; + gap: 0; +} + +.ops-eq-card-head { + padding: 8px 10px 6px; + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + gap: 6px; +} + +.ops-eq-card-head strong { + flex: 1; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ops-signal-rows { + padding: 6px 10px; + display: flex; + flex-direction: column; + gap: 3px; +} + +.ops-signal-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; +} + +.ops-signal-label { + width: 36px; + color: var(--text-3); + font-size: 11px; + text-transform: uppercase; + flex-shrink: 0; +} + +.ops-signal-value { + flex: 1; + font-weight: 500; +} + +.ops-eq-card-actions { + padding: 6px 10px 8px; + display: flex; + gap: 6px; + border-top: 1px solid var(--border-light); +} + +.ops-eq-card-actions button { + flex: 1; + padding: 3px 0; + font-size: 12px; +} + +/* ops unit list item */ +.ops-unit-item { + padding: 8px 10px; + cursor: pointer; + border-bottom: 1px solid var(--border-light); + display: flex; + flex-direction: column; + gap: 3px; +} + +.ops-unit-item:hover { background: var(--accent-bg); } +.ops-unit-item.selected { + background: var(--accent-bg); + border-left: 3px solid var(--accent); +} + +.ops-unit-item-name { + font-size: 13px; + font-weight: 600; +} + +.ops-unit-item-meta { + font-size: 11px; + color: var(--text-3); + display: flex; + gap: 6px; +} + /* ── Panel Header ───────────────────────────────── */ .panel-head { @@ -585,6 +755,7 @@ button.danger:hover { background: var(--danger-hover); } backdrop-filter: blur(2px); } +.hidden { display: none !important; } .modal.hidden { display: none; } .modal-content { @@ -1084,7 +1255,8 @@ button.danger:hover { background: var(--danger-hover); } /* ── Responsive ───────────────────────────────────── */ @media (max-width: 900px) { - .grid { + .grid-config, + .grid-ops { grid-template-columns: 1fr; grid-template-rows: auto auto auto auto; height: auto; @@ -1092,9 +1264,9 @@ button.danger:hover { background: var(--danger-hover); } body { height: auto; overflow: auto; } .panel.top-left { min-height: 200px; } .panel.top-right { min-height: 300px; } - .panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; } - .panel.bottom-middle { grid-column: 1; grid-row: 4; min-height: 200px; } - .panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; } + .grid-config .panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; } + .grid-config .panel.bottom-mid { grid-column: 1; grid-row: 4; min-height: 200px; } + .grid-config .panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; } .drawer { width: 100vw; } .drawer-body { grid-template-columns: 1fr; } .equipment-layout { grid-template-columns: 1fr; }