compares jetstreams to diagnose if an account / PDS is not being relayed by a particular jetstream
jetstream-diag.html
613 lines 20 kB view raw
1<!DOCTYPE html> 2<html lang="en"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <title>Jetstream Multi-Instance Diagnostic</title> 7 <style> 8 :root { 9 --bg-color: #1a1b1e; 10 --text-color: #e0e0e0; 11 --border-color: #333; 12 --accent-color: #3b82f6; 13 --success-color: #22c55e; 14 --error-color: #ef4444; 15 --warn-color: #f59e0b; 16 --panel-bg: #25262b; 17 } 18 19 body { 20 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 21 Helvetica, Arial, sans-serif; 22 background-color: var(--bg-color); 23 color: var(--text-color); 24 margin: 0; 25 padding: 20px; 26 font-size: 14px; 27 } 28 29 h1 { 30 margin-top: 0; 31 font-size: 1.5rem; 32 } 33 34 /* Controls */ 35 .controls { 36 background: var(--panel-bg); 37 padding: 15px; 38 border-radius: 8px; 39 margin-bottom: 20px; 40 display: flex; 41 gap: 10px; 42 align-items: center; 43 border: 1px solid var(--border-color); 44 } 45 46 input[type="text"] { 47 background: #141517; 48 border: 1px solid var(--border-color); 49 color: white; 50 padding: 8px 12px; 51 border-radius: 4px; 52 width: 300px; 53 font-family: monospace; 54 } 55 56 button { 57 padding: 8px 16px; 58 border-radius: 4px; 59 border: none; 60 cursor: pointer; 61 font-weight: 600; 62 transition: opacity 0.2s; 63 } 64 65 button.primary { 66 background-color: var(--accent-color); 67 color: white; 68 } 69 button.danger { 70 background-color: var(--error-color); 71 color: white; 72 } 73 button.secondary { 74 background-color: #444; 75 color: white; 76 } 77 button:hover { 78 opacity: 0.9; 79 } 80 button:disabled { 81 opacity: 0.5; 82 cursor: not-allowed; 83 } 84 85 /* Connection Status Grid */ 86 .status-grid { 87 display: grid; 88 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 89 gap: 10px; 90 margin-bottom: 20px; 91 } 92 93 .status-card { 94 background: var(--panel-bg); 95 padding: 10px; 96 border-radius: 4px; 97 border: 1px solid var(--border-color); 98 font-size: 12px; 99 display: flex; 100 justify-content: space-between; 101 align-items: center; 102 } 103 104 .status-dot { 105 width: 8px; 106 height: 8px; 107 border-radius: 50%; 108 background-color: #666; 109 margin-right: 8px; 110 display: inline-block; 111 } 112 .status-dot.connected { 113 background-color: var(--success-color); 114 box-shadow: 0 0 5px var(--success-color); 115 } 116 .status-dot.error { 117 background-color: var(--error-color); 118 } 119 .status-dot.closed { 120 background-color: #666; 121 } 122 123 /* Matrix Table */ 124 .table-container { 125 overflow-x: auto; 126 background: var(--panel-bg); 127 border-radius: 8px; 128 border: 1px solid var(--border-color); 129 } 130 131 table { 132 width: 100%; 133 border-collapse: collapse; 134 font-size: 12px; 135 } 136 137 th, 138 td { 139 padding: 8px 12px; 140 text-align: left; 141 border-bottom: 1px solid var(--border-color); 142 white-space: nowrap; 143 } 144 145 th { 146 background-color: #2c2e33; 147 position: sticky; 148 top: 0; 149 z-index: 10; 150 } 151 152 tr:hover { 153 background-color: #2c2e33; 154 } 155 156 /* Discrepancy Highlighting */ 157 .row-complete { 158 border-left: 3px solid var(--success-color); 159 } 160 .row-partial { 161 border-left: 3px solid var(--warn-color); 162 background-color: rgba(245, 158, 11, 0.05); 163 } 164 165 .cell-received { 166 color: var(--success-color); 167 font-family: monospace; 168 } 169 .cell-pending { 170 color: #666; 171 font-style: italic; 172 } 173 174 .kind-badge { 175 padding: 2px 6px; 176 border-radius: 3px; 177 font-size: 10px; 178 text-transform: uppercase; 179 font-weight: bold; 180 } 181 .kind-commit { 182 background: rgba(59, 130, 246, 0.2); 183 color: #60a5fa; 184 } 185 .kind-identity { 186 background: rgba(168, 85, 247, 0.2); 187 color: #c084fc; 188 } 189 .kind-account { 190 background: rgba(239, 68, 68, 0.2); 191 color: #f87171; 192 } 193 194 .details-col { 195 font-family: monospace; 196 color: #aaa; 197 max-width: 400px; 198 overflow: hidden; 199 text-overflow: ellipsis; 200 } 201 .time-col { 202 color: #888; 203 } 204 </style> 205 </head> 206 <body> 207 <h1>Jetstream Repository Diagnostic</h1> 208 209 <div class="controls"> 210 <label for="didInput">Target DID or PDS URL:</label> 211 <input type="text" id="didInput" placeholder="did:plc:... or https://..." value="" /> 212 <button id="btnConnect" class="primary" onclick="toggleConnection()"> 213 Connect 214 </button> 215 <button id="btnClear" class="secondary" onclick="clearLogs()"> 216 Clear Logs 217 </button> 218 <label for="timeSlider">Start at:</label> 219 <input type="range" id="timeSlider" min="0" max="1000" value="1000" step="1" style="flex-grow: 1;"/> 220 <span id="timeBack" style="width: 120px;">now</span> 221 </div> 222 223 <div id="statusArea" class="status-grid"> 224 <!-- Status cards generated here --> 225 </div> 226 227 <div class="table-container"> 228 <table id="logTable"> 229 <thead> 230 <tr id="tableHeader"> 231 <th>Time (Client)</th> 232 <th>Kind</th> 233 <th>Details (Collection / RKey / CID)</th> 234 <!-- Instance columns added dynamically --> 235 </tr> 236 </thead> 237 <tbody id="tableBody"></tbody> 238 </table> 239 </div> 240 241 <script> 242 // --- Configuration --- 243 const rawEndpoints = [ 244 "wss://jetstream1.us-east.bsky.network/subscribe", 245 "wss://jetstream2.us-east.bsky.network/subscribe", 246 "wss://jetstream1.us-west.bsky.network/subscribe", 247 "wss://jetstream2.us-west.bsky.network/subscribe", 248 "wss://jetstream.whey.party/subscribe", 249 "wss://jetstream.fire.hose.cam/subscribe", 250 "wss://jetstream2.fr.hose.cam/subscribe", 251 ]; 252 const shortNames = ["bsky-e1", "bsky-e2", "bsky-w1", "bsky-w2", "whey", "micro-1", "micro-2"]; 253 254 // --- State --- 255 let sockets = []; 256 let isConnected = false; 257 let isPDS = false; 258 let hasAccountDidColumn = false; 259 let latestPerConnection = new Array(rawEndpoints.length).fill(0); 260 // Map of EventKey -> { rowElement, receivedCount, firstSeen } 261 // EventKey = Unique ID for the event (CID for commits, Seq for others) 262 const eventMap = new Map(); 263 const ENDPOINT_COUNT = rawEndpoints.length; 264 265 // --- Functions --- 266 function addAccountDidColumn() { 267 const headerRow = document.getElementById("tableHeader"); 268 const th = document.createElement("th"); 269 th.innerText = "Account DID"; 270 headerRow.insertBefore(th, headerRow.children[2]); 271 const tbody = document.getElementById("tableBody"); 272 for (let row of tbody.children) { 273 const td = document.createElement("td"); 274 td.innerText = "-"; 275 row.insertBefore(td, row.children[2]); 276 } 277 } 278 279 function removeAccountDidColumn() { 280 const headerRow = document.getElementById("tableHeader"); 281 headerRow.removeChild(headerRow.children[2]); 282 const tbody = document.getElementById("tableBody"); 283 for (let row of tbody.children) { 284 row.removeChild(row.children[2]); 285 } 286 } 287 288 // --- Initialization --- 289 function init() { 290 const statusArea = document.getElementById("statusArea"); 291 const headerRow = document.getElementById("tableHeader"); 292 const didInput = document.getElementById("didInput"); 293 294 rawEndpoints.forEach((url, index) => { 295 // Create Status Card 296 const fullName = url.replace("wss://", "").replace("/subscribe", ""); 297 const card = document.createElement("div"); 298 card.className = "status-card"; 299 card.innerHTML = ` 300 <span><span id="dot-${index}" class="status-dot closed"></span>${fullName}</span> 301 <span id="stat-${index}">Idle</span> 302 `; 303 statusArea.appendChild(card); 304 305 // Add Table Column 306 const th = document.createElement("th"); 307 th.innerText = shortNames[index]; 308 th.title = url; 309 headerRow.appendChild(th); 310 }); 311 312 // Add input listener for Account DID column 313 didInput.addEventListener('input', () => { 314 const input = didInput.value.trim(); 315 const newIsPDS = input.startsWith("http"); 316 isPDS = newIsPDS; 317 if (newIsPDS && !hasAccountDidColumn) { 318 addAccountDidColumn(); 319 hasAccountDidColumn = true; 320 } else if (!newIsPDS && hasAccountDidColumn) { 321 removeAccountDidColumn(); 322 hasAccountDidColumn = false; 323 } 324 }); 325 326 // Add input listener for time slider 327 const timeSlider = document.getElementById("timeSlider"); 328 const timeBack = document.getElementById("timeBack"); 329 timeSlider.addEventListener('input', () => { 330 const value = parseInt(timeSlider.value); 331 if (value === 1000) { 332 timeBack.textContent = "now"; 333 } else { 334 const now = Date.now(); 335 const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000; 336 const selectedTime = threeDaysAgo + (value / 1000) * (now - threeDaysAgo); 337 const backMs = now - selectedTime; 338 timeBack.textContent = formatDuration(backMs); 339 } 340 }); 341 342 function formatDuration(ms) { 343 const seconds = Math.floor(ms / 1000); 344 if (seconds < 60) return `${seconds} second${seconds !== 1 ? 's' : ''} ago`; 345 const minutes = Math.floor(seconds / 60); 346 if (minutes < 60 * 2) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; 347 const hours = Math.floor(minutes / 60); 348 if (hours < 24 * 3) return `${hours} hour${hours !== 1 ? 's' : ''} ago`; 349 const days = Math.floor(hours / 24); 350 return `${days} day${days !== 1 ? 's' : ''} ago`; 351 } 352 } 353 354 // --- Logic --- 355 356 function toggleConnection() { 357 if (isConnected) { 358 disconnectAll(); 359 } else { 360 const input = document.getElementById("didInput").value.trim(); 361 isPDS = input.startsWith("http"); 362 if (isPDS && !hasAccountDidColumn) { 363 addAccountDidColumn(); 364 hasAccountDidColumn = true; 365 } else if (!isPDS && hasAccountDidColumn) { 366 removeAccountDidColumn(); 367 hasAccountDidColumn = false; 368 } 369 if (!isPDS && !input.startsWith("did:")) { 370 alert("Please enter a valid DID (starts with 'did:') or PDS URL (starts with 'http')"); 371 return; 372 } 373 connectAll(input); 374 } 375 } 376 377 async function connectAll(input) { 378 document.getElementById("btnConnect").innerText = "Disconnect"; 379 document.getElementById("btnConnect").className = "danger"; 380 document.getElementById("didInput").disabled = true; 381 document.getElementById("timeSlider").disabled = true; 382 isConnected = true; 383 384 let wantedDidsParam = input; 385 let didList = []; 386 if (isPDS) { 387 try { 388 let allDids = []; 389 let cursor = null; 390 let page = 0; 391 do { 392 const url = `${input}/xrpc/com.atproto.sync.listRepos?limit=1000${cursor ? `&cursor=${cursor}` : ''}`; 393 const response = await fetch(url, { 394 method: 'GET', 395 headers: { 'Content-Type': 'application/json' } 396 }); 397 if (!response.ok) throw new Error('Failed to fetch repos'); 398 const data = await response.json(); 399 allDids.push(...data.repos.map(repo => repo.did)); 400 cursor = data.cursor; 401 page++; 402 } while (cursor && page < 5); 403 didList = allDids; 404 wantedDidsParam = didList.length > 0 ? didList[0] : ''; 405 } catch (e) { 406 alert('Failed to fetch repos from PDS: ' + e.message); 407 disconnectAll(); 408 return; 409 } 410 } 411 412 const activeLatest = latestPerConnection.filter(t => t > 0); 413 let cursor = ''; 414 if (activeLatest.length > 0) { 415 const cursorTime = Math.min(...activeLatest); 416 cursor = `&cursor=${cursorTime}`; 417 } else { 418 const sliderValue = parseInt(document.getElementById('timeSlider').value); 419 if (sliderValue < 1000) { 420 const now = Date.now(); 421 const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000; 422 const selectedTime = threeDaysAgo + (sliderValue / 1000) * (now - threeDaysAgo); 423 cursor = `&cursor=${Math.floor(selectedTime * 1000)}`; 424 } 425 } 426 427 rawEndpoints.forEach((url, index) => { 428 try { 429 const wsUrl = wantedDidsParam ? `${url}?wantedDids=${wantedDidsParam}${cursor}` : `${url}${cursor ? '?' + cursor.slice(1) : ''}`; 430 const ws = new WebSocket(wsUrl); 431 sockets[index] = ws; 432 433 updateStatus(index, "connecting", "Connecting..."); 434 435 ws.onopen = () => { 436 updateStatus(index, "connected", "Connected"); 437 if (isPDS && didList.length > 1) { 438 ws.send(JSON.stringify({ 439 type: "options_update", 440 payload: { wantedDids: didList.slice(1) } 441 })); 442 } 443 }; 444 445 ws.onclose = (e) => 446 updateStatus(index, "closed", `Closed (${e.code})`); 447 448 ws.onerror = () => updateStatus(index, "error", "Error"); 449 450 ws.onmessage = (msg) => { 451 try { 452 const data = JSON.parse(msg.data); 453 handleEvent(data, index); 454 } catch (e) { 455 console.error("Parse error", e); 456 } 457 }; 458 } catch (e) { 459 updateStatus(index, "error", "Init Fail"); 460 } 461 }); 462 } 463 464 function disconnectAll() { 465 sockets.forEach((ws) => { 466 if (ws) ws.close(); 467 }); 468 sockets = []; 469 document.getElementById("btnConnect").innerText = "Connect"; 470 document.getElementById("btnConnect").className = "primary"; 471 document.getElementById("didInput").disabled = false; 472 document.getElementById("timeSlider").disabled = false; 473 isConnected = false; 474 } 475 476 function updateStatus(index, state, text) { 477 const dot = document.getElementById(`dot-${index}`); 478 const stat = document.getElementById(`stat-${index}`); 479 480 dot.className = `status-dot ${state}`; 481 stat.innerText = text; 482 } 483 484 function clearLogs() { 485 document.getElementById("tableBody").innerHTML = ""; 486 eventMap.clear(); 487 latestPerConnection.fill(0); 488 } 489 490 // --- Event Processing --- 491 492 function getEventKey(data) { 493 // Unique Identifier Logic 494 if (data.kind === "commit") { 495 // Use CID as primary key. 496 // Note: technically rev is better for ordering, but CID is unique content. 497 return `C:${data.commit.cid}`; 498 } else if (data.kind === "identity") { 499 return `I:${data.identity.seq}`; 500 } else if (data.kind === "account") { 501 return `A:${data.account.seq}`; 502 } 503 return `U:${data.time_us}`; // Fallback 504 } 505 506 function getEventDetails(data) { 507 if (data.kind === "commit") { 508 return `${data.commit.collection}<br><span style="opacity:0.7">${data.commit.rkey}</span>`; 509 } else if (data.kind === "identity") { 510 return `Seq: ${data.identity.seq}`; 511 } else if (data.kind === "account") { 512 return `Active: ${data.account.active}`; 513 } 514 return "-"; 515 } 516 517 function handleEvent(data, sourceIndex) { 518 latestPerConnection[sourceIndex] = Math.max(latestPerConnection[sourceIndex], data.time_us); 519 const key = getEventKey(data); 520 const now = new Date(); 521 const timeStr = now.toLocaleTimeString() + "." + now.getMilliseconds(); 522 523 let entry = eventMap.get(key); 524 525 // Calculate latency from event time_us (if reasonable clock sync) 526 // Data.time_us is microseconds. 527 const eventTimeMs = data.time_us / 1000; 528 const latency = (Date.now() - eventTimeMs).toFixed(0); 529 530 if (!entry) { 531 // Create new Row 532 const tbody = document.getElementById("tableBody"); 533 const row = document.createElement("tr"); 534 row.className = "row-partial"; // Starts partial until all receive it (unlikely instantly) 535 536 const kindClass = `kind-${data.kind}`; 537 538 // Base columns 539 let html = ` 540 <td class="time-col" title="Event TS: ${ 541 data.time_us 542 }">${timeStr}</td> 543 <td><span class="kind-badge ${kindClass}">${ 544 data.kind 545 }</span></td> 546 `; 547 if (isPDS) { 548 html += `<td>${data.did}</td>`; 549 } 550 html += ` 551 <td class="details-col" title="${key}"> 552 ${ 553 data.kind === "commit" 554 ? `<span style="color:#888">${data.commit.operation}</span> ` 555 : "" 556 } 557 ${getEventDetails(data)} 558 </td> 559 `; 560 561 // Instance columns placeholders 562 for (let i = 0; i < ENDPOINT_COUNT; i++) { 563 html += `<td id="cell-${key}-${i}" class="cell-pending">-</td>`; 564 } 565 566 row.innerHTML = html; 567 568 // Insert at top 569 tbody.insertBefore(row, tbody.firstChild); 570 571 entry = { 572 key: key, 573 row: row, 574 receivedSet: new Set(), 575 firstSeen: Date.now(), 576 }; 577 eventMap.set(key, entry); 578 } 579 580 // Check if already received from this source (duplicates exist in reconnect scenarios) 581 if (entry.receivedSet.has(sourceIndex)) return; 582 583 entry.receivedSet.add(sourceIndex); 584 585 // Update the specific cell 586 const cell = document.getElementById(`cell-${key}-${sourceIndex}`); 587 if (cell) { 588 cell.innerText = `+${latency}ms`; 589 cell.className = "cell-received"; 590 cell.title = `Size: ${JSON.stringify(data).length} bytes`; 591 } 592 593 // Check Discrepancy Status 594 // We consider it "Complete" if we have received it from all *Connected* sockets. 595 // However, sockets flux. For simplicity, we check against total endpoints list or just highlight partials. 596 checkRowStatus(entry); 597 } 598 599 function checkRowStatus(entry) { 600 // If we received from all defined endpoints 601 if (entry.receivedSet.size === ENDPOINT_COUNT) { 602 entry.row.className = "row-complete"; 603 } else { 604 // It remains partial. 605 // Optional: Logic to detect if it's been "too long" and mark as definitely missed 606 } 607 } 608 609 // Run init 610 init(); 611 </script> 612 </body> 613</html>