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: multi-layer glow, trend hover, nav scroll

- trend lines use 3-layer glow (halo + medium + core) for subtle lightning energy
- hover on trend: crosshair, highlights full line, dims others, shows tooltip
with relay name, coverage %, and timestamp
- previous runs nav: horizontal scroll instead of awkward wrapping
- add .gitignore for zig build artifacts

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

zzstoatzz 561d7d92 4149106f

+174 -49
+2
relay-eval/.gitignore
··· 1 + .zig-cache/ 2 + zig-out/
+172 -49
relay-eval/src/static/index.html
··· 28 28 pointer-events: none; z-index: 0; 29 29 } 30 30 31 + /* trend tooltip */ 32 + #trend-tip { 33 + position: fixed; z-index: 100; display: none; 34 + background: rgba(22, 27, 34, 0.92); border: 1px solid var(--border-strong); 35 + border-radius: 6px; padding: 0.45rem 0.65rem; 36 + font-size: 0.72rem; color: var(--fg); line-height: 1.5; 37 + pointer-events: none; white-space: nowrap; 38 + box-shadow: 0 4px 12px rgba(0,0,0,0.5); 39 + } 40 + #trend-tip .tt-host { font-weight: 600; } 41 + #trend-tip .tt-val { font-variant-numeric: tabular-nums; } 42 + #trend-tip .tt-time { color: var(--muted); font-size: 0.65rem; } 43 + 31 44 /* glass panel — translucent, content floats over the trend */ 32 45 .glass { 33 46 position: relative; z-index: 1; ··· 131 144 /* runs nav */ 132 145 .nav { 133 146 margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); 134 - display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; 147 + display: flex; gap: 0.35rem; align-items: center; 148 + overflow-x: auto; -webkit-overflow-scrolling: touch; 149 + scrollbar-width: none; 135 150 } 136 - .nav-label { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-right: 0.3rem; } 151 + .nav::-webkit-scrollbar { display: none; } 152 + .nav-label { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-right: 0.3rem; white-space: nowrap; flex-shrink: 0; } 137 153 .nav a { 138 154 font-size: 0.75rem; color: var(--accent); text-decoration: none; cursor: pointer; 139 155 padding: 0.2rem 0.5rem; border-radius: 4px; border: 1px solid transparent; 140 - transition: all 0.1s; 156 + transition: all 0.1s; white-space: nowrap; flex-shrink: 0; 141 157 } 142 158 .nav a:hover { border-color: var(--border-strong); background: rgba(22, 27, 34, 0.5); } 143 159 .nav .time-detail { color: var(--muted); font-size: 0.65rem; margin-left: 0.15rem; } ··· 173 189 <body> 174 190 175 191 <canvas id="trend"></canvas> 192 + <div id="trend-tip"></div> 176 193 177 194 <div class="glass"> 178 195 <h1>relay-eval</h1> ··· 250 267 251 268 // --- trend visualization --- 252 269 253 - function drawTrend(raw) { 270 + let _trendData = null; 271 + let _tc = null; // computed trend cache 272 + 273 + function buildTrendCache(raw) { 274 + const runs = [], rmap = {}; 275 + for (const p of raw) { 276 + if (!rmap[p.ts]) { rmap[p.ts] = { ts: p.ts, union: p.union, relays: {} }; runs.push(rmap[p.ts]); } 277 + rmap[p.ts].relays[p.host] = p.dids; 278 + } 279 + if (runs.length < 2) return null; 280 + const hosts = [...new Set(raw.map(p => p.host))]; 281 + const series = {}; 282 + for (const host of hosts) { 283 + series[host] = runs.map(run => { 284 + const dids = run.relays[host]; 285 + return (dids != null && run.union > 0) ? (dids / run.union) * 100 : null; 286 + }); 287 + } 288 + const avgOf = s => { const v = s.filter(x => x !== null); return v.length ? v.reduce((a,b) => a+b, 0) / v.length : 0; }; 289 + const sorted = [...hosts].sort((a, b) => avgOf(series[a]) - avgOf(series[b])); 290 + return { runs, series, hosts, sorted }; 291 + } 292 + 293 + function drawTrend(hover) { 254 294 const canvas = document.getElementById('trend'); 255 - if (!canvas) return; 295 + if (!canvas || !_tc) return; 296 + const { runs, series, hosts, sorted } = _tc; 256 297 const ctx = canvas.getContext('2d'); 257 298 const dpr = window.devicePixelRatio || 1; 258 299 const W = window.innerWidth, H = canvas.clientHeight || 280; 259 300 canvas.width = W * dpr; canvas.height = H * dpr; 260 301 ctx.scale(dpr, dpr); 261 - 262 - // clear to transparent (page bg shows through) 263 302 ctx.clearRect(0, 0, W, H); 264 303 265 - // group flat array into runs 266 - const runs = [], rmap = {}; 267 - for (const p of raw) { 268 - if (!rmap[p.ts]) { rmap[p.ts] = { ts: p.ts, union: p.union, relays: {} }; runs.push(rmap[p.ts]); } 269 - rmap[p.ts].relays[p.host] = p.dids; 270 - } 271 - if (runs.length < 2) return; 272 - 273 - const hosts = [...new Set(raw.map(p => p.host))]; 274 304 const pad = { t: 20, r: 20, b: 20, l: 20 }; 275 305 const cw = W - pad.l - pad.r, ch = H - pad.t - pad.b; 276 - 277 - // compute coverage series and y range 278 - const series = {}; 279 306 let lo = 100, hi = 0; 280 307 for (const host of hosts) { 281 - series[host] = runs.map(run => { 282 - const dids = run.relays[host]; 283 - if (dids == null || run.union === 0) return null; 284 - const v = (dids / run.union) * 100; 285 - if (v < lo) lo = v; 286 - if (v > hi) hi = v; 287 - return v; 288 - }); 308 + for (const v of series[host]) { if (v != null) { if (v < lo) lo = v; if (v > hi) hi = v; } } 289 309 } 290 310 lo = Math.max(0, Math.floor(lo - 1)); 291 311 hi = Math.min(100, Math.ceil(hi + 0.5)); ··· 293 313 294 314 const toX = i => pad.l + (i / (runs.length - 1)) * cw; 295 315 const toY = v => pad.t + ch - ((v - lo) / yr) * ch; 296 - 297 - // sort by avg coverage (worst first = painted underneath) 298 - const avgOf = s => { const v = s.filter(x => x !== null); return v.length ? v.reduce((a,b) => a+b, 0) / v.length : 0; }; 299 - const sorted = [...hosts].sort((a, b) => avgOf(series[a]) - avgOf(series[b])); 316 + _tc.layout = { pad, cw, ch, lo, hi, yr, toX, toY, W, H }; 300 317 301 - // helper: get valid (non-null) points for a host 302 318 const validPts = host => series[host].map((v, i) => [i, v]).filter(p => p[1] !== null); 303 319 304 - // draw glowing lines only — no fills, emerging from darkness 305 320 for (const host of sorted) { 306 321 const vp = validPts(host); 307 322 if (vp.length < 2) continue; 308 323 const color = op(host).color; 324 + const isHovered = hover && hover.host === host; 325 + const isDimmed = hover && hover.host && !isHovered; 309 326 const trace = () => { 310 327 ctx.beginPath(); 311 328 ctx.moveTo(toX(vp[0][0]), toY(vp[0][1])); 312 329 for (let j = 1; j < vp.length; j++) ctx.lineTo(toX(vp[j][0]), toY(vp[j][1])); 313 330 }; 314 331 315 - // outer glow 316 - ctx.save(); 332 + if (isDimmed) { 333 + trace(); 334 + ctx.strokeStyle = color; ctx.lineWidth = 0.5; ctx.globalAlpha = 0.06; 335 + ctx.stroke(); ctx.globalAlpha = 1; 336 + continue; 337 + } 338 + 339 + const b = isHovered ? 2.5 : 1; 340 + 341 + // layer 0: wide halo 342 + ctx.save(); trace(); 343 + ctx.shadowColor = color; ctx.shadowBlur = 20 * b; 344 + ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.globalAlpha = 0.04 * b; 345 + ctx.stroke(); ctx.restore(); 346 + 347 + // layer 1: medium glow 348 + ctx.save(); trace(); 349 + ctx.shadowColor = color; ctx.shadowBlur = 6 * b; 350 + ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.globalAlpha = 0.12 * b; 351 + ctx.stroke(); ctx.restore(); 352 + 353 + // layer 2: core 317 354 trace(); 318 - ctx.shadowColor = color; ctx.shadowBlur = 10; 319 - ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.25; 320 - ctx.stroke(); 321 - ctx.restore(); 355 + ctx.strokeStyle = color; 356 + ctx.lineWidth = isHovered ? 1.5 : 0.7; 357 + ctx.globalAlpha = isHovered ? 0.65 : 0.35; 358 + ctx.stroke(); ctx.globalAlpha = 1; 359 + 360 + // layer 3: bright center on hover 361 + if (isHovered) { 362 + trace(); 363 + ctx.strokeStyle = '#fff'; ctx.lineWidth = 0.5; ctx.globalAlpha = 0.2; 364 + ctx.stroke(); ctx.globalAlpha = 1; 365 + } 366 + } 367 + 368 + // crosshair + dots on hover 369 + if (hover && hover.runIdx >= 0 && hover.runIdx < runs.length) { 370 + const x = toX(hover.runIdx); 371 + ctx.beginPath(); ctx.moveTo(x, pad.t); ctx.lineTo(x, H - pad.b); 372 + ctx.strokeStyle = 'rgba(201, 209, 217, 0.1)'; 373 + ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.stroke(); ctx.setLineDash([]); 322 374 323 - // core 324 - trace(); 325 - ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.globalAlpha = 0.45; 326 - ctx.stroke(); 327 - ctx.globalAlpha = 1; 375 + for (const host of hosts) { 376 + const v = series[host][hover.runIdx]; 377 + if (v == null) continue; 378 + const color = op(host).color; 379 + const active = hover.host === host; 380 + ctx.beginPath(); ctx.arc(x, toY(v), active ? 3.5 : 1.5, 0, Math.PI * 2); 381 + ctx.fillStyle = active ? '#fff' : color; 382 + ctx.globalAlpha = active ? 0.9 : 0.2; 383 + ctx.fill(); 384 + if (active) { 385 + ctx.beginPath(); ctx.arc(x, toY(v), 3.5, 0, Math.PI * 2); 386 + ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.8; 387 + ctx.stroke(); 388 + } 389 + ctx.globalAlpha = 1; 390 + } 328 391 } 329 392 } 393 + 394 + // trend hover 395 + document.addEventListener('mousemove', function(e) { 396 + if (!_tc || !_tc.layout) return; 397 + const canvas = document.getElementById('trend'); 398 + if (!canvas) return; 399 + const rect = canvas.getBoundingClientRect(); 400 + const mx = e.clientX - rect.left, my = e.clientY - rect.top; 401 + const tip = document.getElementById('trend-tip'); 402 + 403 + if (my < 0 || my > rect.height || mx < 0 || mx > rect.width) { 404 + if (tip) tip.style.display = 'none'; 405 + drawTrend(null); 406 + return; 407 + } 408 + 409 + const { runs, series, hosts, layout } = _tc; 410 + const { toX, toY } = layout; 411 + 412 + // nearest run by x 413 + let ri = 0, bestDx = Infinity; 414 + for (let i = 0; i < runs.length; i++) { 415 + const dx = Math.abs(toX(i) - mx); 416 + if (dx < bestDx) { bestDx = dx; ri = i; } 417 + } 418 + 419 + // nearest host by y at that run 420 + let nh = null, bestDy = Infinity; 421 + for (const host of hosts) { 422 + const v = series[host][ri]; 423 + if (v == null) continue; 424 + const dy = Math.abs(toY(v) - my); 425 + if (dy < bestDy) { bestDy = dy; nh = host; } 426 + } 427 + if (bestDy > 50) nh = null; 428 + 429 + drawTrend({ runIdx: ri, host: nh }); 430 + 431 + if (tip && nh) { 432 + const run = runs[ri]; 433 + const v = series[nh][ri]; 434 + const o = op(nh); 435 + tip.innerHTML = `<span style="color:${o.color}">${o.sym}</span> <span class="tt-host">${nh}</span><br>` 436 + + `<span class="tt-val" style="color:${o.color}">${v.toFixed(2)}%</span> coverage<br>` 437 + + `<span class="tt-time">${utc(run.ts)}</span>`; 438 + tip.style.display = 'block'; 439 + const tx = e.clientX + 14, ty = e.clientY - 8; 440 + tip.style.left = (tx + tip.offsetWidth > window.innerWidth ? e.clientX - tip.offsetWidth - 10 : tx) + 'px'; 441 + tip.style.top = ty + 'px'; 442 + } else if (tip) { 443 + tip.style.display = 'none'; 444 + } 445 + }); 446 + document.addEventListener('mouseleave', function() { 447 + const tip = document.getElementById('trend-tip'); 448 + if (tip) tip.style.display = 'none'; 449 + drawTrend(null); 450 + }); 330 451 331 452 // --- dashboard render --- 332 453 ··· 480 601 document.getElementById('content').innerHTML = render(data); 481 602 } 482 603 483 - let _trendData = null; 484 - 485 604 async function init() { 486 - fetch('/api/trend').then(r => r.json()).then(d => { _trendData = d; drawTrend(d); }).catch(() => {}); 605 + fetch('/api/trend').then(r => r.json()).then(d => { 606 + _trendData = d; 607 + _tc = buildTrendCache(d); 608 + drawTrend(null); 609 + }).catch(() => {}); 487 610 488 611 const runs = await fetch('/api/runs').then(r => r.json()); 489 612 const nav = document.getElementById('runs-nav'); ··· 494 617 } 495 618 496 619 let nh = '<span class="nav-label">previous runs</span>'; 497 - for (const r of runs.slice(0, 8)) { 620 + for (const r of runs.slice(0, 12)) { 498 621 nh += `<a onclick="loadRun(${r.id})">${ago(r.timestamp)} <span class="time-detail">${clockTime(r.timestamp)}</span></a>`; 499 622 } 500 623 nav.className = 'nav'; ··· 504 627 } 505 628 506 629 init(); 507 - window.addEventListener('resize', () => { if (_trendData) drawTrend(_trendData); }); 630 + window.addEventListener('resize', () => { if (_tc) drawTrend(null); }); 508 631 </script> 509 632 </body> 510 633 </html>