/* ============================================================================ Connection-detail page — live per-PLC view over /hub/status (Phase 5). Subscribes to one PLC's "plc" group; renders grouped counter cards and the real-time debug view (per-tag PLC-side raw BCD vs client-side decoded value). ========================================================================= */ 'use strict'; (function () { // ── PLC name from the URL path: /plc/{name} ──────────────────────────── const plcName = decodeURIComponent(location.pathname.replace(/^\/plc\//, '')); const $ = (id) => document.getElementById(id); document.title = `mbproxy — ${plcName}`; $('crumb-name').textContent = plcName; $('plc-name').textContent = plcName; // ── Helpers ──────────────────────────────────────────────────────────── function num(n) { if (n === null || n === undefined) return '—'; return n.toLocaleString('en-US'); } function escapeHtml(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); } function hex4(n) { return '0x' + (n & 0xffff).toString(16).toUpperCase().padStart(4, '0'); } // First debug-row cell: the tag's friendly name (when configured) over its PDU // address, or just the address when unnamed. All dynamic text is escaped. function tagCell(t) { const addr = Number(t.address); if (t.name) { return `${escapeHtml(t.name)}` + `PDU ${addr} · ${hex4(addr)}`; } return `${addr} ${hex4(addr)}`; } function formatAge(sec) { if (sec === null || sec === undefined) return '—'; if (sec < 1) return 'now'; if (sec < 60) return sec.toFixed(1) + 's'; const m = Math.floor(sec / 60); if (m < 60) return `${m}m ${Math.floor(sec % 60)}s`; return `${Math.floor(m / 60)}h ${m % 60}m`; } function shortTime(iso) { try { return new Date(iso).toLocaleTimeString('en-US', { hour12: false }); } catch { return iso; } } // ── Card renderer ────────────────────────────────────────────────────── // rows: array of [key, value, optionalClass]; extra: raw HTML appended. function card(title, rows, extra) { const body = rows.map(([k, v, cls]) => `
${escapeHtml(k)}` + `${v}
`).join(''); return `
${escapeHtml(title)}
${body}${extra || ''}
`; } function stateChip(state) { const cls = state === 'bound' ? 'chip-ok' : state === 'recovering' ? 'chip-warn' : 'chip-idle'; return `${escapeHtml(state)}`; } // ── Render: PLC counters ─────────────────────────────────────────────── function renderPlc(plc) { $('notice').hidden = true; $('cards').hidden = false; $('plc-sub').textContent = `${plc.host}:${plc.listenPort}`; $('plc-state').innerHTML = stateChip(plc.listener.state); const b = plc.backend, p = plc.pdus, l = plc.listener; const e = b.exceptionsByCode || {}; const excnTotal = (e.code01 || 0) + (e.code02 || 0) + (e.code03 || 0) + (e.code04 || 0) + (e.codeOther || 0); const cards = []; // Listener cards.push(card('Listener', [ ['State', stateChip(l.state)], ['Recovery attempts', num(l.recoveryAttempts), l.recoveryAttempts > 0 ? 'warn' : ''], ['Last bind error', l.lastBindError ? escapeHtml(l.lastBindError) : '—', l.lastBindError ? 'bad' : ''], ])); // Clients const clientLines = (plc.clients.remoteEndpoints || []).map(c => `
${escapeHtml(c.remote)}` + ` · ${num(c.pdusForwarded)} PDUs · since ${escapeHtml(shortTime(c.connectedAtUtc))}
` ).join(''); cards.push(card('Clients', [ ['Connected', num(plc.clients.connected)], ], clientLines)); // PDU traffic cards.push(card('PDU traffic', [ ['Forwarded', num(p.forwarded)], ['FC03 read HR', num(p.byFc.fc03)], ['FC04 read IR', num(p.byFc.fc04)], ['FC06 write single', num(p.byFc.fc06)], ['FC16 write multiple', num(p.byFc.fc16)], ['Other FCs', num(p.byFc.other)], ['BCD slots rewritten', num(p.rewrittenSlots)], ['Partial-BCD warnings', num(p.partialBcdWarnings), p.partialBcdWarnings > 0 ? 'warn' : ''], ['Invalid-BCD warnings', num(p.invalidBcdWarnings), p.invalidBcdWarnings > 0 ? 'warn' : ''], ])); // Backend health cards.push(card('Backend health', [ ['Connects ok', num(b.connectsSuccess)], ['Connects failed', num(b.connectsFailed), b.connectsFailed > 0 ? 'bad' : ''], ['Round-trip (EWMA)', (b.lastRoundTripMs || 0).toFixed(1) + ' ms'], ['Exceptions total', num(excnTotal), excnTotal > 0 ? 'bad' : ''], ['· 01 illegal function', num(e.code01)], ['· 02 illegal address', num(e.code02)], ['· 03 illegal value', num(e.code03)], ['· 04 device failure', num(e.code04)], ['· other', num(e.codeOther)], ])); // Multiplexer cards.push(card('Multiplexer', [ ['In flight', num(b.inFlight)], ['Max in flight', num(b.maxInFlight)], ['TxId wraps', num(b.txIdWraps)], ['Disconnect cascades', num(b.disconnectCascades), b.disconnectCascades > 0 ? 'warn' : ''], ['Queue depth', num(b.queueDepth), b.queueDepth > 0 ? 'warn' : ''], ])); // Coalescing cards.push(card('Read coalescing', [ ['Hits', num(b.coalescedHitCount)], ['Misses', num(b.coalescedMissCount)], ['Hit ratio', ratioText(b.coalescedHitCount, b.coalescedMissCount)], ['Resp. to dead upstream', num(b.coalescedResponseToDeadUpstream)], ])); // Cache cards.push(card('Response cache', [ ['Hits', num(b.cacheHitCount)], ['Misses', num(b.cacheMissCount)], ['Hit ratio', ratioText(b.cacheHitCount, b.cacheMissCount)], ['Invalidations', num(b.cacheInvalidations)], ['Entries', num(b.cacheEntryCount)], ['Approx. bytes', num(b.cacheBytes)], ])); // Keepalive cards.push(card('Keepalive', [ ['Heartbeats sent', num(b.backendHeartbeatsSent)], ['Heartbeats failed', num(b.backendHeartbeatsFailed), b.backendHeartbeatsFailed > 0 ? 'bad' : ''], ['Idle disconnects', num(b.backendIdleDisconnects), b.backendIdleDisconnects > 0 ? 'warn' : ''], ])); // Bytes cards.push(card('Bytes', [ ['Upstream in', num(plc.bytes.upstreamIn)], ['Upstream out', num(plc.bytes.upstreamOut)], ])); $('cards').innerHTML = cards.join(''); } function ratioText(hit, miss) { const total = (hit || 0) + (miss || 0); if (total === 0) return '—'; return Math.round((100 * hit) / total) + '%'; } // ── Notices: PLC removed by hot-reload, or unknown / unreachable ─────── function showNotice(msg) { const n = $('notice'); n.textContent = msg; n.hidden = false; $('cards').hidden = true; } function renderMissing() { showNotice('This PLC is no longer in the configuration — it was likely ' + 'removed by a hot-reload. Counters and the debug view are unavailable.'); $('cards').innerHTML = ''; $('plc-sub').textContent = 'not configured'; $('plc-state').innerHTML = 'removed'; } // ── Render: debug view ───────────────────────────────────────────────── function renderDebug(debug) { const cap = $('capture-state'); cap.dataset.armed = String(debug.captureArmed); cap.textContent = debug.captureArmed ? 'capture armed' : 'capture idle'; const tbody = $('debug-rows'); if (!debug.tags || debug.tags.length === 0) { tbody.innerHTML = 'No BCD tags configured for this PLC.'; return; } tbody.innerHTML = debug.tags.map(t => { if (!t.hasValue) { return ` ${tagCell(t)} ${Number(t.width)}-bit no traffic yet — `; } const dirCls = t.direction === 'write' ? 'dir-write' : 'dir-read'; const stale = (t.ageSeconds || 0) > 30 ? ' stale' : ''; return ` ${tagCell(t)} ${Number(t.width)}-bit ${escapeHtml(t.direction)} ${escapeHtml(t.rawHex)} ${num(t.decodedValue)} ${formatAge(t.ageSeconds)} `; }).join(''); } // ── Snapshot handler ─────────────────────────────────────────────────── function onDetail(detail) { gotSnapshot(); if (detail.plc) renderPlc(detail.plc); else renderMissing(); renderDebug(detail.debug || { captureArmed: false, tags: [] }); } // ── Connection-state pill ────────────────────────────────────────────── function setConn(state, text) { $('conn').dataset.state = state; $('conn-text').textContent = text || state; } // ── Unknown-PLC watchdog ─────────────────────────────────────────────── // SubscribePlc succeeds for any name; an unconfigured PLC simply never // produces a 'plc' push. If no snapshot lands shortly after connecting, // say so instead of sitting on "Waiting for first snapshot…" forever. let firstSnapshotTimer = null; function armSnapshotWatchdog() { clearTimeout(firstSnapshotTimer); firstSnapshotTimer = setTimeout(() => { showNotice(`No data for "${plcName}". This PLC is not in the proxy ` + `configuration, or the admin feed is not delivering — ` + `check the name against the fleet page.`); }, 6000); } function gotSnapshot() { clearTimeout(firstSnapshotTimer); firstSnapshotTimer = null; } // ── SignalR ──────────────────────────────────────────────────────────── // Stable per-page-load id so a transport reconnect (which assigns a fresh // ConnectionId) is recognised server-side as the same viewer — that is what // keeps the PLC's tag-value capture from leaking armed. Math.random, not // crypto.randomUUID: the dashboard is served over plain http on the LAN, // where randomUUID is unavailable (non-secure context). const tabId = 't-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); const connection = new signalR.HubConnectionBuilder() .withUrl('/hub/status') .withAutomaticReconnect([0, 1000, 2000, 5000, 10000]) .build(); connection.on('plc', onDetail); connection.onreconnecting(() => setConn('connecting', 'reconnecting')); connection.onreconnected(() => { setConn('connected'); connection.invoke('SubscribePlc', plcName, tabId).catch(() => {}); }); connection.onclose(() => setConn('disconnected', 'disconnected')); // Cold start. withAutomaticReconnect only recovers an already-established // connection, so the initial connect needs its own retry: capped exponential // backoff, and start() only when the socket is actually Disconnected so a // subscribe-only failure never tries to re-start a live connection. let retryMs = 1000; async function connect() { setConn('connecting', 'connecting'); try { if (connection.state === signalR.HubConnectionState.Disconnected) await connection.start(); await connection.invoke('SubscribePlc', plcName, tabId); setConn('connected'); armSnapshotWatchdog(); retryMs = 1000; } catch { setConn('disconnected', 'retrying'); setTimeout(connect, retryMs); retryMs = Math.min(retryMs * 2, 30000); } } document.addEventListener('DOMContentLoaded', connect); })();