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>
This commit is contained in:
@@ -175,10 +175,16 @@
|
||||
return Math.round((100 * hit) / total) + '%';
|
||||
}
|
||||
|
||||
// ── Render: PLC removed by hot-reload ──────────────────────────────────
|
||||
function renderMissing() {
|
||||
$('notice').hidden = false;
|
||||
// ── 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>';
|
||||
@@ -220,6 +226,7 @@
|
||||
|
||||
// ── Snapshot handler ───────────────────────────────────────────────────
|
||||
function onDetail(detail) {
|
||||
gotSnapshot();
|
||||
if (detail.plc) renderPlc(detail.plc);
|
||||
else renderMissing();
|
||||
renderDebug(detail.debug || { captureArmed: false, tags: [] });
|
||||
@@ -231,31 +238,65 @@
|
||||
$('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 ────────────────────────────────────────────────────────────
|
||||
function connect() {
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl('/hub/status')
|
||||
.withAutomaticReconnect([0, 1000, 2000, 5000, 10000])
|
||||
.build();
|
||||
// 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);
|
||||
|
||||
connection.on('plc', onDetail);
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl('/hub/status')
|
||||
.withAutomaticReconnect([0, 1000, 2000, 5000, 10000])
|
||||
.build();
|
||||
|
||||
connection.onreconnecting(() => setConn('connecting', 'reconnecting'));
|
||||
connection.onreconnected(() => { setConn('connected'); connection.invoke('SubscribePlc', plcName); });
|
||||
connection.onclose(() => setConn('disconnected', 'disconnected'));
|
||||
connection.on('plc', onDetail);
|
||||
connection.onreconnecting(() => setConn('connecting', 'reconnecting'));
|
||||
connection.onreconnected(() => {
|
||||
setConn('connected');
|
||||
connection.invoke('SubscribePlc', plcName, tabId).catch(() => {});
|
||||
});
|
||||
connection.onclose(() => setConn('disconnected', 'disconnected'));
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
setConn('connecting', 'connecting');
|
||||
// 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);
|
||||
setConn('connected');
|
||||
} catch {
|
||||
setConn('disconnected', 'retrying');
|
||||
setTimeout(start, 3000);
|
||||
}
|
||||
await connection.invoke('SubscribePlc', plcName, tabId);
|
||||
setConn('connected');
|
||||
armSnapshotWatchdog();
|
||||
retryMs = 1000;
|
||||
} catch {
|
||||
setConn('disconnected', 'retrying');
|
||||
setTimeout(connect, retryMs);
|
||||
retryMs = Math.min(retryMs * 2, 30000);
|
||||
}
|
||||
start();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', connect);
|
||||
|
||||
Reference in New Issue
Block a user