+55
-17
src/App.svelte
+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>