+101
-29
src/App.svelte
+101
-29
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;
···
31
36
mempool?: {
32
37
count: number;
33
38
eta_next_bundle_seconds: number;
39
+
last_time: Date;
34
40
};
35
41
latency?: number;
36
42
}
···
45
51
status?: StatusResponse | StatusResponseError;
46
52
modern?: boolean;
47
53
_head?: boolean;
54
+
_oldRoot?: boolean;
55
+
_conflict?: boolean;
48
56
}
49
57
50
58
type LastKnownBundle = {
···
53
61
mempool: number | null;
54
62
mempoolPercent: number;
55
63
mempoolBundle: number;
64
+
lastTime?: Date;
56
65
time?: string;
57
66
etaNext?: Date | null;
58
67
totalSize?: number | null;
···
69
78
70
79
let isUpdating = $state(false)
71
80
let canRefresh = $state(true)
72
-
let isConflict = $state(false)
81
+
let consensus = $state({})
82
+
let isConflict = $state(consensus)
83
+
let instancesInConflict = $state<string[]>([])
73
84
let lastUpdated = $state(new Date())
74
85
let autoRefreshEnabled = $state(true)
75
86
let instances = $state<Instance[]>(instancesData.sort(() => Math.random() - 0.5))
76
87
77
-
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
+
]
78
92
79
93
async function getStatus(instance: Instance): Promise<StatusResponse | StatusResponseError> {
80
94
let statusResp: StatusResponse | undefined;
···
124
138
125
139
function recalculateHead() {
126
140
isConflict = false
127
-
const headHashes: string[] = []
141
+
instancesInConflict = []
142
+
const headHashes: any = {}
128
143
for (const instance of instances) {
129
-
if (instance.status && 'error' in instance.status) {
144
+
if ((instance.status && 'error' in instance.status) || !instance.status) {
130
145
continue
131
146
}
132
-
instance._head = instance.status?.bundles?.last_bundle === lastKnownBundle.number
133
-
if (instance._head && instance.status?.bundles?.head_hash) {
134
-
headHashes.push(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] = []
154
+
}
155
+
headHashes[head_hash].push(instance.url)
135
156
}
136
157
}
137
-
isConflict = [...new Set(headHashes)].length > 1
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)
138
171
}
139
172
140
173
async function doCheck() {
···
155
188
instance.status = status
156
189
if ('ok' in status && status.ok) {
157
190
158
-
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)) {
159
192
lastKnownBundle.number = status.bundles.last_bundle
160
193
lastKnownBundle.hash = status.bundles.head_hash
161
194
lastKnownBundle.time = status.bundles.end_time
···
163
196
if (status?.mempool?.count && (!lastKnownBundle.mempool || status.mempool.count > lastKnownBundle.mempool || status.bundles.last_bundle > lastKnownBundle.mempoolBundle)) {
164
197
lastKnownBundle.mempoolBundle = status.bundles.last_bundle
165
198
lastKnownBundle.mempool = status.mempool.count
199
+
lastKnownBundle.lastTime = status.mempool.last_time
166
200
lastKnownBundle.mempoolPercent = Math.round((lastKnownBundle.mempool/100)*100)/100
167
201
lastKnownBundle.etaNext = status.mempool.eta_next_bundle_seconds ? addSeconds(new Date(), status.mempool.eta_next_bundle_seconds) : null
168
202
lastKnownBundle.totalSize = status.bundles.total_size
···
180
214
setTimeout(() => { canRefresh = true }, 500)
181
215
}
182
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
+
183
225
function updateTitle() {
184
226
const arr: string[] = []
185
227
if (lastUpdated) {
186
-
const upCount = instances.filter(i => i._head)
228
+
const upCount = instances.filter(i => i._head && !i._conflict)
187
229
arr.push(`${isConflict ? 'โ ๏ธ' : 'โ
'} [${upCount.length}/${instances.length}]`)
188
230
}
189
231
document.title = [...arr, APP_TITLE].join(' ')
190
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'
191
246
}
192
247
193
248
let autoRefreshTimer: ReturnType<typeof setTimeout> | null = null;
···
249
304
<div>
250
305
<span class="opacity-50">{#if lastKnownBundle?.time} {formatDistanceToNow(lastKnownBundle.time, { addSuffix: true })}{/if}</span>
251
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>
252
314
</div>
253
315
</div>
254
316
<div>
···
257
319
</div>
258
320
<div class="flex gap-4">
259
321
<div class="mt-4">
260
-
<Progress value={lastKnownBundle.mempoolPercent} class="items-center {lastKnownBundle.mempoolPercent > 98 ? 'animate-pulse' : ''}">
261
-
<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' : ''}>
262
324
<Progress.CircleTrack />
263
325
<Progress.CircleRange />
264
326
</Progress.Circle>
···
282
344
<h2 class="opacity-75 text-sm">Statistics</h2>
283
345
</div>
284
346
<div class="mt-2 grid grid-cols-1 gap-1">
285
-
<div><span class="opacity-50">Instances:</span> {instances.filter(i => i._head).length} latest / {instances.length} total</div>
347
+
<div><span class="opacity-50">Instances:</span> {instances.filter(i => i._head && !i._conflict).length} latest / {instances.length} total</div>
286
348
<div><span class="opacity-50">PLC Operations:</span> {formatNumber((lastKnownBundle.number * BUNDLE_OPS) + (lastKnownBundle.mempool || 0))}</div>
287
349
<div><span class="opacity-50">Bundles Size:</span> {#if lastKnownBundle.totalSize}{filesize(lastKnownBundle.totalSize)}{/if}</div>
288
350
<div><span class="opacity-50">Uncompressed:</span> {#if lastKnownBundle.totalSizeUncompressed}{filesize(lastKnownBundle.totalSizeUncompressed)}{/if}</div>
···
300
362
<th>mempool</th>
301
363
<th>age</th>
302
364
<th>head</th>
303
-
<th>first</th>
365
+
<th>root</th>
304
366
<th>version</th>
367
+
<th>rsv?</th>
305
368
<th>ws?</th>
306
369
<th>uptime</th>
307
370
<th>latency</th>
···
311
374
{#each orderBy(instances, ...instanceOrderBy) as instance}
312
375
<tr>
313
376
<td><a href={instance.url} target="_blank" class="font-semibold">{instance.url.replace("https://", "")}</a></td>
314
-
<td>{#if instance._head && instance.status?.ok}{#if isConflict}โ ๏ธ{: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>
315
378
{#if instance.status?.error}
316
-
<td colspan="5" class="opacity-50 text-xs">Error: {instance.status?.error}</td>
379
+
<td colspan="8" class="opacity-50 text-xs">Error: {instance.status?.error}</td>
317
380
{:else}
318
-
<td>{#if instance.status?.bundles?.last_bundle}{instance.status?.bundles?.last_bundle}{/if}</td>
319
-
<td>{#if instance.status?.mempool && instance._head}{formatNumber(instance.status?.mempool.count)}{: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>
320
-
<td class="text-xs opacity-50">{#if instance.status?.mempool && instance._head}{instance.status?.mempool.last_op_age_seconds || 0}s{/if}</td>
321
-
<td><span class="font-mono text-xs {instance._head ? (isConflict ? '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>
322
-
<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>
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>
323
399
{/if}
324
-
325
-
<td class="text-xs">{#if instance.status?.server?.version}{instance.status?.server?.version}{/if}</td>
326
-
<td class="text-xs">{#if instance.status?.server?.websocket_enabled}โ๏ธ{:else if instance.status}<span class="opacity-25">-</span>{/if}</td>
327
400
<td class="text-xs">{#if instance.status?.server?.uptime_seconds}{formatUptime(instance.status?.server?.uptime_seconds)}{/if}</td>
328
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>
329
402
</tr>
···
344
417
Last updated: {formatISO9075(lastUpdated)}
345
418
</div>
346
419
</div>
347
-
<hr class="hr my-10" />
348
-
349
-
<BundleDownloader instances={instances} />
420
+
<!--hr class="hr my-10" /-->
421
+
<!--BundleDownloader instances={instances} /-->
350
422
351
423
<hr class="hr mb-6 mt-12" />
352
424
<div class="opacity-50">
+50
-21
src/instances.json
+50
-21
src/instances.json
···
1
1
[
2
-
{
3
-
"url": "https://plcbundle.atscan.net",
4
-
"country": "AT",
5
-
"maintainer": "@tree.fail"
6
-
},
7
-
{
8
-
"url": "https://plc.j4ck.xyz"
9
-
},
10
-
{
11
-
"url": "https://plc.indexx.dev"
12
-
},
13
-
{
14
-
"url": "https://plc.nyxt.dev"
15
-
},
16
-
{
17
-
"url": "https://plc.madebydanny.uk"
18
-
},
19
-
{
20
-
"url": "https://plc.tartarus.us"
21
-
}
22
-
]
2
+
{
3
+
"url": "https://plcbundle.atscan.net",
4
+
"country": "AT",
5
+
"maintainer": "@tree.fail"
6
+
},
7
+
{
8
+
"url": "https://plcbundle2.atscan.net",
9
+
"country": "CZ",
10
+
"maintainer": "@tree.fail"
11
+
},
12
+
{
13
+
"url": "https://plcbundle3.atscan.net",
14
+
"country": "CZ",
15
+
"maintainer": "@tree.fail"
16
+
},
17
+
{
18
+
"url": "https://plc.j4ck.xyz",
19
+
"country": "UK",
20
+
"maintainer": "@j4ck.xyz"
21
+
},
22
+
{
23
+
"url": "https://plc.indexx.dev",
24
+
"country": "US",
25
+
"maintainer": "@indexx.dev"
26
+
},
27
+
{
28
+
"url": "https://plc.nyxt.dev"
29
+
},
30
+
{
31
+
"url": "https://plc.madebydanny.uk",
32
+
"country": "US",
33
+
"maintainer": "@madebydanny.uk"
34
+
},
35
+
{
36
+
"url": "https://plc.tartarus.us"
37
+
},
38
+
{
39
+
"url": "https://plcbundle.snek.cc",
40
+
"country": "US",
41
+
"maintainer": "@jackvalinsky.com"
42
+
},
43
+
{
44
+
"url": "https://plc.dane.computer",
45
+
"country": "CA",
46
+
"maintainer": "@dane.is.extraordinarily.cool"
47
+
},
48
+
{
49
+
"url": "https://plc.witchcraft.systems"
50
+
}
51
+
]