compares plc.directory with other mirrors
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

save snapshots per file, show missing cids

dawn b1a7199b 136a43b2

+233 -31
+162 -2
index.html
··· 304 304 overflow: hidden; 305 305 text-overflow: ellipsis; 306 306 } 307 - .hist-cells { display: flex; gap: 2px; } 307 + .hist-cells { flex: 1; overflow: hidden; display: flex; gap: 2px; justify-content: flex-end; background: #141414; border-radius: 3px; padding: 2px; } 308 308 .hist-cell { 309 309 width: 11px; 310 310 height: 18px; 311 311 border-radius: 2px; 312 - cursor: default; 312 + cursor: pointer; 313 313 flex-shrink: 0; 314 314 transition: filter 0.1s; 315 315 } ··· 335 335 #floatTip .ft-row { display: flex; gap: 0.8rem; justify-content: space-between; } 336 336 #floatTip .ft-key { color: #666; } 337 337 #floatTip .ft-val { color: #ddd; font-variant-numeric: tabular-nums; } 338 + 339 + /* ── missed ops modal ── */ 340 + #missModal { 341 + display: none; 342 + position: fixed; 343 + inset: 0; 344 + background: rgba(0,0,0,0.72); 345 + z-index: 2000; 346 + align-items: center; 347 + justify-content: center; 348 + } 349 + #missModal.show { display: flex; } 350 + #missBox { 351 + background: #141414; 352 + border: 1px solid #2e2e2e; 353 + border-radius: 7px; 354 + width: min(560px, 94vw); 355 + max-height: 78vh; 356 + display: flex; 357 + flex-direction: column; 358 + box-shadow: 0 14px 44px rgba(0,0,0,0.85); 359 + } 360 + #missHead { 361 + display: flex; 362 + align-items: center; 363 + gap: 0.6rem; 364 + padding: 0.7rem 1rem; 365 + border-bottom: 1px solid #242424; 366 + flex-shrink: 0; 367 + } 368 + #missTitle { font-size: 0.82rem; font-weight: 600; color: #e0e0e0; flex: 1; min-width: 0; } 369 + #missTime { font-size: 0.71rem; color: #555; white-space: nowrap; } 370 + #missCopyBtn { 371 + font-size: 0.72rem; 372 + padding: 0.22rem 0.7rem; 373 + white-space: nowrap; 374 + } 375 + #missCloseBtn { 376 + font-size: 0.78rem; 377 + padding: 0.22rem 0.65rem; 378 + color: #888; 379 + border-color: #2a2a2a; 380 + } 381 + #missBody { 382 + overflow-y: auto; 383 + padding: 0.75rem 1rem 1rem; 384 + font-size: 0.76rem; 385 + line-height: 1.65; 386 + } 387 + .miss-empty { color: #555; font-style: italic; } 388 + .miss-did { 389 + margin-top: 0.6rem; 390 + padding-top: 0.4rem; 391 + border-top: 1px solid #1e1e1e; 392 + } 393 + .miss-did:first-child { margin-top: 0; border-top: none; padding-top: 0; } 394 + .miss-did-label { 395 + color: #c8c8c8; 396 + font-size: 0.74rem; 397 + margin-bottom: 0.15rem; 398 + word-break: break-all; 399 + } 400 + .miss-cids { 401 + display: flex; 402 + flex-wrap: wrap; 403 + gap: 4px 6px; 404 + } 405 + .miss-cid { 406 + font-family: ui-monospace, 'SF Mono', Menlo, monospace; 407 + font-size: 0.69rem; 408 + color: #888; 409 + background: #1a1a1a; 410 + border: 1px solid #282828; 411 + border-radius: 3px; 412 + padding: 1px 5px; 413 + cursor: default; 414 + user-select: all; 415 + } 338 416 </style> 339 417 </head> 340 418 <body> ··· 433 511 434 512 <div id="floatTip"></div> 435 513 514 + <!-- ── Missed ops modal ─────────────────────────────────────────────────────── --> 515 + <div id="missModal"> 516 + <div id="missBox"> 517 + <div id="missHead"> 518 + <span id="missTitle"></span> 519 + <span id="missTime"></span> 520 + <button id="missCopyBtn">copy json</button> 521 + <button id="missCloseBtn">✕</button> 522 + </div> 523 + <div id="missBody"></div> 524 + </div> 525 + </div> 526 + 436 527 <!-- ── Live Compare ─────────────────────────────────────────────────────────── --> 437 528 <section class="section"> 438 529 <div class="section-head"> ··· 698 789 cell.addEventListener('mouseenter', e => showFloatTip(e, html)); 699 790 cell.addEventListener('mousemove', moveFloatTip); 700 791 cell.addEventListener('mouseleave', hideFloatTip); 792 + cell.addEventListener('click', () => { hideFloatTip(); openMissModal(m.name, snap, url); }); 701 793 cells.appendChild(cell); 702 794 } 703 795 ··· 953 1045 }); 954 1046 covBody.addEventListener('mousemove', e => { if (floatTip.style.display !== 'none') moveFloatTip(e); }); 955 1047 covBody.addEventListener('mouseleave', hideFloatTip); 1048 + 1049 + // ── missed ops modal ────────────────────────────────────────────────────────── 1050 + let missModalData = null; // { mirrorName, snap, url } 1051 + 1052 + function openMissModal(mirrorName, snap, url) { 1053 + missModalData = { mirrorName, snap, url }; 1054 + const sm = snap.mirrors[url] ?? {}; 1055 + const missed = sm.missedCids ?? {}; 1056 + const dt = new Date(snap.ts); 1057 + const timeStr = dt.toLocaleString([], { month: 'short', day: 'numeric', 1058 + hour: '2-digit', minute: '2-digit' }); 1059 + 1060 + document.getElementById('missTitle').textContent = `${mirrorName} — missed ops`; 1061 + document.getElementById('missTime').textContent = timeStr; 1062 + 1063 + const body = document.getElementById('missBody'); 1064 + const dids = Object.keys(missed); 1065 + if (!dids.length) { 1066 + body.innerHTML = '<div class="miss-empty">no missed ops recorded for this interval</div>'; 1067 + } else { 1068 + body.innerHTML = dids.sort().map(did => { 1069 + const cids = missed[did]; 1070 + const cidBadges = cids.map(c => 1071 + `<span class="miss-cid" title="${c}">${c.slice(0, 7)}</span>` 1072 + ).join(''); 1073 + return `<div class="miss-did"> 1074 + <div class="miss-did-label">${did} <span style="color:#555;font-size:0.68rem">(${cids.length} op${cids.length !== 1 ? 's' : ''})</span></div> 1075 + <div class="miss-cids">${cidBadges}</div> 1076 + </div>`; 1077 + }).join(''); 1078 + } 1079 + 1080 + document.getElementById('missModal').classList.add('show'); 1081 + } 1082 + 1083 + function closeMissModal() { 1084 + document.getElementById('missModal').classList.remove('show'); 1085 + missModalData = null; 1086 + } 1087 + 1088 + document.getElementById('missCloseBtn').onclick = closeMissModal; 1089 + document.getElementById('missModal').addEventListener('click', e => { 1090 + if (e.target === document.getElementById('missModal')) closeMissModal(); 1091 + }); 1092 + document.addEventListener('keydown', e => { 1093 + if (e.key === 'Escape') closeMissModal(); 1094 + }); 1095 + 1096 + document.getElementById('missCopyBtn').onclick = () => { 1097 + if (!missModalData) return; 1098 + const { mirrorName, snap, url } = missModalData; 1099 + const sm = snap.mirrors[url] ?? {}; 1100 + const payload = { 1101 + mirror: mirrorName, 1102 + snapshot: new Date(snap.ts).toISOString(), 1103 + missed: sm.missedCids ?? {}, 1104 + }; 1105 + navigator.clipboard.writeText(JSON.stringify(payload, null, 2)).catch(() => { 1106 + const btn = document.getElementById('missCopyBtn'); 1107 + btn.textContent = 'failed'; 1108 + setTimeout(() => { btn.textContent = 'copy json'; }, 1500); 1109 + }).then(() => { 1110 + const btn = document.getElementById('missCopyBtn'); 1111 + if (!btn) return; 1112 + btn.textContent = 'copied!'; 1113 + setTimeout(() => { btn.textContent = 'copy json'; }, 1500); 1114 + }); 1115 + }; 956 1116 957 1117 setInterval(tick, BUCKET_MS); 958 1118 setInterval(fetchStats, 2000);
+71 -29
server.ts
··· 11 11 12 12 import net from "net"; 13 13 import dns from "dns"; 14 - import { readFileSync, writeFileSync } from "fs"; 14 + import { readFileSync, writeFileSync, readdirSync, unlinkSync, mkdirSync } from "fs"; 15 15 16 16 const PRIMARY = "wss://plc.directory"; 17 17 const WINDOW_MS = 15 * 60 * 1000; // rolling coverage window for live table ··· 19 19 const RTT_KEEP = 30; // RTT samples to average over 20 20 const PING_MS = 5_000; // TCP ping interval 21 21 const SNAP_INTERVAL = 5 * 60 * 1000; // snapshot every 5 min 22 - const SNAP_KEEP = 24; // 24 × 5 min = 2 hours of history 23 - const DATA_FILE = "./data.json"; 22 + const SNAP_KEEP = 72; // 72 × 5 min = 6 hours of history 23 + const DATA_DIR = "./data"; 24 24 const PORT = 7331; 25 25 26 26 // ── types ───────────────────────────────────────────────────────────────────── ··· 29 29 primaryRecvMs: number | null; 30 30 mirrorRecv: Map<string, number>; // url -> server recv timestamp 31 31 firstSeen: number; 32 + did?: string; 32 33 } 33 34 34 35 interface SnapMirror { ··· 36 37 missed: number; 37 38 ops: number; // ops received by mirror in this interval 38 39 primaryOps: number; // ops received by primary in this interval 40 + missedCids: Record<string, string[]>; // did -> [cid, ...] 39 41 } 40 42 41 43 interface Snapshot { ··· 53 55 ws: WebSocket | null; 54 56 } 55 57 56 - interface SavedData { 57 - version: number; 58 - savedAt: number; 59 - totals: Record<string, number>; // url -> lifetime totalEvents 60 - snapshots: Snapshot[]; 58 + interface SavedTotals { 59 + totals: Record<string, number>; // url -> lifetime totalEvents 61 60 } 62 61 63 62 // ── state ───────────────────────────────────────────────────────────────────── ··· 70 69 // ── persistence ─────────────────────────────────────────────────────────────── 71 70 72 71 function loadData(): void { 73 - try { 74 - const raw = readFileSync(DATA_FILE, "utf-8"); 75 - const data = JSON.parse(raw) as SavedData; 76 - if (data.version !== 1) return; 72 + mkdirSync(DATA_DIR, { recursive: true }); 77 73 78 - for (const snap of data.snapshots ?? []) snapshots.push(snap); 74 + // load totals 75 + try { 76 + const raw = readFileSync(`${DATA_DIR}/totals.json`, "utf-8"); 77 + const data = JSON.parse(raw) as SavedTotals; 79 78 for (const [url, n] of Object.entries(data.totals ?? {})) savedTotals.set(url, n); 79 + } catch { /* no totals yet */ } 80 80 81 - console.log(`loaded ${snapshots.length} snapshots and ${savedTotals.size} totals from ${DATA_FILE}`); 82 - } catch { 83 - // no saved data yet — start fresh 84 - } 81 + // load snapshot files — named <ts>.json, sorted oldest-first 82 + try { 83 + const files = readdirSync(DATA_DIR) 84 + .filter(f => /^\d+\.json$/.test(f)) 85 + .sort((a, b) => Number(a.slice(0, -5)) - Number(b.slice(0, -5))); 86 + for (const f of files) { 87 + try { 88 + const snap = JSON.parse(readFileSync(`${DATA_DIR}/${f}`, "utf-8")) as Snapshot; 89 + snapshots.push(snap); 90 + } catch { /* skip corrupt file */ } 91 + } 92 + // trim to SNAP_KEEP most recent (shouldn't normally be needed) 93 + while (snapshots.length > SNAP_KEEP) snapshots.shift(); 94 + } catch { /* data dir unreadable */ } 95 + 96 + console.log(`loaded ${snapshots.length} snapshots and ${savedTotals.size} totals from ${DATA_DIR}/`); 85 97 } 86 98 87 - function saveData(): void { 99 + function saveTotals(): void { 88 100 const totals: Record<string, number> = {}; 89 101 for (const [url, m] of mirrors) totals[url] = m.totalEvents; 90 - const data: SavedData = { version: 1, savedAt: Date.now(), totals, snapshots }; 91 102 try { 92 - writeFileSync(DATA_FILE, JSON.stringify(data)); 103 + writeFileSync(`${DATA_DIR}/totals.json`, JSON.stringify({ totals })); 93 104 } catch (e) { 94 - console.error("failed to save data:", e); 105 + console.error("failed to save totals:", e); 95 106 } 96 107 } 97 108 ··· 159 170 } catch { return; } 160 171 161 172 const cid = ev.cid as string | undefined; 173 + const did = ev.did as string | undefined; 162 174 if (!cid) return; 163 175 164 176 m.totalEvents++; 165 177 const now = Date.now(); 166 178 167 179 if (!tracker.has(cid)) { 168 - tracker.set(cid, { primaryRecvMs: null, mirrorRecv: new Map(), firstSeen: now }); 180 + tracker.set(cid, { primaryRecvMs: null, mirrorRecv: new Map(), firstSeen: now, did }); 169 181 } 170 182 const entry = tracker.get(cid)!; 171 183 ··· 309 321 function computeIntervalStats(): Record<string, SnapMirror> { 310 322 const cut = Date.now() - SNAP_INTERVAL; 311 323 let primaryOps = 0; 312 - const mirrorOps = new Map<string, number>(); 324 + 325 + // per-mirror accumulators 326 + const mirrorOps = new Map<string, number>(); 327 + const mirrorMissed = new Map<string, Record<string, string[]>>(); // url -> did -> [cid] 328 + for (const [url] of mirrors) { 329 + if (url === PRIMARY) continue; 330 + mirrorOps.set(url, 0); 331 + mirrorMissed.set(url, {}); 332 + } 313 333 314 - for (const [, e] of tracker) { 334 + for (const [cid, e] of tracker) { 315 335 if (e.primaryRecvMs === null || e.primaryRecvMs < cut) continue; 316 336 primaryOps++; 317 - for (const [mu] of e.mirrorRecv) { 318 - mirrorOps.set(mu, (mirrorOps.get(mu) ?? 0) + 1); 337 + for (const [url] of mirrors) { 338 + if (url === PRIMARY) continue; 339 + if (e.mirrorRecv.has(url)) { 340 + mirrorOps.set(url, (mirrorOps.get(url) ?? 0) + 1); 341 + } else { 342 + const did = e.did ?? "?"; 343 + const mm = mirrorMissed.get(url)!; 344 + if (!mm[did]) mm[did] = []; 345 + mm[did].push(cid); 346 + } 319 347 } 320 348 } 321 349 ··· 328 356 missed: Math.max(0, primaryOps - ops), 329 357 ops, 330 358 primaryOps, 359 + missedCids: mirrorMissed.get(url) ?? {}, 331 360 }; 332 361 } 333 362 return out; ··· 336 365 // ── snapshots ───────────────────────────────────────────────────────────────── 337 366 338 367 function takeSnapshot(): void { 339 - snapshots.push({ ts: Date.now(), mirrors: computeIntervalStats() }); 340 - if (snapshots.length > SNAP_KEEP) snapshots.shift(); 341 - saveData(); 368 + const snap: Snapshot = { ts: Date.now(), mirrors: computeIntervalStats() }; 369 + snapshots.push(snap); 370 + 371 + try { 372 + writeFileSync(`${DATA_DIR}/${snap.ts}.json`, JSON.stringify(snap)); 373 + } catch (e) { 374 + console.error("failed to write snapshot:", e); 375 + } 376 + 377 + // prune oldest snapshot file when over limit 378 + if (snapshots.length > SNAP_KEEP) { 379 + const oldest = snapshots.shift()!; 380 + try { unlinkSync(`${DATA_DIR}/${oldest.ts}.json`); } catch { /* already gone */ } 381 + } 382 + 383 + saveTotals(); 342 384 } 343 385 344 386 // ── HTTP server ────────────────────────────────────────────────────────────────