declarative relay deployment on hetzner relay-eval.waow.tech
atproto relay
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

relay-eval dashboard: group by operator, rename classifications

- group relays by operator with colored symbols (matches pulsar's "run by")
- "real gaps" → "missed (active)" — factual, not vague
- "can't resolve" → "unresolvable", "inactive" → "deactivated"
- operator legend, breakdown sorted by most missed first

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+300
+300
relay-eval/src/static/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>relay-eval</title> 7 + <style> 8 + :root { --bg: #0d1117; --fg: #c9d1d9; --border: #30363d; --accent: #58a6ff; --green: #3fb950; --red: #f85149; --yellow: #d29922; --muted: #8b949e; } 9 + * { margin: 0; padding: 0; box-sizing: border-box; } 10 + body { font-family: 'SF Mono', 'Cascadia Code', monospace; background: var(--bg); color: var(--fg); padding: 2rem; max-width: 1100px; margin: 0 auto; } 11 + h1 { font-size: 1.4rem; margin-bottom: 0.5rem; color: var(--accent); } 12 + h2 { font-size: 1.1rem; margin: 1.5rem 0 0.75rem; color: var(--fg); border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; } 13 + .meta { color: var(--muted); font-size: 0.85rem; margin-bottom: 1.5rem; } 14 + .run-info { color: var(--muted); font-size: 0.8rem; margin-bottom: 1rem; } 15 + table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; } 16 + th, td { text-align: left; padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); font-size: 0.85rem; } 17 + th { color: var(--muted); font-weight: normal; } 18 + .num { text-align: right; font-variant-numeric: tabular-nums; } 19 + .missed-active { color: var(--red); } 20 + .cant-resolve { color: var(--yellow); } 21 + .inactive { color: var(--muted); } 22 + .connected { color: var(--green); } 23 + .disconnected { color: var(--red); } 24 + .empty { color: var(--muted); font-style: italic; padding: 2rem; text-align: center; } 25 + #runs-nav { margin: 1.5rem 0; } 26 + #runs-nav a { color: var(--accent); text-decoration: none; margin-right: 1rem; font-size: 0.85rem; cursor: pointer; } 27 + #runs-nav a:hover { text-decoration: underline; } 28 + .loading { color: var(--muted); } 29 + .tip { cursor: help; border-bottom: 1px dotted var(--muted); } 30 + .tooltip { position: relative; display: inline-block; } 31 + .tooltip .tt { visibility: hidden; background: #161b22; border: 1px solid var(--border); color: var(--fg); padding: 0.5rem 0.75rem; border-radius: 4px; position: absolute; z-index: 1; bottom: 125%; left: 50%; transform: translateX(-50%); white-space: nowrap; font-size: 0.8rem; font-weight: normal; } 32 + .tooltip:hover .tt { visibility: visible; } 33 + .explain { color: var(--muted); font-size: 0.8rem; margin-bottom: 0.75rem; line-height: 1.5; } 34 + .did-link { color: var(--accent); text-decoration: none; } 35 + .did-link:hover { text-decoration: underline; } 36 + .bar { display: inline-block; height: 10px; border-radius: 2px; } 37 + .bar-ok { background: var(--green); } 38 + .bar-gap { background: var(--red); } 39 + .bar-container { display: flex; gap: 1px; align-items: center; } 40 + .legend { display: flex; gap: 1rem; font-size: 0.8rem; color: var(--muted); margin-bottom: 0.5rem; flex-wrap: wrap; } 41 + .legend-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 0.25rem; vertical-align: middle; } 42 + .collapsible { cursor: pointer; user-select: none; } 43 + .collapsible:hover { color: var(--accent); } 44 + .details { display: none; } 45 + .details.open { display: table-row-group; } 46 + .detail-row td { padding-left: 2rem; font-size: 0.8rem; border-bottom: 1px solid #21262d; } 47 + .operator { font-size: 0.75rem; color: var(--muted); } 48 + .op-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 0.4rem; vertical-align: middle; } 49 + .group-header td { padding-top: 0.8rem; font-size: 0.8rem; color: var(--muted); border-bottom: 1px solid var(--border); } 50 + </style> 51 + </head> 52 + <body> 53 + <h1>relay-eval</h1> 54 + <p class="meta">comparing what each relay sees on the ATProto network</p> 55 + 56 + <div id="content"><p class="loading">loading...</p></div> 57 + 58 + <div id="runs-nav"></div> 59 + 60 + <script> 61 + // operator registry — host → { name, symbol } 62 + // symbols: ◆ ▲ ■ ● ◇ ▼ ★ ◎ 63 + const operators = { 64 + 'bsky.network': { name: 'bluesky', symbol: '◆', color: '#58a6ff' }, 65 + 'relay1.us-east.bsky.network': { name: 'bluesky', symbol: '◆', color: '#58a6ff' }, 66 + 'relay1.us-west.bsky.network': { name: 'bluesky', symbol: '◆', color: '#58a6ff' }, 67 + 'zlay.waow.tech': { name: '@zzstoatzz.io', symbol: '▲', color: '#3fb950' }, 68 + 'relay.waow.tech': { name: '@zzstoatzz.io', symbol: '▲', color: '#3fb950' }, 69 + 'relay.bas.sh': { name: '@bas.sh', symbol: '■', color: '#f0883e' }, 70 + 'northamerica.firehose.network': { name: '@sri.xyz', symbol: '●', color: '#bc8cff' }, 71 + 'asia.firehose.network': { name: '@sri.xyz', symbol: '●', color: '#bc8cff' }, 72 + 'europe.firehose.network': { name: '@sri.xyz', symbol: '●', color: '#bc8cff' }, 73 + 'relay.fire.hose.cam': { name: '@bad-example.com', symbol: '◇', color: '#f85149' }, 74 + 'relay3.fr.hose.cam': { name: '@bad-example.com', symbol: '◇', color: '#f85149' }, 75 + 'relay.xero.systems': { name: '@besaid.zone', symbol: '★', color: '#d29922' }, 76 + 'atproto.africa': { name: 'blacksky', symbol: '◎', color: '#da7dae' }, 77 + 'relay.upcloud.world': { name: 'upcloud', symbol: '▼', color: '#79c0ff' }, 78 + 'relay.feeds.blue': { name: '@mackuba.eu', symbol: '▲', color: '#56d364' }, 79 + }; 80 + 81 + function op(host) { 82 + return operators[host] || { name: host.split('.').slice(-2).join('.'), symbol: '·', color: '#8b949e' }; 83 + } 84 + 85 + async function fetchJSON(url) { 86 + const r = await fetch(url); 87 + return r.json(); 88 + } 89 + 90 + function tt(label, tip) { 91 + return `<span class="tooltip"><span class="tip">${label}</span><span class="tt">${tip}</span></span>`; 92 + } 93 + 94 + function didLink(did) { 95 + const short = did.length > 28 ? did.slice(0, 28) + '...' : did; 96 + return `<a class="did-link" href="https://internect.info/did/${did}" target="_blank" title="${did}">${short}</a>`; 97 + } 98 + 99 + function pct(n, total) { 100 + if (total === 0) return '\u2014'; 101 + return (n / total * 100).toFixed(2) + '%'; 102 + } 103 + 104 + function coverageBar(seen, union) { 105 + if (union === 0) return ''; 106 + const w = 120; 107 + const okW = Math.round(seen / union * w); 108 + const missW = w - okW; 109 + return `<div class="bar-container">` + 110 + (okW > 0 ? `<span class="bar bar-ok" style="width:${okW}px"></span>` : '') + 111 + (missW > 0 ? `<span class="bar bar-gap" style="width:${missW}px"></span>` : '') + 112 + `</div>`; 113 + } 114 + 115 + function relayLabel(host) { 116 + const o = op(host); 117 + return `<span class="op-dot" style="background:${o.color}" title="${o.name}"></span>${host}`; 118 + } 119 + 120 + // group stats by operator, sorted by best coverage within each group 121 + function groupByOperator(stats) { 122 + const groups = {}; 123 + for (const s of stats) { 124 + const o = op(s.host); 125 + if (!groups[o.name]) groups[o.name] = { op: o, relays: [] }; 126 + groups[o.name].relays.push(s); 127 + } 128 + // sort groups by best coverage (highest unique_dids) descending 129 + const sorted = Object.values(groups).sort((a, b) => { 130 + const aMax = Math.max(...a.relays.map(r => r.unique_dids)); 131 + const bMax = Math.max(...b.relays.map(r => r.unique_dids)); 132 + return bMax - aMax; 133 + }); 134 + // sort relays within each group by unique_dids descending 135 + for (const g of sorted) g.relays.sort((a, b) => b.unique_dids - a.unique_dids); 136 + return sorted; 137 + } 138 + 139 + function renderRun(data) { 140 + if (!data) return '<p class="empty">no runs yet</p>'; 141 + 142 + const union = data.union_dids || 0; 143 + let html = ''; 144 + 145 + html += `<p class="run-info">${data.timestamp.replace('T', ' ').replace('Z', ' UTC')} \u00b7 ${data.window_seconds}s window \u00b7 ${union.toLocaleString()} unique accounts across all relays</p>`; 146 + 147 + // aggregate diffs per relay 148 + const relayDiffs = {}; 149 + for (const d of data.diffs) { 150 + if (!relayDiffs[d.relay]) relayDiffs[d.relay] = { coverage_gap: 0, unresolvable: 0, deactivated: 0, dids: [] }; 151 + relayDiffs[d.relay][d.classification]++; 152 + relayDiffs[d.relay].dids.push(d); 153 + } 154 + 155 + // operator legend 156 + const seenOps = {}; 157 + for (const s of data.stats) { const o = op(s.host); seenOps[o.name] = o; } 158 + html += `<div class="legend">`; 159 + for (const [name, o] of Object.entries(seenOps)) { 160 + html += `<span><span class="op-dot" style="background:${o.color}"></span>${name}</span>`; 161 + } 162 + html += `</div>`; 163 + 164 + // relay coverage table — grouped by operator 165 + html += `<h2>relay coverage</h2>`; 166 + html += `<p class="explain">each relay independently discovers and subscribes to PDS hosts. coverage = fraction of the network's accounts seen during this window.</p>`; 167 + 168 + html += `<table><tr>`; 169 + html += `<th>${tt('relay', 'the relay host being evaluated')}</th>`; 170 + html += `<th class="num">${tt('events', 'total firehose events received')}</th>`; 171 + html += `<th class="num">${tt('accounts', 'unique accounts (DIDs) seen')}</th>`; 172 + html += `<th class="num">${tt('coverage', 'accounts seen / union of all relays')}</th>`; 173 + html += `<th class="num">${tt('missed', 'accounts other relays saw but this one did not')}</th>`; 174 + html += `<th></th>`; 175 + html += `</tr>`; 176 + 177 + const groups = groupByOperator(data.stats); 178 + for (const group of groups) { 179 + for (const s of group.relays) { 180 + const rd = relayDiffs[s.host] || { coverage_gap: 0, unresolvable: 0, deactivated: 0, dids: [] }; 181 + const missed = rd.coverage_gap + rd.unresolvable + rd.deactivated; 182 + const connClass = s.connected ? 'connected' : 'disconnected'; 183 + 184 + html += `<tr>`; 185 + html += `<td>${relayLabel(s.host)} <span class="${connClass}" title="${s.connected ? 'connected' : 'disconnected'}">\u25cf</span></td>`; 186 + html += `<td class="num">${s.events.toLocaleString()}</td>`; 187 + html += `<td class="num">${s.unique_dids.toLocaleString()}</td>`; 188 + html += `<td class="num">${pct(s.unique_dids, union)}</td>`; 189 + html += `<td class="num">${missed > 0 ? missed : '\u2014'}</td>`; 190 + html += `<td>${coverageBar(s.unique_dids, union)}</td>`; 191 + html += `</tr>`; 192 + } 193 + } 194 + html += '</table>'; 195 + 196 + // missed accounts breakdown 197 + const relaysWithMisses = data.stats.filter(s => { 198 + const rd = relayDiffs[s.host]; 199 + return rd && (rd.coverage_gap + rd.unresolvable + rd.deactivated) > 0; 200 + }); 201 + 202 + if (relaysWithMisses.length > 0) { 203 + html += `<h2>missed accounts breakdown</h2>`; 204 + html += `<p class="explain">for relays that missed accounts, each DID is resolved to classify the miss. ` 205 + + `<span class="missed-active">missed (active)</span> = account has a live PDS \u2014 the relay did not see it. ` 206 + + `<span class="cant-resolve">unresolvable</span> = DID resolution failed (DNS/PLC issue, very new account). ` 207 + + `<span class="inactive">deactivated</span> = account is deactivated or deleted.</p>`; 208 + 209 + html += `<table id="breakdown-table"><thead><tr>`; 210 + html += `<th>relay</th>`; 211 + html += `<th class="num">${tt('missed (active)', 'account is live but this relay did not see it')}</th>`; 212 + html += `<th class="num">${tt('unresolvable', 'DID could not be resolved')}</th>`; 213 + html += `<th class="num">${tt('deactivated', 'account is deactivated or deleted')}</th>`; 214 + html += `<th class="num">total</th>`; 215 + html += `</tr></thead>`; 216 + 217 + // sort by total missed descending 218 + relaysWithMisses.sort((a, b) => { 219 + const ra = relayDiffs[a.host], rb = relayDiffs[b.host]; 220 + const ta = ra.coverage_gap + ra.unresolvable + ra.deactivated; 221 + const tb = rb.coverage_gap + rb.unresolvable + rb.deactivated; 222 + return tb - ta; 223 + }); 224 + 225 + for (const s of relaysWithMisses) { 226 + const rd = relayDiffs[s.host]; 227 + const total = rd.coverage_gap + rd.unresolvable + rd.deactivated; 228 + const rid = s.host.replace(/\./g, '_'); 229 + 230 + html += `<tbody>`; 231 + html += `<tr class="collapsible" onclick="toggleDetails('${rid}')">`; 232 + html += `<td>\u25b8 ${relayLabel(s.host)}</td>`; 233 + html += `<td class="num missed-active">${rd.coverage_gap || '\u2014'}</td>`; 234 + html += `<td class="num cant-resolve">${rd.unresolvable || '\u2014'}</td>`; 235 + html += `<td class="num inactive">${rd.deactivated || '\u2014'}</td>`; 236 + html += `<td class="num">${total}</td>`; 237 + html += `</tr>`; 238 + 239 + // expandable detail rows 240 + html += `<tbody class="details" id="details-${rid}">`; 241 + const shown = rd.dids.slice(0, 30); 242 + for (const d of shown) { 243 + const cls = d.classification === 'coverage_gap' ? 'missed-active' : d.classification === 'unresolvable' ? 'cant-resolve' : 'inactive'; 244 + const label = d.classification === 'coverage_gap' ? 'active (missed)' : d.classification === 'unresolvable' ? 'unresolvable' : 'deactivated'; 245 + html += `<tr class="detail-row"><td>${didLink(d.did)}</td><td colspan="4" class="${cls}">${label}</td></tr>`; 246 + } 247 + if (rd.dids.length > 30) { 248 + html += `<tr class="detail-row"><td colspan="5" class="inactive">... and ${rd.dids.length - 30} more</td></tr>`; 249 + } 250 + html += `</tbody>`; 251 + html += `</tbody>`; 252 + } 253 + html += '</table>'; 254 + } else { 255 + html += '<p class="empty">all relays saw the same accounts during this window</p>'; 256 + } 257 + 258 + return html; 259 + } 260 + 261 + function toggleDetails(id) { 262 + const el = document.getElementById('details-' + id); 263 + if (!el) return; 264 + el.classList.toggle('open'); 265 + const row = el.previousElementSibling?.querySelector('.collapsible') || el.previousElementSibling; 266 + if (row) { 267 + const td = row.querySelector('td'); 268 + if (td) td.innerHTML = td.innerHTML.replace('\u25b8', '\u25be').replace('\u25be', el.classList.contains('open') ? '\u25be' : '\u25b8'); 269 + } 270 + } 271 + 272 + async function loadRun(id) { 273 + const data = id === 'latest' 274 + ? await fetchJSON('/api/latest') 275 + : await fetchJSON(`/api/runs/${id}`); 276 + document.getElementById('content').innerHTML = renderRun(data); 277 + } 278 + 279 + async function init() { 280 + const runs = await fetchJSON('/api/runs'); 281 + const nav = document.getElementById('runs-nav'); 282 + 283 + if (runs.length === 0) { 284 + document.getElementById('content').innerHTML = '<p class="empty">no evaluation runs yet</p>'; 285 + return; 286 + } 287 + 288 + let navHtml = '<span style="color:#8b949e;font-size:0.85rem">runs: </span>'; 289 + for (const r of runs.slice(0, 10)) { 290 + navHtml += `<a onclick="loadRun(${r.id})">${r.timestamp.replace('T', ' ').replace('Z', '')}</a> `; 291 + } 292 + nav.innerHTML = navHtml; 293 + 294 + await loadRun('latest'); 295 + } 296 + 297 + init(); 298 + </script> 299 + </body> 300 + </html>