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

838 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```html
<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:
```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 8294) 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
<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**
```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
<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:
```html
<section class="panel ops-bottom">
```
- [ ] **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 = '<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`**
```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
<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`:
```js
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**
```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.