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: translucent glass, subtle trend, mobile adaptive

- trend canvas: fixed behind content, no background fill, lines-only
glow at low opacity (0.25 glow / 0.45 core) emerging from darkness
- glass panel: 80% opacity with backdrop-filter blur, no displacement
- mobile (<640px): hide run-by/bar/detail columns, smaller spacing,
operator legend 2-col, disable css tooltips, shorter trend canvas
- tables wrapped in overflow-x container for edge cases
- summary card uses semi-transparent bg to match glass aesthetic

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

zzstoatzz 4149106f d3261da1

+66 -93
+66 -93
relay-eval/src/static/index.html
··· 21 21 -webkit-font-smoothing: antialiased; 22 22 } 23 23 24 - /* trend background */ 25 - .trend-wrap { 26 - position: relative; 27 - width: 100%; height: 260px; 28 - overflow: hidden; 24 + /* trend canvas — fixed behind everything */ 25 + #trend { 26 + position: fixed; top: 0; left: 0; 27 + width: 100vw; height: 280px; 28 + pointer-events: none; z-index: 0; 29 29 } 30 - #trend { display: block; width: 100%; height: 100%; } 31 30 32 - /* glass panel */ 31 + /* glass panel — translucent, content floats over the trend */ 33 32 .glass { 34 33 position: relative; z-index: 1; 35 - max-width: 960px; margin: -2.5rem auto 0; 34 + max-width: 960px; margin: 0 auto; 36 35 padding: 2rem 2rem 2.5rem; 37 - background: rgba(13, 17, 23, 0.91); 38 - backdrop-filter: blur(20px); 39 - -webkit-backdrop-filter: blur(20px); 40 - border: 1px solid rgba(255, 255, 255, 0.04); 41 - border-top: 1px solid rgba(255, 255, 255, 0.08); 42 - border-radius: 16px 16px 0 0; 43 - min-height: calc(100vh - 230px); 44 - box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.5); 36 + background: rgba(13, 17, 23, 0.80); 37 + backdrop-filter: blur(24px); 38 + -webkit-backdrop-filter: blur(24px); 39 + min-height: 100vh; 45 40 } 46 41 47 42 h1 { font-size: 1.25rem; font-weight: 600; color: var(--fg); letter-spacing: -0.02em; } ··· 50 45 /* summary card */ 51 46 .summary { 52 47 margin-top: 1.5rem; padding: 0.9rem 1.1rem; 53 - background: var(--surface); border: 1px solid var(--border); border-radius: 8px; 48 + background: rgba(22, 27, 34, 0.7); border: 1px solid var(--border); border-radius: 8px; 54 49 font-size: 0.85rem; color: var(--fg); line-height: 1.7; 55 - box-shadow: 0 1px 3px rgba(0,0,0,0.3); 56 50 } 57 51 .summary .stat { font-weight: 600; } 58 52 ··· 74 68 .sec-desc { font-size: 0.8rem; color: var(--muted); margin-bottom: 0.75rem; line-height: 1.55; } 75 69 76 70 /* tables */ 71 + .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } 77 72 table { width: 100%; border-collapse: collapse; } 78 73 th { 79 74 text-align: left; padding: 0.5rem 0.6rem; font-size: 0.7rem; font-weight: 500; ··· 112 107 113 108 /* expandable */ 114 109 .xrow { cursor: pointer; user-select: none; } 115 - .xrow:hover td { background: var(--surface); } 110 + .xrow:hover td { background: rgba(22, 27, 34, 0.5); } 116 111 .xi { display: inline-block; width: 1.1em; text-align: center; transition: transform 0.15s; } 117 112 .xbody { display: none; } 118 113 .xbody.open { display: table-row-group; } ··· 144 139 padding: 0.2rem 0.5rem; border-radius: 4px; border: 1px solid transparent; 145 140 transition: all 0.1s; 146 141 } 147 - .nav a:hover { border-color: var(--border-strong); background: var(--surface); } 142 + .nav a:hover { border-color: var(--border-strong); background: rgba(22, 27, 34, 0.5); } 148 143 .nav .time-detail { color: var(--muted); font-size: 0.65rem; margin-left: 0.15rem; } 149 144 150 145 .empty { color: var(--muted); font-style: italic; padding: 3rem; text-align: center; } 151 146 .loading { color: var(--muted); padding: 3rem; text-align: center; } 147 + 148 + /* mobile */ 149 + @media (max-width: 640px) { 150 + .glass { padding: 1.25rem 1rem 1.5rem; } 151 + h1 { font-size: 1.1rem; } 152 + .subtitle { font-size: 0.78rem; } 153 + .summary { font-size: 0.78rem; padding: 0.7rem 0.85rem; line-height: 1.6; } 154 + .op-legend { grid-template-columns: repeat(2, auto); font-size: 0.75rem; } 155 + .sec { font-size: 0.85rem; } 156 + .sec-desc { font-size: 0.75rem; } 157 + th { padding: 0.35rem 0.4rem; font-size: 0.65rem; } 158 + td { padding: 0.35rem 0.4rem; font-size: 0.75rem; } 159 + .hm { display: none; } 160 + .rn { font-size: 0.72rem; } 161 + .sym { font-size: 0.75em; } 162 + .bar { width: 48px; } 163 + .class-legend { flex-wrap: wrap; gap: 0.5rem 1rem; font-size: 0.72rem; } 164 + .drow td { padding-left: 1.2rem; font-size: 0.72rem; } 165 + .did-link { font-size: 0.68rem; } 166 + .nav a { font-size: 0.7rem; padding: 0.15rem 0.35rem; } 167 + .nav .time-detail { display: none; } 168 + .tip::after { display: none; } 169 + #trend { height: 200px; } 170 + } 152 171 </style> 153 172 </head> 154 173 <body> 155 174 156 - <div class="trend-wrap"> 157 - <canvas id="trend"></canvas> 158 - </div> 175 + <canvas id="trend"></canvas> 159 176 160 177 <div class="glass"> 161 178 <h1>relay-eval</h1> ··· 236 253 function drawTrend(raw) { 237 254 const canvas = document.getElementById('trend'); 238 255 if (!canvas) return; 239 - const wrap = canvas.parentElement; 240 256 const ctx = canvas.getContext('2d'); 241 257 const dpr = window.devicePixelRatio || 1; 242 - const W = wrap.clientWidth, H = wrap.clientHeight; 258 + const W = window.innerWidth, H = canvas.clientHeight || 280; 243 259 canvas.width = W * dpr; canvas.height = H * dpr; 244 - canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; 245 260 ctx.scale(dpr, dpr); 246 261 262 + // clear to transparent (page bg shows through) 263 + ctx.clearRect(0, 0, W, H); 264 + 247 265 // group flat array into runs 248 266 const runs = [], rmap = {}; 249 267 for (const p of raw) { 250 268 if (!rmap[p.ts]) { rmap[p.ts] = { ts: p.ts, union: p.union, relays: {} }; runs.push(rmap[p.ts]); } 251 269 rmap[p.ts].relays[p.host] = p.dids; 252 270 } 253 - if (runs.length < 2) { 254 - // single run or empty: just draw dark background 255 - ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, W, H); 256 - return; 257 - } 271 + if (runs.length < 2) return; 258 272 259 273 const hosts = [...new Set(raw.map(p => p.host))]; 260 - const pad = { t: 24, r: 16, b: 28, l: 16 }; 274 + const pad = { t: 20, r: 20, b: 20, l: 20 }; 261 275 const cw = W - pad.l - pad.r, ch = H - pad.t - pad.b; 262 276 263 277 // compute coverage series and y range ··· 280 294 const toX = i => pad.l + (i / (runs.length - 1)) * cw; 281 295 const toY = v => pad.t + ch - ((v - lo) / yr) * ch; 282 296 283 - // background 284 - const bg = ctx.createLinearGradient(0, 0, 0, H); 285 - bg.addColorStop(0, '#10151c'); bg.addColorStop(1, '#0d1117'); 286 - ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); 287 - 288 - // faint grid 289 - ctx.strokeStyle = 'rgba(255,255,255,0.025)'; ctx.lineWidth = 1; 290 - for (let v = Math.ceil(lo); v <= Math.floor(hi); v++) { 291 - const yy = toY(v); 292 - ctx.beginPath(); ctx.moveTo(pad.l, yy); ctx.lineTo(W - pad.r, yy); ctx.stroke(); 293 - } 294 - 295 297 // sort by avg coverage (worst first = painted underneath) 296 298 const avgOf = s => { const v = s.filter(x => x !== null); return v.length ? v.reduce((a,b) => a+b, 0) / v.length : 0; }; 297 299 const sorted = [...hosts].sort((a, b) => avgOf(series[a]) - avgOf(series[b])); ··· 299 301 // helper: get valid (non-null) points for a host 300 302 const validPts = host => series[host].map((v, i) => [i, v]).filter(p => p[1] !== null); 301 303 302 - // stained glass fills (additive blending) 303 - ctx.save(); 304 - ctx.globalCompositeOperation = 'lighter'; 305 - for (const host of sorted) { 306 - const vp = validPts(host); 307 - if (vp.length < 2) continue; 308 - ctx.beginPath(); 309 - ctx.moveTo(toX(vp[0][0]), pad.t + ch); 310 - for (const [i, v] of vp) ctx.lineTo(toX(i), toY(v)); 311 - ctx.lineTo(toX(vp[vp.length - 1][0]), pad.t + ch); 312 - ctx.closePath(); 313 - ctx.fillStyle = op(host).color + '06'; 314 - ctx.fill(); 315 - } 316 - ctx.restore(); 317 - 318 - // electric lines 304 + // draw glowing lines only — no fills, emerging from darkness 319 305 for (const host of sorted) { 320 306 const vp = validPts(host); 321 307 if (vp.length < 2) continue; ··· 326 312 for (let j = 1; j < vp.length; j++) ctx.lineTo(toX(vp[j][0]), toY(vp[j][1])); 327 313 }; 328 314 329 - // glow 315 + // outer glow 330 316 ctx.save(); 331 317 trace(); 332 - ctx.shadowColor = color; ctx.shadowBlur = 14; 333 - ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.globalAlpha = 0.5; 318 + ctx.shadowColor = color; ctx.shadowBlur = 10; 319 + ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.25; 334 320 ctx.stroke(); 335 321 ctx.restore(); 336 322 337 - // bright core 323 + // core 338 324 trace(); 339 - ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.globalAlpha = 0.85; 325 + ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.globalAlpha = 0.45; 340 326 ctx.stroke(); 341 327 ctx.globalAlpha = 1; 342 328 } 343 - 344 - // bottom fade into page bg 345 - const fade = ctx.createLinearGradient(0, H - 36, 0, H); 346 - fade.addColorStop(0, 'rgba(13,17,23,0)'); fade.addColorStop(1, 'rgba(13,17,23,1)'); 347 - ctx.fillStyle = fade; ctx.fillRect(0, H - 36, W, 36); 348 - 349 - // labels 350 - ctx.fillStyle = 'rgba(139,148,158,0.3)'; ctx.font = '10px monospace'; 351 - ctx.textAlign = 'left'; ctx.fillText(ago(runs[0].ts), pad.l, H - 6); 352 - ctx.textAlign = 'right'; ctx.fillText('now', W - pad.r, H - 6); 353 - ctx.textAlign = 'left'; ctx.fillText('coverage %', pad.l, 14); 354 329 } 355 330 356 331 // --- dashboard render --- ··· 402 377 h += `<p class="sec">coverage</p>`; 403 378 h += `<p class="sec-desc">each relay independently discovers PDS hosts. coverage = accounts this relay saw / accounts any relay saw.</p>`; 404 379 405 - h += `<table><thead><tr>`; 406 - h += `<th>relay</th><th>run by</th><th class="num">events</th><th class="num">accounts</th>`; 407 - h += `<th class="num">coverage</th><th class="num">missed</th><th></th>`; 380 + h += `<div class="table-wrap"><table><thead><tr>`; 381 + h += `<th>relay</th><th class="hm">run by</th><th class="num">events</th><th class="num">accounts</th>`; 382 + h += `<th class="num">coverage</th><th class="num">missed</th><th class="hm"></th>`; 408 383 h += `</tr></thead><tbody>`; 409 384 410 385 const ranked = [...data.stats].sort((a, b) => b.unique_dids - a.unique_dids); ··· 419 394 const pctAbove = isOutlier ? Math.round((s.events / median - 1) * 100) : 0; 420 395 h += `<tr${s.events === 0 ? ' class="dimmed"' : ''}>`; 421 396 h += `<td>${rn(s.host)}</td>`; 422 - h += `<td class="run-by">${byLink}</td>`; 397 + h += `<td class="run-by hm">${byLink}</td>`; 423 398 h += `<td class="num">${s.events.toLocaleString()}${isOutlier ? `<span class="outlier" title="${pctAbove}% above median \u2014 likely replaying backlog after a restart">\u26a0</span>` : ''}</td>`; 424 399 h += `<td class="num">${s.unique_dids.toLocaleString()}</td>`; 425 400 h += `<td class="num">${pct(s.unique_dids, union)}</td>`; 426 401 h += `<td class="num">${missed > 0 ? missed.toLocaleString() : '\u2014'}</td>`; 427 - h += `<td>${bar(s.unique_dids, union)}</td>`; 402 + h += `<td class="hm">${bar(s.unique_dids, union)}</td>`; 428 403 h += `</tr>`; 429 404 } 430 - h += `</tbody></table>`; 405 + h += `</tbody></table></div>`; 431 406 432 407 if (outliers.size > 0) { 433 408 const names = [...outliers].map(host => rn(host)).join(', '); ··· 451 426 h += `<span><span class="cdot" style="background:var(--muted)"></span> ${tip('deactivated', 'account is deactivated or deleted')}</span>`; 452 427 h += `</div>`; 453 428 454 - h += `<table><thead><tr>`; 429 + h += `<div class="table-wrap"><table><thead><tr>`; 455 430 h += `<th>relay</th>`; 456 431 h += `<th class="num">${tip('active', 'account has a working PDS but this relay didn\u2019t see it')}</th>`; 457 - h += `<th class="num">${tip('unresolvable', 'DID lookup failed')}</th>`; 458 - h += `<th class="num">${tip('deactivated', 'account is deactivated or deleted')}</th>`; 432 + h += `<th class="num hm">${tip('unresolvable', 'DID lookup failed')}</th>`; 433 + h += `<th class="num hm">${tip('deactivated', 'account is deactivated or deleted')}</th>`; 459 434 h += `<th class="num">total</th>`; 460 435 h += `</tr></thead>`; 461 436 ··· 467 442 h += `<tbody><tr class="xrow" onclick="toggle('${id}')">`; 468 443 h += `<td><span class="xi" id="xi-${id}">\u25b8</span>${rn(s.host)}</td>`; 469 444 h += `<td class="num c-gap">${d.coverage_gap || '\u2014'}</td>`; 470 - h += `<td class="num c-unr">${d.unresolvable || '\u2014'}</td>`; 471 - h += `<td class="num c-dead">${d.deactivated || '\u2014'}</td>`; 445 + h += `<td class="num c-unr hm">${d.unresolvable || '\u2014'}</td>`; 446 + h += `<td class="num c-dead hm">${d.deactivated || '\u2014'}</td>`; 472 447 h += `<td class="num">${total.toLocaleString()}</td>`; 473 448 h += `</tr></tbody>`; 474 449 ··· 483 458 } 484 459 h += `</tbody>`; 485 460 } 486 - h += `</table>`; 461 + h += `</table></div>`; 487 462 } else { 488 463 h += `<p class="empty">all relays saw the same accounts during this window</p>`; 489 464 } ··· 504 479 const data = await fetch(url).then(r => r.json()); 505 480 document.getElementById('content').innerHTML = render(data); 506 481 } 482 + 483 + let _trendData = null; 507 484 508 485 async function init() { 509 - // load trend in parallel with run data 510 486 fetch('/api/trend').then(r => r.json()).then(d => { _trendData = d; drawTrend(d); }).catch(() => {}); 511 487 512 488 const runs = await fetch('/api/runs').then(r => r.json()); ··· 528 504 } 529 505 530 506 init(); 531 - 532 - // cache trend data for resize redraws 533 - let _trendData = null; 534 507 window.addEventListener('resize', () => { if (_trendData) drawTrend(_trendData); }); 535 508 </script> 536 509 </body>