new root

Changed files
+55 -17
src
+55 -17
src/App.svelte
··· 2 2 3 3 import { onMount } from 'svelte'; 4 4 import { filesize } from 'filesize'; 5 - import { formatDistanceToNow, addSeconds, subSeconds, formatDate, formatISO9075 } from 'date-fns'; 5 + import { formatDistanceToNow, addSeconds, subSeconds, formatDate, formatISO9075, differenceInSeconds } from 'date-fns'; 6 6 import { Progress, Switch } from '@skeletonlabs/skeleton-svelte'; 7 7 import orderBy from 'lodash/orderBy'; 8 8 import BundleDownloader from './BundleDownloader.svelte'; ··· 11 11 12 12 const APP_TITLE = 'plcbundle instances' 13 13 const PLC_DIRECTORY = 'plc.directory' 14 - const ROOT = 'cbab6809a136d6a621906ee11199d3b0faf85b422fe0d0d2c346ce8e9dcd7485' 14 + const ROOT = 'f743c3ae1e3f6023e89e492bce63b52a9ed03ee46a163c2f4a3b997eaf2aaf85' 15 15 const AUTO_REFRESH_INTERVAL = 10 // in seconds 16 16 const BUNDLE_OPS = 10_000 17 + const PAST_ROOTS = [ 18 + // November 2025: https://bsky.app/profile/atproto.com/post/3m4e3mnxb7s2p 19 + "cbab6809a136d6a621906ee11199d3b0faf85b422fe0d0d2c346ce8e9dcd7485" 20 + ] 17 21 18 22 type StatusResponse = { 19 23 ok: boolean; ··· 24 28 end_time?: string; 25 29 total_size?: number; 26 30 uncompressed_size?: number; 31 + updated_at?: string; 27 32 }; 28 33 server: { 29 34 uptime: number; ··· 46 51 status?: StatusResponse | StatusResponseError; 47 52 modern?: boolean; 48 53 _head?: boolean; 54 + _oldRoot?: boolean; 49 55 _conflict?: boolean; 50 56 } 51 57 ··· 79 85 let autoRefreshEnabled = $state(true) 80 86 let instances = $state<Instance[]>(instancesData.sort(() => Math.random() - 0.5)) 81 87 82 - const instanceOrderBy = [['status.error', '_head', 'status.bundles.last_bundle', 'status.latency'], ['desc', 'desc', 'desc', 'asc']] 88 + const instanceOrderBy = [ 89 + ['status.error', '_head', '_oldRoot' , 'status.bundles.last_bundle', 'status.latency'], 90 + ['desc', 'desc', 'asc', 'desc', 'asc'] 91 + ] 83 92 84 93 async function getStatus(instance: Instance): Promise<StatusResponse | StatusResponseError> { 85 94 let statusResp: StatusResponse | undefined; ··· 132 141 instancesInConflict = [] 133 142 const headHashes: any = {} 134 143 for (const instance of instances) { 135 - if (instance.status && 'error' in instance.status) { 144 + if ((instance.status && 'error' in instance.status) || !instance.status) { 136 145 continue 137 146 } 138 - instance._head = instance.status?.bundles?.last_bundle === lastKnownBundle.number 139 - if (instance._head && instance.status?.bundles?.head_hash) { 140 - if (!headHashes[instance.status.bundles.head_hash]) { 141 - headHashes[instance.status.bundles.head_hash] = [] 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] = [] 142 154 } 143 - headHashes[instance.status.bundles.head_hash].push(instance.url) 155 + headHashes[head_hash].push(instance.url) 144 156 } 145 157 } 158 + console.log(headHashes) 146 159 // second pass 147 160 const sorted: any = Object.fromEntries( 148 161 Object.entries(headHashes).sort(([, a]: any, [, b]: any) => b.length - a.length) ··· 175 188 instance.status = status 176 189 if ('ok' in status && status.ok) { 177 190 178 - if (status?.bundles?.last_bundle && status.bundles.last_bundle >= lastKnownBundle.number) { 191 + if (status?.bundles?.last_bundle && status.bundles.last_bundle >= lastKnownBundle.number && !PAST_ROOTS.includes(status.bundles.root_hash)) { 179 192 lastKnownBundle.number = status.bundles.last_bundle 180 193 lastKnownBundle.hash = status.bundles.head_hash 181 194 lastKnownBundle.time = status.bundles.end_time ··· 219 232 return true 220 233 } 221 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 + 222 248 let autoRefreshTimer: ReturnType<typeof setTimeout> | null = null; 223 249 224 250 onMount(() => { ··· 293 319 </div> 294 320 <div class="flex gap-4"> 295 321 <div class="mt-4"> 296 - <Progress value={lastKnownBundle.mempoolPercent} class="items-center {lastKnownBundle.mempoolPercent > 98 ? 'animate-pulse' : ''}"> 297 - <Progress.Circle style="--size: 64px; --thickness: 10px;"> 322 + <Progress value={lastKnownBundle.mempoolPercent} class="items-center"> 323 + <Progress.Circle style="--size: 64px; --thickness: 10px;" class={lastKnownBundle.mempoolPercent > 95 ? 'animate-pulse' : ''}> 298 324 <Progress.CircleTrack /> 299 325 <Progress.CircleRange /> 300 326 </Progress.Circle> ··· 338 364 <th>head</th> 339 365 <th>first</th> 340 366 <th>version</th> 367 + <th>rsv?</th> 341 368 <th>ws?</th> 342 369 <th>uptime</th> 343 370 <th>latency</th> ··· 347 374 {#each orderBy(instances, ...instanceOrderBy) as instance} 348 375 <tr> 349 376 <td><a href={instance.url} target="_blank" class="font-semibold">{instance.url.replace("https://", "")}</a></td> 350 - <td>{#if instance._head && instance.status?.ok}{#if instance._conflict}⚠️{:else}✅{/if}{:else if instance.status && instance.status?.ok}🔄{:else if instance.status?.error}❌{:else}⌛{/if}</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> 351 378 {#if instance.status?.error} 352 379 <td colspan="5" class="opacity-50 text-xs">Error: {instance.status?.error}</td> 353 380 {:else} 354 381 <td>{#if instance.status?.bundles?.last_bundle}<span class="{instance._conflict ? 'text-error-600' : ''}">{instance.status?.bundles?.last_bundle}</span>{/if}</td> 355 - <td>{#if instance.status?.mempool && instance._head}<span class="{instance._conflict ? 'text-error-600' : ''}">{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> 356 - <td>{#if instance.status?.mempool && instance._head}<span class="text-xs opacity-50 {instance._conflict ? 'text-error-600' : ''}">{instance.status?.mempool.last_op_age_seconds || 0}s</span>{/if}</td> 357 - <td><span class="font-mono text-xs {instance._head ? (instance._conflict ? 'text-error-600' : 'text-success-600') : 'opacity-50'}">{#if instance.status?.bundles?.head_hash}{instance.status?.bundles?.head_hash.slice(0, 7)}{/if}</span></td> 358 - <td><span class="font-mono text-xs {instance.status ? (instance.status?.bundles?.root_hash === ROOT ? 'text-success-600' : 'text-error-600') : ''}">{#if instance.status?.bundles?.root_hash}{instance.status?.bundles?.root_hash.slice(0, 7)}{/if}</span></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> 359 396 {/if} 360 397 361 398 <td class="text-xs">{#if instance.status?.server?.version}<span title={instance.status?.server?.version}>{normalizedVersion(instance.status?.server?.version)}</span>{/if}</td> 399 + <td class="text-xs">{#if instance.status?.server?.resolver_enabled}✔︎{:else if instance.status}<span class="opacity-25">-</span>{/if}</td> 362 400 <td class="text-xs">{#if instance.status?.server?.websocket_enabled}✔︎{:else if instance.status}<span class="opacity-25">-</span>{/if}</td> 363 401 <td class="text-xs">{#if instance.status?.server?.uptime_seconds}{formatUptime(instance.status?.server?.uptime_seconds)}{/if}</td> 364 402 <td class="text-xs opacity-50">{#if instance.status?.latency}<a href="{instance.url}/status">{Math.round(instance.status?.latency)}ms</a>{/if}</td>