# Dual-View Web UI Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a top-level Tab switch between an **运维视图** (operational, equipment-first) and the existing **配置视图** (configuration, point-first), with the ops view showing realtime signal values on equipment cards and a system-events panel at the bottom, while the config view replaces the events panel with a realtime SSE log stream. **Architecture:** Two CSS grid classes (`grid-ops` / `grid-config`) on `
` control which panels are visible. A new `ops.js` module drives the ops view: it calls `GET /api/unit/{id}/detail` on unit selection, renders equipment cards with per-role signal cells, and registers point DOM elements in `state.opsPointEls` so the existing WebSocket handler can push live updates. The SSE log stream (`/api/logs/stream`) is revived as a separate panel shown only in config view, started/stopped on tab switch. **Tech Stack:** Vanilla JS ES modules, CSS Grid, SSE (`EventSource`), existing WebSocket infrastructure, existing `/api/unit/{id}/detail` endpoint. --- ## Current layout (reference) ``` grid (3 cols × 2 rows): top-left → equipment-panel.html (col 1, row 1) top-right → points-panel.html (col 2-3, row 1) bottom-left → source-panel.html (col 1, row 2) — units + sources stacked bottom-mid → logs-panel.html (col 2, row 2) — system events bottom-right→ chart-panel.html (col 3, row 2) ``` ## Target layouts ``` grid-config (same as current): top-left → equipment-panel (col 1, row 1) top-right → points-panel (col 2-3, row 1) bottom-left → source-panel (col 1, row 2) bottom-mid → log-stream-panel (NEW) (col 2, row 2) — SSE logs bottom-right→ chart-panel (col 3, row 2) grid-ops (new): top → ops-panel (NEW) (col 1-3, row 1) — unit sidebar + equipment cards bottom → logs-panel (col 1-3, row 2) — system events (full width) ``` ## File Map | File | Action | Purpose | |---|---|---| | `web/html/topbar.html` | Modify | Add `#tabOps` / `#tabConfig` tab buttons | | `web/html/ops-panel.html` | **Create** | Ops view: `#opsUnitList` sidebar + `#opsEquipmentArea` card grid | | `web/html/log-stream-panel.html` | **Create** | Config view bottom-mid: SSE log stream (`#logView`) | | `web/index.html` | Modify | Add new partials, version bump | | `web/js/ops.js` | **Create** | Load unit detail, render equipment cards, expose `updateOpsPoint()` | | `web/js/state.js` | Modify | Add `activeView`, `opsPointEls`, `logSource` | | `web/js/dom.js` | Modify | Add refs: `tabOps`, `tabConfig`, `opsUnitList`, `opsEquipmentArea`, `logView` | | `web/js/logs.js` | Modify | Restore `startLogs` / `stopLogs`; call `updateOpsPoint` in WS handler | | `web/js/app.js` | Modify | Tab switch logic, bind ops unit-click, start/stop log stream on switch | | `web/styles.css` | Modify | Tab styles, `grid-ops`, `grid-config`, ops card + signal row styles | --- ## Task 1: Tab scaffold + CSS layout switching **Files:** - Modify: `web/html/topbar.html` - Modify: `web/index.html` - Modify: `web/js/state.js` - Modify: `web/js/dom.js` - Modify: `web/js/app.js` - Modify: `web/styles.css` - [ ] **Step 1: Add tab buttons to topbar** Replace `web/html/topbar.html` with: ```html
PLC Control
Ready
``` - [ ] **Step 2: Add tab + grid CSS to `web/styles.css`** After the existing `.topbar-actions` block, add: ```css /* ── 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; } ``` Replace the existing `.grid` block (lines 82–94) with: ```css .grid-ops, .grid-config { display: grid; gap: 1px; height: calc(100vh - var(--topbar-h)); } .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; } ``` - [ ] **Step 3: Add `activeView` and `logSource` to `web/js/state.js`** ```js export const state = { // ... existing fields ... activeView: "ops", // "ops" | "config" opsPointEls: new Map(), // point_id -> { valueEl, qualityEl } logSource: null, }; ``` - [ ] **Step 4: Add DOM refs in `web/js/dom.js`** ```js tabOps: byId("tabOps"), tabConfig: byId("tabConfig"), opsUnitList: byId("opsUnitList"), opsEquipmentArea: byId("opsEquipmentArea"), logView: byId("logView"), ``` - [ ] **Step 5: Add `switchView` function + wiring in `web/js/app.js`** Add at top of `app.js`: ```js import { startOps, handleOpsUnitClick } from "./ops.js"; import { startLogs, stopLogs } from "./logs.js"; ``` Add `switchView` function before `bindEvents`: ```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(); } } ``` In `bindEvents`, add: ```js dom.tabOps.addEventListener("click", () => switchView("ops")); dom.tabConfig.addEventListener("click", () => switchView("config")); ``` In `bootstrap`, call after `bindEvents()`: ```js switchView("ops"); // default to ops view ``` - [ ] **Step 6: Update `web/index.html` — add new partials, default grid class, version bump** ```html
``` Bump version: `?v=20260325a` on both CSS and JS. - [ ] **Step 7: Verify panels show/hide correctly** Open browser, click 运维 / 配置 tabs — panels should swap. Layout may be unstyled; that's fine for now. - [ ] **Step 8: Commit** ```bash git add web/html/topbar.html web/index.html web/js/state.js web/js/dom.js web/js/app.js web/styles.css git commit -m "feat(web): add tab scaffold for ops/config dual-view layout" ``` --- ## Task 2: Ops panel HTML + CSS skeleton **Files:** - Create: `web/html/ops-panel.html` - Modify: `web/styles.css` - [ ] **Step 1: Create `web/html/ops-panel.html`** ```html
← 选择控制单元
``` Note: `logs-panel.html` already has `id="eventList"` and class structure. Add `ops-bottom` class to it in HTML: In `web/html/logs-panel.html`, change: ```html
``` - [ ] **Step 2: Add ops layout CSS to `web/styles.css`** ```css /* ── 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; } ``` - [ ] **Step 3: Verify layout renders correctly (empty, no JS yet)** Refresh browser in ops tab — sidebar and card area should be visible with placeholder text. - [ ] **Step 4: Commit** ```bash git add web/html/ops-panel.html web/html/logs-panel.html web/styles.css git commit -m "feat(web): add ops panel HTML skeleton and layout CSS" ``` --- ## Task 3: ops.js — unit list + equipment card rendering **Files:** - Create: `web/js/ops.js` - Modify: `web/js/app.js` The ops view unit list is separate from the config view's `#unitList`. When a unit is clicked, `GET /api/unit/{id}/detail` returns the nested structure and we render equipment cards. Equipment card signal roles to display (in order): `rem`, `run`, `flt`. Show label + quality dot + value. Start/Stop buttons only for `coal_feeder` / `distributor` kind. - [ ] **Step 1: Create `web/js/ops.js`** ```js import { apiFetch } from "./api.js"; import { dom } from "./dom.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) { dom.opsEquipmentArea.innerHTML = '
← 选择控制单元
'; state.opsPointEls.clear(); return; } dom.opsEquipmentArea.innerHTML = '
加载中...
'; state.opsPointEls.clear(); const detail = await apiFetch(`/api/unit/${state.selectedOpsUnitId}/detail`); renderOpsEquipments(detail.equipments || []); } function renderOpsEquipments(equipments) { dom.opsEquipmentArea.innerHTML = ""; if (!equipments.length) { dom.opsEquipmentArea.innerHTML = '
该单元下暂无设备
'; return; } equipments.forEach((eq) => { const runtime = state.runtimes.get(state.selectedOpsUnitId); const card = document.createElement("div"); card.className = "ops-eq-card"; // Build role → point_id 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 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 }); } }); }); } export function startOps() { renderOpsUnits(); } ``` - [ ] **Step 2: Add `selectedOpsUnitId` to `web/js/state.js`** ```js selectedOpsUnitId: null, ``` - [ ] **Step 3: Wire ops into `web/js/app.js`** Add import: ```js import { startOps, renderOpsUnits } from "./ops.js"; ``` In `bootstrap`, add after `loadUnits`: ```js await withStatus(loadUnits()); // already exists startOps(); // initialise ops unit list ``` Also update the `equipments-updated` listener to also call `renderOpsUnits`: ```js document.addEventListener("equipments-updated", () => { renderUnits(); renderOpsUnits(); }); ``` After `loadUnits()` is called anywhere (e.g., `refreshUnitBtn`), `renderOpsUnits()` should also be triggered. Simplest: call `renderOpsUnits()` inside `loadUnits()` in `units.js` — add at end of that function: In `web/js/units.js`, at end of `loadUnits()`: ```js // notify ops view document.dispatchEvent(new Event("units-loaded")); ``` In `web/js/app.js`: ```js document.addEventListener("units-loaded", renderOpsUnits); ``` - [ ] **Step 4: Verify unit list renders and card area populates on click** Click 运维 tab → unit list shows → click a unit → equipment cards appear with signal row placeholders. - [ ] **Step 5: Commit** ```bash git add web/js/ops.js web/js/state.js web/js/app.js web/js/units.js git commit -m "feat(web): ops view unit list and equipment card rendering" ``` --- ## Task 4: Realtime signal values in ops cards **Files:** - Modify: `web/js/logs.js` The WebSocket `PointNewValue` handler already updates `state.pointEls`. Add a second lookup for `state.opsPointEls`. - [ ] **Step 1: Update WebSocket handler in `web/js/logs.js`** In the `PointNewValue` branch, after the existing `state.pointEls` block: ```js 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); entry.quality.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`; entry.quality.textContent = (data.quality || "unknown").toUpperCase(); 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); } return; } ``` Also update `UnitRuntimeChanged` to re-render ops unit list: ```js if (payload.type === "UnitRuntimeChanged") { const runtime = payload.data; state.runtimes.set(runtime.unit_id, runtime); renderUnits(); // lazy import to avoid circular dep import("./ops.js").then(({ renderOpsUnits }) => renderOpsUnits()); return; } ``` - [ ] **Step 2: Verify realtime updates** With a live OPC UA source connected, open ops view, select a unit — signal cells should show live quality badges and values updating in real time. - [ ] **Step 3: Commit** ```bash git add web/js/logs.js git commit -m "feat(web): ops card signal cells update from WebSocket PointNewValue" ``` --- ## Task 5: Log stream panel for config view **Files:** - Create: `web/html/log-stream-panel.html` - Modify: `web/js/logs.js` - Modify: `web/js/dom.js` - Modify: `web/js/app.js` Restore the SSE `EventSource` log stream, but only active when in config view. The `startLogs` / `stopLogs` functions are called by `switchView` in `app.js` (already wired in Task 1 Step 5). - [ ] **Step 1: Create `web/html/log-stream-panel.html`** ```html

实时日志

``` - [ ] **Step 2: Restore `startLogs` / `stopLogs` in `web/js/logs.js`** Add before `startPointSocket`: ```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; } } ``` - [ ] **Step 3: Add `logView` to `web/js/dom.js`** (already added in Task 1 Step 4 — verify it's present) - [ ] **Step 4: Verify config view shows SSE log stream** Click 配置 tab → bottom-middle panel should show "实时日志" with live log lines streaming. Click 运维 tab → SSE connection closes. - [ ] **Step 5: Commit** ```bash git add web/html/log-stream-panel.html web/js/logs.js web/js/dom.js git commit -m "feat(web): restore SSE log stream panel in config view" ``` --- ## Task 6: Final wiring, cleanup and polish **Files:** - Modify: `web/styles.css` (log panel, minor tweaks) - Modify: `web/js/units.js` (dispatch units-loaded) - Modify: `web/index.html` (version bump) - [ ] **Step 1: Add log panel CSS** (if not already in styles.css from previous work) Verify `.log`, `.log-line`, `.level-info`, `.level-warn`, `.level-error` styles exist. If not, add: ```css .log { flex: 1 1 auto; overflow-y: auto; font-family: monospace; font-size: 12px; padding: 4px 8px; } .log-line { padding: 1px 0; border-bottom: 1px solid var(--border-light); } .log-line .level { font-weight: 700; margin-right: 6px; } .log-line.level-error { color: var(--danger); } .log-line.level-warn { color: var(--warning); } .log-line.level-info { color: var(--text-2); } .log-line .message { color: var(--text); } ``` - [ ] **Step 2: Bump version in `web/index.html`** Change `?v=20260325a` → `?v=20260325b` on both CSS and JS links. - [ ] **Step 3: Final verification checklist** - [ ] 运维 tab: unit list renders, click unit → equipment cards appear - [ ] Equipment cards show REM / RUN / FLT rows with live values - [ ] Start/Stop buttons work (coal_feeder / distributor only) - [ ] `UnitRuntimeChanged` WS message updates ops unit list badges - [ ] 配置 tab: all existing panels visible (equipment, points, sources, chart) - [ ] 配置 tab bottom-mid shows SSE log stream, lines append in real time - [ ] Switching tabs starts/stops SSE correctly (no duplicate connections) - [ ] 配置 tab events/chart/points work as before - [ ] **Step 4: Final commit** ```bash git add web/styles.css web/js/units.js web/index.html git commit -m "feat(web): dual-view UI complete — ops cards + config log stream" ``` --- ## Notes for implementer - `state.opsPointEls` is cleared and rebuilt every time a different unit is selected in ops view — no stale references. - The lazy `import("./ops.js")` in `logs.js` for `UnitRuntimeChanged` avoids a circular dependency (`ops.js` → `logs.js` → `ops.js`). Alternatively, expose a `document.dispatchEvent(new Event("unit-runtime-changed"))` and listen in `ops.js`. - The ops view does **not** reload `state.equipments` separately — it uses the `/api/unit/{id}/detail` response which is self-contained. - `startLogs()` is idempotent (guards with `if (state.logSource) return`), so double-calling is safe. - Backend log CSS classes: the existing styles from before the log removal commit should still be in `styles.css`. If they were removed, add them back per Task 6 Step 1.