596 lines
29 KiB
HTML
596 lines
29 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="color-scheme" content="light dark">
|
|
<title>Xahau Validation Pulse</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light;
|
|
--bg: #f7f8f7;
|
|
--line: #d9dfdc;
|
|
--text: #17201d;
|
|
--muted: #62706a;
|
|
--green: #167a55;
|
|
--cyan: #286c73;
|
|
--amber: #946b16;
|
|
--red: #a13f3f;
|
|
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
}
|
|
|
|
:root[data-theme="dark"] {
|
|
color-scheme: dark;
|
|
--bg: #111614;
|
|
--line: #2d3935;
|
|
--text: #edf2f0;
|
|
--muted: #9aa9a3;
|
|
--green: #58c99a;
|
|
--cyan: #73bdc3;
|
|
--amber: #d3ad61;
|
|
--red: #e07b7b;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
html { background: var(--bg); }
|
|
body {
|
|
margin: 0;
|
|
min-width: 320px;
|
|
min-height: 100vh;
|
|
color: var(--text);
|
|
background: var(--bg);
|
|
font-family: var(--sans);
|
|
}
|
|
|
|
.shell { width: min(1320px, calc(100% - 40px)); margin: 0 auto; padding: 30px 0 52px; }
|
|
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; margin-bottom: 34px; }
|
|
.eyebrow { color: var(--green); font: 600 12px/1.4 var(--mono); letter-spacing: .16em; text-transform: uppercase; }
|
|
h1 { margin: 8px 0 6px; font-size: clamp(28px, 4vw, 42px); font-weight: 520; letter-spacing: -.035em; line-height: 1.1; }
|
|
.subtitle { margin: 0; color: var(--muted); font-size: 15px; }
|
|
.header-actions { display: flex; align-items: center; gap: 20px; }
|
|
.theme-toggle { border: 1px solid var(--line); background: transparent; color: var(--text); border-radius: 4px; padding: 7px 10px; font: 12px/1 var(--sans); cursor: pointer; }
|
|
.theme-toggle:hover { background: color-mix(in srgb, var(--text) 5%, transparent); }
|
|
.connection { display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: 13px; white-space: nowrap; }
|
|
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 5px rgba(241, 197, 109, .08); }
|
|
.connection.live .dot { background: var(--green); box-shadow: none; }
|
|
.connection.offline .dot { background: var(--red); box-shadow: 0 0 0 5px rgba(255, 125, 125, .08); }
|
|
|
|
.stream-callout { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin-bottom: 34px; padding: 18px 20px; border: 1px solid var(--line); background: color-mix(in srgb, var(--green) 4%, var(--bg)); }
|
|
.stream-callout p { margin: 3px 0 0; color: var(--muted); font-size: 13px; }
|
|
.stream-callout .metadata-note { margin-top: 8px; font-size: 12px; }
|
|
.stream-label { color: var(--green); font-size: 13px; font-weight: 600; }
|
|
.stream-endpoints { display: grid; gap: 7px; }
|
|
.stream-endpoint { display: flex; justify-content: space-between; gap: 18px; padding: 8px 10px; color: var(--text); background: var(--bg); border: 1px solid var(--line); font: 500 12px/1 var(--mono); white-space: nowrap; }
|
|
.stream-endpoint small { color: var(--muted); font: 11px/1 var(--sans); }
|
|
|
|
.consensus-section { margin: 16px 0 42px; }
|
|
.section-head { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; margin-bottom: 15px; }
|
|
h2 { margin: 0; font-size: 15px; font-weight: 550; letter-spacing: .01em; }
|
|
.section-note { color: var(--muted); font: 12px/1.4 var(--mono); }
|
|
.consensus-graphic { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
|
.consensus-svg { display: block; width: min(100%, 600px); height: auto; margin: 0 auto; }
|
|
.ring { fill: none; stroke: var(--line); stroke-width: 1; }
|
|
.spoke { stroke: var(--line); stroke-width: 1; }
|
|
.spoke.agree { stroke: color-mix(in srgb, var(--green) 45%, var(--line)); }
|
|
.spoke.differ { stroke: color-mix(in srgb, var(--red) 55%, var(--line)); }
|
|
.validator-node { stroke: var(--bg); stroke-width: 2; }
|
|
.arrival { fill: none; stroke: var(--green); stroke-width: 1.5; animation: arrival 1.4s ease-out forwards; pointer-events: none; }
|
|
.ring-label { fill: var(--muted); font: 11px/1 var(--sans); text-anchor: middle; }
|
|
.ring-label { letter-spacing: .08em; text-transform: uppercase; }
|
|
.consensus-meta { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 13px 0; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
|
|
.ring-stats { display: flex; flex-wrap: wrap; gap: 10px 24px; }
|
|
.ring-stat strong { margin-left: 6px; color: var(--text); font: 500 13px/1 var(--mono); }
|
|
.legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 14px; white-space: nowrap; }
|
|
.legend-item { display: inline-flex; align-items: center; gap: 6px; }
|
|
.legend-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--line); }
|
|
.legend-dot.agree { background: var(--green); }
|
|
.legend-dot.differ { background: var(--red); }
|
|
@keyframes arrival { from { r: 8; opacity: .8; } to { r: 16; opacity: 0; } }
|
|
|
|
.offline-summary { margin: 0 0 24px; padding: 17px 20px; border: 1px solid color-mix(in srgb, var(--red) 45%, var(--line)); background: color-mix(in srgb, var(--red) 5%, var(--bg)); }
|
|
.offline-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; }
|
|
.offline-count { color: var(--red); font: 500 13px/1 var(--mono); }
|
|
.offline-description { margin: 6px 0 0; color: var(--muted); font-size: 12px; }
|
|
.offline-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(310px, 1fr)); gap: 8px 24px; margin: 15px 0 0; padding: 0; list-style: none; }
|
|
.offline-list li { display: flex; justify-content: space-between; gap: 12px; padding-top: 9px; border-top: 1px solid var(--line); min-width: 0; }
|
|
.offline-name { overflow: hidden; color: var(--text); font: 12px/1.4 var(--mono); text-overflow: ellipsis; white-space: nowrap; }
|
|
.offline-age { flex: none; color: var(--red); font-size: 12px; }
|
|
|
|
.table-wrap { overflow-x: auto; border-top: 1px solid var(--line); }
|
|
table { width: 100%; border-collapse: collapse; min-width: 850px; }
|
|
th { padding: 13px 14px; color: var(--muted); font-size: 11px; font-weight: 520; letter-spacing: .1em; text-align: left; text-transform: uppercase; border-bottom: 1px solid var(--line); }
|
|
.sort-button { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; color: inherit; background: none; font: inherit; letter-spacing: inherit; text-transform: inherit; cursor: pointer; }
|
|
.sort-button::after { content: '↕'; color: var(--line); font-size: 10px; }
|
|
th[aria-sort="ascending"] .sort-button::after { content: '↑'; color: var(--text); }
|
|
th[aria-sort="descending"] .sort-button::after { content: '↓'; color: var(--text); }
|
|
th:last-child .sort-button { margin-left: auto; }
|
|
td { padding: 15px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
|
th:first-child, td:first-child { padding-left: 0; }
|
|
th:last-child, td:last-child { padding-right: 0; text-align: right; }
|
|
tbody tr { transition: background .25s ease; }
|
|
tbody tr.fresh { background: color-mix(in srgb, var(--green) 6%, transparent); }
|
|
tbody tr.offline { background: color-mix(in srgb, var(--red) 4%, transparent); }
|
|
.key { font: 12px/1.5 var(--mono); color: var(--text); white-space: nowrap; }
|
|
.key-short { display: none; }
|
|
.vl-mark { display: inline-block; margin-left: 9px; padding: 3px 6px; border: 1px solid color-mix(in srgb, var(--green) 50%, var(--line)); color: var(--green); font: 700 11px/1 var(--sans); letter-spacing: .07em; }
|
|
.version { font: 12px/1.4 var(--mono); color: var(--cyan); white-space: nowrap; }
|
|
.version.pending { color: var(--muted); font-family: var(--sans); }
|
|
.age { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
|
.empty { padding: 44px 0; color: var(--muted); text-align: center; font-size: 14px; }
|
|
footer { display: flex; justify-content: space-between; gap: 20px; margin-top: 24px; color: var(--muted); font-size: 11px; }
|
|
footer code { font-family: var(--mono); color: #b9cec7; }
|
|
|
|
@media (max-width: 720px) {
|
|
.shell { width: min(100% - 24px, 1440px); padding-top: 22px; }
|
|
header { display: block; margin-bottom: 25px; }
|
|
.connection { margin-top: 16px; }
|
|
.stream-callout { display: block; margin-bottom: 25px; padding: 15px; }
|
|
.stream-endpoints { margin-top: 13px; overflow-x: auto; }
|
|
.consensus-meta { display: block; }
|
|
.legend { justify-content: flex-start; margin-top: 10px; }
|
|
.key-full { display: none; }
|
|
.key-short { display: inline; }
|
|
footer { display: block; line-height: 1.7; }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*, *::before, *::after { transition: none !important; }
|
|
.arrival { animation: none; display: none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="shell">
|
|
<header>
|
|
<div>
|
|
<div class="eyebrow">Xahau network telemetry</div>
|
|
<h1>Validation pulse</h1>
|
|
<p class="subtitle">Live consensus validations, observed as they arrive.</p>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button id="theme-toggle" class="theme-toggle" type="button" aria-pressed="false">Dark mode</button>
|
|
<div id="connection" class="connection" role="status" aria-live="polite">
|
|
<span class="dot" aria-hidden="true"></span>
|
|
<span id="connection-text">Connecting</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="offline-summary" aria-labelledby="offline-title">
|
|
<div class="offline-heading">
|
|
<h2 id="offline-title">Offline VL validators</h2>
|
|
<span id="offline-count" class="offline-count">Assessing…</span>
|
|
</div>
|
|
<p class="offline-description">A VL validator is shown offline immediately if it has never been observed, or after it misses two ledgers. A 15-second timeout is used until ledger activity is available.</p>
|
|
<ul id="offline-list" class="offline-list">
|
|
<li><span class="offline-name">Waiting for validator-list data…</span></li>
|
|
</ul>
|
|
</section>
|
|
|
|
<aside class="stream-callout" aria-label="Public validation WebSocket">
|
|
<div>
|
|
<div class="stream-label">Use the live validation stream</div>
|
|
<p>This dashboard is one example. Connect your own application directly to the public WebSocket.</p>
|
|
<p class="metadata-note"><strong>Public metadata</strong> means the validator's master key, manifest domain, <code>vl.xahau.org</code> listing and offline status, last known server version, latest ledger and last-seen time. With <code>?dashboard=1</code>, known metadata is sent when you connect and updated as it changes; versions may arrive immediately from the relay's version store.</p>
|
|
</div>
|
|
<div class="stream-endpoints">
|
|
<code class="stream-endpoint"><span>wss://xahauvalidations.alloy.ee/</span><small>unchanged validations</small></code>
|
|
<code class="stream-endpoint"><span>wss://xahauvalidations.alloy.ee/?dashboard=1</span><small>+ public metadata</small></code>
|
|
</div>
|
|
</aside>
|
|
|
|
<section class="consensus-section" aria-labelledby="consensus-title">
|
|
<div class="section-head">
|
|
<h2 id="consensus-title">Live consensus</h2>
|
|
<span class="section-note">current ledger validations</span>
|
|
</div>
|
|
<div class="consensus-graphic">
|
|
<svg id="consensus-ring" class="consensus-svg" viewBox="0 0 600 360" role="img" aria-labelledby="consensus-title consensus-description">
|
|
<desc id="consensus-description">Validators arranged in fixed positions around the latest ledger. Inner nodes are listed by vl.xahau.org.</desc>
|
|
</svg>
|
|
<div class="consensus-meta">
|
|
<div class="ring-stats" aria-label="Live validation metrics">
|
|
<span class="ring-stat">Current ledger <strong id="ledger">—</strong></span>
|
|
<span class="ring-stat">Next flag ledger <strong id="next-flag-ledger">—</strong> <span id="flag-ledgers-remaining"></span></span>
|
|
<span class="ring-stat">Validators <strong id="validators">0</strong></span>
|
|
</div>
|
|
<div class="legend" aria-label="Consensus legend">
|
|
<span class="legend-item"><span class="legend-dot agree"></span>Leading hash</span>
|
|
<span class="legend-item"><span class="legend-dot differ"></span>Different hash</span>
|
|
<span class="legend-item"><span class="legend-dot"></span>Previous ledger</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section aria-labelledby="validators-title">
|
|
<div class="section-head">
|
|
<h2 id="validators-title">Validator activity</h2>
|
|
<span id="updated" class="section-note">Waiting for data</span>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th><button class="sort-button" type="button" data-sort="masterKey">Master key</button></th>
|
|
<th aria-sort="ascending"><button class="sort-button" type="button" data-sort="domain">Domain</button></th>
|
|
<th><button class="sort-button" type="button" data-sort="version">Version</button></th>
|
|
<th><button class="sort-button" type="button" data-sort="lastSeen">Last seen</button></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="validator-rows">
|
|
<tr><td class="empty" colspan="4">Waiting for the first validation…</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<footer>
|
|
<span>Versions are announced on flag-ledger validations. No data is persisted in this browser.</span>
|
|
<span>WebSocket <code>wss://xahauvalidations.alloy.ee/</code></span>
|
|
</footer>
|
|
</main>
|
|
|
|
<script>
|
|
(() => {
|
|
const MAX_ROWS = 100;
|
|
const validators = new Map();
|
|
const metadata = new Map();
|
|
let latestLedger = 0;
|
|
let reconnectDelay = 1000;
|
|
let sortKey = 'domain';
|
|
let sortDirection = 1;
|
|
let socket;
|
|
|
|
const elements = {
|
|
connection: document.getElementById('connection'),
|
|
connectionText: document.getElementById('connection-text'),
|
|
validators: document.getElementById('validators'),
|
|
ledger: document.getElementById('ledger'),
|
|
nextFlagLedger: document.getElementById('next-flag-ledger'),
|
|
flagLedgersRemaining: document.getElementById('flag-ledgers-remaining'),
|
|
consensusRing: document.getElementById('consensus-ring'),
|
|
offlineCount: document.getElementById('offline-count'),
|
|
offlineList: document.getElementById('offline-list'),
|
|
rows: document.getElementById('validator-rows'),
|
|
updated: document.getElementById('updated'),
|
|
themeToggle: document.getElementById('theme-toggle'),
|
|
sortButtons: Array.from(document.querySelectorAll('.sort-button')),
|
|
};
|
|
|
|
function setConnection(mode, label) {
|
|
elements.connection.className = `connection ${mode}`;
|
|
elements.connectionText.textContent = label;
|
|
}
|
|
|
|
function decodeVersion(raw) {
|
|
if (raw === undefined || raw === null || raw === '') return null;
|
|
try {
|
|
const encoded = BigInt(String(raw));
|
|
const implementation = Number((encoded >> 48n) & 0xffffn);
|
|
if (implementation !== 0x183b) return `Unknown · ${raw}`;
|
|
const year = Number((encoded >> 40n) & 0xffn) + 2023;
|
|
const minor = Number((encoded >> 32n) & 0xffn);
|
|
const patch = Number((encoded >> 24n) & 0xffn);
|
|
const stage = Number((encoded >> 16n) & 0xffn);
|
|
const build = Number(encoded & 0xffffn);
|
|
const kind = stage & 0xc0;
|
|
const number = stage & 0x3f;
|
|
const prerelease = kind === 0x80 ? `-rc${number}` : kind === 0x40 ? `-b${number}` : '';
|
|
return `${year}.${minor}.${patch}${prerelease}+${build}`;
|
|
} catch {
|
|
return `Unknown · ${raw}`;
|
|
}
|
|
}
|
|
|
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
|
|
function svgElement(name, attributes = {}) {
|
|
const element = document.createElementNS(SVG_NS, name);
|
|
for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, value);
|
|
return element;
|
|
}
|
|
|
|
function stableAngle(masterKey) {
|
|
let hash = 2166136261;
|
|
for (let index = 0; index < masterKey.length; index += 1) {
|
|
hash ^= masterKey.charCodeAt(index);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return ((hash >>> 0) / 4294967296) * Math.PI * 2 - Math.PI / 2;
|
|
}
|
|
|
|
function renderConsensus() {
|
|
const svg = elements.consensusRing;
|
|
svg.replaceChildren();
|
|
const description = svgElement('desc', { id: 'consensus-description' });
|
|
description.textContent = 'Validators arranged in fixed positions around the latest ledger. Inner nodes are listed by vl.xahau.org.';
|
|
svg.appendChild(description);
|
|
const centerX = 300;
|
|
const centerY = 180;
|
|
const current = Array.from(validators.values()).filter((validator) => Number(validator.ledger) === latestLedger);
|
|
const hashCounts = new Map();
|
|
for (const validator of current) {
|
|
if (validator.hash) hashCounts.set(validator.hash, (hashCounts.get(validator.hash) || 0) + 1);
|
|
}
|
|
const leadingHash = Array.from(hashCounts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
|
|
const outerRing = svgElement('circle', { cx: centerX, cy: centerY, r: 142, class: 'ring' });
|
|
const innerRing = svgElement('circle', { cx: centerX, cy: centerY, r: 96, class: 'ring' });
|
|
svg.append(outerRing, innerRing);
|
|
const innerLabel = svgElement('text', { x: centerX, y: 70, class: 'ring-label' });
|
|
innerLabel.textContent = 'VL validators';
|
|
svg.appendChild(innerLabel);
|
|
|
|
for (const validator of Array.from(validators.values()).sort((a, b) => a.masterKey.localeCompare(b.masterKey))) {
|
|
const angle = stableAngle(validator.masterKey);
|
|
const radius = validator.listed ? 96 : 142;
|
|
const x = centerX + Math.cos(angle) * radius;
|
|
const y = centerY + Math.sin(angle) * radius;
|
|
const onCurrentLedger = Number(validator.ledger) === latestLedger;
|
|
const agrees = onCurrentLedger && (!leadingHash || validator.hash === leadingHash);
|
|
const differs = onCurrentLedger && leadingHash && validator.hash && validator.hash !== leadingHash;
|
|
const color = agrees ? 'var(--green)' : differs ? 'var(--red)' : 'var(--line)';
|
|
|
|
const spoke = svgElement('line', {
|
|
x1: centerX,
|
|
y1: centerY,
|
|
x2: x,
|
|
y2: y,
|
|
class: `spoke${agrees ? ' agree' : differs ? ' differ' : ''}`,
|
|
});
|
|
svg.appendChild(spoke);
|
|
if (Date.now() - validator.lastSeen < 1500 && onCurrentLedger) {
|
|
svg.appendChild(svgElement('circle', { cx: x, cy: y, r: 8, class: 'arrival' }));
|
|
}
|
|
const node = svgElement('circle', {
|
|
cx: x,
|
|
cy: y,
|
|
r: validator.listed ? 7 : 6,
|
|
fill: color,
|
|
class: 'validator-node',
|
|
});
|
|
svg.appendChild(node);
|
|
}
|
|
}
|
|
|
|
function relativeTime(timestamp) {
|
|
if (!Number.isFinite(timestamp)) return 'Never observed';
|
|
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
|
|
if (seconds < 5) return 'now';
|
|
if (seconds < 60) return `${seconds}s ago`;
|
|
return `${Math.floor(seconds / 60)}m ago`;
|
|
}
|
|
|
|
function domainSortValue(domain) {
|
|
if (!domain) return '\uffff';
|
|
const normalized = domain.toLowerCase().replace(/\.$/, '');
|
|
const labels = normalized.split('.');
|
|
const baseDomain = labels.length > 1 ? labels.slice(-2).join('.') : normalized;
|
|
return `${baseDomain}\u0000${normalized}`;
|
|
}
|
|
|
|
function compareValidators(a, b) {
|
|
const listedOrder = Number(Boolean(b.listed)) - Number(Boolean(a.listed));
|
|
if (listedOrder) return listedOrder;
|
|
let comparison = 0;
|
|
if (sortKey === 'lastSeen') {
|
|
comparison = (a.lastSeen || 0) - (b.lastSeen || 0);
|
|
} else if (sortKey === 'domain') {
|
|
comparison = domainSortValue(a.domain).localeCompare(domainSortValue(b.domain));
|
|
} else {
|
|
comparison = String(a[sortKey] || '').localeCompare(String(b[sortKey] || ''));
|
|
}
|
|
return comparison * sortDirection || a.masterKey.localeCompare(b.masterKey);
|
|
}
|
|
|
|
function updateSortIndicators() {
|
|
for (const button of elements.sortButtons) {
|
|
const active = button.dataset.sort === sortKey;
|
|
const header = button.closest('th');
|
|
if (active) header.setAttribute('aria-sort', sortDirection === 1 ? 'ascending' : 'descending');
|
|
else header.removeAttribute('aria-sort');
|
|
}
|
|
}
|
|
|
|
function isOffline(validator) {
|
|
return validator.listed === true && validator.offline === true;
|
|
}
|
|
|
|
function renderOfflineSummary() {
|
|
const listed = Array.from(validators.values()).filter((validator) => validator.listed);
|
|
const offline = listed.filter(isOffline)
|
|
.sort((a, b) => domainSortValue(a.domain).localeCompare(domainSortValue(b.domain)) || a.masterKey.localeCompare(b.masterKey));
|
|
elements.offlineCount.textContent = listed.length === 0
|
|
? 'VL unavailable'
|
|
: `${offline.length} of ${listed.length} offline`;
|
|
elements.offlineList.replaceChildren();
|
|
|
|
if (listed.length === 0) {
|
|
const item = document.createElement('li');
|
|
item.innerHTML = '<span class="offline-name">Waiting for validator-list data…</span>';
|
|
elements.offlineList.appendChild(item);
|
|
return;
|
|
}
|
|
if (offline.length === 0) {
|
|
const item = document.createElement('li');
|
|
item.innerHTML = '<span class="offline-name">No VL validators are currently offline.</span>';
|
|
elements.offlineList.appendChild(item);
|
|
return;
|
|
}
|
|
for (const validator of offline) {
|
|
const item = document.createElement('li');
|
|
const name = document.createElement('span');
|
|
name.className = 'offline-name';
|
|
name.textContent = validator.domain ? `${validator.domain} · ${validator.masterKey}` : validator.masterKey;
|
|
name.title = name.textContent;
|
|
const age = document.createElement('span');
|
|
age.className = 'offline-age';
|
|
age.textContent = relativeTime(validator.lastSeen);
|
|
item.append(name, age);
|
|
elements.offlineList.appendChild(item);
|
|
}
|
|
}
|
|
|
|
function renderMetrics() {
|
|
const observed = Array.from(validators.values()).filter((validator) => Number.isFinite(validator.lastSeen));
|
|
elements.validators.textContent = observed.length.toLocaleString();
|
|
elements.ledger.textContent = latestLedger ? latestLedger.toLocaleString() : '—';
|
|
if (latestLedger) {
|
|
const nextFlagLedger = latestLedger + (256 - (latestLedger % 256));
|
|
const remaining = nextFlagLedger - latestLedger;
|
|
elements.nextFlagLedger.textContent = nextFlagLedger.toLocaleString();
|
|
elements.flagLedgersRemaining.textContent = `(${remaining} ledger${remaining === 1 ? '' : 's'})`;
|
|
} else {
|
|
elements.nextFlagLedger.textContent = '—';
|
|
elements.flagLedgersRemaining.textContent = '';
|
|
}
|
|
}
|
|
|
|
function appendCell(row, className, value) {
|
|
const cell = document.createElement('td');
|
|
cell.className = className;
|
|
cell.textContent = value;
|
|
row.appendChild(cell);
|
|
return cell;
|
|
}
|
|
|
|
function renderRows() {
|
|
const ordered = Array.from(validators.values())
|
|
.sort(compareValidators)
|
|
.slice(0, MAX_ROWS);
|
|
elements.rows.replaceChildren();
|
|
|
|
if (ordered.length === 0) {
|
|
const row = document.createElement('tr');
|
|
appendCell(row, 'empty', 'Waiting for the first validation…').colSpan = 4;
|
|
elements.rows.appendChild(row);
|
|
return;
|
|
}
|
|
|
|
for (const validator of ordered) {
|
|
const row = document.createElement('tr');
|
|
if (isOffline(validator)) row.className = 'offline';
|
|
else if (Date.now() - validator.lastSeen < 1200) row.className = 'fresh';
|
|
|
|
const keyCell = document.createElement('td');
|
|
keyCell.className = 'key';
|
|
const full = document.createElement('span');
|
|
full.className = 'key-full';
|
|
full.textContent = validator.masterKey;
|
|
const short = document.createElement('span');
|
|
short.className = 'key-short';
|
|
short.textContent = `${validator.masterKey.slice(0, 12)}…${validator.masterKey.slice(-8)}`;
|
|
keyCell.append(full, short);
|
|
if (validator.listed) {
|
|
const mark = document.createElement('span');
|
|
mark.className = 'vl-mark';
|
|
mark.textContent = 'VL';
|
|
mark.setAttribute('aria-label', 'Listed by vl.xahau.org');
|
|
keyCell.appendChild(mark);
|
|
}
|
|
row.appendChild(keyCell);
|
|
|
|
appendCell(row, validator.domain ? 'version' : 'version pending', validator.domain || '—');
|
|
appendCell(row, validator.version ? 'version' : 'version pending', validator.version || 'Awaiting flag ledger');
|
|
appendCell(row, 'age', relativeTime(validator.lastSeen));
|
|
elements.rows.appendChild(row);
|
|
}
|
|
}
|
|
|
|
function handleValidation(message) {
|
|
if (message.type === 'validatorMetadata') {
|
|
const details = metadata.get(message.master_key) || {};
|
|
if (Object.prototype.hasOwnProperty.call(message, 'domain')) details.domain = message.domain;
|
|
if (Object.prototype.hasOwnProperty.call(message, 'listed')) details.listed = message.listed;
|
|
if (Object.prototype.hasOwnProperty.call(message, 'offline')) details.offline = message.offline;
|
|
if (message.server_version !== undefined) details.version = decodeVersion(message.server_version);
|
|
if (message.ledger_index !== undefined) details.ledger = message.ledger_index;
|
|
if (message.last_seen !== undefined) details.lastSeen = Date.parse(message.last_seen);
|
|
if (message.monitoring_since !== undefined) details.monitoringSince = Date.parse(message.monitoring_since);
|
|
metadata.set(message.master_key, details);
|
|
const current = validators.get(message.master_key) || (details.listed ? { masterKey: message.master_key } : null);
|
|
if (current) {
|
|
Object.assign(current, details);
|
|
if (!current.listed && !Number.isFinite(current.lastSeen)) validators.delete(message.master_key);
|
|
else validators.set(message.master_key, current);
|
|
renderMetrics();
|
|
renderConsensus();
|
|
renderOfflineSummary();
|
|
renderRows();
|
|
}
|
|
return;
|
|
}
|
|
if (message.type !== 'validationReceived') return;
|
|
const masterKey = message.master_key;
|
|
if (!masterKey) return;
|
|
|
|
const now = Date.now();
|
|
const ledger = Number.parseInt(message.ledger_index, 10) || 0;
|
|
latestLedger = Math.max(latestLedger, ledger);
|
|
const decoded = decodeVersion(message.server_version);
|
|
|
|
const current = validators.get(masterKey) || { masterKey, ...(metadata.get(masterKey) || {}) };
|
|
current.lastSeen = now;
|
|
current.ledger = message.ledger_index || current.ledger;
|
|
current.hash = message.ledger_hash || current.hash;
|
|
if (decoded) current.version = decoded;
|
|
validators.set(masterKey, current);
|
|
|
|
renderMetrics();
|
|
elements.updated.textContent = `Updated ${new Date(now).toLocaleTimeString()}`;
|
|
renderConsensus();
|
|
renderOfflineSummary();
|
|
renderRows();
|
|
}
|
|
|
|
function connect() {
|
|
setConnection('', 'Connecting');
|
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
socket = new WebSocket(`${protocol}//${location.host}/?dashboard=1`);
|
|
socket.addEventListener('open', () => {
|
|
reconnectDelay = 1000;
|
|
setConnection('live', 'Live');
|
|
});
|
|
socket.addEventListener('message', (event) => {
|
|
try { handleValidation(JSON.parse(event.data)); } catch {}
|
|
});
|
|
socket.addEventListener('close', () => {
|
|
setConnection('offline', `Reconnecting in ${Math.ceil(reconnectDelay / 1000)}s`);
|
|
window.setTimeout(connect, reconnectDelay);
|
|
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
|
});
|
|
socket.addEventListener('error', () => socket.close());
|
|
}
|
|
|
|
window.setInterval(() => {
|
|
renderConsensus();
|
|
renderOfflineSummary();
|
|
renderRows();
|
|
}, 1000);
|
|
elements.themeToggle.addEventListener('click', () => {
|
|
const dark = document.documentElement.dataset.theme !== 'dark';
|
|
document.documentElement.dataset.theme = dark ? 'dark' : '';
|
|
elements.themeToggle.textContent = dark ? 'Light mode' : 'Dark mode';
|
|
elements.themeToggle.setAttribute('aria-pressed', String(dark));
|
|
});
|
|
for (const button of elements.sortButtons) {
|
|
button.addEventListener('click', () => {
|
|
const nextKey = button.dataset.sort;
|
|
if (sortKey === nextKey) sortDirection *= -1;
|
|
else {
|
|
sortKey = nextKey;
|
|
sortDirection = 1;
|
|
}
|
|
updateSortIndicators();
|
|
renderRows();
|
|
});
|
|
}
|
|
updateSortIndicators();
|
|
connect();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|