feature: decoded hover for UPDATE bytes; opt-in auto-refresh
- Hovering (or focusing) a byte segment opens a popup that decodes it: header fields, the two length fields, and each path attribute with its flags spelled out and its value in readable form. AS_PATH shows segment type, ASNs, path length and ASN width; COMMUNITIES shows well-known names; LARGE_COMMUNITIES shows the SwissIX meaning of known values; MP_REACH/UNREACH show address family, next hop and prefixes; NLRI and withdrawn routes list prefixes. Decoding happens on hover from the bytes themselves. /api/routes/local/wire gains four_octet_asn so the AS_PATH width is known. - The page is now a snapshot by default. ?refresh=<seconds> (1..3600) turns on reloading of all tables at that interval and connects the event log; the header offers 2s / 5s / 30s, "turn off" and "reload now". Events only feed the log, so the tables never change faster than asked. The reader's own announce / withdraw still shows its effect at once. - Removed the "tables refresh on every event" footer text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
20cad53b24
commit
dd838eec9a
3 files changed
+156
-30
No files matched your search
@@ -113,6 +113,7 @@ async fn test_announce_over_http_reaches_peer() -> Result<()> {
|
||||
let wire = get_json(&client, &format!("{base}/api/routes/local/wire?prefix=10.100.1.0/24")).await?;
|
||||
assert_eq!(wire.as_array().unwrap().len(), 1);
|
||||
assert_eq!(wire[0]["peer_asn"], PEER_ASN);
|
||||
assert_eq!(wire[0]["four_octet_asn"], true);
|
||||
assert_eq!(wire[0]["summary"]["as_path"], json!([LOCAL_ASN, LOCAL_ASN]));
|
||||
let hex = wire[0]["hex"].as_str().unwrap();
|
||||
let ctx = ParserContext { four_octet_asn: peer.ctx.four_octet_asn, address_family: None };
|
||||
|
||||
@@ -256,6 +256,8 @@ pub struct WireView {
|
||||
pub peer_addr: String,
|
||||
pub peer_asn: u32,
|
||||
pub next_hop: String,
|
||||
/// Whether AS numbers in AS_PATH are 4 bytes wide on this session.
|
||||
pub four_octet_asn: bool,
|
||||
/// Complete BGP message including the 19-byte header, as lowercase hex.
|
||||
pub hex: String,
|
||||
pub length: usize,
|
||||
@@ -426,6 +428,7 @@ pub async fn wire(
|
||||
peer_addr: session.peer.addr.to_string(),
|
||||
peer_asn: session.peer.asn,
|
||||
next_hop: session.local_addr.ip().to_string(),
|
||||
four_octet_asn: session.four_octet_asn,
|
||||
hex: buf.iter().map(|b| format!("{b:02x}")).collect(),
|
||||
length: buf.len(),
|
||||
summary: summarize(&update.path_attributes),
|
||||
|
||||
+152
-30
@@ -34,6 +34,14 @@ pre.bytes { border: 1px solid #99a; background: #fff; padding: 4px; margin: 4px
|
||||
.hh { background: #dde; } .hl { background: #fcc; } .ha { background: #cfc; } .ha2 { background: #ada; } .hn { background: #ffc; }
|
||||
#event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; }
|
||||
#event-log div { white-space: pre-wrap; }
|
||||
pre.bytes span[data-kind] { cursor: help; }
|
||||
pre.bytes span[data-kind]:hover, pre.bytes span[data-kind]:focus { outline: 1px solid #00c; }
|
||||
#tip { position: absolute; z-index: 10; max-width: 46em; background: #fff; border: 1px solid #99a; font-size: 12px; }
|
||||
#tip .tip-title { background: #eef; font-weight: bold; text-align: center; padding: 1px 6px; border-bottom: 1px solid #99a; }
|
||||
#tip table { border-collapse: collapse; }
|
||||
#tip td { padding: 1px 6px; vertical-align: top; }
|
||||
#tip td:first-child { color: #666; white-space: nowrap; }
|
||||
#tip td.mono { word-break: break-word; }
|
||||
address { font-size: smaller; margin-top: 2em; color: #444; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -50,7 +58,7 @@ Router ID <b id="s-router-id">...</b>, local AS <b id="s-asn">...</b><br>
|
||||
<div class="righthand">
|
||||
Running on <span id="s-hostname">...</span><br>
|
||||
View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <a href="/api/peers">peers json</a>, <a href="/api/routes/local">local routes json</a>, <a href="/api/routes/received">received routes json</a><br>
|
||||
Live updates: <span id="live-text" class="warn">connecting</span><br>
|
||||
<span id="refresh-ctl"></span><br>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,9 +103,11 @@ Live updates: <span id="live-text" class="warn">connecting</span><br>
|
||||
</table>
|
||||
|
||||
<h1 id="events">Event log</h1>
|
||||
<div id="log-state" class="dim"></div>
|
||||
<div id="event-log"></div>
|
||||
<div id="tip" hidden></div>
|
||||
|
||||
<address>ferrix_bgp · <a href="/statusz">/statusz</a> <a href="/metricz">/metricz</a> <a href="/api/events">/api/events</a> · tables refresh on every event</address>
|
||||
<address>ferrix_bgp · <a href="/statusz">/statusz</a> <a href="/metricz">/metricz</a> <a href="/api/events">/api/events</a></address>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
@@ -215,19 +225,114 @@ Live updates: <span id="live-text" class="warn">connecting</span><br>
|
||||
const ATTR_NAMES = {1:'ORIGIN',2:'AS_PATH',3:'NEXT_HOP',4:'MED',5:'LOCAL_PREF',6:'ATOMIC_AGGREGATE',7:'AGGREGATOR',8:'COMMUNITIES',14:'MP_REACH_NLRI',15:'MP_UNREACH_NLRI',16:'EXT_COMMUNITIES',32:'LARGE_COMMUNITIES'};
|
||||
function segmentUpdate(hex) {
|
||||
const bytes = hex.match(/../g) || [], segs = [];
|
||||
const take = (n, cls, label) => { segs.push({ cls, label, hex: bytes.splice(0, n).join(' ') }); };
|
||||
take(16, 'hh', 'marker'); take(2, 'hh', 'length'); take(1, 'hh', 'type = UPDATE');
|
||||
const wlen = parseInt(hex.substr(38, 4), 16); take(2, 'hl', `withdrawn routes length = ${wlen}`); if (wlen) take(wlen, 'hn', 'withdrawn routes');
|
||||
const alen = parseInt(bytes.slice(0, 2).join(''), 16); take(2, 'hl', `path attributes length = ${alen}`);
|
||||
const take = (n, cls, kind) => { segs.push({ cls, kind, hex: bytes.splice(0, n).join(' ') }); };
|
||||
take(16, 'hh', 'marker'); take(2, 'hh', 'length'); take(1, 'hh', 'type');
|
||||
const wlen = parseInt(hex.substr(38, 4), 16); take(2, 'hl', 'wlen'); if (wlen) take(wlen, 'hn', 'withdrawn');
|
||||
const alen = parseInt(bytes.slice(0, 2).join(''), 16); take(2, 'hl', 'alen');
|
||||
let rem = alen, i = 0;
|
||||
while (rem > 0) {
|
||||
const flags = parseInt(bytes[0], 16), type = parseInt(bytes[1], 16), ext = flags & 0x10;
|
||||
const ext = parseInt(bytes[0], 16) & 0x10;
|
||||
const hdr = ext ? 4 : 3, len = ext ? parseInt(bytes[2] + bytes[3], 16) : parseInt(bytes[2], 16);
|
||||
take(hdr + len, i++ % 2 ? 'ha2' : 'ha', `${ATTR_NAMES[type] || 'attribute ' + type} (flags 0x${flags.toString(16)}, length ${len})`); rem -= hdr + len;
|
||||
take(hdr + len, i++ % 2 ? 'ha2' : 'ha', 'attr'); rem -= hdr + len;
|
||||
}
|
||||
if (bytes.length) take(bytes.length, 'hn', 'NLRI');
|
||||
if (bytes.length) take(bytes.length, 'hn', 'nlri');
|
||||
return segs;
|
||||
}
|
||||
|
||||
// ---- decoding a hovered segment into something readable ----------------
|
||||
const u = (b, o, n) => { let v = 0; for (let k = 0; k < n; k++) v = v * 256 + b[o + k]; return v; };
|
||||
const fmtV4 = b => b.slice(0, 4).join('.');
|
||||
function fmtV6(b) {
|
||||
const g = []; for (let k = 0; k < 16; k += 2) g.push(((b[k] << 8) | b[k + 1]).toString(16));
|
||||
let best = -1, bestLen = 0;
|
||||
for (let k = 0; k < 8; k++) { let n = 0; while (k + n < 8 && g[k + n] === '0') n++; if (n > bestLen && n > 1) { best = k; bestLen = n; } }
|
||||
if (best < 0) return g.join(':');
|
||||
return g.slice(0, best).join(':') + '::' + g.slice(best + bestLen).join(':');
|
||||
}
|
||||
function prefixList(b, afi) {
|
||||
const out = []; let o = 0;
|
||||
while (o < b.length) {
|
||||
const len = b[o++], n = Math.ceil(len / 8), raw = b.slice(o, o + n); o += n;
|
||||
const full = raw.concat(Array((afi === 2 ? 16 : 4) - raw.length).fill(0));
|
||||
out.push((afi === 2 ? fmtV6(full) : fmtV4(full)) + '/' + len);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function largeLabel(c) {
|
||||
for (const [pattern, label] of LARGE_PRESETS) {
|
||||
const m = c.match(new RegExp('^' + pattern.replace('PEER', '(\\d+)') + '$'));
|
||||
if (m) return label.replace('peer AS', 'AS' + (m[1] || ''));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const AFI = { 1: 'IPv4', 2: 'IPv6' }, SAFI = { 1: 'unicast', 2: 'multicast' };
|
||||
function decodeAttr(b, w4) {
|
||||
const flags = b[0], type = b[1], ext = flags & 0x10, hdr = ext ? 4 : 3, len = ext ? u(b, 2, 2) : b[2], v = b.slice(hdr);
|
||||
const name = ATTR_NAMES[type] || `attribute ${type}`;
|
||||
const rows = [
|
||||
['flags', `0x${flags.toString(16)}: ${flags & 0x80 ? 'optional' : 'well-known'}, ${flags & 0x40 ? 'transitive' : 'non-transitive'}${flags & 0x20 ? ', partial' : ''}${ext ? ', 2-byte length' : ''}`],
|
||||
['type code', `${type}`], ['value length', `${len} bytes`],
|
||||
];
|
||||
switch (type) {
|
||||
case 1: rows.push(['origin', ['IGP (originated inside the AS)', 'EGP', 'INCOMPLETE (learned some other way)'][v[0]] || `unknown ${v[0]}`]); break;
|
||||
case 2: {
|
||||
const w = w4 ? 4 : 2; let o = 0, hops = 0;
|
||||
while (o + 2 <= v.length) {
|
||||
const st = v[o], n = v[o + 1], asns = []; o += 2;
|
||||
for (let k = 0; k < n; k++, o += w) asns.push(u(v, o, w));
|
||||
rows.push([st === 2 ? 'AS_SEQUENCE' : st === 1 ? 'AS_SET' : `segment ${st}`, asns.join(' ')]);
|
||||
hops += st === 2 ? n : 1;
|
||||
}
|
||||
rows.push(['path length', `${hops} (what best-path selection compares)`], ['AS number width', w4 ? '4 bytes (RFC 6793)' : '2 bytes']);
|
||||
break;
|
||||
}
|
||||
case 3: rows.push(['next hop', fmtV4(v)]); break;
|
||||
case 4: rows.push(['MED', `${u(v, 0, 4)} (lower is preferred, compared between routes from the same neighbour AS)`]); break;
|
||||
case 5: rows.push(['local preference', `${u(v, 0, 4)} (higher is preferred)`]); break;
|
||||
case 8: for (let o = 0; o + 4 <= v.length; o += 4) { const c = `${u(v, o, 2)}:${u(v, o + 2, 2)}`; rows.push(['community', c + (state.wellKnown[c] ? ` = ${state.wellKnown[c]}` : '')]); } break;
|
||||
case 32: for (let o = 0; o + 12 <= v.length; o += 12) { const c = `${u(v, o, 4)}:${u(v, o + 4, 4)}:${u(v, o + 8, 4)}`, l = largeLabel(c); rows.push(['large community', c + (l ? ` = SwissIX: ${l}` : '')]); } break;
|
||||
case 14: {
|
||||
const afi = u(v, 0, 2), nhLen = v[3], nh = v.slice(4, 4 + nhLen);
|
||||
rows.push(['address family', `${AFI[afi] || afi} ${SAFI[v[2]] || v[2]}`]);
|
||||
if (nhLen === 4) rows.push(['next hop', fmtV4(nh)]);
|
||||
else if (nhLen === 16) rows.push(['next hop', fmtV6(nh)]);
|
||||
else if (nhLen === 32) rows.push(['next hop', fmtV6(nh.slice(0, 16))], ['link-local next hop', fmtV6(nh.slice(16))]);
|
||||
prefixList(v.slice(5 + nhLen), afi).forEach(p => rows.push(['announced prefix', p]));
|
||||
break;
|
||||
}
|
||||
case 15: { const afi = u(v, 0, 2); rows.push(['address family', `${AFI[afi] || afi} ${SAFI[v[2]] || v[2]}`]); prefixList(v.slice(3), afi).forEach(p => rows.push(['withdrawn prefix', p])); break; }
|
||||
default: rows.push(['value', v.map(x => x.toString(16).padStart(2, '0')).join(' ')]);
|
||||
}
|
||||
return { title: `Path attribute: ${name}`, rows };
|
||||
}
|
||||
function decodeSegment(kind, b, w4) {
|
||||
switch (kind) {
|
||||
case 'marker': return { title: 'Header: marker', rows: [['value', '16 bytes of 0xff'], ['meaning', 'Fixed filler. It once carried authentication data; now it only helps a receiver find message boundaries.']] };
|
||||
case 'length': return { title: 'Header: message length', rows: [['value', `${u(b, 0, 2)} bytes, including this 19-byte header`], ['limits', '19 to 4096 bytes']] };
|
||||
case 'type': return { title: 'Header: message type', rows: [['value', `${b[0]} = ${['', 'OPEN', 'UPDATE', 'NOTIFICATION', 'KEEPALIVE', 'ROUTE-REFRESH'][b[0]] || 'unknown'}`]] };
|
||||
case 'wlen': return { title: 'Withdrawn routes length', rows: [['value', `${u(b, 0, 2)} bytes`], ['meaning', u(b, 0, 2) ? 'IPv4 prefixes being withdrawn follow.' : 'No IPv4 prefixes are withdrawn by this message.']] };
|
||||
case 'withdrawn': return { title: 'Withdrawn routes', rows: prefixList(b, 1).map(p => ['withdrawn prefix', p]) };
|
||||
case 'alen': return { title: 'Total path attribute length', rows: [['value', `${u(b, 0, 2)} bytes`], ['meaning', 'The path attributes follow; whatever remains after them is IPv4 NLRI.']] };
|
||||
case 'nlri': return { title: 'NLRI: announced IPv4 prefixes', rows: prefixList(b, 1).map(p => ['announced prefix', p]).concat([['encoding', 'one length byte, then only as many address bytes as the length needs']]) };
|
||||
default: return decodeAttr(b, w4);
|
||||
}
|
||||
}
|
||||
function showTip(el) {
|
||||
const b = el.dataset.hex.split(' ').map(x => parseInt(x, 16));
|
||||
const d = decodeSegment(el.dataset.kind, b, el.dataset.w4 === '1'), tip = $('tip');
|
||||
tip.innerHTML = `<div class="tip-title">${esc(d.title)}</div><table>${d.rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td class="mono">${esc(v)}</td></tr>`).join('')}</table>`;
|
||||
tip.hidden = false;
|
||||
const rects = el.getClientRects(), first = rects[0], last = rects[rects.length - 1];
|
||||
const left = Math.max(8, Math.min(first.left, window.innerWidth - tip.offsetWidth - 8));
|
||||
tip.style.left = `${left + window.scrollX}px`; tip.style.top = `${last.bottom + window.scrollY + 4}px`;
|
||||
}
|
||||
const hideTip = () => { $('tip').hidden = true; };
|
||||
const segOf = e => e.target.closest && e.target.closest('pre.bytes span[data-kind]');
|
||||
document.addEventListener('mouseover', e => { const el = segOf(e); if (el) showTip(el); });
|
||||
document.addEventListener('mouseout', e => { if (segOf(e)) hideTip(); });
|
||||
document.addEventListener('focusin', e => { const el = segOf(e); if (el) showTip(el); });
|
||||
document.addEventListener('focusout', e => { if (segOf(e)) hideTip(); });
|
||||
|
||||
// The bytes for a route depend only on the route itself and on which
|
||||
// sessions are up, so they are fetched once per such combination and kept.
|
||||
const wireSig = r => JSON.stringify(r) + '|' + state.peers.filter(p => p.state === 'Established').map(p => p.peer_addr + p.local_addr).join(',');
|
||||
@@ -249,8 +354,8 @@ Live updates: <span id="live-text" class="warn">connecting</span><br>
|
||||
html = !views.length ? '<span class="dim">No established session to send this on.</span>' :
|
||||
views.map(v => `UPDATE as sent to <b>${esc(v.peer_addr)}</b> AS${v.peer_asn}, ${v.length} bytes, next hop ${esc(v.next_hop)}
|
||||
<span class="hh"> header </span> <span class="hl"> lengths </span> <span class="ha"> path attributes </span> <span class="hn"> NLRI </span>
|
||||
<span class="dim">(hover a segment for its name)</span>
|
||||
<pre class="bytes">${segmentUpdate(v.hex).map(s => `<span class="${s.cls}" title="${esc(s.label)}">${s.hex}</span>`).join(' ')}</pre>`).join('');
|
||||
<span class="dim">(hover a segment to decode it)</span>
|
||||
<pre class="bytes">${segmentUpdate(v.hex).map(s => `<span class="${s.cls}" tabindex="0" data-kind="${s.kind}" data-w4="${v.four_octet_asn ? 1 : 0}" data-hex="${s.hex}">${s.hex}</span>`).join(' ')}</pre>`).join('');
|
||||
} catch (e) { html = `<span class="bad">${esc(e.message)}</span>`; }
|
||||
state.wire.set(route.prefix, { sig, html });
|
||||
renderLocal();
|
||||
@@ -284,22 +389,36 @@ Live updates: <span id="live-text" class="warn">connecting</span><br>
|
||||
default: return esc(JSON.stringify(ev));
|
||||
}
|
||||
}
|
||||
function setLive(text, cls) { const e = $('live-text'); e.textContent = text; e.className = cls; }
|
||||
function connect() {
|
||||
const st = $('log-state');
|
||||
const es = new EventSource('/api/events');
|
||||
es.onopen = () => { setLive('connected', 'ok'); loadAll().catch(console.error); };
|
||||
es.onerror = () => setLive('reconnecting', 'bad');
|
||||
es.addEventListener('bgp', e => {
|
||||
const ev = JSON.parse(e.data); logEvent(describe(ev));
|
||||
const t = ev.type;
|
||||
if (t.startsWith('session')) loadPeers().catch(console.error);
|
||||
else if (t.startsWith('local')) loadLocal().catch(console.error);
|
||||
else if (t.startsWith('received')) loadReceived().catch(console.error);
|
||||
else loadAll().catch(console.error);
|
||||
});
|
||||
es.onopen = () => { st.textContent = 'Live: connected to the event stream.'; };
|
||||
es.onerror = () => { st.textContent = 'Reconnecting to the event stream...'; };
|
||||
// Events only feed the log. How often the tables reload is decided by
|
||||
// ?refresh=, so the page never changes faster than the reader asked for.
|
||||
es.addEventListener('bgp', e => logEvent(describe(JSON.parse(e.data))));
|
||||
}
|
||||
|
||||
// ---- auto-refresh, opt-in through ?refresh=<seconds> --------------------
|
||||
const refreshParam = parseInt(new URLSearchParams(location.search).get('refresh'), 10);
|
||||
const REFRESH = Number.isFinite(refreshParam) && refreshParam >= 1 ? Math.min(refreshParam, 3600) : 0;
|
||||
function refreshLink(seconds, text) {
|
||||
const url = new URL(location.href);
|
||||
if (seconds) url.searchParams.set('refresh', seconds); else url.searchParams.delete('refresh');
|
||||
return `<a href="${esc(url.pathname + url.search + url.hash)}">${text}</a>`;
|
||||
}
|
||||
function renderRefreshControl() {
|
||||
$('refresh-ctl').innerHTML = (REFRESH
|
||||
? `Auto-refresh: <b>every ${fmtDur(REFRESH)}</b> (${refreshLink(0, 'turn off')})`
|
||||
: `Auto-refresh: off (turn on: ${[2, 5, 30].map(n => refreshLink(n, n + 's')).join(', ')})`) +
|
||||
` · <a href="#" id="reload-now">reload now</a>`;
|
||||
$('reload-now').addEventListener('click', e => { e.preventDefault(); loadAll().catch(console.error); });
|
||||
}
|
||||
|
||||
// ---- form --------------------------------------------------------------
|
||||
// The reader's own action is not "auto" refresh: show its effect at once,
|
||||
// and again shortly after for whatever the network sends back.
|
||||
function reloadAfterAction() { loadAll().catch(console.error); setTimeout(() => loadAll().catch(console.error), 1500); }
|
||||
const splitList = s => s.split(',').map(x => x.trim()).filter(Boolean);
|
||||
function setMsg(text, cls) { const m = $('announce-msg'); m.className = cls || ''; m.textContent = text; }
|
||||
function appendTo(id, value) {
|
||||
@@ -319,7 +438,7 @@ Live updates: <span id="live-text" class="warn">connecting</span><br>
|
||||
const r = await fetch('/api/routes/local', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const up = state.peers.filter(p => p.state === 'Established').length;
|
||||
if (r.ok) { setMsg(`announced ${j.prefix} to ${up} peer(s)`, 'ok'); loadLocal().catch(console.error); }
|
||||
if (r.ok) { setMsg(`announced ${j.prefix} to ${up} peer(s)`, 'ok'); reloadAfterAction(); }
|
||||
else setMsg(`${r.status}: ${j.error || 'request failed'}`, 'bad');
|
||||
});
|
||||
$('f-clear').addEventListener('click', () => { ['f-prefix','f-communities','f-large','f-med'].forEach(id => $(id).value = ''); $('f-prepend').value = 0; $('f-origin').value = 'IGP'; setMsg(''); });
|
||||
@@ -343,7 +462,7 @@ Live updates: <span id="live-text" class="warn">connecting</span><br>
|
||||
if (!confirm(`Withdraw ${prefix}?`)) return;
|
||||
const r = await fetch('/api/routes/local', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prefix, owner: myOwner() }) });
|
||||
if (!r.ok) { const j = await r.json().catch(() => ({})); setMsg(`${r.status}: ${j.error || 'withdraw failed'}`, 'bad'); }
|
||||
else { setMsg(`withdrew ${prefix}`, 'warn'); state.openWire.delete(prefix); state.wire.delete(prefix); loadLocal().catch(console.error); }
|
||||
else { setMsg(`withdrew ${prefix}`, 'warn'); state.openWire.delete(prefix); state.wire.delete(prefix); reloadAfterAction(); }
|
||||
});
|
||||
|
||||
// ---- boot --------------------------------------------------------------
|
||||
@@ -353,13 +472,16 @@ Live updates: <span id="live-text" class="warn">connecting</span><br>
|
||||
list.forEach(c => { state.wellKnown[c.value] = c.name; const o = document.createElement('option'); o.value = c.name; o.textContent = `${c.name} (${c.value})`; $('f-wk').appendChild(o); });
|
||||
renderLocal(); renderReceived();
|
||||
}).catch(console.error);
|
||||
renderRefreshControl();
|
||||
loadAll().catch(e => logEvent(`<span class="bad">${esc(e.message)}</span>`));
|
||||
connect();
|
||||
// Dial-state changes (Connecting, Backoff, ...) carry no event, so poll the
|
||||
// peers table; it is only redrawn if something changed. Everything else is
|
||||
// event-driven. Ages, countdowns and uptime tick as plain text.
|
||||
setInterval(() => loadPeers().catch(console.error), 5000);
|
||||
setInterval(tick, 1000);
|
||||
if (REFRESH) {
|
||||
connect();
|
||||
setInterval(() => loadAll().catch(console.error), REFRESH * 1000);
|
||||
setInterval(tick, 1000);
|
||||
} else {
|
||||
// A snapshot: nothing moves until the reader reloads or turns refresh on.
|
||||
$('log-state').textContent = 'The event log is live only while auto-refresh is on.';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user