Something went wrong. Try again.
compares jetstreams to diagnose if an account / PDS is not being relayed by a particular jetstream
Something went wrong. Try again.
20 kB · 613 lines · Created 10mo ago
HTML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Jetstream Multi-Instance Diagnostic</title> <style> :root { --bg-color: #1a1b1e; --text-color: #e0e0e0; --border-color: #333; --accent-color: #3b82f6; --success-color: #22c55e; --error-color: #ef4444; --warn-color: #f59e0b; --panel-bg: #25262b; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: var(--bg-color); color: var(--text-color); margin: 0; padding: 20px; font-size: 14px; }
h1 { margin-top: 0; font-size: 1.5rem; }
/* Controls */ .controls { background: var(--panel-bg); padding: 15px; border-radius: 8px; margin-bottom: 20px; display: flex; gap: 10px; align-items: center; border: 1px solid var(--border-color); }
input[type="text"] { background: #141517; border: 1px solid var(--border-color); color: white; padding: 8px 12px; border-radius: 4px; width: 300px; font-family: monospace; }
button { padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; font-weight: 600; transition: opacity 0.2s; }
button.primary { background-color: var(--accent-color); color: white; } button.danger { background-color: var(--error-color); color: white; } button.secondary { background-color: #444; color: white; } button:hover { opacity: 0.9; } button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Connection Status Grid */ .status-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; margin-bottom: 20px; }
.status-card { background: var(--panel-bg); padding: 10px; border-radius: 4px; border: 1px solid var(--border-color); font-size: 12px; display: flex; justify-content: space-between; align-items: center; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background-color: #666; margin-right: 8px; display: inline-block; } .status-dot.connected { background-color: var(--success-color); box-shadow: 0 0 5px var(--success-color); } .status-dot.error { background-color: var(--error-color); } .status-dot.closed { background-color: #666; }
/* Matrix Table */ .table-container { overflow-x: auto; background: var(--panel-bg); border-radius: 8px; border: 1px solid var(--border-color); }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border-color); white-space: nowrap; }
th { background-color: #2c2e33; position: sticky; top: 0; z-index: 10; }
tr:hover { background-color: #2c2e33; }
/* Discrepancy Highlighting */ .row-complete { border-left: 3px solid var(--success-color); } .row-partial { border-left: 3px solid var(--warn-color); background-color: rgba(245, 158, 11, 0.05); }
.cell-received { color: var(--success-color); font-family: monospace; } .cell-pending { color: #666; font-style: italic; }
.kind-badge { padding: 2px 6px; border-radius: 3px; font-size: 10px; text-transform: uppercase; font-weight: bold; } .kind-commit { background: rgba(59, 130, 246, 0.2); color: #60a5fa; } .kind-identity { background: rgba(168, 85, 247, 0.2); color: #c084fc; } .kind-account { background: rgba(239, 68, 68, 0.2); color: #f87171; }
.details-col { font-family: monospace; color: #aaa; max-width: 400px; overflow: hidden; text-overflow: ellipsis; } .time-col { color: #888; } </style> </head> <body> <h1>Jetstream Repository Diagnostic</h1>
<div class="controls"> <label for="didInput">Target DID or PDS URL:</label> <input type="text" id="didInput" placeholder="did:plc:... or https://..." value="" /> <button id="btnConnect" class="primary" onclick="toggleConnection()"> Connect </button> <button id="btnClear" class="secondary" onclick="clearLogs()"> Clear Logs </button> <label for="timeSlider">Start at:</label> <input type="range" id="timeSlider" min="0" max="1000" value="1000" step="1" style="flex-grow: 1;"/> <span id="timeBack" style="width: 120px;">now</span> </div>
<div id="statusArea" class="status-grid"> <!-- Status cards generated here --> </div>
<div class="table-container"> <table id="logTable"> <thead> <tr id="tableHeader"> <th>Time (Client)</th> <th>Kind</th> <th>Details (Collection / RKey / CID)</th> <!-- Instance columns added dynamically --> </tr> </thead> <tbody id="tableBody"></tbody> </table> </div>
<script> // --- Configuration --- const rawEndpoints = [ "wss://jetstream1.us-east.bsky.network/subscribe", "wss://jetstream2.us-east.bsky.network/subscribe", "wss://jetstream1.us-west.bsky.network/subscribe", "wss://jetstream2.us-west.bsky.network/subscribe", "wss://jetstream.whey.party/subscribe", "wss://jetstream.fire.hose.cam/subscribe", "wss://jetstream2.fr.hose.cam/subscribe", ]; const shortNames = ["bsky-e1", "bsky-e2", "bsky-w1", "bsky-w2", "whey", "micro-1", "micro-2"];
// --- State --- let sockets = []; let isConnected = false; let isPDS = false; let hasAccountDidColumn = false; let latestPerConnection = new Array(rawEndpoints.length).fill(0); // Map of EventKey -> { rowElement, receivedCount, firstSeen } // EventKey = Unique ID for the event (CID for commits, Seq for others) const eventMap = new Map(); const ENDPOINT_COUNT = rawEndpoints.length;
// --- Functions --- function addAccountDidColumn() { const headerRow = document.getElementById("tableHeader"); const th = document.createElement("th"); th.innerText = "Account DID"; headerRow.insertBefore(th, headerRow.children[2]); const tbody = document.getElementById("tableBody"); for (let row of tbody.children) { const td = document.createElement("td"); td.innerText = "-"; row.insertBefore(td, row.children[2]); } }
function removeAccountDidColumn() { const headerRow = document.getElementById("tableHeader"); headerRow.removeChild(headerRow.children[2]); const tbody = document.getElementById("tableBody"); for (let row of tbody.children) { row.removeChild(row.children[2]); } }
// --- Initialization --- function init() { const statusArea = document.getElementById("statusArea"); const headerRow = document.getElementById("tableHeader"); const didInput = document.getElementById("didInput");
rawEndpoints.forEach((url, index) => { // Create Status Card const fullName = url.replace("wss://", "").replace("/subscribe", ""); const card = document.createElement("div"); card.className = "status-card"; card.innerHTML = ` <span><span id="dot-${index}" class="status-dot closed"></span>${fullName}</span> <span id="stat-${index}">Idle</span> `; statusArea.appendChild(card);
// Add Table Column const th = document.createElement("th"); th.innerText = shortNames[index]; th.title = url; headerRow.appendChild(th); });
// Add input listener for Account DID column didInput.addEventListener('input', () => { const input = didInput.value.trim(); const newIsPDS = input.startsWith("http"); isPDS = newIsPDS; if (newIsPDS && !hasAccountDidColumn) { addAccountDidColumn(); hasAccountDidColumn = true; } else if (!newIsPDS && hasAccountDidColumn) { removeAccountDidColumn(); hasAccountDidColumn = false; } });
// Add input listener for time slider const timeSlider = document.getElementById("timeSlider"); const timeBack = document.getElementById("timeBack"); timeSlider.addEventListener('input', () => { const value = parseInt(timeSlider.value); if (value === 1000) { timeBack.textContent = "now"; } else { const now = Date.now(); const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000; const selectedTime = threeDaysAgo + (value / 1000) * (now - threeDaysAgo); const backMs = now - selectedTime; timeBack.textContent = formatDuration(backMs); } });
function formatDuration(ms) { const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds} second${seconds !== 1 ? 's' : ''} ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60 * 2) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; const hours = Math.floor(minutes / 60); if (hours < 24 * 3) return `${hours} hour${hours !== 1 ? 's' : ''} ago`; const days = Math.floor(hours / 24); return `${days} day${days !== 1 ? 's' : ''} ago`; } }
// --- Logic ---
function toggleConnection() { if (isConnected) { disconnectAll(); } else { const input = document.getElementById("didInput").value.trim(); isPDS = input.startsWith("http"); if (isPDS && !hasAccountDidColumn) { addAccountDidColumn(); hasAccountDidColumn = true; } else if (!isPDS && hasAccountDidColumn) { removeAccountDidColumn(); hasAccountDidColumn = false; } if (!isPDS && !input.startsWith("did:")) { alert("Please enter a valid DID (starts with 'did:') or PDS URL (starts with 'http')"); return; } connectAll(input); } }
async function connectAll(input) { document.getElementById("btnConnect").innerText = "Disconnect"; document.getElementById("btnConnect").className = "danger"; document.getElementById("didInput").disabled = true; document.getElementById("timeSlider").disabled = true; isConnected = true;
let wantedDidsParam = input; let didList = []; if (isPDS) { try { let allDids = []; let cursor = null; let page = 0; do { const url = `${input}/xrpc/com.atproto.sync.listRepos?limit=1000${cursor ? `&cursor=${cursor}` : ''}`; const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) throw new Error('Failed to fetch repos'); const data = await response.json(); allDids.push(...data.repos.map(repo => repo.did)); cursor = data.cursor; page++; } while (cursor && page < 5); didList = allDids; wantedDidsParam = didList.length > 0 ? didList[0] : ''; } catch (e) { alert('Failed to fetch repos from PDS: ' + e.message); disconnectAll(); return; } }
const activeLatest = latestPerConnection.filter(t => t > 0); let cursor = ''; if (activeLatest.length > 0) { const cursorTime = Math.min(...activeLatest); cursor = `&cursor=${cursorTime}`; } else { const sliderValue = parseInt(document.getElementById('timeSlider').value); if (sliderValue < 1000) { const now = Date.now(); const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000; const selectedTime = threeDaysAgo + (sliderValue / 1000) * (now - threeDaysAgo); cursor = `&cursor=${Math.floor(selectedTime * 1000)}`; } }
rawEndpoints.forEach((url, index) => { try { const wsUrl = wantedDidsParam ? `${url}?wantedDids=${wantedDidsParam}${cursor}` : `${url}${cursor ? '?' + cursor.slice(1) : ''}`; const ws = new WebSocket(wsUrl); sockets[index] = ws;
updateStatus(index, "connecting", "Connecting...");
ws.onopen = () => { updateStatus(index, "connected", "Connected"); if (isPDS && didList.length > 1) { ws.send(JSON.stringify({ type: "options_update", payload: { wantedDids: didList.slice(1) } })); } };
ws.onclose = (e) => updateStatus(index, "closed", `Closed (${e.code})`);
ws.onerror = () => updateStatus(index, "error", "Error");
ws.onmessage = (msg) => { try { const data = JSON.parse(msg.data); handleEvent(data, index); } catch (e) { console.error("Parse error", e); } }; } catch (e) { updateStatus(index, "error", "Init Fail"); } }); }
function disconnectAll() { sockets.forEach((ws) => { if (ws) ws.close(); }); sockets = []; document.getElementById("btnConnect").innerText = "Connect"; document.getElementById("btnConnect").className = "primary"; document.getElementById("didInput").disabled = false; document.getElementById("timeSlider").disabled = false; isConnected = false; }
function updateStatus(index, state, text) { const dot = document.getElementById(`dot-${index}`); const stat = document.getElementById(`stat-${index}`);
dot.className = `status-dot ${state}`; stat.innerText = text; }
function clearLogs() { document.getElementById("tableBody").innerHTML = ""; eventMap.clear(); latestPerConnection.fill(0); }
// --- Event Processing ---
function getEventKey(data) { // Unique Identifier Logic if (data.kind === "commit") { // Use CID as primary key. // Note: technically rev is better for ordering, but CID is unique content. return `C:${data.commit.cid}`; } else if (data.kind === "identity") { return `I:${data.identity.seq}`; } else if (data.kind === "account") { return `A:${data.account.seq}`; } return `U:${data.time_us}`; // Fallback }
function getEventDetails(data) { if (data.kind === "commit") { return `${data.commit.collection}<br><span style="opacity:0.7">${data.commit.rkey}</span>`; } else if (data.kind === "identity") { return `Seq: ${data.identity.seq}`; } else if (data.kind === "account") { return `Active: ${data.account.active}`; } return "-"; }
function handleEvent(data, sourceIndex) { latestPerConnection[sourceIndex] = Math.max(latestPerConnection[sourceIndex], data.time_us); const key = getEventKey(data); const now = new Date(); const timeStr = now.toLocaleTimeString() + "." + now.getMilliseconds();
let entry = eventMap.get(key);
// Calculate latency from event time_us (if reasonable clock sync) // Data.time_us is microseconds. const eventTimeMs = data.time_us / 1000; const latency = (Date.now() - eventTimeMs).toFixed(0);
if (!entry) { // Create new Row const tbody = document.getElementById("tableBody"); const row = document.createElement("tr"); row.className = "row-partial"; // Starts partial until all receive it (unlikely instantly)
const kindClass = `kind-${data.kind}`;
// Base columns let html = ` <td class="time-col" title="Event TS: ${ data.time_us }">${timeStr}</td> <td><span class="kind-badge ${kindClass}">${ data.kind }</span></td> `; if (isPDS) { html += `<td>${data.did}</td>`; } html += ` <td class="details-col" title="${key}"> ${ data.kind === "commit" ? `<span style="color:#888">${data.commit.operation}</span> ` : "" } ${getEventDetails(data)} </td> `;
// Instance columns placeholders for (let i = 0; i < ENDPOINT_COUNT; i++) { html += `<td id="cell-${key}-${i}" class="cell-pending">-</td>`; }
row.innerHTML = html;
// Insert at top tbody.insertBefore(row, tbody.firstChild);
entry = { key: key, row: row, receivedSet: new Set(), firstSeen: Date.now(), }; eventMap.set(key, entry); }
// Check if already received from this source (duplicates exist in reconnect scenarios) if (entry.receivedSet.has(sourceIndex)) return;
entry.receivedSet.add(sourceIndex);
// Update the specific cell const cell = document.getElementById(`cell-${key}-${sourceIndex}`); if (cell) { cell.innerText = `+${latency}ms`; cell.className = "cell-received"; cell.title = `Size: ${JSON.stringify(data).length} bytes`; }
// Check Discrepancy Status // We consider it "Complete" if we have received it from all *Connected* sockets. // However, sockets flux. For simplicity, we check against total endpoints list or just highlight partials. checkRowStatus(entry); }
function checkRowStatus(entry) { // If we received from all defined endpoints if (entry.receivedSet.size === ENDPOINT_COUNT) { entry.row.className = "row-complete"; } else { // It remains partial. // Optional: Logic to detect if it's been "too long" and mark as definitely missed } }
// Run init init(); </script> </body></html>