plc_control/docs/superpowers/plans/2026-03-25-dual-view-web.md

25 KiB
Raw Blame History

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 <main> 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:

<header class="topbar">
  <div class="title">PLC Control</div>
  <div class="tab-bar">
    <button type="button" class="tab-btn active" id="tabOps">运维</button>
    <button type="button" class="tab-btn" id="tabConfig">配置</button>
  </div>
  <div class="topbar-actions">
    <button type="button" class="secondary" id="clearEquipmentFilter">设备筛选: 全部</button>
    <button type="button" class="secondary" id="openApiDoc">API.md</button>
    <div class="status" id="statusText">Ready</div>
  </div>
</header>
  • Step 2: Add tab + grid CSS to web/styles.css

After the existing .topbar-actions block, add:

/* ── 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 8294) with:

.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
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
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:

import { startOps, handleOpsUnitClick } from "./ops.js";
import { startLogs, stopLogs } from "./logs.js";

Add switchView function before bindEvents:

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:

dom.tabOps.addEventListener("click", () => switchView("ops"));
dom.tabConfig.addEventListener("click", () => switchView("config"));

In bootstrap, call after bindEvents():

switchView("ops");   // default to ops view
  • Step 6: Update web/index.html — add new partials, default grid class, version bump
<main class="grid-ops">
  <div data-partial="/ui/html/ops-panel.html"></div>
  <div data-partial="/ui/html/equipment-panel.html"></div>
  <div data-partial="/ui/html/points-panel.html"></div>
  <div data-partial="/ui/html/source-panel.html"></div>
  <div data-partial="/ui/html/log-stream-panel.html"></div>
  <div data-partial="/ui/html/logs-panel.html"></div>
  <div data-partial="/ui/html/chart-panel.html"></div>
</main>

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
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

<section class="panel ops-main">
  <div class="ops-layout">
    <aside class="ops-unit-sidebar">
      <div class="panel-head">
        <h2>控制单元</h2>
      </div>
      <div class="list ops-unit-list" id="opsUnitList"></div>
    </aside>
    <div class="ops-equipment-area" id="opsEquipmentArea">
      <div class="muted ops-placeholder">← 选择控制单元</div>
    </div>
  </div>
</section>

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:

<section class="panel ops-bottom">
  • Step 2: Add ops layout CSS to web/styles.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
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
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 = '<div class="muted" style="padding:12px">暂无控制单元</div>';
    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 = `
      <div class="ops-unit-item-name">${unit.code} / ${unit.name}</div>
      <div class="ops-unit-item-meta">
        <span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
        ${runtime ? `<span>Acc ${Math.floor(runtime.accumulated_run_sec / 1000)}s</span>` : ""}
      </div>
    `;
    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 = '<div class="muted ops-placeholder">← 选择控制单元</div>';
    state.opsPointEls.clear();
    return;
  }

  dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">加载中...</div>';
  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 = '<div class="muted ops-placeholder">该单元下暂无设备</div>';
    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 `
        <div class="ops-signal-row">
          <span class="ops-signal-label">${ROLE_LABELS[role] || role}</span>
          <span class="badge quality-unknown" data-ops-quality="${point.id}">?</span>
          <span class="ops-signal-value" data-ops-value="${point.id}">--</span>
        </div>`;
    }).join("");

    const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor";

    card.innerHTML = `
      <div class="ops-eq-card-head">
        <strong title="${eq.name}">${eq.code}</strong>
        <span class="badge">${eq.kind || "--"}</span>
      </div>
      <div class="ops-signal-rows">${signalRowsHtml || '<span class="muted" style="font-size:11px;padding:2px 0">无绑定信号</span>'}</div>
      ${canControl ? '<div class="ops-eq-card-actions"></div>' : ""}
    `;

    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
selectedOpsUnitId: null,
  • Step 3: Wire ops into web/js/app.js

Add import:

import { startOps, renderOpsUnits } from "./ops.js";

In bootstrap, add after loadUnits:

await withStatus(loadUnits());   // already exists
startOps();                       // initialise ops unit list

Also update the equipments-updated listener to also call renderOpsUnits:

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():

// notify ops view
document.dispatchEvent(new Event("units-loaded"));

In web/js/app.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
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:

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:

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
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
<section class="panel bottom-mid">
  <div class="panel-head">
    <h2>实时日志</h2>
  </div>
  <div class="log" id="logView"></div>
</section>
  • Step 2: Restore startLogs / stopLogs in web/js/logs.js

Add before startPointSocket:

function escapeHtml(text) {
  return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}

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 = [
      `<span class="level">${escapeHtml(levelRaw || "LOG")}</span>`,
      parsed.timestamp ? `<span class="muted"> ${escapeHtml(parsed.timestamp)}</span>` : "",
      parsed.target ? `<span class="muted"> ${escapeHtml(parsed.target)}</span>` : "",
      `<span class="message">${escapeHtml(parsed.fields?.message || parsed.message || parsed.msg || line)}</span>`,
    ].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
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:

.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

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.jslogs.jsops.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.