@relay-federation/bridge 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -76,6 +76,24 @@
76
76
  white-space: nowrap;
77
77
  }
78
78
  .header h1 span { color: var(--accent-blue); }
79
+ .protocol-badge {
80
+ font-family: var(--mono);
81
+ font-size: 12px;
82
+ font-weight: 600;
83
+ color: var(--accent-blue);
84
+ padding: 3px 10px;
85
+ border-radius: 20px;
86
+ border: 1px solid rgba(88,166,255,0.3);
87
+ background: rgba(88,166,255,0.08);
88
+ text-shadow: 0 0 8px rgba(88,166,255,0.5);
89
+ box-shadow: 0 0 12px rgba(88,166,255,0.15), inset 0 0 8px rgba(88,166,255,0.05);
90
+ letter-spacing: 1px;
91
+ animation: protocolPulse 3s ease-in-out infinite;
92
+ }
93
+ @keyframes protocolPulse {
94
+ 0%, 100% { box-shadow: 0 0 12px rgba(88,166,255,0.15), inset 0 0 8px rgba(88,166,255,0.05); }
95
+ 50% { box-shadow: 0 0 20px rgba(88,166,255,0.3), inset 0 0 12px rgba(88,166,255,0.1); }
96
+ }
79
97
  .mesh-badge {
80
98
  display: flex;
81
99
  align-items: center;
@@ -229,7 +247,11 @@
229
247
  }
230
248
  .detail-card {
231
249
  padding: 16px 20px;
250
+ cursor: pointer;
251
+ transition: border-color 0.2s;
252
+ border: 1px solid transparent;
232
253
  }
254
+ .detail-card:hover { border-color: rgba(88,166,255,0.2); }
233
255
  .detail-card h3 {
234
256
  color: var(--text-muted);
235
257
  font-size: 10px;
@@ -238,6 +260,50 @@
238
260
  margin-bottom: 10px;
239
261
  font-weight: 500;
240
262
  }
263
+ .card-overlay {
264
+ position: fixed;
265
+ top: 0; left: 0; right: 0; bottom: 0;
266
+ z-index: 9999;
267
+ background: rgba(0,0,0,0.85);
268
+ display: flex;
269
+ align-items: center;
270
+ justify-content: center;
271
+ }
272
+ .card-overlay .card-expanded {
273
+ position: relative;
274
+ width: 90vw;
275
+ max-width: 700px;
276
+ max-height: 85vh;
277
+ overflow-y: auto;
278
+ border-radius: var(--radius);
279
+ background: radial-gradient(ellipse at center, rgba(20,30,50,0.95) 0%, rgba(8,10,14,0.98) 100%);
280
+ border: 1px solid var(--border-light);
281
+ padding: 24px 28px;
282
+ }
283
+ .card-overlay .card-expanded h2 {
284
+ color: var(--text-primary);
285
+ font-size: 14px;
286
+ text-transform: uppercase;
287
+ letter-spacing: 1.2px;
288
+ margin-bottom: 16px;
289
+ font-weight: 500;
290
+ }
291
+ .card-overlay .card-close {
292
+ position: absolute;
293
+ top: 12px; right: 16px;
294
+ z-index: 10;
295
+ background: rgba(0,0,0,0.5);
296
+ border: 1px solid var(--border);
297
+ color: var(--text-muted);
298
+ font-size: 18px;
299
+ width: 32px; height: 32px;
300
+ border-radius: 50%;
301
+ cursor: pointer;
302
+ display: flex;
303
+ align-items: center;
304
+ justify-content: center;
305
+ }
306
+ .card-overlay .card-close:hover { color: var(--text-primary); background: rgba(255,255,255,0.1); }
241
307
  .panel-row {
242
308
  display: flex;
243
309
  justify-content: space-between;
@@ -872,6 +938,7 @@
872
938
  <div class="header">
873
939
  <div class="header-left">
874
940
  <h1><span>Federation</span> Mesh</h1>
941
+ <span class="protocol-badge">70016</span>
875
942
  <div class="mesh-badge">
876
943
  <span class="status-dot green pulse" id="meshDot"></span>
877
944
  <span id="meshStatus">loading</span>
@@ -1009,6 +1076,20 @@ let BRIDGES = SEED_URLS.map(url => ({ name: null, url }));
1009
1076
  const POLL_INTERVAL = 5000;
1010
1077
  let discoveryDone = false;
1011
1078
 
1079
+ const BOND_EXPLAINER = '<h2>Surety Bond (BND)</h2>' +
1080
+ '<div style="color:var(--text-muted);font-size:13px;line-height:1.6;max-width:480px">' +
1081
+ '<p style="margin-bottom:12px"><strong style="color:var(--text-primary)">This is NOT proof-of-stake.</strong> There is no staking, no delegation, and no block rewards.</p>' +
1082
+ '<p style="margin-bottom:12px">A <strong>surety bond</strong> is economic collateral. Bridge operators lock BSV to their own address as a signal of commitment. The bond UTXO is monitored on-chain — if a bridge spends its bond, the network flags it and its reputation score drops.</p>' +
1083
+ '<p style="margin-bottom:12px">Think of it like a security deposit: you get it back when you leave, but spending it while active tells the network you may not be serious.</p>' +
1084
+ '<p style="margin-bottom:12px"><strong style="color:var(--text-primary)">How BND score works:</strong></p>' +
1085
+ '<ul style="margin:0 0 12px 16px;padding:0">' +
1086
+ '<li style="margin-bottom:6px">Bond amount — more BSV locked = higher score</li>' +
1087
+ '<li style="margin-bottom:6px">Bond age — longer held unspent = more trust</li>' +
1088
+ '<li>Minimum bond: 0.01 BSV (1,000,000 satoshis)</li>' +
1089
+ '</ul>' +
1090
+ '<p style="color:var(--text-dim);font-size:11px">BSV disabled OP_CHECKLOCKTIMEVERIFY at Genesis (Feb 2020), so script-level timelocks are not possible. Enforcement is done by monitoring the UTXO.</p>' +
1091
+ '</div>';
1092
+
1012
1093
  // ── State ──────────────────────────────────────────
1013
1094
  let bridgeData = new Map();
1014
1095
  let selectedBridge = null;
@@ -1020,6 +1101,189 @@ let savedExplorerInput = '';
1020
1101
  let savedExplorerResult = '';
1021
1102
  let appsData = null;
1022
1103
  let latestPrice = null;
1104
+ let mempoolHistory = [];
1105
+ let peerCountHistory = [];
1106
+
1107
+ function openCardOverlay(type) {
1108
+ const b = bridgeData.get(selectedBridge);
1109
+ if (!b) return;
1110
+ const op = isOperator(b._url);
1111
+ let html = renderCardExpanded(type, b, op);
1112
+ if (!html) return;
1113
+ const overlay = document.createElement('div');
1114
+ overlay.className = 'card-overlay';
1115
+ overlay.id = 'cardOverlay';
1116
+ overlay.innerHTML = '<div class="card-expanded"><button class="card-close" onclick="closeCardOverlay()">&times;</button>' + html + '</div>';
1117
+ document.body.appendChild(overlay);
1118
+ overlay.addEventListener('click', function(e) { if (e.target === overlay) closeCardOverlay(); });
1119
+ // Async: load wallet tx history
1120
+ if (type === 'wallet' && b.bridge.address) {
1121
+ fetch('/address/' + b.bridge.address + '/history', { signal: AbortSignal.timeout(10000) })
1122
+ .then(function(r) { return r.ok ? r.json() : null; })
1123
+ .then(function(data) {
1124
+ var el = document.getElementById('walletTxList');
1125
+ if (!el || !data || !data.history) { if (el) el.textContent = 'No transactions found'; return; }
1126
+ var txs = data.history.slice(0, 10);
1127
+ if (txs.length === 0) { el.textContent = 'No transactions yet'; return; }
1128
+ var h = '';
1129
+ for (var i = 0; i < txs.length; i++) {
1130
+ var tx = txs[i];
1131
+ var txid = tx.tx_hash || tx.txid || tx;
1132
+ var short = typeof txid === 'string' ? txid.slice(0, 10) + '...' + txid.slice(-6) : '?';
1133
+ var height = tx.height || '';
1134
+ h += '<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid var(--border)">';
1135
+ h += '<a href="https://whatsonchain.com/tx/' + txid + '" target="_blank" style="color:var(--accent-blue);text-decoration:none;font-family:var(--mono)">' + short + '</a>';
1136
+ if (height) h += '<span style="color:var(--text-dim);font-size:10px">block ' + height + '</span>';
1137
+ h += '</div>';
1138
+ }
1139
+ el.innerHTML = h;
1140
+ })
1141
+ .catch(function() {
1142
+ var el = document.getElementById('walletTxList');
1143
+ if (el) el.textContent = 'Failed to load transactions';
1144
+ });
1145
+ }
1146
+ }
1147
+ function closeCardOverlay() {
1148
+ const el = document.getElementById('cardOverlay');
1149
+ if (el) el.remove();
1150
+ }
1151
+ function renderCardExpanded(type, b, op) {
1152
+ let html = '';
1153
+ switch (type) {
1154
+ case 'bridge':
1155
+ html += '<h2>Bridge</h2>';
1156
+ html += '<div style="font-size:11px;color:var(--text-dim);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px">Identity</div>';
1157
+ html += '<div class="panel-row"><span class="label">Status</span><span class="value ' + (b._error ? 'red' : 'green') + '">' + (b._error ? 'offline' : 'online') + '</span></div>';
1158
+ html += '<div class="panel-row"><span class="label">Registration</span><span class="value ' + (b.bridge.endpoint ? 'green' : 'yellow') + '">' + (b.bridge.endpoint ? 'Registered' : 'Unregistered') + '</span></div>';
1159
+ html += '<div class="panel-row"><span class="label">Pubkey</span><span class="value" style="font-size:10px;word-break:break-all;cursor:pointer" onclick="event.stopPropagation();copyText(\'' + (b.bridge.pubkeyHex || '') + '\', this)">' + (b.bridge.pubkeyHex || '-') + '</span></div>';
1160
+ html += '<div class="panel-row"><span class="label">Endpoint</span><span class="value" style="cursor:pointer" onclick="event.stopPropagation();copyText(\'' + (b.bridge.endpoint || '') + '\', this)">' + (b.bridge.endpoint || '-') + '</span></div>';
1161
+ if (b.bridge.address) {
1162
+ html += '<div class="panel-row"><span class="label">Address</span><span class="value" style="font-size:10px;word-break:break-all;cursor:pointer" onclick="event.stopPropagation();copyText(\'' + b.bridge.address + '\', this)">' + b.bridge.address + '</span></div>';
1163
+ }
1164
+ html += '<div class="panel-row"><span class="label">Mesh ID</span><span class="value">' + (b.bridge.meshId || '-') + '</span></div>';
1165
+ // Domains as clickable links
1166
+ if (b.bridge.domains && b.bridge.domains.length > 0) {
1167
+ html += '<div style="font-size:11px;color:var(--text-dim);margin:16px 0 12px;text-transform:uppercase;letter-spacing:1px">Domains</div>';
1168
+ for (var di = 0; di < b.bridge.domains.length; di++) {
1169
+ var d = b.bridge.domains[di];
1170
+ html += '<div class="panel-row"><span class="value"><a href="https://' + d + '" target="_blank" style="color:var(--accent-blue);text-decoration:none">' + d + '</a></span></div>';
1171
+ }
1172
+ }
1173
+ html += '<div style="font-size:11px;color:var(--text-dim);margin:16px 0 12px;text-transform:uppercase;letter-spacing:1px">Uptime</div>';
1174
+ html += '<div class="panel-row"><span class="label">Current</span><span class="value">' + fmtUptime(b.bridge.uptimeSeconds) + '</span></div>';
1175
+ var maxUptime = Math.max(...[...bridgeData.values()].map(bb => bb.bridge.uptimeSeconds || 0), 1);
1176
+ var uptimePct = Math.min(100, (b.bridge.uptimeSeconds / maxUptime) * 100);
1177
+ html += '<div style="margin-top:8px"><div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:4px"><span style="color:var(--text-dim)">vs mesh leader</span><span style="color:var(--text-muted)">' + uptimePct.toFixed(0) + '%</span></div>';
1178
+ html += '<div style="height:6px;background:var(--border);border-radius:3px;overflow:hidden"><div style="height:100%;width:' + uptimePct + '%;background:var(--accent-green);border-radius:3px;transition:width 0.5s"></div></div></div>';
1179
+ // Peer count sparkline
1180
+ if (peerCountHistory.length > 1) {
1181
+ html += '<div style="font-size:11px;color:var(--text-dim);margin:16px 0 12px;text-transform:uppercase;letter-spacing:1px">Peer Connections (last ' + peerCountHistory.length + ' polls)</div>';
1182
+ html += renderSparkline(peerCountHistory, 'rgba(63,185,80,0.7)');
1183
+ }
1184
+ break;
1185
+ case 'network':
1186
+ html += '<h2>Network</h2>';
1187
+ html += '<div style="font-size:11px;color:var(--text-dim);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px">Chain</div>';
1188
+ html += '<div class="panel-row"><span class="label">Best Height</span><span class="value green">' + fmtHeight(b.headers.bestHeight) + '</span></div>';
1189
+ html += '<div class="panel-row"><span class="label">Best Hash</span><span class="value" style="font-size:10px;word-break:break-all">' + (b.headers.bestHash || '-') + '</span></div>';
1190
+ html += '<div class="panel-row"><span class="label">Local Headers</span><span class="value">' + b.headers.count.toLocaleString() + '</span></div>';
1191
+ html += '<div style="font-size:11px;color:var(--text-dim);margin:16px 0 12px;text-transform:uppercase;letter-spacing:1px">Connectivity</div>';
1192
+ html += '<div class="panel-row"><span class="label">BSV P2P</span><span class="value ' + (b.bsvNode && b.bsvNode.connected ? 'green' : 'red') + '">' + (b.bsvNode && b.bsvNode.connected ? 'Connected' : 'Disconnected') + ' — ' + (b.bsvNode ? b.bsvNode.peers : 0) + ' peers</span></div>';
1193
+ if (b.bsvNode && b.bsvNode.height) {
1194
+ html += '<div class="panel-row"><span class="label">Node Height</span><span class="value">' + b.bsvNode.height.toLocaleString() + '</span></div>';
1195
+ }
1196
+ html += '<div class="panel-row"><span class="label">Mesh Bridges</span><span class="value">' + b.peers.connected + ' connected</span></div>';
1197
+ html += '<div style="font-size:11px;color:var(--text-dim);margin:16px 0 12px;text-transform:uppercase;letter-spacing:1px">Transactions</div>';
1198
+ html += '<div class="panel-row"><span class="label">Mempool</span><span class="value">' + b.txs.mempool + ' txs</span></div>';
1199
+ html += '<div class="panel-row"><span class="label">Seen</span><span class="value">' + b.txs.seen + ' txs</span></div>';
1200
+ // Mempool sparkline (last 20 polls)
1201
+ if (mempoolHistory.length > 1) {
1202
+ html += '<div style="margin-top:12px"><div style="font-size:11px;color:var(--text-dim);margin-bottom:6px">Mempool History (last ' + mempoolHistory.length + ' polls)</div>';
1203
+ html += renderSparkline(mempoolHistory, 'rgba(88,166,255,0.7)');
1204
+ html += '</div>';
1205
+ }
1206
+ break;
1207
+ case 'wallet':
1208
+ if (!op || !b.wallet) return '';
1209
+ // Big balance hero
1210
+ var balSats = b.wallet.balanceSats || 0;
1211
+ var balUsd = (latestPrice && latestPrice.usd) ? (balSats / 1e8 * latestPrice.usd).toFixed(2) : null;
1212
+ html += '<div style="text-align:center;padding:20px 0 16px">';
1213
+ html += '<div style="font-size:32px;font-weight:700;color:var(--accent-green);letter-spacing:-1px">' + fmtSats(balSats) + ' <span style="font-size:14px;font-weight:400;color:var(--text-muted)">sats</span></div>';
1214
+ if (balUsd) html += '<div style="font-size:18px;color:var(--accent-blue);margin-top:4px">$' + balUsd + ' <span style="font-size:11px;color:var(--text-dim)">USD</span></div>';
1215
+ html += '</div>';
1216
+ // Send button
1217
+ html += '<div style="text-align:center;margin-bottom:20px"><button class="btn primary" style="padding:8px 32px;font-size:13px" onclick="event.stopPropagation();closeCardOverlay();showSendModal(\'' + b._url + '\')">Send BSV</button></div>';
1218
+ // Address + QR
1219
+ html += '<div style="border-top:1px solid var(--border);padding-top:16px">';
1220
+ html += '<div style="font-size:11px;color:var(--text-dim);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px">Address</div>';
1221
+ if (b.bridge.address) {
1222
+ html += '<div style="text-align:center;font-size:11px;color:var(--text-muted);word-break:break-all;cursor:pointer;margin-bottom:12px" onclick="event.stopPropagation();copyText(\'' + b.bridge.address + '\', this)">' + b.bridge.address + '</div>';
1223
+ html += '<div style="text-align:center;padding-bottom:16px"><img src="' + generateQR(b.bridge.address) + '" alt="QR" style="border-radius:8px;border:2px solid var(--border)" width="180" height="180"></div>';
1224
+ }
1225
+ html += '</div>';
1226
+ // UTXOs
1227
+ html += '<div style="border-top:1px solid var(--border);padding-top:12px">';
1228
+ html += '<div class="panel-row"><span class="label">UTXOs</span><span class="value">' + (b.wallet.utxoCount || 0) + '</span></div>';
1229
+ html += '</div>';
1230
+ // Recent transactions — loaded async
1231
+ if (b.bridge.address) {
1232
+ html += '<div style="border-top:1px solid var(--border);padding-top:12px;margin-top:12px">';
1233
+ html += '<div style="font-size:11px;color:var(--text-dim);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px">Recent Transactions</div>';
1234
+ html += '<div id="walletTxList" style="font-size:11px;color:var(--text-dim)">Loading...</div>';
1235
+ html += '</div>';
1236
+ }
1237
+ break;
1238
+ case 'bsvnode':
1239
+ if (!b.bsvNode) return '';
1240
+ html += '<h2>BSV Node</h2>';
1241
+ html += '<div class="panel-row"><span class="label">Status</span><span class="value ' + (b.bsvNode.connected ? 'green' : 'red') + '">' + (b.bsvNode.connected ? 'Connected' : 'Disconnected') + '</span></div>';
1242
+ html += '<div class="panel-row"><span class="label">Peers</span><span class="value">' + (b.bsvNode.peers || 0) + '</span></div>';
1243
+ html += '<div class="panel-row"><span class="label">Node Height</span><span class="value">' + (b.bsvNode.height ? b.bsvNode.height.toLocaleString() : '-') + '</span></div>';
1244
+ html += '<div class="panel-row"><span class="label">Bridge Headers</span><span class="value">' + b.headers.count.toLocaleString() + '</span></div>';
1245
+ if (b.bsvNode.height && b.headers.count) {
1246
+ const syncPct = Math.min(100, (b.headers.count / b.bsvNode.height * 100)).toFixed(1);
1247
+ html += '<div class="panel-row"><span class="label">Sync</span><span class="value">' + syncPct + '%</span></div>';
1248
+ }
1249
+ break;
1250
+ case 'peers':
1251
+ // Only show connected peers
1252
+ var onlinePeers = b.peers.list.filter(function(p) { return p.connected; });
1253
+ html += '<h2>Peers (' + onlinePeers.length + ')</h2>';
1254
+ // Sort by score descending
1255
+ var sortedPeers = onlinePeers.slice().sort(function(a, c) { return (c.score || 0) - (a.score || 0); });
1256
+ if (sortedPeers.length === 0) {
1257
+ html += '<div style="color:var(--text-dim);font-size:12px;padding:12px 0">No peers connected</div>';
1258
+ }
1259
+ for (var pi = 0; pi < sortedPeers.length; pi++) {
1260
+ var p = sortedPeers[pi];
1261
+ var scoreVal = p.score !== undefined ? p.score.toFixed(2) : '?';
1262
+ var scoreClr = p.score >= 0.7 ? 'var(--accent-green)' : p.score >= 0.4 ? 'var(--accent-yellow)' : 'var(--accent-red)';
1263
+ html += '<div style="padding:10px 0;border-bottom:1px solid var(--border)">';
1264
+ html += '<div style="display:flex;align-items:center;justify-content:space-between">';
1265
+ html += '<div><span class="peer-dot green"></span><span class="peer-name" style="cursor:pointer" onclick="event.stopPropagation();copyText(\'' + (p.pubkeyHex || '') + '\', this)">' + truncPubkey(p.pubkeyHex) + '</span></div>';
1266
+ html += '<span style="font-family:var(--mono);font-size:13px;font-weight:600;color:' + scoreClr + '">' + scoreVal + '</span>';
1267
+ html += '</div>';
1268
+ html += '<div style="font-size:11px;color:var(--text-dim);margin-top:2px">' + (p.endpoint || 'no endpoint') + (p.health ? ' &middot; ' + p.health : '') + '</div>';
1269
+ if (p.scoreBreakdown) {
1270
+ html += '<div style="margin-top:6px">' + renderScoreBars(p.scoreBreakdown) + '</div>';
1271
+ }
1272
+ html += '</div>';
1273
+ }
1274
+ // Surety bond explainer
1275
+ html += '<div style="margin-top:16px;padding:12px;border:1px solid var(--border);border-radius:8px;background:rgba(255,255,255,0.03)">';
1276
+ html += '<h3 style="margin-bottom:8px;font-size:11px;letter-spacing:1px">SURETY BOND (BND)</h3>';
1277
+ html += '<div style="color:var(--text-muted);font-size:12px;line-height:1.5">';
1278
+ html += '<p style="margin-bottom:8px"><strong style="color:var(--text-primary)">This is not proof-of-stake.</strong> No staking, no delegation, no block rewards.</p>';
1279
+ html += '<p style="margin-bottom:8px">A surety bond is economic collateral. Operators lock BSV to their own address as a signal of commitment. The UTXO is monitored on-chain — spending your bond while active flags your bridge and drops your reputation.</p>';
1280
+ html += '<p style="margin-bottom:8px">More BSV bonded + longer held unspent = higher BND score. Minimum: 0.01 BSV.</p>';
1281
+ html += '</div></div>';
1282
+ break;
1283
+ default: return '';
1284
+ }
1285
+ return html;
1286
+ }
1023
1287
 
1024
1288
  try { const saved = localStorage.getItem('relay_operator_tokens'); if (saved) operatorTokens = JSON.parse(saved); } catch {}
1025
1289
 
@@ -1032,6 +1296,20 @@ function fmtUptime(s) {
1032
1296
  return m + 'm';
1033
1297
  }
1034
1298
  function truncPubkey(pk) { return pk ? pk.slice(0, 8) + ' ... ' + pk.slice(-6) : '(none)'; }
1299
+ function copyText(text, el) { navigator.clipboard.writeText(text).then(() => { var orig = el.textContent; el.textContent = 'Copied!'; el.style.color = 'var(--accent-green)'; setTimeout(() => { el.textContent = orig; el.style.color = ''; }, 1200); }); }
1300
+ function renderSparkline(data, color) {
1301
+ if (data.length < 2) return '';
1302
+ var max = Math.max(...data, 1), w = 300, h = 50, pts = [];
1303
+ for (var i = 0; i < data.length; i++) {
1304
+ var x = (i / (data.length - 1)) * w;
1305
+ var y = h - (data[i] / max) * (h - 4) - 2;
1306
+ pts.push(x.toFixed(1) + ',' + y.toFixed(1));
1307
+ }
1308
+ var svg = '<svg width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '" style="width:100%;height:50px">';
1309
+ svg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="' + color + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
1310
+ svg += '<polygon points="0,' + h + ' ' + pts.join(' ') + ' ' + w + ',' + h + '" fill="' + color.replace('0.7', '0.1') + '"/>';
1311
+ return svg + '</svg>';
1312
+ }
1035
1313
  function scoreColor(v) { return v >= 0.7 ? 'green' : v >= 0.4 ? 'yellow' : 'red'; }
1036
1314
  function fmtSats(n) { return n === null || n === undefined ? '-' : n.toLocaleString(); }
1037
1315
  function fmtHeight(n) { return n > 0 ? n.toLocaleString() : '-'; }
@@ -1123,9 +1401,13 @@ async function pollAll() {
1123
1401
  if (!selectedBridge && bridgeData.size > 0) {
1124
1402
  selectedBridge = bridgeData.keys().next().value;
1125
1403
  }
1404
+ // Track history for sparklines (last 20 polls)
1405
+ const sel = bridgeData.get(selectedBridge);
1406
+ if (sel && sel.txs) { mempoolHistory.push(sel.txs.mempool); if (mempoolHistory.length > 20) mempoolHistory.shift(); }
1407
+ if (sel && sel.peers) { peerCountHistory.push(sel.peers.connected); if (peerCountHistory.length > 20) peerCountHistory.shift(); }
1126
1408
  // Fetch price from self (same-origin, always works)
1127
1409
  fetch('/price', { signal: AbortSignal.timeout(5000) })
1128
- .then(r => r.ok ? r.json() : null).then(d => { if (d) latestPrice = d; }).catch(() => {});
1410
+ .then(r => r.ok ? r.json() : null).then(d => { if (d) { latestPrice = d; const el = document.querySelector('.stats-hero .stat-card:last-child .stat-value'); if (el) el.textContent = '$' + d.usd.toFixed(2); } }).catch(() => {});
1129
1411
  renderHeader();
1130
1412
  renderBridgeRail();
1131
1413
  renderActiveTab(true);
@@ -1138,14 +1420,13 @@ function renderHeader() {
1138
1420
  const totalPeers = online.reduce((s, b) => s + b.peers.connected, 0);
1139
1421
  const bestH = Math.max(...all.map(b => b.headers.bestHeight));
1140
1422
 
1141
- document.getElementById('hBridgeCount').textContent = online.length + '/' + all.length;
1423
+ document.getElementById('hBridgeCount').textContent = online.length;
1142
1424
  document.getElementById('hTotalPeers').textContent = totalPeers;
1143
1425
  document.getElementById('hBestHeight').textContent = bestH > 0 ? bestH.toLocaleString() : '-';
1144
1426
 
1145
1427
  const dot = document.getElementById('meshDot');
1146
1428
  const status = document.getElementById('meshStatus');
1147
- if (online.length === all.length) { dot.className = 'status-dot green pulse'; status.textContent = 'healthy'; }
1148
- else if (online.length > 0) { dot.className = 'status-dot yellow pulse'; status.textContent = 'degraded'; }
1429
+ if (online.length > 0) { dot.className = 'status-dot green pulse'; status.textContent = 'healthy'; }
1149
1430
  else { dot.className = 'status-dot red pulse'; status.textContent = 'offline'; }
1150
1431
  }
1151
1432
 
@@ -1154,15 +1435,17 @@ function renderBridgeRail() {
1154
1435
  const el = document.getElementById('bridgeRail');
1155
1436
  let html = '<div class="bridge-rail-header">Bridges</div>';
1156
1437
  for (const [name, b] of bridgeData) {
1157
- const dotColor = b._error ? 'red' : (b.peers.connected > 0 ? 'green' : 'yellow');
1438
+ if (b._error) continue; // Skip offline bridges
1439
+ const dotColor = b.peers.connected > 0 ? 'green' : 'yellow';
1158
1440
  const sel = selectedBridge === name ? ' selected' : '';
1159
1441
  html += '<div class="rail-item' + sel + '" onclick="selectBridge(\'' + name + '\')">';
1160
1442
  html += '<span class="status-dot ' + dotColor + '"></span>';
1161
1443
  html += '<div style="display:flex;flex-direction:column;flex:1;min-width:0"><span class="rail-name">' + name + '</span>';
1162
1444
  const ip = (b._url || '').replace(/^https?:\/\//, '').replace(/:\d+$/, '');
1163
1445
  html += '<span style="font-size:10px;color:var(--text-muted);opacity:0.6">' + ip + '</span></div>';
1164
- if (b._error) html += '<span class="rail-height offline">off</span>';
1165
- else html += '<span class="rail-height">' + fmtHeight(b.headers.bestHeight) + '</span>';
1446
+ html += '<div style="text-align:right"><span class="rail-height">' + fmtHeight(b.headers.bestHeight) + '</span>';
1447
+ if (b.bridge && b.bridge.version) html += '<div style="font-size:9px;color:var(--text-dim)">v' + b.bridge.version + '</div>';
1448
+ html += '</div>';
1166
1449
  html += '</div>';
1167
1450
  }
1168
1451
  el.innerHTML = html;
@@ -1245,7 +1528,7 @@ function renderOverviewTab() {
1245
1528
  const totalMempool = online.reduce((s, b) => s + (b.txs ? b.txs.mempool : 0), 0);
1246
1529
 
1247
1530
  let html = '<div class="stats-hero">';
1248
- html += '<div class="stat-card glass"><div class="stat-value">' + online.length + '<span style="color:var(--text-dim);font-weight:400"> / ' + all.length + '</span></div><div class="stat-label">Bridges Online</div></div>';
1531
+ html += '<div class="stat-card glass"><div class="stat-value">' + online.length + '</div><div class="stat-label">Bridges Online</div></div>';
1249
1532
  html += '<div class="stat-card glass"><div class="stat-value green">' + fmtHeight(bestH) + '</div><div class="stat-label">Best Height</div></div>';
1250
1533
  html += '<div class="stat-card glass"><div class="stat-value">' + totalPeers + '</div><div class="stat-label">Mesh Peers</div></div>';
1251
1534
  html += '<div class="stat-card glass"><div class="stat-value">' + totalMempool + '</div><div class="stat-label">Mempool Txs</div></div>';
@@ -1269,6 +1552,13 @@ function renderOverviewTab() {
1269
1552
  else if (!op && isOperator(b._url)) html += '<button class="btn sm" onclick="showAuthModal(\'' + b._url + '\')">Re-auth</button>';
1270
1553
  html += '</div>';
1271
1554
 
1555
+ // Version + timestamp
1556
+ var myVer = b.bridge && b.bridge.version ? b.bridge.version : '?';
1557
+ var allVersions = [...bridgeData.values()].filter(x => !x._error && x.bridge && x.bridge.version).map(x => x.bridge.version);
1558
+ var newestVer = allVersions.length > 0 ? allVersions.sort().pop() : myVer;
1559
+ var verColor = myVer === newestVer ? 'var(--text-muted)' : 'var(--accent-yellow)';
1560
+ var verWarn = myVer !== newestVer ? ' (latest: v' + newestVer + ')' : '';
1561
+ html += '<div style="font-size:11px;margin-bottom:12px"><span style="color:' + verColor + ';font-weight:600">v' + myVer + verWarn + '</span> <span style="color:var(--text-dim)">&middot; ' + new Date().toLocaleString() + '</span></div>';
1272
1562
  if (b._error) html += '<div class="error-banner">' + b._error + '</div>';
1273
1563
 
1274
1564
  // 3D Mesh Map — compact inline, click to expand
@@ -1279,10 +1569,22 @@ function renderOverviewTab() {
1279
1569
  html += '</div>';
1280
1570
  }
1281
1571
 
1572
+ // Operator toolbar (spans full width, under topology)
1573
+ if (op) {
1574
+ html += '<div class="glass" style="display:flex;align-items:center;gap:8px;padding:8px 14px;margin-bottom:12px;border-radius:var(--radius-sm);flex-wrap:wrap">';
1575
+ html += '<button class="btn primary" onclick="showRegisterModal(\'' + b._url + '\')">Register</button>';
1576
+ html += '<button class="btn danger" onclick="showDeregisterModal(\'' + b._url + '\')">Deregister</button>';
1577
+ html += '<button class="btn" onclick="showSendModal(\'' + b._url + '\')">Send</button>';
1578
+ html += '<button class="btn" onclick="showConnectModal(\'' + b._url + '\')">Connect Peer</button>';
1579
+ html += '<button class="btn" onclick="openLogViewer(\'' + b._url + '\')">View Logs</button>';
1580
+ html += '<button class="btn" onclick="logoutOperator(\'' + b._url + '\')">Logout</button>';
1581
+ html += '</div>';
1582
+ }
1583
+
1282
1584
  html += '<div class="cards-grid">';
1283
1585
 
1284
1586
  // Bridge info card
1285
- html += '<div class="detail-card glass"><h3>Bridge</h3>';
1587
+ html += '<div class="detail-card glass" onclick="openCardOverlay(\'bridge\')"><h3>Bridge <span style="font-size:9px;color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0">— click to expand</span></h3>';
1286
1588
  html += '<div class="panel-row"><span class="label">Status</span><span class="value ' + color + '">' + statusText + '</span></div>';
1287
1589
  html += '<div class="panel-row"><span class="label">Pubkey</span><span class="value">' + truncPubkey(b.bridge.pubkeyHex) + '</span></div>';
1288
1590
  if (op) {
@@ -1296,17 +1598,16 @@ function renderOverviewTab() {
1296
1598
  html += '</div>';
1297
1599
 
1298
1600
  // Network card
1299
- html += '<div class="detail-card glass"><h3>Network</h3>';
1601
+ html += '<div class="detail-card glass" onclick="openCardOverlay(\'network\')"><h3>Network <span style="font-size:9px;color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0">— click to expand</span></h3>';
1300
1602
  html += '<div class="panel-row"><span class="label">Best Height</span><span class="value green">' + fmtHeight(b.headers.bestHeight) + '</span></div>';
1301
- html += '<div class="panel-row"><span class="label">Best Hash</span><span class="value">' + (b.headers.bestHash ? b.headers.bestHash.slice(0, 16) + '...' : '-') + '</span></div>';
1302
- html += '<div class="panel-row"><span class="label">Headers</span><span class="value">' + b.headers.count.toLocaleString() + '</span></div>';
1603
+ html += '<div class="panel-row"><span class="label">BSV P2P</span><span class="value ' + (b.bsvNode && b.bsvNode.connected ? 'green' : 'red') + '">' + (b.bsvNode ? b.bsvNode.peers : 0) + ' peers</span></div>';
1604
+ html += '<div class="panel-row"><span class="label">Mesh</span><span class="value">' + b.peers.connected + ' bridges</span></div>';
1303
1605
  html += '<div class="panel-row"><span class="label">Mempool</span><span class="value">' + b.txs.mempool + ' txs</span></div>';
1304
- html += '<div class="panel-row"><span class="label">Seen</span><span class="value">' + b.txs.seen + ' txs</span></div>';
1305
1606
  html += '</div>';
1306
1607
 
1307
1608
  // Wallet card (operator)
1308
1609
  if (op && b.wallet) {
1309
- html += '<div class="detail-card glass"><h3>Wallet</h3>';
1610
+ html += '<div class="detail-card glass" onclick="openCardOverlay(\'wallet\')"><h3>Wallet <span style="font-size:9px;color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0">— click to expand</span></h3>';
1310
1611
  if (b.bridge.address) {
1311
1612
  html += '<div class="panel-row"><span class="label">Address</span><span class="value" style="font-size:10px;word-break:break-all">' + b.bridge.address + '</span></div>';
1312
1613
  html += '<div style="text-align:center;padding:10px 0"><img src="' + generateQR(b.bridge.address) + '" alt="QR" style="border-radius:6px" width="100" height="100"></div>';
@@ -1316,53 +1617,28 @@ function renderOverviewTab() {
1316
1617
  html += '</div>';
1317
1618
  }
1318
1619
 
1319
- // BSV Node card
1320
- if (b.bsvNode) {
1321
- html += '<div class="detail-card glass"><h3>BSV Node</h3>';
1322
- html += '<div class="panel-row"><span class="label">Status</span><span class="value ' + (b.bsvNode.connected ? 'green' : 'red') + '">' + (b.bsvNode.connected ? 'Connected' : 'Disconnected') + '</span></div>';
1323
- html += '<div class="panel-row"><span class="label">Peers</span><span class="value">' + (b.bsvNode.peers || 0) + '</span></div>';
1324
- html += '<div class="panel-row"><span class="label">Height</span><span class="value">' + (b.bsvNode.height ? b.bsvNode.height.toLocaleString() : '-') + '</span></div>';
1325
- html += '</div>';
1326
- }
1327
-
1328
- // Peers card
1329
- html += '<div class="detail-card glass"><h3>Peers (' + b.peers.connected + ')</h3>';
1330
- if (b.peers.list.length === 0) {
1620
+ // Peers card — only show connected peers
1621
+ const connectedPeers = b.peers.list.filter(p => p.connected);
1622
+ html += '<div class="detail-card glass" style="max-height:300px;overflow-y:auto" onclick="openCardOverlay(\'peers\')"><h3 style="display:flex;justify-content:space-between;align-items:center">Peers (' + connectedPeers.length + ') <span style="font-size:9px;color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0">— click to expand</span><button class="btn ghost" style="font-size:9px;padding:2px 8px;text-transform:none;letter-spacing:0" onclick="event.stopPropagation();openModal(BOND_EXPLAINER)">What is BND?</button></h3>';
1623
+ if (connectedPeers.length === 0) {
1331
1624
  html += '<div style="color:var(--text-dim);font-size:12px">No peers connected</div>';
1332
1625
  } else {
1333
1626
  const showAll = window._showAllPeers || false;
1334
- const visiblePeers = showAll ? b.peers.list : b.peers.list.slice(0, 3);
1627
+ const visiblePeers = showAll ? connectedPeers : connectedPeers.slice(0, 4);
1335
1628
  for (const p of visiblePeers) {
1336
- const dotC = p.connected ? 'green' : 'red';
1337
- html += '<div class="peer-entry" style="cursor:pointer" onclick="this.classList.toggle(\'expanded\')"><div><span class="peer-dot ' + dotC + '"></span><span class="peer-name">' + truncPubkey(p.pubkeyHex) + '</span> <span style="color:var(--text-dim)">score ' + (p.score !== undefined ? p.score.toFixed(2) : '?') + '</span></div>';
1629
+ html += '<div class="peer-entry" style="cursor:pointer" onclick="event.stopPropagation();this.classList.toggle(\'expanded\')"><div><span class="peer-dot green"></span><span class="peer-name">' + truncPubkey(p.pubkeyHex) + '</span> <span style="color:var(--text-dim)">score ' + (p.score !== undefined ? p.score.toFixed(2) : '?') + '</span></div>';
1338
1630
  html += '<div class="peer-meta">' + (p.endpoint || '') + (p.health ? ' &middot; ' + p.health : '') + '</div>';
1339
1631
  if (p.scoreBreakdown) html += '<div class="score-bars">' + renderScoreBars(p.scoreBreakdown) + '</div>';
1340
1632
  html += '</div>';
1341
1633
  }
1342
- if (b.peers.list.length > 3) {
1343
- html += '<div style="text-align:center;padding:4px 0"><button class="btn ghost" style="font-size:11px;padding:2px 10px" onclick="window._showAllPeers=!window._showAllPeers;renderActiveTab()">' + (showAll ? 'Show less' : 'Show all ' + b.peers.list.length + ' peers') + '</button></div>';
1634
+ if (connectedPeers.length > 4) {
1635
+ html += '<div style="text-align:center;padding:4px 0"><button class="btn ghost" style="font-size:11px;padding:2px 10px" onclick="event.stopPropagation();window._showAllPeers=!window._showAllPeers;renderActiveTab()">' + (showAll ? 'Show less' : 'Show all ' + connectedPeers.length + ' peers') + '</button></div>';
1344
1636
  }
1345
1637
  }
1346
1638
  html += '</div>';
1347
1639
 
1348
- // Actions card (operator)
1349
- if (op) {
1350
- html += '<div class="detail-card glass"><h3>Actions</h3>';
1351
- html += '<div class="actions-grid">';
1352
- html += '<button class="btn primary" onclick="showRegisterModal(\'' + b._url + '\')">Register</button>';
1353
- html += '<button class="btn danger" onclick="showDeregisterModal(\'' + b._url + '\')">Deregister</button>';
1354
- html += '<button class="btn" onclick="showSendModal(\'' + b._url + '\')">Send</button>';
1355
- html += '<button class="btn" onclick="showConnectModal(\'' + b._url + '\')">Connect Peer</button>';
1356
- html += '</div>';
1357
- html += '<button class="btn ghost" onclick="openLogViewer(\'' + b._url + '\')" style="width:100%;margin-top:8px">View Live Logs</button>';
1358
- if (isOperator(b._url)) html += '<button class="btn ghost" onclick="logoutOperator(\'' + b._url + '\')" style="width:100%;margin-top:4px;color:var(--text-dim)">Logout</button>';
1359
- html += '<div style="margin-top:10px;padding:8px 10px;background:rgba(88,166,255,0.06);border:1px solid rgba(88,166,255,0.12);border-radius:var(--radius-sm);font-size:11px;color:var(--text-muted)">To backfill historical inscriptions and tokens, run <span style="font-family:var(--mono);color:var(--accent-blue)">relay-bridge backfill</span> from the CLI.</div>';
1360
- html += '</div>';
1361
- }
1362
-
1363
1640
  html += '</div>'; // cards-grid
1364
1641
 
1365
- html += '<div style="color:var(--text-dim);font-size:10px;text-align:center;padding:12px 0">Updated ' + new Date().toLocaleTimeString() + '</div>';
1366
1642
  return html;
1367
1643
  }
1368
1644
 
@@ -2222,11 +2498,12 @@ function renderMiniMap(b) {
2222
2498
 
2223
2499
  function renderScoreBars(breakdown) {
2224
2500
  if (!breakdown) return '';
2225
- const labels = [['UPT', breakdown.uptime], ['RTT', breakdown.responseTime], ['ACC', breakdown.dataAccuracy], ['STK', breakdown.stakeAge]];
2501
+ const labels = [['UPT', breakdown.uptime], ['RTT', breakdown.responseTime], ['ACC', breakdown.dataAccuracy], ['BND', breakdown.stakeAge]];
2226
2502
  let html = '<div class="score-bars">';
2227
2503
  for (const [label, val] of labels) {
2228
2504
  const pct = Math.round(val * 100);
2229
- html += '<div class="score-row"><span class="score-label">' + label + '</span><div class="score-bar-bg"><div class="score-bar-fill ' + scoreColor(val) + '" style="width:' + pct + '%"></div></div><span class="score-val">' + val.toFixed(2) + '</span></div>';
2505
+ const color = label === 'BND' ? (val >= 0.4 ? 'green' : val > 0 ? 'yellow' : 'yellow') : scoreColor(val);
2506
+ html += '<div class="score-row"><span class="score-label">' + label + '</span><div class="score-bar-bg"><div class="score-bar-fill ' + color + '" style="width:' + pct + '%"></div></div><span class="score-val">' + val.toFixed(2) + '</span></div>';
2230
2507
  }
2231
2508
  return html + '</div>';
2232
2509
  }
@@ -27,6 +27,7 @@ import { handlePostData, handleGetTopics, handleGetData } from './data-endpoints
27
27
 
28
28
  const __dirname = dirname(fileURLToPath(import.meta.url))
29
29
  const DASHBOARD_HTML = readFileSync(join(__dirname, '..', 'dashboard', 'index.html'), 'utf8')
30
+ const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version
30
31
  export class StatusServer {
31
32
  /**
32
33
  * @param {object} opts
@@ -121,6 +122,7 @@ export class StatusServer {
121
122
  const status = {
122
123
  bridge: {
123
124
  name: this._config.name || null,
125
+ version: PKG_VERSION,
124
126
  pubkeyHex: this._config.pubkeyHex || null,
125
127
  meshId: this._config.meshId || null,
126
128
  uptimeSeconds: Math.floor((Date.now() - this._startedAt) / 1000)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relay-federation/bridge",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Bridge server — WebSocket peering, header sync, tx relay, CLI",
5
5
  "type": "module",
6
6
  "bin": {