jetstream-diag.html
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>