feat(web): dual-view UI — 运维/配置 tab, ops equipment cards with live signal values

- Add 运维/配置 tab switch; grid-ops / grid-config layout classes
- New ops-panel: unit sidebar + equipment card grid (REM/RUN/FLT signals)
- All equipment cards shown by default; unit click acts as filter
- Signal cells seed from point_monitor cache on render, then update via WS PointNewValue
- New log-stream-panel: SSE realtime log stream, active only in config view
- Backend: get_unit_detail now includes point_monitor (current value) in each point

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-03-25 10:25:20 +08:00
parent c2ed1e70fb
commit 4076f6575e
13 changed files with 494 additions and 19 deletions

View File

@ -114,11 +114,18 @@ pub async fn get_unit(
} }
} }
#[derive(serde::Serialize)]
pub struct PointDetail {
#[serde(flatten)]
pub point: crate::model::Point,
pub point_monitor: Option<crate::telemetry::PointMonitorInfo>,
}
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct EquipmentDetail { pub struct EquipmentDetail {
#[serde(flatten)] #[serde(flatten)]
pub equipment: crate::model::Equipment, pub equipment: crate::model::Equipment,
pub points: Vec<crate::model::Point>, pub points: Vec<PointDetail>,
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@ -140,13 +147,21 @@ pub async fn get_unit_detail(
let equipment_ids: Vec<Uuid> = equipments.iter().map(|e| e.id).collect(); let equipment_ids: Vec<Uuid> = equipments.iter().map(|e| e.id).collect();
let all_points = crate::service::get_points_by_equipment_ids(&state.pool, &equipment_ids).await?; let all_points = crate::service::get_points_by_equipment_ids(&state.pool, &equipment_ids).await?;
let monitor_guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
let equipments = equipments let equipments = equipments
.into_iter() .into_iter()
.map(|eq| { .map(|eq| {
let points = all_points let points = all_points
.iter() .iter()
.filter(|p| p.equipment_id == Some(eq.id)) .filter(|p| p.equipment_id == Some(eq.id))
.cloned() .map(|p| PointDetail {
point_monitor: monitor_guard.get(&p.id).cloned(),
point: p.clone(),
})
.collect(); .collect();
EquipmentDetail { equipment: eq, points } EquipmentDetail { equipment: eq, points }
}) })

View File

@ -0,0 +1,6 @@
<section class="panel bottom-mid">
<div class="panel-head">
<h2>实时日志</h2>
</div>
<div class="log" id="logView"></div>
</section>

View File

@ -1,4 +1,4 @@
<section class="panel bottom-middle"> <section class="panel ops-bottom">
<div class="panel-head"> <div class="panel-head">
<h2>系统事件</h2> <h2>系统事件</h2>
<button type="button" class="secondary" id="refreshEventBtn">刷新</button> <button type="button" class="secondary" id="refreshEventBtn">刷新</button>

13
web/html/ops-panel.html Normal file
View File

@ -0,0 +1,13 @@
<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>

View File

@ -1,5 +1,9 @@
<header class="topbar"> <header class="topbar">
<div class="title">PLC Control</div> <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"> <div class="topbar-actions">
<button type="button" class="secondary" id="clearEquipmentFilter">设备筛选: 全部</button> <button type="button" class="secondary" id="clearEquipmentFilter">设备筛选: 全部</button>
<button type="button" class="secondary" id="openApiDoc">API.md</button> <button type="button" class="secondary" id="openApiDoc">API.md</button>

View File

@ -4,15 +4,17 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PLC Control</title> <title>PLC Control</title>
<link rel="stylesheet" href="/ui/styles.css?v=20260324a" /> <link rel="stylesheet" href="/ui/styles.css?v=20260325b" />
</head> </head>
<body> <body>
<div data-partial="/ui/html/topbar.html"></div> <div data-partial="/ui/html/topbar.html"></div>
<main class="grid"> <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/equipment-panel.html"></div>
<div data-partial="/ui/html/points-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/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/logs-panel.html"></div>
<div data-partial="/ui/html/chart-panel.html"></div> <div data-partial="/ui/html/chart-panel.html"></div>
</main> </main>
@ -20,6 +22,6 @@
<div data-partial="/ui/html/modals.html"></div> <div data-partial="/ui/html/modals.html"></div>
<div data-partial="/ui/html/api-doc-drawer.html"></div> <div data-partial="/ui/html/api-doc-drawer.html"></div>
<script type="module" src="/ui/js/index.js?v=20260324a"></script> <script type="module" src="/ui/js/index.js?v=20260325b"></script>
</body> </body>
</html> </html>

View File

@ -14,7 +14,8 @@ import {
resetEquipmentForm, resetEquipmentForm,
saveEquipment, saveEquipment,
} from "./equipment.js"; } from "./equipment.js";
import { startPointSocket } from "./logs.js"; import { startPointSocket, startLogs, stopLogs } from "./logs.js";
import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js";
import { import {
clearBatchBinding, clearBatchBinding,
browseAndLoadTree, browseAndLoadTree,
@ -34,6 +35,36 @@ import { state } from "./state.js";
import { loadSources, saveSource } from "./sources.js"; import { loadSources, saveSource } from "./sources.js";
import { closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js"; import { closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js";
function switchView(view) {
state.activeView = view;
const main = document.querySelector("main");
main.className = view === "ops" ? "grid-ops" : "grid-config";
dom.tabOps.classList.toggle("active", view === "ops");
dom.tabConfig.classList.toggle("active", view === "config");
// config-only panels
["top-left", "top-right", "bottom-left", "bottom-right"].forEach((cls) => {
const el = main.querySelector(`.panel.${cls}`);
if (el) el.classList.toggle("hidden", view === "ops");
});
// bottom-mid is log-stream in config, hidden in ops
const logStreamPanel = main.querySelector(".panel.bottom-mid");
if (logStreamPanel) logStreamPanel.classList.toggle("hidden", view === "ops");
// ops-only panels
const opsMain = main.querySelector(".panel.ops-main");
const opsBottom = main.querySelector(".panel.ops-bottom");
if (opsMain) opsMain.classList.toggle("hidden", view === "config");
if (opsBottom) opsBottom.classList.toggle("hidden", view === "config");
if (view === "config") {
startLogs();
} else {
stopLogs();
}
}
function bindEvents() { function bindEvents() {
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event))); dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event)));
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event))); dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
@ -125,13 +156,23 @@ function bindEvents() {
} }
}); });
dom.tabOps.addEventListener("click", () => switchView("ops"));
dom.tabConfig.addEventListener("click", () => switchView("config"));
document.addEventListener("equipments-updated", () => { document.addEventListener("equipments-updated", () => {
renderUnits(); renderUnits();
renderOpsUnits();
});
document.addEventListener("units-loaded", () => {
renderOpsUnits();
if (!state.selectedOpsUnitId) loadAllEquipmentCards();
}); });
} }
async function bootstrap() { async function bootstrap() {
bindEvents(); bindEvents();
switchView("ops");
renderSelectedNodes(); renderSelectedNodes();
updateSelectedPointSummary(); updateSelectedPointSummary();
updatePointFilterSummary(); updatePointFilterSummary();
@ -139,6 +180,7 @@ async function bootstrap() {
startPointSocket(); startPointSocket();
await withStatus(loadUnits()); await withStatus(loadUnits());
startOps();
await withStatus(loadSources()); await withStatus(loadSources());
await withStatus(loadEquipments()); await withStatus(loadEquipments());
await withStatus(loadEvents()); await withStatus(loadEvents());

View File

@ -2,6 +2,11 @@ const byId = (id) => document.getElementById(id);
export const dom = { export const dom = {
statusText: byId("statusText"), statusText: byId("statusText"),
tabOps: byId("tabOps"),
tabConfig: byId("tabConfig"),
opsUnitList: byId("opsUnitList"),
opsEquipmentArea: byId("opsEquipmentArea"),
logView: byId("logView"),
sourceList: byId("sourceList"), sourceList: byId("sourceList"),
unitList: byId("unitList"), unitList: byId("unitList"),
eventList: byId("eventList"), eventList: byId("eventList"),

View File

@ -1,9 +1,60 @@
import { appendChartPoint } from "./chart.js"; import { appendChartPoint } from "./chart.js";
import { dom } from "./dom.js";
import { prependEvent } from "./events.js"; import { prependEvent } from "./events.js";
import { formatValue } from "./points.js"; import { formatValue } from "./points.js";
import { state } from "./state.js"; import { state } from "./state.js";
import { renderUnits } from "./units.js"; import { renderUnits } from "./units.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;
}
}
export function startPointSocket() { export function startPointSocket() {
const protocol = location.protocol === "https:" ? "wss" : "ws"; const protocol = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`); const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
@ -14,6 +65,8 @@ export function startPointSocket() {
const payload = JSON.parse(event.data); const payload = JSON.parse(event.data);
if (payload.type === "PointNewValue" || payload.type === "point_new_value") { if (payload.type === "PointNewValue" || payload.type === "point_new_value") {
const data = payload.data; const data = payload.data;
// config view point table
const entry = state.pointEls.get(data.point_id); const entry = state.pointEls.get(data.point_id);
if (entry) { if (entry) {
entry.value.textContent = formatValue(data); entry.value.textContent = formatValue(data);
@ -22,6 +75,14 @@ export function startPointSocket() {
entry.time.textContent = data.timestamp || "--"; 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) { if (state.chartPointId === data.point_id) {
appendChartPoint(data); appendChartPoint(data);
} }
@ -36,6 +97,8 @@ export function startPointSocket() {
const runtime = payload.data; const runtime = payload.data;
state.runtimes.set(runtime.unit_id, runtime); state.runtimes.set(runtime.unit_id, runtime);
renderUnits(); renderUnits();
// lazy import to avoid circular dep (ops.js -> logs.js -> ops.js)
import("./ops.js").then(({ renderOpsUnits }) => renderOpsUnits());
return; return;
} }
} catch { } catch {

148
web/js/ops.js Normal file
View File

@ -0,0 +1,148 @@
import { apiFetch } from "./api.js";
import { dom } from "./dom.js";
import { formatValue } from "./points.js";
import { state } from "./state.js";
const SIGNAL_ROLES = ["rem", "run", "flt"];
const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" };
export function renderOpsUnits() {
if (!dom.opsUnitList) return;
dom.opsUnitList.innerHTML = "";
if (!state.units.length) {
dom.opsUnitList.innerHTML = '<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) {
await loadAllEquipmentCards();
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 || []);
}
export async function loadAllEquipmentCards() {
if (!dom.opsEquipmentArea) return;
if (!state.units.length) {
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">暂无控制单元</div>';
return;
}
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">加载中...</div>';
state.opsPointEls.clear();
const details = await Promise.all(
state.units.map((u) => apiFetch(`/api/unit/${u.id}/detail`).catch(() => ({ equipments: [] })))
);
const allEquipments = details.flatMap((d) => d.equipments || []);
renderOpsEquipments(allEquipments);
}
function renderOpsEquipments(equipments) {
dom.opsEquipmentArea.innerHTML = "";
if (!equipments.length) {
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">该单元下暂无设备</div>';
return;
}
equipments.forEach((eq) => {
const card = document.createElement("div");
card.className = "ops-eq-card";
// Build role → point map
const roleMap = {};
(eq.points || []).forEach((p) => {
if (p.signal_role) roleMap[p.signal_role] = p;
});
// Signal rows HTML (placeholders; WS will fill values)
const signalRowsHtml = SIGNAL_ROLES.map((role) => {
const point = roleMap[role];
if (!point) return "";
return `
<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, then seed from cached monitor data
SIGNAL_ROLES.forEach((role) => {
const point = roleMap[role];
if (!point) return;
const valueEl = card.querySelector(`[data-ops-value="${point.id}"]`);
const qualityEl = card.querySelector(`[data-ops-quality="${point.id}"]`);
if (valueEl && qualityEl) {
state.opsPointEls.set(point.id, { valueEl, qualityEl });
if (point.point_monitor) {
const m = point.point_monitor;
valueEl.textContent = formatValue(m);
qualityEl.className = `badge quality-${(m.quality || "unknown").toLowerCase()}`;
qualityEl.textContent = (m.quality || "unknown").toUpperCase();
}
}
});
});
}
export function startOps() {
renderOpsUnits();
loadAllEquipmentCards();
}

View File

@ -21,4 +21,8 @@ export const state = {
pointSocket: null, pointSocket: null,
apiDocLoaded: false, apiDocLoaded: false,
runtimes: new Map(), // unit_id -> UnitRuntime runtimes: new Map(), // unit_id -> UnitRuntime
activeView: "ops", // "ops" | "config"
opsPointEls: new Map(), // point_id -> { valueEl, qualityEl }
logSource: null,
selectedOpsUnitId: null,
}; };

View File

@ -191,6 +191,7 @@ export async function loadUnits() {
renderUnits(); renderUnits();
renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId); renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);
renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId); renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId);
document.dispatchEvent(new Event("units-loaded"));
} }
export async function saveUnit(event) { export async function saveUnit(event) {

View File

@ -59,6 +59,30 @@ body {
gap: 10px; gap: 10px;
} }
/* ── Tabs ───────────────────────────────────────── */
.tab-bar {
display: flex;
gap: 2px;
}
.tab-btn {
padding: 0 16px;
height: 28px;
font-size: 13px;
font-weight: 500;
background: transparent;
border: 1px solid var(--border);
color: var(--text-2);
cursor: pointer;
}
.tab-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.link-button { .link-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -79,19 +103,33 @@ body {
/* ── Grid Layout ────────────────────────────────── */ /* ── Grid Layout ────────────────────────────────── */
.grid { .grid-ops,
.grid-config {
display: grid; display: grid;
grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr);
grid-template-rows: 1fr 380px;
gap: 1px; gap: 1px;
height: calc(100vh - var(--topbar-h)); height: calc(100vh - var(--topbar-h));
} }
.panel.top-left { grid-column: 1; grid-row: 1; } .grid-config {
.panel.top-right { grid-column: 2 / 4; grid-row: 1; } grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr);
.panel.bottom-left { grid-column: 1; grid-row: 2; min-height: 0; } grid-template-rows: 1fr 380px;
.panel.bottom-middle { grid-column: 2; grid-row: 2; min-height: 0; } }
.panel.bottom-right { grid-column: 3; grid-row: 2; min-height: 0; }
.grid-ops {
grid-template-columns: 260px minmax(0, 1fr);
grid-template-rows: 1fr 260px;
}
/* config view slot assignments */
.grid-config .panel.top-left { grid-column: 1; grid-row: 1; }
.grid-config .panel.top-right { grid-column: 2 / 4; grid-row: 1; }
.grid-config .panel.bottom-left { grid-column: 1; grid-row: 2; }
.grid-config .panel.bottom-mid { grid-column: 2; grid-row: 2; }
.grid-config .panel.bottom-right{ grid-column: 3; grid-row: 2; }
/* ops view slot assignments */
.grid-ops .panel.ops-main { grid-column: 1 / 3; grid-row: 1; }
.grid-ops .panel.ops-bottom { grid-column: 1 / 3; grid-row: 2; }
.panel { .panel {
background: var(--surface); background: var(--surface);
@ -119,6 +157,138 @@ body {
border-top: 1px solid var(--border-light); border-top: 1px solid var(--border-light);
} }
/* ── Ops View ───────────────────────────────────── */
.ops-layout {
display: flex;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
}
.ops-unit-sidebar {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.ops-unit-list {
flex: 1 1 auto;
overflow-y: auto;
}
.ops-equipment-area {
flex: 1 1 auto;
overflow: auto;
padding: 12px;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 12px;
}
.ops-placeholder {
padding: 20px;
}
/* Equipment ops card */
.ops-eq-card {
width: 220px;
border: 1px solid var(--border);
background: var(--surface);
display: flex;
flex-direction: column;
gap: 0;
}
.ops-eq-card-head {
padding: 8px 10px 6px;
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
gap: 6px;
}
.ops-eq-card-head strong {
flex: 1;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops-signal-rows {
padding: 6px 10px;
display: flex;
flex-direction: column;
gap: 3px;
}
.ops-signal-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.ops-signal-label {
width: 36px;
color: var(--text-3);
font-size: 11px;
text-transform: uppercase;
flex-shrink: 0;
}
.ops-signal-value {
flex: 1;
font-weight: 500;
}
.ops-eq-card-actions {
padding: 6px 10px 8px;
display: flex;
gap: 6px;
border-top: 1px solid var(--border-light);
}
.ops-eq-card-actions button {
flex: 1;
padding: 3px 0;
font-size: 12px;
}
/* ops unit list item */
.ops-unit-item {
padding: 8px 10px;
cursor: pointer;
border-bottom: 1px solid var(--border-light);
display: flex;
flex-direction: column;
gap: 3px;
}
.ops-unit-item:hover { background: var(--accent-bg); }
.ops-unit-item.selected {
background: var(--accent-bg);
border-left: 3px solid var(--accent);
}
.ops-unit-item-name {
font-size: 13px;
font-weight: 600;
}
.ops-unit-item-meta {
font-size: 11px;
color: var(--text-3);
display: flex;
gap: 6px;
}
/* ── Panel Header ───────────────────────────────── */ /* ── Panel Header ───────────────────────────────── */
.panel-head { .panel-head {
@ -585,6 +755,7 @@ button.danger:hover { background: var(--danger-hover); }
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
.hidden { display: none !important; }
.modal.hidden { display: none; } .modal.hidden { display: none; }
.modal-content { .modal-content {
@ -1084,7 +1255,8 @@ button.danger:hover { background: var(--danger-hover); }
/* ── Responsive ───────────────────────────────────── */ /* ── Responsive ───────────────────────────────────── */
@media (max-width: 900px) { @media (max-width: 900px) {
.grid { .grid-config,
.grid-ops {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto auto auto auto; grid-template-rows: auto auto auto auto;
height: auto; height: auto;
@ -1092,9 +1264,9 @@ button.danger:hover { background: var(--danger-hover); }
body { height: auto; overflow: auto; } body { height: auto; overflow: auto; }
.panel.top-left { min-height: 200px; } .panel.top-left { min-height: 200px; }
.panel.top-right { min-height: 300px; } .panel.top-right { min-height: 300px; }
.panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; } .grid-config .panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
.panel.bottom-middle { grid-column: 1; grid-row: 4; min-height: 200px; } .grid-config .panel.bottom-mid { grid-column: 1; grid-row: 4; min-height: 200px; }
.panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; } .grid-config .panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; }
.drawer { width: 100vw; } .drawer { width: 100vw; }
.drawer-body { grid-template-columns: 1fr; } .drawer-body { grid-template-columns: 1fr; }
.equipment-layout { grid-template-columns: 1fr; } .equipment-layout { grid-template-columns: 1fr; }