25 KiB
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 82–94) 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
activeViewandlogSourcetoweb/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
switchViewfunction + wiring inweb/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
selectedOpsUnitIdtoweb/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/stopLogsinweb/js/logs.js
Add before startPointSocket:
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 = [
`<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
logViewtoweb/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)
-
UnitRuntimeChangedWS 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.opsPointElsis cleared and rebuilt every time a different unit is selected in ops view — no stale references.- The lazy
import("./ops.js")inlogs.jsforUnitRuntimeChangedavoids a circular dependency (ops.js→logs.js→ops.js). Alternatively, expose adocument.dispatchEvent(new Event("unit-runtime-changed"))and listen inops.js. - The ops view does not reload
state.equipmentsseparately — it uses the/api/unit/{id}/detailresponse which is self-contained. startLogs()is idempotent (guards withif (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.