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: cleaner design with operator symbols

- use unicode symbols (◆ ▲ ■ ● etc.) per operator instead of colored CSS dots
- remove confusing connection-status dots; offline relays get dimmed + tag
- human-readable timestamps ("3h ago") with exact UTC on hover
- plain english window ("5 minutes" not "300s")
- cleaner visual hierarchy, slimmer coverage bars
- better classification names in breakdown section

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

+251 -211
+251 -211
relay-eval/src/static/index.html
··· 5 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 6 <title>relay-eval</title> 7 7 <style> 8 - :root { --bg: #0d1117; --fg: #c9d1d9; --border: #30363d; --accent: #58a6ff; --green: #3fb950; --red: #f85149; --yellow: #d29922; --muted: #8b949e; } 8 + :root { 9 + --bg: #0d1117; --fg: #c9d1d9; --muted: #8b949e; 10 + --border: #21262d; --border-strong: #30363d; 11 + --surface: #161b22; --accent: #58a6ff; 12 + --green: #3fb950; --red: #f85149; --yellow: #d29922; 13 + } 9 14 * { 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; } 15 + body { 16 + font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; 17 + font-size: 13px; 18 + background: var(--bg); color: var(--fg); 19 + padding: 2.5rem 2rem; max-width: 960px; margin: 0 auto; 20 + line-height: 1.55; 21 + } 22 + h1 { font-size: 1.25rem; font-weight: 600; color: var(--fg); letter-spacing: -0.02em; } 23 + .subtitle { color: var(--muted); font-size: 0.85rem; margin-top: 0.2rem; } 24 + 25 + /* summary card */ 26 + .summary { 27 + margin-top: 1.5rem; padding: 0.9rem 1.1rem; 28 + background: var(--surface); border: 1px solid var(--border); border-radius: 6px; 29 + font-size: 0.85rem; color: var(--fg); line-height: 1.7; 30 + } 31 + .summary .stat { font-weight: 600; } 32 + 33 + /* operator legend */ 34 + .op-legend { 35 + display: flex; flex-wrap: wrap; gap: 0.5rem 1.1rem; 36 + margin-top: 1.1rem; font-size: 0.8rem; color: var(--muted); 37 + } 38 + .op-legend span { white-space: nowrap; } 39 + .sym { font-size: 0.85em; margin-right: 0.3rem; } 40 + 41 + /* sections */ 42 + .sec { font-size: 0.9rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.2rem; } 43 + .sec-desc { font-size: 0.8rem; color: var(--muted); margin-bottom: 0.75rem; line-height: 1.55; } 44 + 45 + /* tables */ 46 + table { width: 100%; border-collapse: collapse; } 47 + th { 48 + text-align: left; padding: 0.45rem 0.6rem; font-size: 0.7rem; font-weight: 500; 49 + color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; 50 + border-bottom: 1px solid var(--border-strong); 51 + } 52 + td { padding: 0.4rem 0.6rem; font-size: 0.82rem; border-bottom: 1px solid var(--border); } 18 53 .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; } 54 + th.num { text-align: right; } 55 + 56 + /* relay cell */ 57 + .rn { white-space: nowrap; } 58 + .rn.offline { opacity: 0.4; } 59 + .offline-tag { font-size: 0.7rem; color: var(--muted); margin-left: 0.4rem; } 60 + 61 + /* coverage bar */ 62 + .bar { display: flex; align-items: center; gap: 1px; width: 72px; } 63 + .bar-ok { height: 5px; border-radius: 2px; background: var(--green); } 64 + .bar-miss { height: 5px; border-radius: 2px; background: var(--red); opacity: 0.5; } 65 + 66 + /* classification */ 67 + .c-gap { color: var(--red); } 68 + .c-unr { color: var(--yellow); } 69 + .c-dead { color: var(--muted); } 70 + 71 + /* expandable */ 72 + .xrow { cursor: pointer; user-select: none; } 73 + .xrow:hover td { background: var(--surface); } 74 + .xi { display: inline-block; width: 1.1em; text-align: center; } 75 + .xbody { display: none; } 76 + .xbody.open { display: table-row-group; } 77 + .drow td { padding-left: 2.2rem; font-size: 0.78rem; color: var(--muted); } 78 + .did-link { color: var(--accent); text-decoration: none; font-size: 0.75rem; } 35 79 .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); } 80 + 81 + /* tooltip via css */ 82 + .tip { position: relative; cursor: help; border-bottom: 1px dotted var(--muted); } 83 + .tip::after { 84 + content: attr(data-tip); position: absolute; bottom: calc(100% + 6px); 85 + left: 50%; transform: translateX(-50%); 86 + background: var(--surface); border: 1px solid var(--border-strong); 87 + color: var(--fg); padding: 0.35rem 0.6rem; border-radius: 4px; 88 + font-size: 0.72rem; white-space: nowrap; pointer-events: none; 89 + opacity: 0; transition: opacity 0.12s; z-index: 10; 90 + } 91 + .tip:hover::after { opacity: 1; } 92 + 93 + /* runs nav */ 94 + .nav { 95 + margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); 96 + display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; 97 + } 98 + .nav-label { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-right: 0.3rem; } 99 + .nav a { 100 + font-size: 0.78rem; color: var(--accent); text-decoration: none; cursor: pointer; 101 + padding: 0.15rem 0.45rem; border-radius: 3px; border: 1px solid transparent; 102 + } 103 + .nav a:hover { border-color: var(--border-strong); background: var(--surface); } 104 + 105 + .empty { color: var(--muted); font-style: italic; padding: 3rem; text-align: center; } 106 + .loading { color: var(--muted); padding: 3rem; text-align: center; } 50 107 </style> 51 108 </head> 52 109 <body> 110 + 53 111 <h1>relay-eval</h1> 54 - <p class="meta">comparing what each relay sees on the ATProto network</p> 112 + <p class="subtitle">comparing what each relay sees on the atproto network</p> 55 113 56 114 <div id="content"><p class="loading">loading...</p></div> 57 - 58 115 <div id="runs-nav"></div> 59 116 60 117 <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' }, 118 + const ops = { 119 + 'bsky.network': { name: 'bluesky', sym: '◆', color: '#58a6ff' }, 120 + 'relay1.us-east.bsky.network': { name: 'bluesky', sym: '◆', color: '#58a6ff' }, 121 + 'relay1.us-west.bsky.network': { name: 'bluesky', sym: '◆', color: '#58a6ff' }, 122 + 'zlay.waow.tech': { name: '@zzstoatzz.io', sym: '▲', color: '#3fb950' }, 123 + 'relay.waow.tech': { name: '@zzstoatzz.io', sym: '▲', color: '#3fb950' }, 124 + 'relay.bas.sh': { name: '@bas.sh', sym: '■', color: '#f0883e' }, 125 + 'northamerica.firehose.network': { name: '@sri.xyz', sym: '●', color: '#bc8cff' }, 126 + 'asia.firehose.network': { name: '@sri.xyz', sym: '●', color: '#bc8cff' }, 127 + 'europe.firehose.network': { name: '@sri.xyz', sym: '●', color: '#bc8cff' }, 128 + 'relay.fire.hose.cam': { name: '@bad-example.com', sym: '◇', color: '#f85149' }, 129 + 'relay3.fr.hose.cam': { name: '@bad-example.com', sym: '◇', color: '#f85149' }, 130 + 'relay.xero.systems': { name: '@besaid.zone', sym: '★', color: '#d29922' }, 131 + 'atproto.africa': { name: 'blacksky', sym: '◎', color: '#da7dae' }, 132 + 'relay.upcloud.world': { name: 'upcloud', sym: '▽', color: '#39d2c0' }, 133 + 'relay.feeds.blue': { name: '@mackuba.eu', sym: '⬡', color: '#d2a8ff' }, 79 134 }; 80 135 81 - function op(host) { 82 - return operators[host] || { name: host.split('.').slice(-2).join('.'), symbol: '·', color: '#8b949e' }; 136 + function op(h) { return ops[h] || { name: h.split('.').slice(-2).join('.'), sym: '·', color: '#8b949e' }; } 137 + 138 + function ago(iso) { 139 + const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60000); 140 + if (m < 1) return 'just now'; 141 + if (m < 60) return m + 'm ago'; 142 + const h = Math.floor(m / 60); 143 + if (h < 24) return h + 'h ago'; 144 + return Math.floor(h / 24) + 'd ago'; 83 145 } 84 146 85 - async function fetchJSON(url) { 86 - const r = await fetch(url); 87 - return r.json(); 147 + function winLabel(s) { 148 + if (s < 60) return s + ' seconds'; 149 + const m = Math.round(s / 60); 150 + return m + ' minute' + (m !== 1 ? 's' : ''); 88 151 } 89 152 90 - function tt(label, tip) { 91 - return `<span class="tooltip"><span class="tip">${label}</span><span class="tt">${tip}</span></span>`; 92 - } 153 + function utc(iso) { return iso.replace('T', ' ').replace('Z', '') + ' UTC'; } 93 154 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 - } 155 + function pct(n, t) { return t === 0 ? '—' : (n / t * 100).toFixed(2) + '%'; } 156 + 157 + function tip(text, t) { return `<span class="tip" data-tip="${t}">${text}</span>`; } 98 158 99 - function pct(n, total) { 100 - if (total === 0) return '\u2014'; 101 - return (n / total * 100).toFixed(2) + '%'; 159 + function rn(host, conn) { 160 + const o = op(host); 161 + const c = conn ? '' : ' offline'; 162 + const tag = conn ? '' : '<span class="offline-tag">offline</span>'; 163 + return `<span class="rn${c}"><span class="sym" style="color:${o.color}">${o.sym}</span> ${host}${tag}</span>`; 102 164 } 103 165 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>`; 166 + function did(d) { 167 + const s = d.length > 32 ? d.slice(0, 16) + '\u2026' + d.slice(-12) : d; 168 + return `<a class="did-link" href="https://internect.info/did/${d}" target="_blank" title="${d}">${s}</a>`; 113 169 } 114 170 115 - function relayLabel(host) { 116 - const o = op(host); 117 - return `<span class="op-dot" style="background:${o.color}" title="${o.name}"></span>${host}`; 171 + function bar(seen, total) { 172 + if (total === 0) return ''; 173 + const w = 72, ok = Math.round(seen / total * w), miss = w - ok; 174 + return `<div class="bar">` 175 + + (ok > 0 ? `<span class="bar-ok" style="width:${ok}px"></span>` : '') 176 + + (miss > 0 ? `<span class="bar-miss" style="width:${miss}px"></span>` : '') 177 + + `</div>`; 118 178 } 119 179 120 - // group stats by operator, sorted by best coverage within each group 121 - function groupByOperator(stats) { 122 - const groups = {}; 180 + function groups(stats) { 181 + const g = {}; 123 182 for (const s of stats) { 124 183 const o = op(s.host); 125 - if (!groups[o.name]) groups[o.name] = { op: o, relays: [] }; 126 - groups[o.name].relays.push(s); 184 + if (!g[o.name]) g[o.name] = { op: o, relays: [] }; 185 + g[o.name].relays.push(s); 127 186 } 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); 187 + const sorted = Object.values(g).sort((a, b) => 188 + Math.max(...b.relays.map(r => r.unique_dids)) - Math.max(...a.relays.map(r => r.unique_dids)) 189 + ); 190 + for (const x of sorted) x.relays.sort((a, b) => b.unique_dids - a.unique_dids); 136 191 return sorted; 137 192 } 138 193 139 - function renderRun(data) { 194 + function render(data) { 140 195 if (!data) return '<p class="empty">no runs yet</p>'; 141 196 142 197 const union = data.union_dids || 0; 143 - let html = ''; 198 + let h = ''; 144 199 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 - } 200 + // summary 201 + h += `<div class="summary">`; 202 + h += `measured ${tip(ago(data.timestamp), utc(data.timestamp))}`; 203 + h += ` \u00b7 watched the network for ${tip(winLabel(data.window_seconds), data.window_seconds + 's collection window')}`; 204 + h += ` \u00b7 <span class="stat">${union.toLocaleString()}</span> active accounts seen`; 205 + h += `</div>`; 154 206 155 207 // 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>`; 208 + const seen = {}; 209 + for (const s of data.stats) { const o = op(s.host); seen[o.name] = o; } 210 + h += `<div class="op-legend">`; 211 + for (const [name, o] of Object.entries(seen)) { 212 + h += `<span><span class="sym" style="color:${o.color}">${o.sym}</span> ${name}</span>`; 161 213 } 162 - html += `</div>`; 214 + h += `</div>`; 163 215 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>`; 216 + // diffs by relay 217 + const rd = {}; 218 + for (const d of data.diffs) { 219 + if (!rd[d.relay]) rd[d.relay] = { coverage_gap: 0, unresolvable: 0, deactivated: 0, dids: [] }; 220 + rd[d.relay][d.classification]++; 221 + rd[d.relay].dids.push(d); 222 + } 167 223 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>`; 224 + // coverage table 225 + h += `<p class="sec">coverage</p>`; 226 + h += `<p class="sec-desc">each relay independently discovers PDS hosts. coverage = accounts this relay saw / accounts any relay saw.</p>`; 176 227 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'; 228 + h += `<table><thead><tr>`; 229 + h += `<th>relay</th><th class="num">events</th><th class="num">accounts</th>`; 230 + h += `<th class="num">coverage</th><th class="num">missed</th><th></th>`; 231 + h += `</tr></thead><tbody>`; 183 232 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>`; 233 + for (const g of groups(data.stats)) { 234 + for (const s of g.relays) { 235 + const d = rd[s.host] || { coverage_gap: 0, unresolvable: 0, deactivated: 0 }; 236 + const missed = d.coverage_gap + d.unresolvable + d.deactivated; 237 + h += `<tr>`; 238 + h += `<td>${rn(s.host, s.connected)}</td>`; 239 + h += `<td class="num">${s.events.toLocaleString()}</td>`; 240 + h += `<td class="num">${s.unique_dids.toLocaleString()}</td>`; 241 + h += `<td class="num">${pct(s.unique_dids, union)}</td>`; 242 + h += `<td class="num">${missed > 0 ? missed.toLocaleString() : '\u2014'}</td>`; 243 + h += `<td>${bar(s.unique_dids, union)}</td>`; 244 + h += `</tr>`; 192 245 } 193 246 } 194 - html += '</table>'; 247 + h += `</tbody></table>`; 195 248 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; 249 + // breakdown 250 + const withMisses = data.stats.filter(s => { 251 + const d = rd[s.host]; 252 + return d && (d.coverage_gap + d.unresolvable + d.deactivated) > 0; 253 + }).sort((a, b) => { 254 + const da = rd[a.host], db = rd[b.host]; 255 + return (db.coverage_gap + db.unresolvable + db.deactivated) - (da.coverage_gap + da.unresolvable + da.deactivated); 200 256 }); 201 257 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>`; 258 + if (withMisses.length > 0) { 259 + h += `<p class="sec">why accounts were missed</p>`; 260 + h += `<p class="sec-desc">`; 261 + h += `each missed account is resolved to understand the cause. `; 262 + h += `<span class="c-gap">missed (active)</span> \u2014 the account has a working PDS but this relay didn\u2019t see it. `; 263 + h += `<span class="c-unr">unresolvable</span> \u2014 the DID couldn\u2019t be looked up (DNS failure, PLC outage, brand-new account). `; 264 + h += `<span class="c-dead">deactivated</span> \u2014 account is deactivated or deleted.`; 265 + h += `</p>`; 208 266 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>`; 267 + h += `<table><thead><tr>`; 268 + h += `<th>relay</th><th class="num">missed (active)</th><th class="num">unresolvable</th>`; 269 + h += `<th class="num">deactivated</th><th class="num">total</th>`; 270 + h += `</tr></thead>`; 216 271 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 - }); 272 + for (const s of withMisses) { 273 + const d = rd[s.host]; 274 + const total = d.coverage_gap + d.unresolvable + d.deactivated; 275 + const id = s.host.replace(/[^a-z0-9]/g, '_'); 224 276 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, '_'); 277 + h += `<tbody><tr class="xrow" onclick="toggle('${id}')">`; 278 + h += `<td><span class="xi" id="xi-${id}">\u25b8</span>${rn(s.host, s.connected)}</td>`; 279 + h += `<td class="num c-gap">${d.coverage_gap || '\u2014'}</td>`; 280 + h += `<td class="num c-unr">${d.unresolvable || '\u2014'}</td>`; 281 + h += `<td class="num c-dead">${d.deactivated || '\u2014'}</td>`; 282 + h += `<td class="num">${total.toLocaleString()}</td>`; 283 + h += `</tr></tbody>`; 229 284 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>`; 285 + h += `<tbody class="xbody" id="xb-${id}">`; 286 + for (const x of d.dids.slice(0, 30)) { 287 + const cls = x.classification === 'coverage_gap' ? 'c-gap' : x.classification === 'unresolvable' ? 'c-unr' : 'c-dead'; 288 + const label = x.classification === 'coverage_gap' ? 'active' : x.classification; 289 + h += `<tr class="drow"><td>${did(x.did)}</td><td colspan="4" class="${cls}">${label}</td></tr>`; 246 290 } 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>`; 291 + if (d.dids.length > 30) { 292 + h += `<tr class="drow"><td colspan="5">\u2026 and ${d.dids.length - 30} more</td></tr>`; 249 293 } 250 - html += `</tbody>`; 251 - html += `</tbody>`; 294 + h += `</tbody>`; 252 295 } 253 - html += '</table>'; 296 + h += `</table>`; 254 297 } else { 255 - html += '<p class="empty">all relays saw the same accounts during this window</p>'; 298 + h += `<p class="empty">all relays saw the same accounts during this window</p>`; 256 299 } 257 300 258 - return html; 301 + return h; 259 302 } 260 303 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 - } 304 + function toggle(id) { 305 + const b = document.getElementById('xb-' + id); 306 + const i = document.getElementById('xi-' + id); 307 + if (!b) return; 308 + b.classList.toggle('open'); 309 + if (i) i.textContent = b.classList.contains('open') ? '\u25be' : '\u25b8'; 270 310 } 271 311 272 312 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); 313 + const url = id === 'latest' ? '/api/latest' : `/api/runs/${id}`; 314 + const data = await fetch(url).then(r => r.json()); 315 + document.getElementById('content').innerHTML = render(data); 277 316 } 278 317 279 318 async function init() { 280 - const runs = await fetchJSON('/api/runs'); 319 + const runs = await fetch('/api/runs').then(r => r.json()); 281 320 const nav = document.getElementById('runs-nav'); 282 321 283 322 if (runs.length === 0) { ··· 285 324 return; 286 325 } 287 326 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> `; 327 + let nh = '<span class="nav-label">previous runs</span>'; 328 + for (const r of runs.slice(0, 8)) { 329 + nh += `<a onclick="loadRun(${r.id})">${ago(r.timestamp)}</a>`; 291 330 } 292 - nav.innerHTML = navHtml; 331 + nav.className = 'nav'; 332 + nav.innerHTML = nh; 293 333 294 334 await loadRun('latest'); 295 335 }