feat: ws backoff, signal dots, dom cap, unwrap fix, batch size limit

- logs.js: WS reconnect exponential backoff 1s→2s→4s…30s
- ops.js: replace badge+text signal display with red/green/yellow dots
  (sig-on=green, sig-fault=red, sig-warn=yellow, gray=off)
- events.js: cap live-prepended event cards at 100 DOM nodes
- source.rs: fix attach_children unwrap() → Option<TreeNode>/filter_map
- point.rs: add max=500 validation to all batch Vec<Uuid> fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-03-25 16:37:14 +08:00
parent 13c4b515d7
commit a8d36578fa
6 changed files with 54 additions and 21 deletions

View File

@ -161,12 +161,14 @@ pub struct UpdatePointReq {
/// Request payload for batch setting point tags. /// Request payload for batch setting point tags.
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
pub struct BatchSetPointTagsReq { pub struct BatchSetPointTagsReq {
#[validate(length(min = 1, max = 500))]
pub point_ids: Vec<Uuid>, pub point_ids: Vec<Uuid>,
pub tag_id: Option<Uuid>, pub tag_id: Option<Uuid>,
} }
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
pub struct BatchSetPointEquipmentReq { pub struct BatchSetPointEquipmentReq {
#[validate(length(min = 1, max = 500))]
pub point_ids: Vec<Uuid>, pub point_ids: Vec<Uuid>,
pub equipment_id: Option<Uuid>, pub equipment_id: Option<Uuid>,
pub signal_role: Option<String>, pub signal_role: Option<String>,
@ -448,6 +450,7 @@ pub async fn delete_point(
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
/// Request payload for batch point creation from node ids. /// Request payload for batch point creation from node ids.
pub struct BatchCreatePointsReq { pub struct BatchCreatePointsReq {
#[validate(length(min = 1, max = 500))]
pub node_ids: Vec<Uuid>, pub node_ids: Vec<Uuid>,
} }
@ -563,6 +566,7 @@ pub async fn batch_create_points(
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
/// Request payload for batch point deletion. /// Request payload for batch point deletion.
pub struct BatchDeletePointsReq { pub struct BatchDeletePointsReq {
#[validate(length(min = 1, max = 500))]
pub point_ids: Vec<Uuid>, pub point_ids: Vec<Uuid>,
} }

View File

@ -171,23 +171,24 @@ fn build_node_tree(nodes: Vec<Node>) -> Vec<TreeNode> {
id: Uuid, id: Uuid,
node_map: &mut HashMap<Uuid, TreeNode>, node_map: &mut HashMap<Uuid, TreeNode>,
children_map: &HashMap<Uuid, Vec<Uuid>>, children_map: &HashMap<Uuid, Vec<Uuid>>,
) -> TreeNode { ) -> Option<TreeNode> {
let mut node = node_map.remove(&id).unwrap(); let mut node = node_map.remove(&id)?;
if let Some(child_ids) = children_map.get(&id) { if let Some(child_ids) = children_map.get(&id) {
for &cid in child_ids { for &cid in child_ids {
let child = attach_children(cid, node_map, children_map); if let Some(child) = attach_children(cid, node_map, children_map) {
node.children.push(child); node.children.push(child);
}
} }
} }
node Some(node)
} }
// ③ 生成最终树 // ③ 生成最终树
roots roots
.into_iter() .into_iter()
.map(|rid| attach_children(rid, &mut node_map, &children_map)) .filter_map(|rid| attach_children(rid, &mut node_map, &children_map))
.collect() .collect()
} }

View File

@ -71,6 +71,10 @@ export function prependEvent(item) {
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
dom.eventList.insertBefore(makeCard(item), dom.eventList.firstChild); dom.eventList.insertBefore(makeCard(item), dom.eventList.firstChild);
// Keep DOM bounded to prevent unbounded growth
const cards = dom.eventList.querySelectorAll(".event-card");
if (cards.length > 100) cards[cards.length - 1].remove();
} }
dom.eventList.addEventListener("scroll", () => { dom.eventList.addEventListener("scroll", () => {

View File

@ -79,12 +79,17 @@ function setWsStatus(connected) {
} }
} }
let _reconnectDelay = 1000;
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`);
state.pointSocket = ws; state.pointSocket = ws;
ws.onopen = () => setWsStatus(true); ws.onopen = () => {
setWsStatus(true);
_reconnectDelay = 1000;
};
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
@ -101,12 +106,14 @@ export function startPointSocket() {
entry.time.textContent = data.timestamp || "--"; entry.time.textContent = data.timestamp || "--";
} }
// ops view signal cell // ops view signal dot
const opsEntry = state.opsPointEls.get(data.point_id); const opsEntry = state.opsPointEls.get(data.point_id);
if (opsEntry) { if (opsEntry) {
opsEntry.valueEl.textContent = formatValue(data); const { dotEl } = opsEntry;
opsEntry.qualityEl.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`; const role = dotEl.dataset.opsRole;
opsEntry.qualityEl.textContent = (data.quality || "unknown").toUpperCase(); import("./ops.js").then(({ sigDotClass }) => {
dotEl.className = sigDotClass(role, data.quality, data.value_text);
});
} }
if (state.chartPointId === data.point_id) { if (state.chartPointId === data.point_id) {
@ -137,7 +144,8 @@ export function startPointSocket() {
ws.onclose = () => { ws.onclose = () => {
setWsStatus(false); setWsStatus(false);
window.setTimeout(startPointSocket, 2000); window.setTimeout(startPointSocket, _reconnectDelay);
_reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
}; };
ws.onerror = () => setWsStatus(false); ws.onerror = () => setWsStatus(false);

View File

@ -7,6 +7,14 @@ import { loadUnits } from "./units.js";
const SIGNAL_ROLES = ["rem", "run", "flt"]; const SIGNAL_ROLES = ["rem", "run", "flt"];
const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" }; const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" };
export function sigDotClass(role, quality, valueText) {
if (!quality || quality.toLowerCase() !== "good") return "sig-dot sig-warn";
const v = String(valueText ?? "").trim().toLowerCase();
const on = v === "1" || v === "true" || v === "on";
if (!on) return "sig-dot";
return role === "flt" ? "sig-dot sig-fault" : "sig-dot sig-on";
}
function runtimeBadge(runtime) { function runtimeBadge(runtime) {
if (!runtime) return '<span class="badge offline">OFFLINE</span>'; if (!runtime) return '<span class="badge offline">OFFLINE</span>';
if (runtime.comm_locked) return '<span class="badge offline">COMM ERR</span>'; if (runtime.comm_locked) return '<span class="badge offline">COMM ERR</span>';
@ -128,8 +136,7 @@ function renderOpsEquipments(equipments) {
return ` return `
<div class="ops-signal-row"> <div class="ops-signal-row">
<span class="ops-signal-label">${ROLE_LABELS[role] || role}</span> <span class="ops-signal-label">${ROLE_LABELS[role] || role}</span>
<span class="badge quality-unknown" data-ops-quality="${point.id}">?</span> <span class="sig-dot sig-warn" data-ops-dot="${point.id}" data-ops-role="${role}"></span>
<span class="ops-signal-value" data-ops-value="${point.id}">--</span>
</div>`; </div>`;
}).join(""); }).join("");
@ -172,15 +179,12 @@ function renderOpsEquipments(equipments) {
SIGNAL_ROLES.forEach((role) => { SIGNAL_ROLES.forEach((role) => {
const point = roleMap[role]; const point = roleMap[role];
if (!point) return; if (!point) return;
const valueEl = card.querySelector(`[data-ops-value="${point.id}"]`); const dotEl = card.querySelector(`[data-ops-dot="${point.id}"]`);
const qualityEl = card.querySelector(`[data-ops-quality="${point.id}"]`); if (dotEl) {
if (valueEl && qualityEl) { state.opsPointEls.set(point.id, { dotEl });
state.opsPointEls.set(point.id, { valueEl, qualityEl });
if (point.point_monitor) { if (point.point_monitor) {
const m = point.point_monitor; const m = point.point_monitor;
valueEl.textContent = formatValue(m); dotEl.className = sigDotClass(role, m.quality, m.value_text);
qualityEl.className = `badge quality-${(m.quality || "unknown").toLowerCase()}`;
qualityEl.textContent = (m.quality || "unknown").toUpperCase();
} }
} }
}); });

View File

@ -261,6 +261,18 @@ body {
font-weight: 500; font-weight: 500;
} }
.sig-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
background: var(--text-3);
transition: background 0.2s;
}
.sig-dot.sig-on { background: var(--success); }
.sig-dot.sig-fault { background: var(--danger); }
.sig-dot.sig-warn { background: var(--warning); }
.ops-eq-card-actions { .ops-eq-card-actions {
padding: 6px 10px 8px; padding: 6px 10px 8px;
display: flex; display: flex;