Skip to content

Commit d279a04

Browse files
Merge pull request #6 from JavaScriptSolidServer/issue-5-relays-directory-page
Relay directory page (wire up firehose health data)
2 parents feb0cff + 427271a commit d279a04

5 files changed

Lines changed: 165 additions & 2 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ MONGO_DB=nostr
55
MONGO_COLLECTION=beacon
66
MONGO_FOLLOWS_COLLECTION=follows
77
MONGO_RELAYS_COLLECTION=relay_lists
8+
# firehose relay-health directory (one doc per relay URL)
9+
MONGO_RELAY_DIRECTORY_COLLECTION=relays
810

911
RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
1012
PORT=3000

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ npm run serve # read API only
3434
- `GET /api/profiles` — recent profiles
3535
- `GET /api/profile/:pubkey`
3636
- `GET /api/follows/:pubkey`
37-
- `GET /api/relays/:pubkey`
37+
- `GET /api/relays/:pubkey` — that pubkey's kind-10002 relay list
38+
- `GET /api/relays-directory` — relay-health directory (`?online=1`, `?sort=quality|latency|recent`, `?limit=`)
3839

3940
## Status
4041

public/index.html

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,42 @@
107107
.cta:disabled{opacity:.55;cursor:default}
108108
.link-status{width:100%;font-size:.88rem;color:var(--muted);margin-top:.5rem;line-height:1.5}
109109
.link-status.ok{color:#1c8c6b} .link-status.err{color:#c0392b}
110+
111+
/* relay directory */
112+
.relays-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:.6rem 1rem;margin:.4rem 0 1rem}
113+
.relays-head h2{font-family:var(--serif);font-size:1.7rem;font-weight:600}
114+
.relays-head .sub{color:var(--muted);font-size:.9rem}
115+
.relays-controls{display:flex;flex-wrap:wrap;gap:.5rem;margin:.2rem 0 1rem}
116+
.toggle{font-size:.82rem;font-weight:500;border:1px solid var(--line);background:#fff;color:var(--muted);
117+
padding:.4rem .85rem;border-radius:999px;cursor:pointer;transition:border-color .15s,color .15s,background .15s}
118+
.toggle:hover{border-color:var(--accent);color:var(--accent)}
119+
.toggle.on{background:var(--accent-soft);border-color:#dcd8f5;color:var(--accent)}
120+
.caveat{font-size:.8rem;color:var(--faint);background:#fff;border:1px solid var(--line);border-radius:10px;padding:.55rem .8rem;margin-bottom:1rem}
121+
.rtable{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--line);border-radius:14px;overflow:hidden;font-size:.86rem}
122+
.rtable thead th{text-align:left;font-size:.7rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--faint);
123+
padding:.7rem .8rem;border-bottom:1px solid var(--line);white-space:nowrap;cursor:pointer;user-select:none}
124+
.rtable thead th:hover{color:var(--accent)}
125+
.rtable thead th.num{text-align:right}
126+
.rtable th .arrow{color:var(--accent);font-size:.65rem}
127+
.rtable tbody td{padding:.6rem .8rem;border-bottom:1px solid var(--line);vertical-align:middle}
128+
.rtable tbody tr:last-child td{border-bottom:none}
129+
.rtable tbody tr:hover{background:#faf9ff}
130+
.rtable td.num{text-align:right;font-family:var(--mono);font-size:.8rem}
131+
.rtable .url{font-family:var(--mono);font-size:.82rem;word-break:break-all}
132+
.rdot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:.45rem;vertical-align:middle}
133+
.rdot.up{background:#1c8c6b;box-shadow:0 0 6px rgba(28,140,107,.5)} .rdot.down{background:#c0392b}
134+
.rbadge{display:inline-block;font-size:.68rem;font-weight:600;padding:.12rem .45rem;border-radius:6px;margin-left:.3rem;white-space:nowrap}
135+
.rbadge.ok{background:#e6f5ef;color:#1c8c6b} .rbadge.no{background:#f3eef0;color:#9a6a76}
136+
.rbadge.pay{background:#fff3e0;color:#b26a00} .rbadge.auth{background:#fdeef0;color:#c0392b}
137+
.ubar{display:inline-block;width:46px;height:6px;border-radius:3px;background:var(--line);vertical-align:middle;margin-left:.5rem;overflow:hidden}
138+
.ubar>i{display:block;height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent))}
110139
</style>
111140
</head>
112141
<body>
113142
<header class="topbar">
114143
<a class="tb-brand" href="/"><span class="dot"></span> nostr.social</a>
115144
<nav class="tb-nav">
145+
<a href="/relays">Relays</a>
116146
<a href="/link">Link your pod</a>
117147
</nav>
118148
</header>
@@ -319,6 +349,84 @@ <h2>${esc(c.name || short(pk))}</h2>
319349
};
320350
}
321351

352+
// ---- relay directory (firehose health data) ----
353+
const relaysState = { online: true, sortKey: 'uptime', dir: -1, rows: [], meta: {} };
354+
const RCOLS = [
355+
{ key: 'relay', label: 'Relay', num: false },
356+
{ key: 'online', label: 'Status', num: false },
357+
{ key: 'uptime', label: 'Uptime', num: true },
358+
{ key: 'responseTime', label: 'Latency', num: true },
359+
{ key: 'acceptsEvents', label: 'Writable', num: false },
360+
{ key: 'lastChecked', label: 'Checked', num: true },
361+
];
362+
363+
function ageStr(iso) {
364+
if (!iso) return '—';
365+
const t = new Date(iso).getTime(); if (isNaN(t)) return '—';
366+
const s = (Date.now() - t) / 1000;
367+
for (const [sec, lbl] of [[31536000, 'y'], [2592000, 'mo'], [86400, 'd'], [3600, 'h'], [60, 'm']]) {
368+
if (s >= sec) return Math.floor(s / sec) + lbl;
369+
}
370+
return 'now';
371+
}
372+
373+
function cmpRelay(a, b, key, dir) {
374+
let av = a[key], bv = b[key];
375+
const an = av == null, bn = bv == null; // unknown values always sort last
376+
if (an && bn) return 0; if (an) return 1; if (bn) return -1;
377+
if (typeof av === 'string') return dir * av.localeCompare(bv);
378+
if (typeof av === 'boolean') { av = av ? 1 : 0; bv = bv ? 1 : 0; }
379+
return dir * (av - bv);
380+
}
381+
382+
function relayRow(r) {
383+
const flags = (r.requiresPayment ? '<span class="rbadge pay">paid</span>' : '')
384+
+ (r.requiresAuth ? '<span class="rbadge auth">auth</span>' : '');
385+
const up = r.uptime == null ? '—'
386+
: `${Math.round(r.uptime)}%<span class="ubar"><i style="width:${Math.max(0, Math.min(100, r.uptime))}%"></i></span>`;
387+
return `<tr>
388+
<td class="url">${esc(r.relay)}${flags}</td>
389+
<td><span class="rdot ${r.online ? 'up' : 'down'}"></span>${r.online ? 'online' : 'offline'}</td>
390+
<td class="num">${up}</td>
391+
<td class="num">${r.responseTime == null ? '—' : r.responseTime + 'ms'}</td>
392+
<td>${r.acceptsEvents === true ? '<span class="rbadge ok">yes</span>' : r.acceptsEvents === false ? '<span class="rbadge no">no</span>' : '—'}</td>
393+
<td class="num">${ageStr(r.lastChecked)}</td>
394+
</tr>`;
395+
}
396+
397+
function renderRelays() {
398+
const { rows, meta, sortKey, dir, online } = relaysState;
399+
const sorted = [...rows].sort((a, b) => cmpRelay(a, b, sortKey, dir));
400+
const head = RCOLS.map((c) => `<th class="${c.num ? 'num' : ''}" data-key="${c.key}">${c.label}${sortKey === c.key ? ` <span class="arrow">${dir < 0 ? '▼' : '▲'}</span>` : ''}</th>`).join('');
401+
view.innerHTML = `<div class="detail">
402+
<div class="back" id="back">← all identities</div>
403+
<div class="relays-head"><h2>Relay directory</h2>
404+
<span class="sub"><strong>${fmt(meta.online)}</strong> online of <strong>${fmt(meta.total)}</strong> known relays</span></div>
405+
<div class="caveat">Health snapshot · relays last checked <strong>${esc(meta.lastChecked ? ageStr(meta.lastChecked) + ' ago' : 'unknown')}</strong>. Live monitoring is paused, so these figures reflect that snapshot.</div>
406+
<div class="relays-controls"><button class="toggle ${online ? 'on' : ''}" id="r-online">Online only</button></div>
407+
<div style="overflow-x:auto"><table class="rtable"><thead><tr>${head}</tr></thead><tbody>${sorted.map(relayRow).join('')}</tbody></table></div>
408+
<div class="caveat" style="margin-top:1rem">Showing ${sorted.length} relays. Click a column to sort.</div>
409+
</div>`;
410+
$('#back').onclick = () => navigate('/');
411+
$('#r-online').onclick = () => { relaysState.online = !relaysState.online; relaysView(); };
412+
view.querySelectorAll('.rtable thead th').forEach((th) => { th.onclick = () => {
413+
const k = th.dataset.key;
414+
if (relaysState.sortKey === k) relaysState.dir = -relaysState.dir;
415+
else { relaysState.sortKey = k; relaysState.dir = k === 'relay' ? 1 : -1; }
416+
renderRelays();
417+
}; });
418+
}
419+
420+
async function relaysView() {
421+
if ($('#q')) $('#q').value = '';
422+
$('#stat').innerHTML = '';
423+
view.innerHTML = '<div class="loading">loading relay directory…</div>';
424+
const data = await fetch(`/api/relays-directory?limit=2000${relaysState.online ? '&online=1' : ''}`).then((r) => r.json()).catch(() => null);
425+
if (!data || !Array.isArray(data.relays)) { view.innerHTML = '<div class="loading">Could not load the relay directory.</div>'; return; }
426+
relaysState.rows = data.relays; relaysState.meta = data;
427+
renderRelays();
428+
}
429+
322430
function navigate(path) { if (location.pathname !== path) history.pushState({}, '', path); route(); }
323431

324432
let searchTimer;
@@ -330,6 +438,7 @@ <h2>${esc(c.name || short(pk))}</h2>
330438

331439
function route() {
332440
if (location.pathname === '/link') return linkView();
441+
if (location.pathname === '/relays') return relaysView();
333442
const m = location.pathname.match(/^\/([0-9a-f]{64})$/i);
334443
if (m) return profile(m[1].toLowerCase());
335444
const h = location.hash.slice(1); // legacy #<hex> links still resolve

src/db.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export const COLLECTIONS = {
1919
10002: process.env.MONGO_RELAYS_COLLECTION || 'relay_lists',
2020
};
2121

22+
// Firehose relay-health directory (one doc per relay URL, written by the
23+
// nostr-beacon monitor). Distinct from `relay_lists` (per-pubkey kind 10002).
24+
const RELAY_DIRECTORY = process.env.MONGO_RELAY_DIRECTORY_COLLECTION || 'relays';
25+
2226
let db, client;
2327

2428
export async function connect() {
@@ -103,6 +107,35 @@ export async function searchProfiles(q, limit = 24) {
103107
.limit(Math.min(limit, 50)).toArray();
104108
}
105109

110+
// ---- relay directory (firehose health data) --------------------------------
111+
112+
// Fields the directory page needs — keep the projection tight; the raw docs
113+
// also carry publish-test internals and error strings we don't surface in v0.
114+
const RELAY_FIELDS = {
115+
_id: 0, relay: 1, online: 1, uptime: 1, responseTime: 1, acceptsEvents: 1,
116+
requiresAuth: 1, requiresPayment: 1, lastChecked: 1, checksOnline: 1, checksTotal: 1,
117+
};
118+
119+
/**
120+
* Read the relay-health directory. Returns a summary (for the freshness caveat)
121+
* plus the matching rows. `online` filters to live relays; `sort` is one of
122+
* 'quality' (uptime desc, latency asc), 'latency', or 'recent' (last checked).
123+
*/
124+
export async function relaysDirectory({ online = false, sort = 'quality', limit = 300 } = {}) {
125+
const col = (await connect()).collection(RELAY_DIRECTORY);
126+
const filter = online ? { online: true } : {};
127+
const sortSpec = sort === 'recent' ? { lastChecked: -1 }
128+
: sort === 'latency' ? { responseTime: 1 }
129+
: { uptime: -1, responseTime: 1 }; // 'quality'
130+
const [total, onlineCount, newest, relays] = await Promise.all([
131+
col.estimatedDocumentCount(),
132+
col.countDocuments({ online: true }),
133+
col.find({}, { projection: { _id: 0, lastChecked: 1 } }).sort({ lastChecked: -1 }).limit(1).toArray(),
134+
col.find(filter, { projection: RELAY_FIELDS }).sort(sortSpec).limit(Math.min(Number(limit) || 300, 2000)).toArray(),
135+
]);
136+
return { total, online: onlineCount, lastChecked: newest[0]?.lastChecked || null, relays };
137+
}
138+
106139
// ---- graph metrics ---------------------------------------------------------
107140

108141
export async function stats() {

src/server.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import express from 'express';
33
import path from 'node:path';
44
import { readFileSync } from 'node:fs';
55
import { fileURLToPath } from 'node:url';
6-
import { getProfile, getFollows, getRelays, recentProfiles, connect, stats, searchProfiles, enrichCounts, followerCount } from './db.js';
6+
import { getProfile, getFollows, getRelays, recentProfiles, connect, stats, searchProfiles, enrichCounts, followerCount, relaysDirectory } from './db.js';
77
import { buildDidDocument } from './diddoc.js';
88
import 'dotenv/config';
99

@@ -58,6 +58,13 @@ export async function startServer(port = process.env.PORT || 3000) {
5858
url: `${SITE}/link`,
5959
})));
6060

61+
// relay directory shell
62+
app.get('/relays', (_req, res) => res.type('html').send(renderShell({
63+
title: 'Relay directory · nostr.social',
64+
description: 'A health-checked directory of Nostr relays — uptime, latency, write-acceptance, and paid/auth requirements.',
65+
url: `${SITE}/relays`,
66+
})));
67+
6168
// per-profile shell at /<pubkey> with that identity's OGP (crawlable canonical
6269
// URL). RegExp route constrained to 64-hex, so it never collides with /api,
6370
// /healthz, /.well-known, or static assets.
@@ -120,6 +127,17 @@ export async function startServer(port = process.env.PORT || 3000) {
120127
try { res.json(await getRelays(req.params.pubkey)); } catch (e) { next(e); }
121128
});
122129

130+
// relay-health directory (firehose data): { total, online, lastChecked, relays }
131+
app.get('/api/relays-directory', async (req, res, next) => {
132+
try {
133+
res.json(await relaysDirectory({
134+
online: req.query.online === '1' || req.query.online === 'true',
135+
sort: req.query.sort,
136+
limit: req.query.limit,
137+
}));
138+
} catch (e) { next(e); }
139+
});
140+
123141
// did:nostr resolution — build the conformant DID document from the index
124142
const resolve = async (pubkey) => {
125143
const [profile, follows, relays] = await Promise.all([

0 commit comments

Comments
 (0)