Files
wwtools/mbproxy/src/Mbproxy/Admin/wwwroot/detail.js
T
Joseph Doherty 374eecd205 mbproxy: fix the dashboard's C2/M-series review findings
Closes the on-demand-capture leak cluster from the code review. The capture's armed state was driven off SignalR's ConnectionId, which changes on every transport reconnect, so a reconnect-during-view leaked a subscriber and left the capture armed forever with no viewer. PlcSubscriptionTracker now keys on a stable per-page-load tabId, and StatusBroadcaster reconciles capture arm state from the live viewer set each push cycle — making arming single-threaded and reconnect-safe. Also fixes the TagValueCapture disarm-vs-record race, the bind-failure broadcaster/listener leak, removes dead JSON-context code, and reworks the frontend cold-start retry plus an unknown-PLC watchdog. Adds tracker / broadcaster-loop / race / wire-shape test coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:12:43 -04:00

304 lines
13 KiB
JavaScript

/* ============================================================================
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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 `<td><span class="tag-name">${escapeHtml(t.name)}</span>` +
`<span class="tag-addr">PDU ${addr} · ${hex4(addr)}</span></td>`;
}
return `<td>${addr} <span class="tag-addr-hex">${hex4(addr)}</span></td>`;
}
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]) =>
`<div class="kv"><span class="k">${escapeHtml(k)}</span>` +
`<span class="v ${cls || ''}">${v}</span></div>`).join('');
return `<div class="metric-card">
<div class="panel-head">${escapeHtml(title)}</div>
${body}${extra || ''}
</div>`;
}
function stateChip(state) {
const cls = state === 'bound' ? 'chip-ok'
: state === 'recovering' ? 'chip-warn'
: 'chip-idle';
return `<span class="chip ${cls}">${escapeHtml(state)}</span>`;
}
// ── 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 =>
`<div class="client-line">${escapeHtml(c.remote)}` +
`<span class="pdu"> · ${num(c.pdusForwarded)} PDUs · since ${escapeHtml(shortTime(c.connectedAtUtc))}</span></div>`
).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 = '<span class="chip chip-idle">removed</span>';
}
// ── 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 = '<tr><td colspan="6" class="empty-row">No BCD tags configured for this PLC.</td></tr>';
return;
}
tbody.innerHTML = debug.tags.map(t => {
if (!t.hasValue) {
return `<tr class="no-traffic">
${tagCell(t)}
<td>${Number(t.width)}-bit</td>
<td colspan="3">no traffic yet</td>
<td class="num">—</td>
</tr>`;
}
const dirCls = t.direction === 'write' ? 'dir-write' : 'dir-read';
const stale = (t.ageSeconds || 0) > 30 ? ' stale' : '';
return `<tr class="${stale.trim()}">
${tagCell(t)}
<td>${Number(t.width)}-bit</td>
<td><span class="dir-tag ${dirCls}">${escapeHtml(t.direction)}</span></td>
<td class="num raw">${escapeHtml(t.rawHex)}</td>
<td class="num dec">${num(t.decodedValue)}</td>
<td class="num">${formatAge(t.ageSeconds)}</td>
</tr>`;
}).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);
})();