at main 18 kB view raw
1<script lang="ts"> 2 3 import { onMount } from 'svelte'; 4 import { filesize } from 'filesize'; 5 import { formatDistanceToNow, addSeconds, subSeconds, formatDate, formatISO9075, differenceInSeconds } from 'date-fns'; 6 import { Progress, Switch } from '@skeletonlabs/skeleton-svelte'; 7 import orderBy from 'lodash/orderBy'; 8 import BundleDownloader from './BundleDownloader.svelte'; 9 import { formatNumber, formatUptime } from './lib/utils'; 10 import instancesData from './instances.json'; 11 12 const APP_TITLE = 'plcbundle instances' 13 const PLC_DIRECTORY = 'plc.directory' 14 const ROOT = 'f743c3ae1e3f6023e89e492bce63b52a9ed03ee46a163c2f4a3b997eaf2aaf85' 15 const AUTO_REFRESH_INTERVAL = 10 // in seconds 16 const BUNDLE_OPS = 10_000 17 const PAST_ROOTS = [ 18 // November 2025: https://bsky.app/profile/atproto.com/post/3m4e3mnxb7s2p 19 "cbab6809a136d6a621906ee11199d3b0faf85b422fe0d0d2c346ce8e9dcd7485" 20 ] 21 22 type StatusResponse = { 23 ok: boolean; 24 bundles: { 25 last_bundle: number; 26 root_hash: string; 27 head_hash: string; 28 end_time?: string; 29 total_size?: number; 30 uncompressed_size?: number; 31 updated_at?: string; 32 }; 33 server: { 34 uptime: number; 35 }; 36 mempool?: { 37 count: number; 38 eta_next_bundle_seconds: number; 39 last_time: Date; 40 }; 41 latency?: number; 42 } 43 44 type StatusResponseError = { 45 error: string; 46 } 47 48 type Instance = { 49 url: string; 50 cors?: boolean; 51 status?: StatusResponse | StatusResponseError; 52 modern?: boolean; 53 _head?: boolean; 54 _oldRoot?: boolean; 55 _conflict?: boolean; 56 } 57 58 type LastKnownBundle = { 59 number: number; 60 hash: string | null; 61 mempool: number | null; 62 mempoolPercent: number; 63 mempoolBundle: number; 64 lastTime?: Date; 65 time?: string; 66 etaNext?: Date | null; 67 totalSize?: number | null; 68 totalSizeUncompressed?: number | null; 69 } 70 71 let lastKnownBundle = $state<LastKnownBundle>({ 72 number: 0, 73 hash: null, 74 mempool: null, 75 mempoolBundle: 0, 76 mempoolPercent: 0, 77 }) 78 79 let isUpdating = $state(false) 80 let canRefresh = $state(true) 81 let consensus = $state({}) 82 let isConflict = $state(consensus) 83 let instancesInConflict = $state<string[]>([]) 84 let lastUpdated = $state(new Date()) 85 let autoRefreshEnabled = $state(true) 86 let instances = $state<Instance[]>(instancesData.sort(() => Math.random() - 0.5)) 87 88 const instanceOrderBy = [ 89 ['status.error', '_head', '_oldRoot' , 'status.bundles.last_bundle', 'status.latency'], 90 ['desc', 'desc', 'asc', 'desc', 'asc'] 91 ] 92 93 async function getStatus(instance: Instance): Promise<StatusResponse | StatusResponseError> { 94 let statusResp: StatusResponse | undefined; 95 let url: string = instance.url; 96 let lastError: string | undefined; 97 const start = performance.now(); 98 try { 99 statusResp = await (await fetch(`${url}/status?${Number(new Date())}`)).json() 100 } catch (e: any) { 101 lastError = e.message; 102 } 103 if (!statusResp) { 104 url = `https://keyoxide.org/api/3/get/http?url=${encodeURIComponent(url)}&format=text&time=${Date.now()}` 105 106 let indexResp: string | undefined; 107 try { 108 indexResp = await (await fetch(url)).text() 109 } catch(e: any) { 110 lastError = e.message; 111 } 112 const match = indexResp?.match(/Range:\s+(\d{6}) - (\d{6})/) 113 if (match) { 114 const [, from, to] = match 115 const rootMatch = indexResp?.match(/Root: ([a-f0-9]{64})/) 116 const headMatch = indexResp?.match(/Head: ([a-f0-9]{64})/) 117 118 statusResp = { 119 ok: true, 120 bundles: { 121 last_bundle: Number(to), 122 root_hash: rootMatch ? rootMatch[1] : '', 123 head_hash: headMatch ? headMatch[1] : '', 124 }, 125 server: { 126 uptime: 1, 127 } 128 } 129 } 130 } 131 if (statusResp) { 132 statusResp.ok = true 133 statusResp.latency = performance.now() - start; 134 } 135 //if (instance.url === 'https://plc.j4ck.xyz') { statusResp.bundles.head_hash = 'f3ad3544452b2c078cba24990486bb9c277a1155'; } 136 return statusResp ?? { error: lastError || 'unknown error' } 137 } 138 139 function recalculateHead() { 140 isConflict = false 141 instancesInConflict = [] 142 const headHashes: any = {} 143 for (const instance of instances) { 144 if ((instance.status && 'error' in instance.status) || !instance.status) { 145 continue 146 } 147 const { head_hash, root_hash, last_bundle } = instance.status.bundles 148 instance._oldRoot = PAST_ROOTS.includes(root_hash) 149 instance._head = !instance._oldRoot && (last_bundle === lastKnownBundle.number) 150 151 if (instance._head && head_hash) { 152 if (!headHashes[head_hash]) { 153 headHashes[head_hash] = [] 154 } 155 headHashes[head_hash].push(instance.url) 156 } 157 } 158 console.log(headHashes) 159 // second pass 160 const sorted: any = Object.fromEntries( 161 Object.entries(headHashes).sort(([, a]: any, [, b]: any) => b.length - a.length) 162 ) 163 for (const instance of instances) { 164 if (Object.keys(sorted).length > 1 && Object.keys(sorted)[0] && !sorted[Object.keys(sorted)[0]].includes(instance.url)) { 165 instance._conflict = true 166 instancesInConflict.push(instance.url) 167 } 168 } 169 //const uniq = [...new Set(headHashes)] 170 isConflict = instancesInConflict.length > Math.ceil(instances.length/2) 171 } 172 173 async function doCheck() { 174 isUpdating = true 175 canRefresh = false 176 for (const i of instances) { 177 if (i.status && 'ok' in i.status) { 178 i.status.ok = false 179 } 180 } 181 182 await Promise.all(instances.map(async (instance) => { 183 const status = await getStatus(instance) 184 if (!status) { 185 return false 186 } 187 188 instance.status = status 189 if ('ok' in status && status.ok) { 190 191 if (status?.bundles?.last_bundle && status.bundles.last_bundle >= lastKnownBundle.number && !PAST_ROOTS.includes(status.bundles.root_hash)) { 192 lastKnownBundle.number = status.bundles.last_bundle 193 lastKnownBundle.hash = status.bundles.head_hash 194 lastKnownBundle.time = status.bundles.end_time 195 196 if (status?.mempool?.count && (!lastKnownBundle.mempool || status.mempool.count > lastKnownBundle.mempool || status.bundles.last_bundle > lastKnownBundle.mempoolBundle)) { 197 lastKnownBundle.mempoolBundle = status.bundles.last_bundle 198 lastKnownBundle.mempool = status.mempool.count 199 lastKnownBundle.lastTime = status.mempool.last_time 200 lastKnownBundle.mempoolPercent = Math.round((lastKnownBundle.mempool/100)*100)/100 201 lastKnownBundle.etaNext = status.mempool.eta_next_bundle_seconds ? addSeconds(new Date(), status.mempool.eta_next_bundle_seconds) : null 202 lastKnownBundle.totalSize = status.bundles.total_size 203 lastKnownBundle.totalSizeUncompressed = status.bundles.uncompressed_size 204 } 205 } 206 } 207 208 lastUpdated = new Date() 209 210 recalculateHead() 211 })) 212 isUpdating = false 213 updateTitle() 214 setTimeout(() => { canRefresh = true }, 500) 215 } 216 217 function normalizedVersion(version: string) { 218 const m = version.trim().match(/^([^\s]+)\.\d+\.\d+\-[0-9a-f]+(\+dirty|)$/) 219 if (m) { 220 return `${m[1]}+dirty` 221 } 222 return version 223 } 224 225 function updateTitle() { 226 const arr: string[] = [] 227 if (lastUpdated) { 228 const upCount = instances.filter(i => i._head && !i._conflict) 229 arr.push(`${isConflict ? '⚠️' : '✅'} [${upCount.length}/${instances.length}]`) 230 } 231 document.title = [...arr, APP_TITLE].join(' ') 232 return true 233 } 234 235 function bundleAge(instance: Instance) { 236 if (!instance.status || !('bundles' in instance.status) || !instance.status.bundles?.updated_at) { 237 return '-' 238 } 239 const str = instance.status?.bundles.updated_at ? instance.status?.bundles.updated_at : instance.status?.bundles.end_time || '' 240 const diff = differenceInSeconds(new Date(), new Date(str)) 241 return diff > 300 ? '>5m' : diff + 's' 242 } 243 244 function secondsAge(seconds: number) { 245 return seconds > 300 ? '>5m' : seconds + 's' 246 } 247 248 let autoRefreshTimer: ReturnType<typeof setTimeout> | null = null; 249 250 onMount(() => { 251 doCheck().then(() => { 252 const scheduleRefresh = () => { 253 autoRefreshTimer = setTimeout(() => { 254 if (autoRefreshEnabled) { 255 doCheck() 256 } 257 scheduleRefresh() 258 }, AUTO_REFRESH_INTERVAL * 1000) 259 } 260 261 scheduleRefresh() 262 263 }) 264 265 return () => { 266 if (autoRefreshTimer) { 267 clearTimeout(autoRefreshTimer) 268 } 269 } 270 }) 271</script> 272 273<main class="w-full mt-10 mb-16"> 274 <div class="max-w-5xl mx-auto px-3"> 275 276 <header class="flex items-center gap-10 flex-wrap"> 277 <div class="grow"> 278 <h1 class="text-3xl linear-text-gradient"><a href="https://plcbundle-watch.pages.dev/" class="no-style">plcbundle instances</a></h1> 279 </div> 280 <div class="flex items-center gap-6"> 281 <Switch class="opacity-75" checked={autoRefreshEnabled} onCheckedChange={(x) => autoRefreshEnabled = x.checked} disabled={isUpdating}> 282 <Switch.Control class="data-[state=checked]:preset-filled-success-500"> 283 <Switch.Thumb /> 284 </Switch.Control> 285 <Switch.Label>Auto-refresh ({AUTO_REFRESH_INTERVAL}s)</Switch.Label> 286 <Switch.HiddenInput /> 287 </Switch> 288 <button type="button" class="btn btn-sm preset-tonal-primary" onclick={() => doCheck()} disabled={isUpdating || canRefresh === false}>Refresh</button> 289 </div> 290 </header> 291 292 <div class="gap-10 mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> 293 <div> 294 <h2 class="opacity-75 text-sm">Last known bundle</h2> 295 <div> 296 <div class="flex items-center gap-5"> 297 <div class="font-semibold text-3xl">{lastKnownBundle.number}</div> 298 {#if !isConflict} 299 <div class="mt-1 font-mono badge preset-outlined-primary-500 text-xs">{lastKnownBundle?.hash?.slice(0, 7)}</div> 300 {:else} 301 <div class="mt-1 badge preset-filled-error-500">⚠️ conflict!</div> 302 {/if} 303 </div> 304 <div> 305 <span class="opacity-50">{#if lastKnownBundle?.time} {formatDistanceToNow(lastKnownBundle.time, { addSuffix: true })}{/if}</span> 306 </div> 307 <div class="mt-1"> 308 {#if instancesInConflict.length > 0} 309 ⚠️ Fork alert on {instancesInConflict.length} instances! 310 {:else if !isConflict} 311 ✅ Everything fine! 312 {/if} 313 </div> 314 </div> 315 </div> 316 <div> 317 <div> 318 <h2 class="opacity-75 text-sm">Next bundle</h2> 319 </div> 320 <div class="flex gap-4"> 321 <div class="mt-4"> 322 <Progress value={lastKnownBundle.mempoolPercent} class="items-center"> 323 <Progress.Circle style="--size: 64px; --thickness: 10px;" class={lastKnownBundle.mempoolPercent > 95 ? 'animate-pulse' : ''}> 324 <Progress.CircleTrack /> 325 <Progress.CircleRange /> 326 </Progress.Circle> 327 <!--Progress.ValueText class="text-xs opacity-50" /--> 328 </Progress> 329 </div> 330 {#if lastKnownBundle.number > 0} 331 <div> 332 <div class="font-semibold text-2xl animate-pulse">{lastKnownBundle.number + 1}</div> 333 <div>{formatNumber(lastKnownBundle.mempool || 0)} / {formatNumber(BUNDLE_OPS)} <span class="opacity-50">({lastKnownBundle.mempoolPercent}%)</span></div> 334 {#if lastKnownBundle.etaNext} 335 <div class="mt-1 opacity-50">ETA: {formatDistanceToNow(lastKnownBundle.etaNext)}</div> 336 {/if} 337 </div> 338 {/if} 339 </div> 340 </div> 341 {#if lastKnownBundle.number > 0} 342 <div class=""> 343 <div> 344 <h2 class="opacity-75 text-sm">Statistics</h2> 345 </div> 346 <div class="mt-2 grid grid-cols-1 gap-1"> 347 <div><span class="opacity-50">Instances:</span> {instances.filter(i => i._head && !i._conflict).length} latest / {instances.length} total</div> 348 <div><span class="opacity-50">PLC Operations:</span> {formatNumber((lastKnownBundle.number * BUNDLE_OPS) + (lastKnownBundle.mempool || 0))}</div> 349 <div><span class="opacity-50">Bundles Size:</span> {#if lastKnownBundle.totalSize}{filesize(lastKnownBundle.totalSize)}{/if}</div> 350 <div><span class="opacity-50">Uncompressed:</span> {#if lastKnownBundle.totalSizeUncompressed}{filesize(lastKnownBundle.totalSizeUncompressed)}{/if}</div> 351 </div> 352 </div> 353 {/if} 354 </div> 355 356 <table class="table mt-10"> 357 <thead> 358 <tr> 359 <th>endpoint</th> 360 <th>ok?</th> 361 <th>last</th> 362 <th>mempool</th> 363 <th>age</th> 364 <th>head</th> 365 <th>root</th> 366 <th>version</th> 367 <th>rsv?</th> 368 <th>ws?</th> 369 <th>uptime</th> 370 <th>latency</th> 371 </tr> 372 </thead> 373 <tbody class="[&>tr]:hover:bg-primary-500/10"> 374 {#each orderBy(instances, ...instanceOrderBy) as instance} 375 <tr> 376 <td><a href={instance.url} target="_blank" class="font-semibold">{instance.url.replace("https://", "")}</a></td> 377 <td>{#if instance._head && instance.status?.ok}{#if instance._conflict}⚠️{:else}{/if}{:else if instance.status?.ok && instance._oldRoot}🦇{:else if instance.status?.ok}🔄{:else if instance.status?.error}{:else}{/if}</td> 378 {#if instance.status?.error} 379 <td colspan="8" class="opacity-50 text-xs">Error: {instance.status?.error}</td> 380 {:else} 381 <td>{#if instance.status?.bundles?.last_bundle}<span class="{instance._conflict ? 'text-error-600' : ''}">{instance.status?.bundles?.last_bundle}</span>{/if}</td> 382 <td>{#if instance.status?.mempool && (instance._head || instance._oldRoot)}<span class="{instance._conflict ? 'text-error-600' : (instance._oldRoot ? 'text-warning-500 opacity-50' : '')}">{formatNumber(instance.status?.mempool.count)}</span>{:else if instance.status?.error}<span class="opacity-25 text-xs">error</span>{:else if instance.status}<span class="opacity-25 text-xs">syncing</span>{/if}</td> 383 <td> 384 {#if instance.status?.mempool} 385 <span class="text-xs opacity-50 {instance._conflict ? 'text-error-600' : (instance._oldRoot ? 'text-warning-500' : '')}"> 386 {#if instance._head || instance._oldRoot} 387 {secondsAge(instance.status?.mempool.last_op_age_seconds || 0)} 388 {:else} 389 {bundleAge(instance)} 390 {/if} 391 </span> 392 {/if} 393 </td> 394 <td><span class="font-mono text-xs {instance._head ? (instance._conflict ? 'text-error-600' : 'text-success-600') : (instance._oldRoot ? 'text-warning-500 opacity-50' : 'opacity-50')}">{#if instance.status?.bundles?.head_hash}{instance.status?.bundles?.head_hash.slice(0, 7)}{/if}</span></td> 395 <td><span class="font-mono text-xs {instance.status ? (instance.status?.bundles?.root_hash === ROOT ? 'text-success-600' : (PAST_ROOTS.includes(instance.status?.bundles?.root_hash) ? 'text-warning-500' : 'text-error-600')) : ''}">{#if instance.status?.bundles?.root_hash}{instance.status?.bundles?.root_hash.slice(0, 7)}{/if}</span></td> 396 <td class="text-xs">{#if instance.status?.server?.version}<span title={instance.status?.server?.version}>{normalizedVersion(instance.status?.server?.version)}</span>{/if}</td> 397 <td class="text-xs">{#if instance.status?.server?.resolver_enabled}✔︎{:else if instance.status}<span class="opacity-25">-</span>{/if}</td> 398 <td class="text-xs">{#if instance.status?.server?.websocket_enabled}✔︎{:else if instance.status}<span class="opacity-25">-</span>{/if}</td> 399 {/if} 400 <td class="text-xs">{#if instance.status?.server?.uptime_seconds}{formatUptime(instance.status?.server?.uptime_seconds)}{/if}</td> 401 <td class="text-xs opacity-50">{#if instance.status?.latency}<a href="{instance.url}/status">{Math.round(instance.status?.latency)}ms</a>{/if}</td> 402 </tr> 403 {/each} 404 </tbody> 405 </table> 406 407 408 <div class="mt-12"> 409 <div> 410 <span class="opacity-75">PLC Directory:</span> <a href="https://{PLC_DIRECTORY}">{PLC_DIRECTORY}</a> <span class="opacity-50">(origin)</span> 411 </div> 412 <div class="mt-2"> 413 <span class="opacity-75">First hash (root):</span> <span class="font-mono text-xs">{ROOT.slice(0)}</span> 414 </div> 415 416 <div class="mt-6 opacity-50"> 417 Last updated: {formatISO9075(lastUpdated)} 418 </div> 419 </div> 420 <!--hr class="hr my-10" /--> 421 <!--BundleDownloader instances={instances} /--> 422 423 <hr class="hr mb-6 mt-12" /> 424 <div class="opacity-50"> 425 <div class="mt-4 text-sm"> 426 <a href="https://tangled.org/@tree.fail/plcbundle-watch">Source Code</a> | ❤️ Made with love for <a href="https://atproto.com/">#atproto</a> community by <a href="https://bsky.app/profile/tree.fail">@tree.fail</a> using <a href="https://vite.dev/">Vite</a> & <a href="https://svelte.dev/">Svelte</a> 427 </div> 428 </div> 429 430 431 </div> 432</main> 433