mbproxy: replace status page with a live SignalR web dashboard
The single auto-refreshing zero-JS status page gave operators a 25-column wall and no way to drill into one connection. This adds a Bootstrap fleet dashboard (filterable/sortable KPI table) and a per-PLC detail page with a real-time debug view of raw PLC-side BCD vs. decoded client-side values, streamed live over a SignalR feed. The debug view is fed by an on-demand per-tag value capture, armed only while a detail page is open. All assets (Bootstrap, SignalR client, fonts) are embedded so the UI works unchanged on firewalled networks; GET /status.json is untouched for scrapers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
/* ============================================================================
|
||||
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'); }
|
||||
|
||||
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}">${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 ${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) + '%';
|
||||
}
|
||||
|
||||
// ── Render: PLC removed by hot-reload ──────────────────────────────────
|
||||
function renderMissing() {
|
||||
$('notice').hidden = false;
|
||||
$('cards').hidden = true;
|
||||
$('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">
|
||||
<td>${t.address} <span class="ratio-sub">${hex4(t.address)}</span></td>
|
||||
<td>${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()}">
|
||||
<td>${t.address} <span class="ratio-sub">${hex4(t.address)}</span></td>
|
||||
<td>${t.width}-bit</td>
|
||||
<td><span class="dir-tag ${dirCls}">${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) {
|
||||
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;
|
||||
}
|
||||
|
||||
// ── SignalR ────────────────────────────────────────────────────────────
|
||||
function connect() {
|
||||
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); });
|
||||
connection.onclose(() => setConn('disconnected', 'disconnected'));
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
setConn('connecting', 'connecting');
|
||||
await connection.start();
|
||||
await connection.invoke('SubscribePlc', plcName);
|
||||
setConn('connected');
|
||||
} catch {
|
||||
setConn('disconnected', 'retrying');
|
||||
setTimeout(start, 3000);
|
||||
}
|
||||
}
|
||||
start();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', connect);
|
||||
})();
|
||||
Reference in New Issue
Block a user