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: OG image, build system, infra, and deploy configs

- dynamic SVG at /og.svg with leaderboard from latest run data
- rasterized PNG at /og via rsvg-convert (ExecStartPost in eval timer)
- OG meta tags in dashboard HTML for link previews
- add relay-eval section + pulsar attribution to repo README
- add build.zig, terraform, systemd units, justfile, classifier

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

+1129 -46
+7
README.md
··· 55 55 ./scripts/jetstream --url wss://jetstream1.us-east.bsky.network 56 56 ``` 57 57 58 + ## relay-eval 59 + 60 + [**relay-eval.waow.tech**](https://relay-eval.waow.tech) — compares what each atproto relay sees by subscribing to multiple firehoses simultaneously and measuring DID coverage overlap. inspired by [pulsar](https://tangled.sh/@mackuba.eu/pulsar) by mackuba (zlib license). 61 + 62 + source: [`relay-eval/`](relay-eval/) 63 + 58 64 ## what's here 59 65 60 66 ``` 61 67 . 62 68 ├── indigo/ # Go relay (indigo) — justfile, deploy configs, terraform 63 69 ├── zlay/ # zig relay (zlay) — justfile, deploy configs, terraform 70 + ├── relay-eval/ # firehose comparison tool — zig, hetzner, systemd 64 71 ├── shared/deploy/ # helm values shared by both deployments 65 72 ├── scripts/ # uv scripts — firehose, jetstream, backfill 66 73 ├── docs/ # architecture, deployment guide, backfill
+62
relay-eval/build.zig
··· 1 + const std = @import("std"); 2 + 3 + pub fn build(b: *std.Build) void { 4 + const target = b.standardTargetOptions(.{}); 5 + const optimize = b.standardOptimizeOption(.{}); 6 + 7 + const zat = b.dependency("zat", .{ 8 + .target = target, 9 + .optimize = optimize, 10 + }); 11 + const websocket = b.dependency("websocket", .{ 12 + .target = target, 13 + .optimize = optimize, 14 + }); 15 + 16 + const imports: []const std.Build.Module.Import = &.{ 17 + .{ .name = "zat", .module = zat.module("zat") }, 18 + .{ .name = "websocket", .module = websocket.module("websocket") }, 19 + }; 20 + 21 + const exe_mod = b.createModule(.{ 22 + .root_source_file = b.path("src/main.zig"), 23 + .target = target, 24 + .optimize = optimize, 25 + .imports = imports, 26 + }); 27 + 28 + const exe = b.addExecutable(.{ 29 + .name = "relay-eval", 30 + .root_module = exe_mod, 31 + }); 32 + exe.linkLibC(); 33 + exe.linkSystemLibrary("sqlite3"); 34 + b.installArtifact(exe); 35 + 36 + const run = b.addRunArtifact(exe); 37 + if (b.args) |args| run.addArgs(args); 38 + const run_step = b.step("run", "run relay-eval"); 39 + run_step.dependOn(&run.step); 40 + 41 + // tests 42 + const test_step = b.step("test", "run unit tests"); 43 + const test_files = .{ 44 + "src/collector.zig", 45 + "src/classifier.zig", 46 + "src/store.zig", 47 + }; 48 + inline for (test_files) |file| { 49 + const test_mod = b.createModule(.{ 50 + .root_source_file = b.path(file), 51 + .target = target, 52 + .optimize = optimize, 53 + .imports = imports, 54 + }); 55 + const t = b.addTest(.{ 56 + .root_module = test_mod, 57 + }); 58 + t.linkLibC(); 59 + t.linkSystemLibrary("sqlite3"); 60 + test_step.dependOn(&b.addRunArtifact(t).step); 61 + } 62 + }
+21
relay-eval/build.zig.zon
··· 1 + .{ 2 + .name = .relay_eval, 3 + .version = "0.0.1", 4 + .fingerprint = 0x5f4f6bc8058cd6d1, 5 + .minimum_zig_version = "0.15.0", 6 + .dependencies = .{ 7 + .zat = .{ 8 + .url = "https://tangled.org/zat.dev/zat/archive/v0.2.16.tar.gz", 9 + .hash = "zat-0.2.16-5PuC7tjwBADbnwV5y8ztKUHhGHMJHh2HouvoYImnZ7y5", 10 + }, 11 + .websocket = .{ 12 + .url = "https://github.com/zzstoatzz/websocket.zig/archive/395d0f4.tar.gz", 13 + .hash = "websocket-0.1.0-ZPISdVJ8AwD7U03ARGgHclzlYSd9GeU91_WDXjRyjYdh", 14 + }, 15 + }, 16 + .paths = .{ 17 + "build.zig", 18 + "build.zig.zon", 19 + "src", 20 + }, 21 + }
+15
relay-eval/deploy/relay-eval-web.service
··· 1 + [Unit] 2 + Description=relay-eval web dashboard 3 + After=network-online.target 4 + Wants=network-online.target 5 + 6 + [Service] 7 + Type=simple 8 + ExecStart=/opt/relay-eval-src/relay-eval/zig-out/bin/relay-eval serve \ 9 + --db /var/lib/relay-eval/relay-eval.db \ 10 + --port 8080 11 + Restart=always 12 + RestartSec=5 13 + 14 + [Install] 15 + WantedBy=multi-user.target
+13
relay-eval/deploy/relay-eval.service
··· 1 + [Unit] 2 + Description=relay-eval firehose comparison run 3 + After=network-online.target 4 + Wants=network-online.target 5 + 6 + [Service] 7 + Type=oneshot 8 + ExecStart=/opt/relay-eval-src/relay-eval/zig-out/bin/relay-eval eval \ 9 + --relays bsky.network,relay1.us-east.bsky.network,relay1.us-west.bsky.network,zlay.waow.tech,relay.waow.tech,relay.bas.sh,northamerica.firehose.network,relay.fire.hose.cam,relay3.fr.hose.cam,asia.firehose.network,europe.firehose.network,relay.xero.systems,atproto.africa,relay.upcloud.world,relay.feeds.blue \ 10 + --window 300 \ 11 + --db /var/lib/relay-eval/relay-eval.db 12 + ExecStartPost=/bin/sh -c 'curl -sf http://localhost:8080/og.svg | rsvg-convert -w 1200 -h 630 -o /var/lib/relay-eval/og.png || true' 13 + TimeoutStartSec=1800
+10
relay-eval/deploy/relay-eval.timer
··· 1 + [Unit] 2 + Description=relay-eval periodic comparison 3 + 4 + [Timer] 5 + OnCalendar=*-*-* *:00/30:00 6 + Persistent=true 7 + RandomizedDelaySec=60 8 + 9 + [Install] 10 + WantedBy=timers.target
+69
relay-eval/infra/main.tf
··· 1 + data "hcloud_ssh_key" "main" { 2 + name = "relay-key" 3 + } 4 + 5 + resource "hcloud_firewall" "relay_eval" { 6 + name = "${var.server_name}-fw" 7 + 8 + # ssh 9 + rule { 10 + direction = "in" 11 + protocol = "tcp" 12 + port = "22" 13 + source_ips = ["0.0.0.0/0", "::/0"] 14 + } 15 + 16 + # http 17 + rule { 18 + direction = "in" 19 + protocol = "tcp" 20 + port = "80" 21 + source_ips = ["0.0.0.0/0", "::/0"] 22 + } 23 + 24 + # https 25 + rule { 26 + direction = "in" 27 + protocol = "tcp" 28 + port = "443" 29 + source_ips = ["0.0.0.0/0", "::/0"] 30 + } 31 + } 32 + 33 + resource "hcloud_server" "relay_eval" { 34 + name = var.server_name 35 + server_type = var.server_type 36 + location = var.location 37 + image = "ubuntu-24.04" 38 + 39 + ssh_keys = [data.hcloud_ssh_key.main.id] 40 + firewall_ids = [hcloud_firewall.relay_eval.id] 41 + 42 + user_data = <<-CLOUDINIT 43 + #cloud-config 44 + package_update: true 45 + packages: 46 + - curl 47 + - jq 48 + - sqlite3 49 + - libsqlite3-dev 50 + - caddy 51 + 52 + runcmd: 53 + # install zig 0.15 (detect arch — zig uses arch-os naming) 54 + - | 55 + cd /tmp 56 + ARCH=$(uname -m) 57 + if [ "$ARCH" = "aarch64" ]; then ZIG_ARCH="aarch64"; else ZIG_ARCH="x86_64"; fi 58 + curl -LO "https://ziglang.org/download/0.15.2/zig-$ZIG_ARCH-linux-0.15.2.tar.xz" 59 + tar xf "zig-$ZIG_ARCH-linux-0.15.2.tar.xz" 60 + mv "zig-$ZIG_ARCH-linux-0.15.2" /opt/zig 61 + ln -sf /opt/zig/zig /usr/local/bin/zig 62 + # create data directory 63 + - mkdir -p /var/lib/relay-eval 64 + # clone source 65 + - git clone https://tangled.org/zzstoatzz.io/relay /opt/relay-eval-src 66 + # signal ready 67 + - touch /run/relay-eval-ready 68 + CLOUDINIT 69 + }
+3
relay-eval/infra/outputs.tf
··· 1 + output "server_ip" { 2 + value = hcloud_server.relay_eval.ipv4_address 3 + }
+23
relay-eval/infra/variables.tf
··· 1 + variable "hcloud_token" { 2 + description = "Hetzner Cloud API token" 3 + type = string 4 + sensitive = true 5 + } 6 + 7 + variable "server_type" { 8 + description = "Hetzner server type (cax21 = 4 vCPU ARM, 8 GB RAM, 80 GB disk)" 9 + type = string 10 + default = "cax21" 11 + } 12 + 13 + variable "location" { 14 + description = "Hetzner datacenter location (hel1 = Helsinki)" 15 + type = string 16 + default = "hel1" 17 + } 18 + 19 + variable "server_name" { 20 + description = "Name for the server" 21 + type = string 22 + default = "relay-eval" 23 + }
+12
relay-eval/infra/versions.tf
··· 1 + terraform { 2 + required_providers { 3 + hcloud = { 4 + source = "hetznercloud/hcloud" 5 + version = "~> 1.45" 6 + } 7 + } 8 + } 9 + 10 + provider "hcloud" { 11 + token = var.hcloud_token 12 + }
+97
relay-eval/justfile
··· 1 + # relay-eval — firehose comparison tool 2 + # required env vars: HCLOUD_TOKEN 3 + 4 + server := "root@" + `terraform -chdir=infra output -raw server_ip 2>/dev/null || echo "NO_SERVER"` 5 + 6 + # --- infrastructure --- 7 + 8 + # initialize terraform 9 + init: 10 + terraform -chdir=infra init 11 + 12 + # create the hetzner server 13 + infra: 14 + terraform -chdir=infra apply -var="hcloud_token=$HCLOUD_TOKEN" 15 + 16 + # destroy all infrastructure 17 + destroy: 18 + terraform -chdir=infra destroy -var="hcloud_token=$HCLOUD_TOKEN" 19 + 20 + # ssh into the server 21 + ssh: 22 + ssh {{ server }} 23 + 24 + # --- build + deploy --- 25 + 26 + # rsync source to the server 27 + sync: 28 + rsync -az --delete \ 29 + --exclude='.zig-cache' --exclude='zig-out' \ 30 + --exclude='.terraform' --exclude='terraform.tfstate*' --exclude='.terraform.lock.hcl' \ 31 + . {{ server }}:/opt/relay-eval-src/relay-eval/ 32 + 33 + # build on the server (syncs first) 34 + build: sync 35 + ssh {{ server }} 'cd /opt/relay-eval-src/relay-eval && zig build -Doptimize=ReleaseSafe' 36 + 37 + # deploy systemd units, rebuild, and restart services 38 + deploy: build 39 + #!/usr/bin/env bash 40 + set -euo pipefail 41 + scp deploy/relay-eval.service {{ server }}:/etc/systemd/system/ 42 + scp deploy/relay-eval.timer {{ server }}:/etc/systemd/system/ 43 + scp deploy/relay-eval-web.service {{ server }}:/etc/systemd/system/ 44 + ssh {{ server }} <<'EOF' 45 + set -euo pipefail 46 + systemctl daemon-reload 47 + systemctl restart relay-eval-web.service 48 + systemctl restart relay-eval.timer 49 + echo "==> deployed" 50 + systemctl list-timers relay-eval.timer --no-pager 51 + EOF 52 + 53 + # --- operations --- 54 + 55 + # check service status + db summary 56 + status: 57 + #!/usr/bin/env bash 58 + ssh {{ server }} <<'EOF' 59 + echo "--- timer ---" 60 + systemctl status relay-eval.timer --no-pager -l 2>/dev/null || true 61 + echo "" 62 + echo "--- web ---" 63 + systemctl status relay-eval-web.service --no-pager -l 2>/dev/null || true 64 + echo "" 65 + echo "--- db ---" 66 + sqlite3 /var/lib/relay-eval/relay-eval.db \ 67 + "SELECT count(*) || ' runs, latest: ' || max(timestamp) FROM runs;" 2>/dev/null || echo "(no db)" 68 + EOF 69 + 70 + # trigger a manual eval run (blocks until complete) 71 + run: 72 + ssh {{ server }} 'systemctl start relay-eval.service && journalctl -u relay-eval --no-pager -n 30' 73 + 74 + # tail eval logs 75 + logs-eval: 76 + ssh {{ server }} journalctl -u relay-eval -f 77 + 78 + # tail web server logs 79 + logs-web: 80 + ssh {{ server }} journalctl -u relay-eval-web -f 81 + 82 + # query latest run results 83 + report: 84 + #!/usr/bin/env bash 85 + ssh {{ server }} <<'EOF' 86 + DB=/var/lib/relay-eval/relay-eval.db 87 + echo "=== latest run ===" 88 + sqlite3 -header -column $DB "SELECT * FROM runs ORDER BY id DESC LIMIT 1;" 89 + echo "" 90 + echo "=== relay stats ===" 91 + sqlite3 -header -column $DB \ 92 + "SELECT host, events, unique_dids, connected FROM relay_stats WHERE run_id = (SELECT max(id) FROM runs);" 93 + echo "" 94 + echo "=== diff summary ===" 95 + sqlite3 -header -column $DB \ 96 + "SELECT only_on, missing_from, classification, count(*) as n FROM diffs WHERE run_id = (SELECT max(id) FROM runs) GROUP BY 1,2,3;" 97 + EOF
+70
relay-eval/src/classifier.zig
··· 1 + const std = @import("std"); 2 + const zat = @import("zat"); 3 + 4 + const log = std.log.scoped(.classifier); 5 + 6 + pub const Classification = enum { 7 + coverage_gap, 8 + unresolvable, 9 + deactivated, 10 + 11 + pub fn toString(self: Classification) []const u8 { 12 + return switch (self) { 13 + .coverage_gap => "coverage_gap", 14 + .unresolvable => "unresolvable", 15 + .deactivated => "deactivated", 16 + }; 17 + } 18 + }; 19 + 20 + pub const ClassifiedDid = struct { 21 + did: []const u8, 22 + classification: Classification, 23 + }; 24 + 25 + /// classify DIDs that appear in one relay but not another. 26 + /// resolves each DID to determine if it's a real coverage gap, 27 + /// an unresolvable DID, or a deactivated account. 28 + pub fn classifyDids( 29 + allocator: std.mem.Allocator, 30 + dids: []const []const u8, 31 + max_count: usize, 32 + ) ![]ClassifiedDid { 33 + var resolver = zat.DidResolver.init(allocator); 34 + defer resolver.deinit(); 35 + 36 + var results: std.ArrayList(ClassifiedDid) = .empty; 37 + errdefer results.deinit(allocator); 38 + 39 + const limit = @min(dids.len, max_count); 40 + for (dids[0..limit], 0..) |did, i| { 41 + if (i > 0 and i % 50 == 0) { 42 + log.info("classified {d}/{d} DIDs", .{ i, limit }); 43 + } 44 + const classification = classifyOne(&resolver, did); 45 + try results.append(allocator, .{ 46 + .did = did, 47 + .classification = classification, 48 + }); 49 + } 50 + log.info("classified {d}/{d} DIDs", .{ limit, limit }); 51 + 52 + return results.toOwnedSlice(allocator); 53 + } 54 + 55 + fn classifyOne(resolver: *zat.DidResolver, did_str: []const u8) Classification { 56 + const parsed = zat.Did.parse(did_str) orelse return .unresolvable; 57 + var doc = resolver.resolve(parsed) catch |err| { 58 + log.debug("resolve failed for {s}: {s}", .{ did_str, @errorName(err) }); 59 + return .unresolvable; 60 + }; 61 + defer doc.deinit(); 62 + 63 + // has PDS endpoint = real account, this is a coverage gap 64 + if (doc.pdsEndpoint()) |_| { 65 + return .coverage_gap; 66 + } 67 + 68 + // resolves but no PDS = deactivated 69 + return .deactivated; 70 + }
+11 -1
relay-eval/src/main.zig
··· 45 45 \\serve options: 46 46 \\ --db <path> sqlite database path (default: relay-eval.db) 47 47 \\ --port <port> listen port (default: 8080) 48 + \\ --trend-limit <n> default trend window size (default: 48, env: RELAY_EVAL_TREND_LIMIT) 48 49 \\ 49 50 , .{}); 50 51 } ··· 257 258 defer if (db_path_alloc) |p| allocator.free(p); 258 259 var port: u16 = 8080; 259 260 261 + // default trend limit: env var, then 48 262 + var trend_limit: u32 = if (std.posix.getenv("RELAY_EVAL_TREND_LIMIT")) |v| 263 + std.fmt.parseInt(u32, v, 10) catch 48 264 + else 265 + 48; 266 + 260 267 var i: usize = 0; 261 268 while (i < args.len) : (i += 1) { 262 269 if (std.mem.eql(u8, args[i], "--db") and i + 1 < args.len) { ··· 266 273 } else if (std.mem.eql(u8, args[i], "--port") and i + 1 < args.len) { 267 274 i += 1; 268 275 port = std.fmt.parseInt(u16, args[i], 10) catch 8080; 276 + } else if (std.mem.eql(u8, args[i], "--trend-limit") and i + 1 < args.len) { 277 + i += 1; 278 + trend_limit = std.fmt.parseInt(u32, args[i], 10) catch trend_limit; 269 279 } 270 280 } 271 281 272 - try server.run(allocator, db_path, port); 282 + try server.run(allocator, db_path, port, trend_limit); 273 283 } 274 284 275 285 fn formatTimestamp(epoch: i64, buf: []u8) []const u8 {
+144 -7
relay-eval/src/server.zig
··· 5 5 6 6 const index_html = @embedFile("static/index.html"); 7 7 8 - pub fn run(allocator: std.mem.Allocator, db_path: [:0]const u8, port: u16) !void { 8 + pub fn run(allocator: std.mem.Allocator, db_path: [:0]const u8, port: u16, trend_limit: u32) !void { 9 9 var store = try Store.open(db_path); 10 10 defer store.close(); 11 11 12 + // derive og.png path from db directory 13 + const db_dir = std.fs.path.dirname(db_path) orelse "."; 14 + const og_png_path = try std.fmt.allocPrint(allocator, "{s}/og.png", .{db_dir}); 15 + defer allocator.free(og_png_path); 16 + 12 17 const addr = std.net.Address.parseIp4("0.0.0.0", port) catch unreachable; 13 18 var listener = try addr.listen(.{ .reuse_address = true }); 14 19 defer listener.deinit(); ··· 20 25 log.err("accept: {s}", .{@errorName(err)}); 21 26 continue; 22 27 }; 23 - handleConnection(allocator, conn.stream, &store) catch |err| { 28 + handleConnection(allocator, conn.stream, &store, trend_limit, og_png_path) catch |err| { 24 29 log.debug("request error: {s}", .{@errorName(err)}); 25 30 }; 26 31 conn.stream.close(); 27 32 } 28 33 } 29 34 30 - fn handleConnection(allocator: std.mem.Allocator, stream: std.net.Stream, store: *Store) !void { 35 + fn handleConnection(allocator: std.mem.Allocator, stream: std.net.Stream, store: *Store, trend_limit: u32, og_png_path: []const u8) !void { 31 36 var buf: [4096]u8 = undefined; 32 37 const n = try stream.read(&buf); 33 38 if (n == 0) return; ··· 40 45 41 46 var parts = std.mem.splitScalar(u8, first_line, ' '); 42 47 const method = parts.next() orelse return; 43 - const path = parts.next() orelse return; 48 + const full_path = parts.next() orelse return; 44 49 45 50 if (!std.mem.eql(u8, method, "GET")) { 46 51 try sendResponse(stream, "405 Method Not Allowed", "text/plain", "method not allowed"); 47 52 return; 48 53 } 49 54 55 + // split path from query string 56 + const qs_sep = std.mem.indexOf(u8, full_path, "?"); 57 + const path = if (qs_sep) |i| full_path[0..i] else full_path; 58 + const query = if (qs_sep) |i| full_path[i + 1 ..] else ""; 59 + 50 60 if (std.mem.eql(u8, path, "/")) { 51 61 try sendResponse(stream, "200 OK", "text/html", index_html); 52 62 } else if (std.mem.eql(u8, path, "/api/runs")) { 53 63 try serveRuns(allocator, stream, store); 64 + } else if (std.mem.eql(u8, path, "/api/runs/count")) { 65 + try serveRunCount(stream, store); 54 66 } else if (std.mem.eql(u8, path, "/api/latest")) { 55 67 try serveLatest(allocator, stream, store); 56 68 } else if (std.mem.startsWith(u8, path, "/api/runs/")) { ··· 61 73 }; 62 74 try serveRunDetail(allocator, stream, store, id); 63 75 } else if (std.mem.eql(u8, path, "/api/trend")) { 64 - try serveTrend(allocator, stream, store); 76 + const limit = parseLimitParam(query, trend_limit, store); 77 + try serveTrend(allocator, stream, store, limit); 78 + } else if (std.mem.eql(u8, path, "/og")) { 79 + try serveOgPng(allocator, stream, og_png_path); 80 + } else if (std.mem.eql(u8, path, "/og.svg")) { 81 + try serveOgSvg(allocator, stream, store); 65 82 } else { 66 83 try sendResponse(stream, "404 Not Found", "text/plain", "not found"); 67 84 } 68 85 } 69 86 87 + fn parseLimitParam(query: []const u8, default: u32, store: *Store) u32 { 88 + const max = store.getRunCount() catch default; 89 + if (query.len == 0) return @min(default, max); 90 + 91 + var it = std.mem.splitScalar(u8, query, '&'); 92 + while (it.next()) |param| { 93 + if (std.mem.startsWith(u8, param, "limit=")) { 94 + const val = std.fmt.parseInt(u32, param["limit=".len..], 10) catch return @min(default, max); 95 + return std.math.clamp(val, 2, if (max > 0) max else default); 96 + } 97 + } 98 + return @min(default, max); 99 + } 100 + 101 + fn serveRunCount(stream: std.net.Stream, store: *Store) !void { 102 + const count = try store.getRunCount(); 103 + var buf: [64]u8 = undefined; 104 + const json = std.fmt.bufPrint(&buf, "{{\"count\":{d}}}", .{count}) catch return; 105 + try sendResponse(stream, "200 OK", "application/json", json); 106 + } 107 + 70 108 fn serveRuns(allocator: std.mem.Allocator, stream: std.net.Stream, store: *Store) !void { 71 109 const runs = try store.getRecentRuns(allocator, 50); 72 110 defer { ··· 144 182 try sendResponse(stream, "200 OK", "application/json", json.items); 145 183 } 146 184 147 - fn serveTrend(allocator: std.mem.Allocator, stream: std.net.Stream, store: *Store) !void { 148 - const points = try store.getTrendData(allocator, 48); 185 + fn serveTrend(allocator: std.mem.Allocator, stream: std.net.Stream, store: *Store, limit: u32) !void { 186 + const points = try store.getTrendData(allocator, limit); 149 187 defer { 150 188 for (points) |p| { 151 189 allocator.free(p.timestamp); ··· 167 205 try json.appendSlice(allocator, "]"); 168 206 169 207 try sendResponse(stream, "200 OK", "application/json", json.items); 208 + } 209 + 210 + fn serveOgPng(allocator: std.mem.Allocator, stream: std.net.Stream, og_png_path: []const u8) !void { 211 + const file = std.fs.cwd().openFile(og_png_path, .{}) catch { 212 + try sendResponse(stream, "404 Not Found", "text/plain", "og image not generated yet"); 213 + return; 214 + }; 215 + defer file.close(); 216 + 217 + const body = file.readToEndAlloc(allocator, 2 * 1024 * 1024) catch { 218 + try sendResponse(stream, "500 Internal Server Error", "text/plain", "failed to read og image"); 219 + return; 220 + }; 221 + defer allocator.free(body); 222 + 223 + try sendResponse(stream, "200 OK", "image/png", body); 224 + } 225 + 226 + fn serveOgSvg(allocator: std.mem.Allocator, stream: std.net.Stream, store: *Store) !void { 227 + const run_id = try store.getLatestRunId() orelse { 228 + try sendResponse(stream, "200 OK", "image/svg+xml", 229 + \\<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630"> 230 + \\<rect width="1200" height="630" fill="#0d1117"/> 231 + \\<text x="600" y="300" text-anchor="middle" fill="#c9d1d9" font-family="monospace" font-size="36">relay-eval</text> 232 + \\<text x="600" y="340" text-anchor="middle" fill="#8b949e" font-family="monospace" font-size="16">no data yet</text> 233 + \\</svg> 234 + ); 235 + return; 236 + }; 237 + 238 + const run_meta = try store.getRun(allocator, run_id) orelse return; 239 + defer allocator.free(run_meta.timestamp); 240 + 241 + const stats = try store.getRunStats(allocator, run_id); 242 + defer { 243 + for (stats) |s| allocator.free(s.host); 244 + allocator.free(stats); 245 + } 246 + 247 + var svg: std.ArrayList(u8) = .empty; 248 + defer svg.deinit(allocator); 249 + 250 + try svg.appendSlice(allocator, 251 + \\<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630"> 252 + \\<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> 253 + \\<stop offset="0%" stop-color="#3fb950" stop-opacity="0.06"/> 254 + \\<stop offset="100%" stop-color="#bc8cff" stop-opacity="0.06"/> 255 + \\</linearGradient></defs> 256 + \\<rect width="1200" height="630" fill="#0d1117"/> 257 + \\<rect width="1200" height="630" fill="url(#bg)"/> 258 + \\<text x="60" y="55" fill="#e6edf3" font-family="monospace" font-size="36" font-weight="bold">relay-eval</text> 259 + \\<text x="60" y="85" fill="#8b949e" font-family="monospace" font-size="16">comparing what each atproto relay sees</text> 260 + ); 261 + 262 + const max_rows: usize = @min(stats.len, 10); 263 + const union_dids: u64 = @intCast(@max(run_meta.union_dids, 1)); 264 + 265 + for (stats[0..max_rows], 0..) |s, i| { 266 + const y: u32 = 130 + @as(u32, @intCast(i)) * 42; 267 + const pct_x10: u64 = @as(u64, s.unique_dids) * 1000 / union_dids; 268 + const pct_int: u32 = @intCast(pct_x10 / 10); 269 + const pct_frac: u32 = @intCast(pct_x10 % 10); 270 + const bar_w: u32 = @intCast(@min(@as(u64, s.unique_dids) * 600 / union_dids, 600)); 271 + 272 + const color: []const u8 = if (pct_x10 >= 990) "#3fb950" else if (pct_x10 >= 950) "#58a6ff" else if (pct_x10 >= 800) "#bc8cff" else "#8b949e"; 273 + 274 + try svg.print(allocator, "<text x=\"60\" y=\"{d}\" fill=\"#c9d1d9\" font-family=\"monospace\" font-size=\"15\">{s}</text>", .{ y + 18, s.host }); 275 + if (bar_w > 0) { 276 + try svg.print(allocator, "<rect x=\"400\" y=\"{d}\" width=\"{d}\" height=\"24\" rx=\"3\" fill=\"{s}\" opacity=\"0.7\"/>", .{ y + 1, bar_w, color }); 277 + } 278 + try svg.print(allocator, "<text x=\"1140\" y=\"{d}\" text-anchor=\"end\" fill=\"#e6edf3\" font-family=\"monospace\" font-size=\"14\">{d}.{d}%</text>", .{ y + 18, pct_int, pct_frac }); 279 + } 280 + 281 + // footer 282 + const window_min: i32 = @divTrunc(run_meta.window_seconds, 60); 283 + try svg.appendSlice(allocator, "<text x=\"60\" y=\"590\" fill=\"#8b949e\" font-family=\"monospace\" font-size=\"14\">"); 284 + try appendFormattedInt(&svg, allocator, run_meta.union_dids); 285 + try svg.print(allocator, " active accounts &#xb7; {d} minute window</text></svg>", .{window_min}); 286 + 287 + try sendResponse(stream, "200 OK", "image/svg+xml", svg.items); 288 + } 289 + 290 + fn appendFormattedInt(svg: *std.ArrayList(u8), allocator: std.mem.Allocator, value: i32) !void { 291 + if (value < 0) { 292 + try svg.append(allocator, '-'); 293 + return appendFormattedInt(svg, allocator, -value); 294 + } 295 + if (value >= 1000) { 296 + try appendFormattedInt(svg, allocator, @divTrunc(value, 1000)); 297 + const r: u32 = @intCast(@rem(value, 1000)); 298 + try svg.append(allocator, ','); 299 + try svg.append(allocator, '0' + @as(u8, @intCast(r / 100))); 300 + try svg.append(allocator, '0' + @as(u8, @intCast(r / 10 % 10))); 301 + try svg.append(allocator, '0' + @as(u8, @intCast(r % 10))); 302 + } else { 303 + var buf: [16]u8 = undefined; 304 + const s = std.fmt.bufPrint(&buf, "{d}", .{value}) catch unreachable; 305 + try svg.appendSlice(allocator, s); 306 + } 170 307 } 171 308 172 309 fn sendResponse(stream: std.net.Stream, status: []const u8, content_type: []const u8, body: []const u8) !void {
+557 -38
relay-eval/src/static/index.html
··· 3 3 <head> 4 4 <meta charset="utf-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3Cfilter id='g'%3E%3CfeGaussianBlur stdDeviation='1.8'/%3E%3C/filter%3E%3ClinearGradient id='b' x1='0' y1='1' x2='1' y2='0'%3E%3Cstop offset='0%25' stop-color='%233fb950'/%3E%3Cstop offset='45%25' stop-color='%2358a6ff'/%3E%3Cstop offset='100%25' stop-color='%23bc8cff'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpolyline points='4,28 11,11 16,19 28,3' fill='none' stroke='url(%23b)' stroke-width='5' stroke-linecap='round' stroke-linejoin='round' opacity='0.2' filter='url(%23g)'/%3E%3Cpolyline points='4,28 11,11 16,19 28,3' fill='none' stroke='url(%23b)' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' opacity='0.8'/%3E%3Cpolyline points='4,28 11,11 16,19 28,3' fill='none' stroke='%23fff' stroke-width='1' stroke-linecap='round' stroke-linejoin='round' opacity='0.35'/%3E%3C/svg%3E"> 6 7 <title>relay-eval</title> 8 + <meta property="og:title" content="relay-eval"> 9 + <meta property="og:description" content="comparing what each atproto relay sees"> 10 + <meta property="og:image" content="https://relay-eval.waow.tech/og"> 11 + <meta property="og:image:type" content="image/png"> 12 + <meta property="og:image:width" content="1200"> 13 + <meta property="og:image:height" content="630"> 14 + <meta property="og:type" content="website"> 15 + <meta name="twitter:card" content="summary_large_image"> 7 16 <style> 8 17 :root { 9 18 --bg: #0d1117; --fg: #c9d1d9; --muted: #8b949e; 10 19 --border: #21262d; --border-strong: #30363d; 11 20 --surface: #161b22; --accent: #58a6ff; 12 - --green: #3fb950; --red: #f85149; --yellow: #d29922; 21 + --green: #6fbf73; --red: #f85149; --yellow: #d29922; 13 22 } 14 23 * { margin: 0; padding: 0; box-sizing: border-box; } 15 24 body { ··· 50 59 backdrop-filter: blur(24px); 51 60 -webkit-backdrop-filter: blur(24px); 52 61 min-height: 100vh; 62 + opacity: 0; transform: translateY(6px); 63 + transition: opacity 0.5s ease, transform 0.5s ease; 53 64 } 65 + .glass.visible { opacity: 1; transform: translateY(0); } 54 66 55 67 h1 { font-size: 1.25rem; font-weight: 600; color: var(--fg); letter-spacing: -0.02em; } 68 + h1 .src { font-size: 0.55rem; color: var(--muted); text-decoration: none; font-weight: 400; vertical-align: middle; } 69 + h1 .src:hover { color: var(--accent); } 70 + h1 .inspired { font-size: 0.55rem; color: var(--muted); font-weight: 400; vertical-align: middle; } 71 + h1 .inspired a { color: var(--muted); text-decoration: none; } 72 + h1 .inspired a:hover { color: var(--accent); text-decoration: underline; } 56 73 .subtitle { color: var(--muted); font-size: 0.85rem; margin-top: 0.2rem; } 57 74 58 75 /* summary card */ ··· 78 95 79 96 /* sections */ 80 97 .sec { font-size: 0.9rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.2rem; } 81 - .sec-desc { font-size: 0.8rem; color: var(--muted); margin-bottom: 0.75rem; line-height: 1.55; } 98 + .sec-desc { font-size: 0.8rem; color: var(--muted); margin-bottom: 0.4rem; line-height: 1.55; } 99 + .sec-formula { 100 + font-size: 0.78rem; color: var(--fg); margin-bottom: 0.75rem; 101 + padding: 0.4rem 0.7rem; border-radius: 5px; 102 + background: rgba(22, 27, 34, 0.5); border-left: 2px solid var(--border-strong); 103 + font-variant-numeric: tabular-nums; letter-spacing: 0.01em; 104 + width: fit-content; 105 + } 106 + .sf-num { color: var(--green); } 107 + .sf-den { color: var(--muted); } 108 + 109 + /* coverage tabs */ 110 + .cov-tabs { 111 + display: inline-flex; gap: 2px; margin-left: 0.75rem; 112 + background: rgba(22, 27, 34, 0.5); border-radius: 6px; 113 + padding: 2px; vertical-align: middle; 114 + } 115 + .cov-tab { 116 + font-size: 0.68rem; padding: 0.2rem 0.55rem; border-radius: 4px; 117 + color: var(--muted); cursor: pointer; border: none; background: none; 118 + transition: all 0.15s; font-family: inherit; line-height: 1.4; 119 + } 120 + .cov-tab:hover { color: var(--fg); } 121 + .cov-tab.active { background: rgba(88, 166, 255, 0.12); color: var(--accent); } 122 + .cov-range { font-size: 0.7rem; color: var(--muted); opacity: 0.6; margin-left: 0.15em; } 82 123 83 124 /* tables */ 84 125 .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } ··· 90 131 } 91 132 td { padding: 0.45rem 0.6rem; font-size: 0.82rem; border-bottom: 1px solid var(--border); } 92 133 tr { transition: background 0.1s; } 93 - .num { text-align: right; font-variant-numeric: tabular-nums; } 134 + .num { text-align: right; font-variant-numeric: tabular-nums; font-size: 0.76rem; } 94 135 th.num { text-align: right; } 95 136 96 137 /* relay cell */ ··· 101 142 102 143 /* coverage bar */ 103 144 .bar { display: flex; align-items: center; gap: 1px; width: 72px; } 104 - .bar-ok { height: 5px; border-radius: 3px; background: var(--green); } 105 - .bar-miss { height: 5px; border-radius: 3px; background: var(--red); opacity: 0.5; } 145 + .bar-ok { height: 5px; border-radius: 3px; background: var(--green); opacity: 0.8; } 146 + .bar-miss { height: 5px; border-radius: 3px; background: var(--red); opacity: 0.45; } 147 + .bar-legend { font-size: 0.58rem; color: var(--muted); opacity: 0.6; white-space: nowrap; } 106 148 107 149 /* classification */ 108 150 .c-gap { color: var(--red); } ··· 128 170 .did-link { color: var(--accent); text-decoration: none; font-size: 0.75rem; transition: color 0.1s; } 129 171 .did-link:hover { text-decoration: underline; } 130 172 131 - /* tooltip via css */ 132 - .tip { position: relative; cursor: help; border-bottom: 1px dotted var(--muted); } 133 - .tip::after { 134 - content: attr(data-tip); position: absolute; bottom: calc(100% + 6px); 135 - left: 50%; transform: translateX(-50%); 173 + /* tooltip — JS-positioned floating div (css ::after gets clipped by overflow) */ 174 + .tip { cursor: help; border-bottom: 1px dotted var(--muted); } 175 + #ftip { 176 + position: fixed; z-index: 200; display: none; 136 177 background: var(--surface); border: 1px solid var(--border-strong); 137 178 color: var(--fg); padding: 0.35rem 0.6rem; border-radius: 4px; 138 179 font-size: 0.72rem; white-space: nowrap; pointer-events: none; 139 - opacity: 0; transition: opacity 0.12s; z-index: 10; 140 180 box-shadow: 0 2px 8px rgba(0,0,0,0.4); 141 181 } 142 - .tip:hover::after { opacity: 1; } 143 182 144 183 /* runs nav */ 145 184 .nav { ··· 161 200 .empty { color: var(--muted); font-style: italic; padding: 3rem; text-align: center; } 162 201 .loading { color: var(--muted); padding: 3rem; text-align: center; } 163 202 203 + /* trend zoom pill */ 204 + .trend-zoom { 205 + position: fixed; top: 8px; right: 12px; 206 + z-index: 2; pointer-events: auto; 207 + padding: 4px 10px; border-radius: 10px; 208 + background: rgba(22, 27, 34, 0.7); 209 + border: 1px solid rgba(48, 54, 61, 0.4); 210 + font-size: 0.6rem; color: var(--muted); 211 + user-select: none; cursor: ns-resize; 212 + transition: opacity 0.3s, color 0.2s; 213 + opacity: 0.45; 214 + -webkit-tap-highlight-color: transparent; 215 + touch-action: none; 216 + min-height: 44px; min-width: 44px; 217 + display: flex; align-items: center; justify-content: center; 218 + } 219 + .trend-zoom:hover, .trend-zoom.active { 220 + opacity: 0.9; color: var(--fg); 221 + } 222 + .trend-zoom.flash { opacity: 0.9; color: var(--fg); } 223 + .trend-zoom-hint { 224 + display: none; margin-left: 0.35em; 225 + opacity: 0.5; font-size: 0.9em; 226 + } 227 + .trend-zoom:hover .trend-zoom-hint { display: inline; } 228 + 229 + /* trend focus toggle — hides glass to reveal trend */ 230 + .trend-toggle { 231 + position: fixed; top: 8px; left: 12px; z-index: 3; 232 + pointer-events: auto; 233 + padding: 4px 10px; border-radius: 10px; 234 + background: rgba(22, 27, 34, 0.7); 235 + border: 1px solid rgba(48, 54, 61, 0.4); 236 + font-size: 0.6rem; color: var(--muted); 237 + cursor: pointer; user-select: none; 238 + transition: opacity 0.3s, color 0.2s; 239 + opacity: 0.45; font-family: inherit; 240 + min-height: 44px; min-width: 44px; 241 + display: flex; align-items: center; justify-content: center; 242 + -webkit-tap-highlight-color: transparent; 243 + } 244 + .trend-toggle:hover { opacity: 0.9; color: var(--fg); } 245 + .trend-toggle.active { opacity: 0.9; color: var(--accent); background: rgba(22, 27, 34, 0.85); } 246 + .glass.hidden { opacity: 0; pointer-events: none; transform: translateY(20px); } 247 + 248 + /* summary items — wrap on narrow screens */ 249 + .summary-item { white-space: nowrap; } 250 + .summary-sep { color: var(--muted); opacity: 0.4; margin: 0 0.2em; } 251 + 164 252 /* mobile */ 165 253 @media (max-width: 640px) { 166 - .glass { padding: 1.25rem 1rem 1.5rem; } 254 + .glass { padding: 3.75rem 1rem 1.5rem; } 167 255 h1 { font-size: 1.1rem; } 168 256 .subtitle { font-size: 0.78rem; } 169 - .summary { font-size: 0.78rem; padding: 0.7rem 0.85rem; line-height: 1.6; } 257 + .summary { 258 + font-size: 0.78rem; padding: 0.7rem 0.85rem; line-height: 1.6; 259 + display: flex; flex-wrap: wrap; gap: 0.15rem 0; 260 + } 261 + .summary-sep { display: block; width: 100%; height: 0; margin: 0; overflow: hidden; } 170 262 .op-legend { grid-template-columns: repeat(2, auto); font-size: 0.75rem; } 171 263 .sec { font-size: 0.85rem; } 172 264 .sec-desc { font-size: 0.75rem; } ··· 181 273 .did-link { font-size: 0.68rem; } 182 274 .nav a { font-size: 0.7rem; padding: 0.15rem 0.35rem; } 183 275 .nav .time-detail { display: none; } 184 - .tip::after { display: none; } 276 + #ftip { display: none !important; } 185 277 #trend { height: 200px; } 278 + .trend-zoom { 279 + top: 8px; right: 8px; 280 + font-size: 0.65rem; padding: 6px 14px; 281 + min-height: 48px; min-width: 60px; 282 + border-radius: 12px; 283 + } 284 + .trend-zoom-hint { display: inline !important; } 285 + .trend-toggle { top: 8px; left: 8px; font-size: 0.65rem; padding: 6px 14px; min-height: 48px; border-radius: 12px; } 186 286 } 187 287 </style> 188 288 </head> ··· 190 290 191 291 <canvas id="trend"></canvas> 192 292 <div id="trend-tip"></div> 293 + <div id="ftip"></div> 294 + <div class="trend-zoom" id="trend-zoom" style="display:none"> 295 + <span id="trend-zoom-label"></span><span class="trend-zoom-hint" id="trend-zoom-hint"></span> 296 + </div> 297 + <button class="trend-toggle" id="trend-toggle" style="display:none" onclick="toggleTrendFocus()">trend</button> 193 298 194 299 <div class="glass"> 195 - <h1>relay-eval</h1> 196 - <p class="subtitle">comparing what each relay sees on the atproto network</p> 197 - <div id="content"><p class="loading">loading...</p></div> 300 + <h1>relay-eval <a href="https://tangled.sh/@zzstoatzz.io/relay/tree/main/relay-eval" target="_blank" class="src">[src]</a> <span class="inspired">inspired by <a href="https://tangled.sh/@mackuba.eu/pulsar" target="_blank">pulsar</a></span></h1> 301 + <p class="subtitle">comparing what each <a href="https://atproto.com/guides/glossary#relay" class="relay-link" style="color:var(--muted)">atproto relay</a> sees</p> 302 + <div id="content"></div> 198 303 <div id="runs-nav"></div> 199 304 </div> 200 305 ··· 237 342 function winLabel(s) { 238 343 if (s < 60) return s + ' seconds'; 239 344 const m = Math.round(s / 60); 240 - return m + ' minute' + (m !== 1 ? 's' : ''); 345 + return m + ' minute'; 241 346 } 242 347 243 348 function utc(iso) { return iso.replace('T', ' ').replace('Z', '') + ' UTC'; } ··· 286 391 }); 287 392 } 288 393 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 }; 394 + const avgs = {}; 395 + for (const host of hosts) avgs[host] = avgOf(series[host]); 396 + const sorted = [...hosts].sort((a, b) => avgs[a] - avgs[b]); 397 + return { runs, series, hosts, sorted, avgs }; 398 + } 399 + 400 + function timeToX(time, runs, toX) { 401 + const t = time.getTime(); 402 + const t0 = new Date(runs[0].ts).getTime(); 403 + const tN = new Date(runs[runs.length - 1].ts).getTime(); 404 + if (t < t0 || t > tN) return null; 405 + for (let i = 0; i < runs.length - 1; i++) { 406 + const a = new Date(runs[i].ts).getTime(); 407 + const b = new Date(runs[i + 1].ts).getTime(); 408 + if (t >= a && t <= b) { 409 + const frac = b === a ? 0 : (t - a) / (b - a); 410 + return toX(i + frac); 411 + } 412 + } 413 + return null; 291 414 } 292 415 293 416 function drawTrend(hover) { ··· 301 424 ctx.scale(dpr, dpr); 302 425 ctx.clearRect(0, 0, W, H); 303 426 304 - const pad = { t: 20, r: 20, b: 20, l: 20 }; 427 + const pad = { t: 20, r: 20, b: 30, l: 20 }; 305 428 const cw = W - pad.l - pad.r, ch = H - pad.t - pad.b; 306 429 307 430 // rank-based (quantile) Y mapping: space values by distribution rank, ··· 382 505 } 383 506 } 384 507 508 + // x-axis: subtle baseline + time markers 509 + { 510 + const axisY = H - pad.b; 511 + // baseline 512 + ctx.beginPath(); ctx.moveTo(pad.l, axisY); ctx.lineTo(W - pad.r, axisY); 513 + ctx.strokeStyle = 'rgba(201, 209, 217, 0.08)'; ctx.lineWidth = 1; 514 + ctx.stroke(); 515 + 516 + if (runs.length >= 2) { 517 + const t0 = new Date(runs[0].ts), tN = new Date(runs[runs.length - 1].ts); 518 + const spanMs = tN - t0; 519 + const markers = []; 520 + // walk from start, find each midnight and noon 521 + const d = new Date(t0); 522 + d.setMinutes(0, 0, 0); 523 + if (d.getHours() < 12) d.setHours(12); else { d.setDate(d.getDate() + 1); d.setHours(0); } 524 + while (d <= tN) { 525 + if (d >= t0) markers.push(new Date(d)); 526 + d.getHours() === 0 ? d.setHours(12) : (d.setDate(d.getDate() + 1), d.setHours(0)); 527 + } 528 + 529 + const spanDays = spanMs / 86400000; 530 + const minGap = W < 640 ? 50 : 40; // min px between labels 531 + let prevLabelX = -Infinity; 532 + 533 + ctx.font = (W < 640 ? '0.5rem' : '0.58rem') + " 'SF Mono', 'Cascadia Code', 'Fira Code', monospace"; 534 + ctx.fillStyle = '#8b949e'; 535 + ctx.textAlign = 'center'; 536 + 537 + for (const m of markers) { 538 + const x = timeToX(m, runs, toX); 539 + if (x == null || x < pad.l || x > W - pad.r) continue; 540 + if (x - prevLabelX < minGap) continue; 541 + 542 + // tick 543 + ctx.beginPath(); ctx.moveTo(x, axisY); ctx.lineTo(x, axisY + 4); 544 + ctx.strokeStyle = 'rgba(201, 209, 217, 0.15)'; ctx.lineWidth = 1; 545 + ctx.stroke(); 546 + 547 + // label 548 + let label; 549 + const h = m.getHours(); 550 + if (h === 0) { 551 + label = spanDays > 1.5 552 + ? m.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) 553 + : '12a'; 554 + } else { 555 + label = '12p'; 556 + } 557 + 558 + ctx.globalAlpha = 0.35; 559 + ctx.fillText(label, x, axisY + 14); 560 + ctx.globalAlpha = 1; 561 + prevLabelX = x; 562 + } 563 + } 564 + } 565 + 385 566 // crosshair + dots on hover 386 567 if (hover && hover.runIdx >= 0 && hover.runIdx < runs.length) { 387 568 const x = toX(hover.runIdx); ··· 413 594 if (!_tc || !_tc.layout) return; 414 595 const canvas = document.getElementById('trend'); 415 596 if (!canvas) return; 597 + const tip = document.getElementById('trend-tip'); 598 + 599 + // don't trigger trend hover when over the zoom pill or the glass panel 600 + if (e.target.closest('.trend-zoom') || e.target.closest('.glass')) { 601 + if (tip) tip.style.display = 'none'; 602 + drawTrend(null); 603 + return; 604 + } 605 + 416 606 const rect = canvas.getBoundingClientRect(); 417 607 const mx = e.clientX - rect.left, my = e.clientY - rect.top; 418 - const tip = document.getElementById('trend-tip'); 419 608 420 609 if (my < 0 || my > rect.height || mx < 0 || mx > rect.width) { 421 610 if (tip) tip.style.display = 'none'; ··· 466 655 drawTrend(null); 467 656 }); 468 657 658 + // --- floating tooltips (replaces css ::after which gets clipped by overflow) --- 659 + document.addEventListener('mouseover', function(e) { 660 + const el = e.target.closest('.tip'); 661 + if (!el) return; 662 + const text = el.getAttribute('data-tip'); 663 + if (!text) return; 664 + const ft = document.getElementById('ftip'); 665 + ft.textContent = text; 666 + ft.style.display = 'block'; 667 + const r = el.getBoundingClientRect(); 668 + let left = r.left + r.width / 2 - ft.offsetWidth / 2; 669 + left = Math.max(4, Math.min(left, window.innerWidth - ft.offsetWidth - 4)); 670 + ft.style.left = left + 'px'; 671 + ft.style.top = (r.top - ft.offsetHeight - 6) + 'px'; 672 + }); 673 + document.addEventListener('mouseout', function(e) { 674 + const el = e.target.closest('.tip'); 675 + if (!el) return; 676 + document.getElementById('ftip').style.display = 'none'; 677 + }); 678 + 469 679 // --- dashboard render --- 470 680 471 681 function render(data) { ··· 476 686 477 687 // summary 478 688 h += `<div class="summary">`; 479 - h += `measured ${tip(ago(data.timestamp), utc(data.timestamp))}`; 480 - h += ` \u00b7 watched the network for ${tip(winLabel(data.window_seconds), data.window_seconds + 's collection window')}`; 481 - h += ` \u00b7 <span class="stat">${union.toLocaleString()}</span> active accounts seen`; 689 + h += `<span class="summary-item">measured ${tip(ago(data.timestamp), utc(data.timestamp))}</span>`; 690 + h += `<span class="summary-sep"> \u00b7 </span>`; 691 + h += `<span class="summary-item">${tip(winLabel(data.window_seconds), data.window_seconds + 's collection window')} window</span>`; 692 + h += `<span class="summary-sep"> \u00b7 </span>`; 693 + h += `<span class="summary-item"><span class="stat">${union.toLocaleString()}</span> active accounts</span>`; 482 694 h += `</div>`; 483 695 484 696 // operator legend ··· 511 723 } 512 724 } 513 725 514 - // coverage table 515 - h += `<p class="sec">coverage</p>`; 516 - h += `<p class="sec-desc">each relay independently discovers PDS hosts. coverage = accounts this relay saw / accounts any relay saw.</p>`; 726 + // coverage table with tabs 727 + h += `<p class="sec">coverage`; 728 + h += `<span class="cov-tabs">`; 729 + h += `<button class="cov-tab active" onclick="setCovTab('snapshot')">snapshot</button>`; 730 + h += `<button class="cov-tab" onclick="setCovTab('alltime')">all time</button>`; 731 + h += `</span></p>`; 732 + h += `<div id="cov-desc"><p class="sec-desc">${tip('measured', 'subscribes to every relay\'s firehose simultaneously, records which accounts (DIDs) emit events on each, then compares against the union')} over a ${tip(winLabel(data.window_seconds), data.window_seconds + 's collection window')} window.</p>`; 733 + h += `<p class="sec-formula">coverage = <span class="sf-num">accounts seen on this relay</span> / <span class="sf-den">accounts seen on any relay</span></p></div>`; 517 734 735 + h += `<div id="cov-table">`; 518 736 h += `<div class="table-wrap"><table><thead><tr>`; 519 - h += `<th>relay</th><th class="hm">run by</th><th class="num">events</th><th class="num">accounts</th>`; 520 - h += `<th class="num">coverage</th><th class="num">missed</th><th class="hm"></th>`; 737 + h += `<th>${tip('relay', 'the relay host being measured')}</th>`; 738 + h += `<th class="hm">${tip('run by', 'the identity that operates this relay')}</th>`; 739 + h += `<th class="num">${tip('events', 'total commit events received during the collection window')}</th>`; 740 + h += `<th class="num">${tip('accounts', 'unique accounts (DIDs) that posted during the window')}</th>`; 741 + h += `<th class="num">${tip('coverage', 'accounts this relay saw / accounts any relay saw')}</th>`; 742 + h += `<th class="num">${tip('missed', 'accounts seen by other relays but not this one')}</th>`; 743 + h += `<th class="hm"><span class="bar-legend">${tip('seen / missed', 'green = accounts seen, red = accounts missed')}</span></th>`; 521 744 h += `</tr></thead><tbody>`; 522 745 523 746 const ranked = [...data.stats].sort((a, b) => b.unique_dids - a.unique_dids); ··· 541 764 h += `</tr>`; 542 765 } 543 766 h += `</tbody></table></div>`; 767 + h += `</div>`; 544 768 545 769 if (outliers.size > 0) { 546 770 const names = [...outliers].map(host => rn(host)).join(', '); ··· 565 789 h += `</div>`; 566 790 567 791 h += `<div class="table-wrap"><table><thead><tr>`; 568 - h += `<th>relay</th>`; 792 + h += `<th>${tip('relay', 'the relay that missed these accounts')}</th>`; 569 793 h += `<th class="num">${tip('active', 'account has a working PDS but this relay didn\u2019t see it')}</th>`; 570 794 h += `<th class="num hm">${tip('unresolvable', 'DID lookup failed')}</th>`; 571 795 h += `<th class="num hm">${tip('deactivated', 'account is deactivated or deleted')}</th>`; 572 - h += `<th class="num">total</th>`; 796 + h += `<th class="num">${tip('total', 'total accounts missed by this relay')}</th>`; 573 797 h += `</tr></thead>`; 574 798 575 799 for (const s of withMisses) { ··· 612 836 if (i) i.textContent = b.classList.contains('open') ? '\u25be' : '\u25b8'; 613 837 } 614 838 839 + function toggleTrendFocus() { 840 + const glass = document.querySelector('.glass'); 841 + const btn = document.getElementById('trend-toggle'); 842 + if (!glass || !btn) return; 843 + const hiding = !glass.classList.contains('hidden'); 844 + glass.classList.toggle('hidden'); 845 + btn.classList.toggle('active'); 846 + btn.textContent = hiding ? 'data' : 'trend'; 847 + } 848 + 849 + // --- coverage tab switching --- 850 + 851 + let _covSnapshotHTML = ''; // stashed snapshot table 852 + let _covSnapshotDesc = ''; // stashed snapshot description 853 + let _lastRunData = null; 854 + 855 + function setCovTab(mode) { 856 + const tabs = document.querySelectorAll('.cov-tab'); 857 + tabs.forEach(t => t.classList.toggle('active', t.textContent.trim() === (mode === 'snapshot' ? 'snapshot' : 'all time'))); 858 + 859 + const container = document.getElementById('cov-table'); 860 + const desc = document.getElementById('cov-desc'); 861 + if (!container) return; 862 + 863 + if (mode === 'snapshot') { 864 + container.innerHTML = _covSnapshotHTML; 865 + if (desc) desc.innerHTML = _covSnapshotDesc; 866 + return; 867 + } 868 + 869 + // all time — build from trend cache 870 + if (!_tc || !_tc.runs || _tc.runs.length < 2) { 871 + container.innerHTML = '<p class="empty">need at least 2 runs for historical data</p>'; 872 + return; 873 + } 874 + 875 + const { runs, series, hosts, avgs } = _tc; 876 + const nRuns = runs.length; 877 + const t0 = new Date(runs[0].ts), tN = new Date(runs[nRuns - 1].ts); 878 + const span = tN - t0; 879 + const spanLabel = span < 3600000 ? Math.round(span / 60000) + 'm' 880 + : span < 86400000 ? Math.round(span / 3600000) + 'h' 881 + : Math.round(span / 86400000) + 'd'; 882 + 883 + if (desc) desc.innerHTML = `<p class="sec-desc">averaged across <strong>${nRuns}</strong> measurements over the last <strong>${spanLabel}</strong>.</p>` 884 + + `<p class="sec-formula">coverage = <span class="sf-num">mean(accounts seen)</span> / <span class="sf-den">mean(union)</span></p>`; 885 + 886 + const sorted = [...hosts].sort((a, b) => (avgs[b] || 0) - (avgs[a] || 0)); 887 + 888 + let h = '<div class="table-wrap"><table><thead><tr>'; 889 + h += `<th>${tip('relay', 'the relay host being measured')}</th>`; 890 + h += `<th class="hm">${tip('run by', 'the identity that operates this relay')}</th>`; 891 + h += `<th class="num">${tip('avg', 'mean coverage across all runs in window')}</th>`; 892 + h += `<th class="num">${tip('min', 'lowest single-run coverage')}</th>`; 893 + h += `<th class="num">${tip('max', 'highest single-run coverage')}</th>`; 894 + h += `<th class="num hm">${tip('runs', 'number of runs where this relay had data')}</th>`; 895 + h += `<th class="hm"><span class="bar-legend">${tip('avg coverage', 'green = average seen, red = average missed')}</span></th>`; 896 + h += '</tr></thead><tbody>'; 897 + 898 + for (const host of sorted) { 899 + const vals = series[host].filter(v => v !== null); 900 + if (vals.length === 0) continue; 901 + const avg = avgs[host]; 902 + const min = Math.min(...vals); 903 + const max = Math.max(...vals); 904 + const o = op(host); 905 + const byLink = o.url ? `<a href="${o.url}" target="_blank">${o.name}</a>` : o.name; 906 + const range = min !== max ? `<span class="cov-range">${min.toFixed(1)}\u2013${max.toFixed(1)}</span>` : ''; 907 + 908 + h += `<tr${avg < 1 ? ' class="dimmed"' : ''}>`; 909 + h += `<td>${rn(host)}</td>`; 910 + h += `<td class="run-by hm">${byLink}</td>`; 911 + h += `<td class="num">${avg.toFixed(2)}%</td>`; 912 + h += `<td class="num">${min.toFixed(2)}%</td>`; 913 + h += `<td class="num">${max.toFixed(2)}%</td>`; 914 + h += `<td class="num hm">${vals.length}</td>`; 915 + h += `<td class="hm">${bar(Math.round(avg), 100)}</td>`; 916 + h += '</tr>'; 917 + } 918 + h += '</tbody></table></div>'; 919 + container.innerHTML = h; 920 + } 921 + 615 922 async function loadRun(id) { 616 923 const url = id === 'latest' ? '/api/latest' : `/api/runs/${id}`; 617 924 const data = await fetch(url).then(r => r.json()); 925 + _lastRunData = data; 618 926 document.getElementById('content').innerHTML = render(data); 927 + // stash snapshot table + description for tab switching 928 + const ct = document.getElementById('cov-table'); 929 + const cd = document.getElementById('cov-desc'); 930 + if (ct) _covSnapshotHTML = ct.innerHTML; 931 + if (cd) _covSnapshotDesc = cd.innerHTML; 619 932 } 620 933 621 - async function init() { 622 - fetch('/api/trend').then(r => r.json()).then(d => { 934 + // --- trend zoom --- 935 + 936 + let _zoomDebounce = null; 937 + let _totalRuns = 0; 938 + let _zoomLevel = 48; 939 + let _zoomFlashTimer = null; 940 + 941 + function initZoom(total) { 942 + _totalRuns = total; 943 + _zoomLevel = Math.min(48, total); 944 + const pill = document.getElementById('trend-zoom'); 945 + const label = document.getElementById('trend-zoom-label'); 946 + if (!pill || total <= 2) return; 947 + 948 + label.textContent = _zoomLevel + ' runs'; 949 + pill.style.display = ''; 950 + 951 + const hint = document.getElementById('trend-zoom-hint'); 952 + const isTouch = 'ontouchstart' in window; 953 + let _hintIdle = null; 954 + 955 + function updateHint() { 956 + if (!hint) return; 957 + if (_zoomLevel <= 2) { hint.textContent = 'min'; return; } 958 + if (_zoomLevel >= _totalRuns) { hint.textContent = 'all runs'; return; } 959 + hint.textContent = isTouch ? 'drag to zoom' : 'scroll to zoom'; 960 + } 961 + updateHint(); 962 + 963 + // desktop: wheel on trend area 964 + document.addEventListener('wheel', function(e) { 965 + if (!_totalRuns || _totalRuns <= 2) return; 966 + const canvas = document.getElementById('trend'); 967 + if (!canvas) return; 968 + const rect = canvas.getBoundingClientRect(); 969 + if (e.clientX < rect.left || e.clientX > rect.right || 970 + e.clientY < rect.top || e.clientY > rect.bottom) return; 971 + 972 + e.preventDefault(); 973 + const dir = e.deltaY > 0 ? 1 : -1; 974 + _zoomLevel = Math.max(2, Math.min(_totalRuns, _zoomLevel + dir)); 975 + label.textContent = _zoomLevel + ' runs'; 976 + 977 + // while scrolling, hide the hint; restore after idle 978 + if (hint) hint.style.display = 'none'; 979 + clearTimeout(_hintIdle); 980 + _hintIdle = setTimeout(() => { updateHint(); hint.style.display = ''; }, 800); 981 + 982 + pill.classList.add('flash'); 983 + clearTimeout(_zoomFlashTimer); 984 + _zoomFlashTimer = setTimeout(() => pill.classList.remove('flash'), 600); 985 + 986 + clearTimeout(_zoomDebounce); 987 + _zoomDebounce = setTimeout(() => fetchTrend(_zoomLevel), 200); 988 + }, { passive: false }); 989 + 990 + // mobile: touch-drag on the pill itself 991 + let touchStartY = 0, touchStartVal = 0; 992 + pill.addEventListener('touchstart', function(e) { 993 + e.preventDefault(); 994 + touchStartY = e.touches[0].clientY; 995 + touchStartVal = _zoomLevel; 996 + pill.classList.add('active'); 997 + }, { passive: false }); 998 + pill.addEventListener('touchmove', function(e) { 999 + e.preventDefault(); 1000 + const dy = e.touches[0].clientY - touchStartY; 1001 + const steps = Math.round(dy / 3); // 3px per step; drag down = more runs 1002 + _zoomLevel = Math.max(2, Math.min(_totalRuns, touchStartVal + steps)); 1003 + label.textContent = _zoomLevel + ' runs'; 1004 + if (hint) hint.style.display = 'none'; 1005 + }, { passive: false }); 1006 + pill.addEventListener('touchend', function() { 1007 + pill.classList.remove('active'); 1008 + if (hint) { updateHint(); hint.style.display = ''; } 1009 + clearTimeout(_zoomDebounce); 1010 + _zoomDebounce = setTimeout(() => fetchTrend(_zoomLevel), 200); 1011 + }); 1012 + } 1013 + 1014 + function fetchTrend(limit) { 1015 + const url = limit ? `/api/trend?limit=${limit}` : '/api/trend'; 1016 + return fetch(url).then(r => r.json()).then(d => { 623 1017 _trendData = d; 624 1018 _tc = buildTrendCache(d); 625 1019 drawTrend(null); 1020 + return d; 626 1021 }).catch(() => {}); 1022 + } 627 1023 628 - const runs = await fetch('/api/runs').then(r => r.json()); 1024 + // --- loading pulse on real trend lines --- 1025 + 1026 + let _loadingRAF = null; 1027 + let _loadingStart = 0; 1028 + 1029 + function pulseRealTrend(t) { 1030 + if (!_loadingStart) _loadingStart = t; 1031 + if (!_tc) { _loadingRAF = requestAnimationFrame(pulseRealTrend); return; } 1032 + 1033 + const elapsed = t - _loadingStart; 1034 + const canvas = document.getElementById('trend'); 1035 + if (!canvas) return; 1036 + const { runs, series, hosts, sorted } = _tc; 1037 + const ctx = canvas.getContext('2d'); 1038 + const dpr = window.devicePixelRatio || 1; 1039 + const W = window.innerWidth, H = canvas.clientHeight || 280; 1040 + canvas.width = W * dpr; canvas.height = H * dpr; 1041 + ctx.scale(dpr, dpr); 1042 + ctx.clearRect(0, 0, W, H); 1043 + 1044 + const pad = { t: 20, r: 20, b: 30, l: 20 }; 1045 + const cw = W - pad.l - pad.r, ch = H - pad.t - pad.b; 1046 + 1047 + const valSet = new Set(); 1048 + for (const host of hosts) { 1049 + for (const v of series[host]) { if (v != null) valSet.add(Math.round(v * 100) / 100); } 1050 + } 1051 + const ranks = [...valSet].sort((a, b) => a - b); 1052 + const toX = i => pad.l + (i / (runs.length - 1)) * cw; 1053 + const toY = v => { 1054 + if (ranks.length <= 1) return pad.t + ch / 2; 1055 + const vr = Math.round(v * 100) / 100; 1056 + let lo = 0, hi = ranks.length - 1; 1057 + if (vr <= ranks[0]) return pad.t + ch; 1058 + if (vr >= ranks[hi]) return pad.t; 1059 + while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (ranks[mid] <= vr) lo = mid; else hi = mid; } 1060 + const frac = (ranks[hi] === ranks[lo]) ? 0.5 : (vr - ranks[lo]) / (ranks[hi] - ranks[lo]); 1061 + return pad.t + ch - ((lo + frac) / (ranks.length - 1)) * ch; 1062 + }; 1063 + 1064 + // cycle through relays ~250ms each 1065 + const cycle = 250; 1066 + const featIdx = Math.floor(elapsed / cycle) % sorted.length; 1067 + const featHost = sorted[featIdx]; 1068 + const phase = (elapsed % cycle) / cycle; 1069 + const featFade = phase < 0.2 ? phase / 0.2 : phase > 0.75 ? (1 - phase) / 0.25 : 1; 1070 + 1071 + for (const host of sorted) { 1072 + const vp = series[host].map((v, i) => [i, v]).filter(p => p[1] !== null); 1073 + if (vp.length < 2) continue; 1074 + const color = op(host).color; 1075 + const isFeat = host === featHost; 1076 + const trace = () => { 1077 + ctx.beginPath(); 1078 + ctx.moveTo(toX(vp[0][0]), toY(vp[0][1])); 1079 + for (let j = 1; j < vp.length; j++) ctx.lineTo(toX(vp[j][0]), toY(vp[j][1])); 1080 + }; 1081 + 1082 + if (isFeat) { 1083 + ctx.save(); trace(); 1084 + ctx.shadowColor = color; ctx.shadowBlur = 18; 1085 + ctx.strokeStyle = color; ctx.lineWidth = 2; 1086 + ctx.globalAlpha = 0.12 * featFade; 1087 + ctx.stroke(); ctx.restore(); 1088 + 1089 + trace(); 1090 + ctx.strokeStyle = color; ctx.lineWidth = 1.2; 1091 + ctx.globalAlpha = 0.5 * featFade + 0.15; 1092 + ctx.stroke(); ctx.globalAlpha = 1; 1093 + } else { 1094 + trace(); 1095 + ctx.strokeStyle = color; ctx.lineWidth = 0.6; 1096 + ctx.globalAlpha = 0.1; 1097 + ctx.stroke(); ctx.globalAlpha = 1; 1098 + } 1099 + } 1100 + 1101 + // draw featured name centered below the lines 1102 + { 1103 + const o = op(featHost); 1104 + const fontSize = W < 640 ? 11 : 13; 1105 + ctx.font = fontSize + "px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace"; 1106 + ctx.textAlign = 'center'; 1107 + ctx.fillStyle = o.color; 1108 + ctx.globalAlpha = 0.55 * featFade; 1109 + ctx.fillText(o.sym + ' ' + featHost, W / 2, H - 8); 1110 + ctx.globalAlpha = 1; 1111 + ctx.textAlign = 'start'; 1112 + } 1113 + 1114 + _loadingRAF = requestAnimationFrame(pulseRealTrend); 1115 + } 1116 + 1117 + function stopLoading() { 1118 + if (_loadingRAF) { cancelAnimationFrame(_loadingRAF); _loadingRAF = null; } 1119 + _loadingStart = 0; 1120 + } 1121 + 1122 + async function init() { 1123 + // fetch trend first — it's fast and gives us something to animate 1124 + const trendPromise = fetchTrend(); 1125 + 1126 + // kick off other fetches in parallel 1127 + const countPromise = fetch('/api/runs/count').then(r => r.json()).catch(() => ({ count: 0 })); 1128 + const runsPromise = fetch('/api/runs').then(r => r.json()).catch(() => []); 1129 + 1130 + // start pulsing the real trend lines as soon as trend data lands 1131 + _loadingRAF = requestAnimationFrame(pulseRealTrend); 1132 + 1133 + const [countRes, runsRes] = await Promise.all([countPromise, runsPromise]); 1134 + await trendPromise; // ensure trend is drawn before we proceed 1135 + 1136 + initZoom(countRes.count || 0); 1137 + 629 1138 const nav = document.getElementById('runs-nav'); 1139 + const glass = document.querySelector('.glass'); 630 1140 631 - if (runs.length === 0) { 1141 + if (runsRes.length === 0) { 632 1142 document.getElementById('content').innerHTML = '<p class="empty">no evaluation runs yet</p>'; 1143 + stopLoading(); 1144 + if (glass) glass.classList.add('visible'); 633 1145 return; 634 1146 } 635 1147 636 1148 let nh = '<span class="nav-label">previous runs</span>'; 637 - for (const r of runs.slice(0, 12)) { 1149 + for (const r of runsRes.slice(0, 12)) { 638 1150 nh += `<a onclick="loadRun(${r.id})">${ago(r.timestamp)} <span class="time-detail">${clockTime(r.timestamp)}</span></a>`; 639 1151 } 640 1152 nav.className = 'nav'; 641 1153 nav.innerHTML = nh; 642 1154 643 1155 await loadRun('latest'); 1156 + 1157 + // data loaded — stop pulsing, draw final trend, reveal glass 1158 + stopLoading(); 1159 + drawTrend(null); 1160 + if (glass) glass.classList.add('visible'); 1161 + const toggleBtn = document.getElementById('trend-toggle'); 1162 + if (toggleBtn) toggleBtn.style.display = ''; 644 1163 } 645 1164 646 1165 init();
+15
relay-eval/src/store.zig
··· 285 285 return points.toOwnedSlice(allocator); 286 286 } 287 287 288 + /// get total number of runs 289 + pub fn getRunCount(self: *Store) !u32 { 290 + const sql = "SELECT COUNT(*) FROM runs"; 291 + var stmt: ?*c.sqlite3_stmt = null; 292 + if (c.sqlite3_prepare_v2(self.db, sql, -1, &stmt, null) != c.SQLITE_OK) { 293 + return self.sqlError(); 294 + } 295 + defer _ = c.sqlite3_finalize(stmt); 296 + 297 + if (c.sqlite3_step(stmt) == c.SQLITE_ROW) { 298 + return @intCast(c.sqlite3_column_int(stmt, 0)); 299 + } 300 + return 0; 301 + } 302 + 288 303 fn colText(self: *Store, allocator: std.mem.Allocator, stmt: ?*c.sqlite3_stmt, col: c_int) ![]const u8 { 289 304 _ = self; 290 305 const ptr = c.sqlite3_column_text(stmt, col);