+3
bun.lock
+3
bun.lock
···
16
16
"@skeletonlabs/skeleton-svelte": "^4.2.2",
17
17
"@sveltejs/vite-plugin-svelte": "^6.2.1",
18
18
"@tsconfig/svelte": "^5.0.5",
19
+
"@types/lodash": "^4.17.20",
19
20
"@types/node": "^24.6.0",
20
21
"svelte": "^5.39.6",
21
22
"svelte-check": "^4.3.2",
···
186
187
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="],
187
188
188
189
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
190
+
191
+
"@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="],
189
192
190
193
"@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="],
191
194
+1
package.json
+1
package.json
+186
-70
src/App.svelte
+186
-70
src/App.svelte
···
1
1
<script lang="ts">
2
+
2
3
import { onMount } from 'svelte';
3
4
import { filesize } from 'filesize';
4
-
import { formatDistanceToNow, addSeconds, subSeconds, formatDate, formatISO9075 } from 'date-fns';
5
+
import { formatDistanceToNow, addSeconds, subSeconds, formatDate, formatISO9075, differenceInSeconds } from 'date-fns';
5
6
import { Progress, Switch } from '@skeletonlabs/skeleton-svelte';
6
-
import orderBy from "lodash/orderBy";
7
+
import orderBy from 'lodash/orderBy';
8
+
import BundleDownloader from './BundleDownloader.svelte';
7
9
import { formatNumber, formatUptime } from './lib/utils';
8
10
import instancesData from './instances.json';
9
11
10
12
const APP_TITLE = 'plcbundle instances'
11
13
const PLC_DIRECTORY = 'plc.directory'
12
-
const ROOT = 'cbab6809a136d6a621906ee11199d3b0faf85b422fe0d0d2c346ce8e9dcd7485'
14
+
const ROOT = 'f743c3ae1e3f6023e89e492bce63b52a9ed03ee46a163c2f4a3b997eaf2aaf85'
13
15
const AUTO_REFRESH_INTERVAL = 10 // in seconds
14
16
const BUNDLE_OPS = 10_000
17
+
const PAST_ROOTS = [
18
+
// November 2025: https://bsky.app/profile/atproto.com/post/3m4e3mnxb7s2p
19
+
"cbab6809a136d6a621906ee11199d3b0faf85b422fe0d0d2c346ce8e9dcd7485"
20
+
]
15
21
16
22
type StatusResponse = {
23
+
ok: boolean;
17
24
bundles: {
18
25
last_bundle: number;
19
26
root_hash: string;
20
27
head_hash: string;
21
28
end_time?: string;
22
-
total_size: number;
23
-
uncompressed_size: number;
29
+
total_size?: number;
30
+
uncompressed_size?: number;
31
+
updated_at?: string;
24
32
};
25
33
server: {
26
34
uptime: number;
···
28
36
mempool?: {
29
37
count: number;
30
38
eta_next_bundle_seconds: number;
39
+
last_time: Date;
31
40
};
32
41
latency?: number;
33
42
}
34
43
44
+
type StatusResponseError = {
45
+
error: string;
46
+
}
47
+
35
48
type Instance = {
36
49
url: string;
37
50
cors?: boolean;
38
-
status?: StatusResponse;
51
+
status?: StatusResponse | StatusResponseError;
39
52
modern?: boolean;
40
53
_head?: boolean;
54
+
_oldRoot?: boolean;
55
+
_conflict?: boolean;
41
56
}
42
57
43
58
type LastKnownBundle = {
···
45
60
hash: string | null;
46
61
mempool: number | null;
47
62
mempoolPercent: number;
63
+
mempoolBundle: number;
64
+
lastTime?: Date;
48
65
time?: string;
49
-
etaNext?: Date;
50
-
totalSize: number;
51
-
totalSizeUncompressed: number;
66
+
etaNext?: Date | null;
67
+
totalSize?: number | null;
68
+
totalSizeUncompressed?: number | null;
52
69
}
53
70
54
71
let lastKnownBundle = $state<LastKnownBundle>({
55
72
number: 0,
56
73
hash: null,
57
74
mempool: null,
75
+
mempoolBundle: 0,
58
76
mempoolPercent: 0,
59
77
})
60
78
61
79
let isUpdating = $state(false)
62
80
let canRefresh = $state(true)
63
-
let isConflict = $state(false)
81
+
let consensus = $state({})
82
+
let isConflict = $state(consensus)
83
+
let instancesInConflict = $state<string[]>([])
64
84
let lastUpdated = $state(new Date())
65
85
let autoRefreshEnabled = $state(true)
66
86
let instances = $state<Instance[]>(instancesData.sort(() => Math.random() - 0.5))
67
87
68
-
const instanceOrderBy = [['_head', 'status.bundles.last_bundle', 'status.latency'], ['desc', 'asc']]
88
+
const instanceOrderBy = [
89
+
['status.error', '_head', '_oldRoot' , 'status.bundles.last_bundle', 'status.latency'],
90
+
['desc', 'desc', 'asc', 'desc', 'asc']
91
+
]
69
92
70
-
async function getStatus(instance: Instance): Promise<StatusResponse | undefined> {
93
+
async function getStatus(instance: Instance): Promise<StatusResponse | StatusResponseError> {
71
94
let statusResp: StatusResponse | undefined;
72
95
let url: string = instance.url;
96
+
let lastError: string | undefined;
73
97
const start = performance.now();
74
98
try {
75
99
statusResp = await (await fetch(`${url}/status?${Number(new Date())}`)).json()
76
-
} catch (e) {}
100
+
} catch (e: any) {
101
+
lastError = e.message;
102
+
}
77
103
if (!statusResp) {
78
104
url = `https://keyoxide.org/api/3/get/http?url=${encodeURIComponent(url)}&format=text&time=${Date.now()}`
79
-
const indexResp = await (await fetch(url)).text()
105
+
106
+
let indexResp: string | undefined;
107
+
try {
108
+
indexResp = await (await fetch(url)).text()
109
+
} catch(e: any) {
110
+
lastError = e.message;
111
+
}
80
112
const match = indexResp?.match(/Range:\s+(\d{6}) - (\d{6})/)
81
113
if (match) {
82
114
const [, from, to] = match
···
84
116
const headMatch = indexResp?.match(/Head: ([a-f0-9]{64})/)
85
117
86
118
statusResp = {
119
+
ok: true,
87
120
bundles: {
88
121
last_bundle: Number(to),
89
122
root_hash: rootMatch ? rootMatch[1] : '',
···
96
129
}
97
130
}
98
131
if (statusResp) {
132
+
statusResp.ok = true
99
133
statusResp.latency = performance.now() - start;
100
134
}
101
135
//if (instance.url === 'https://plc.j4ck.xyz') { statusResp.bundles.head_hash = 'f3ad3544452b2c078cba24990486bb9c277a1155'; }
102
-
return statusResp
136
+
return statusResp ?? { error: lastError || 'unknown error' }
103
137
}
104
138
105
139
function recalculateHead() {
106
140
isConflict = false
107
-
const headHashes: string[] = []
141
+
instancesInConflict = []
142
+
const headHashes: any = {}
108
143
for (const instance of instances) {
109
-
instance._head = instance.status?.bundles?.last_bundle === lastKnownBundle.number
110
-
if (instance._head && instance.status?.bundles?.head_hash) {
111
-
headHashes.push(instance.status.bundles.head_hash)
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)
112
156
}
113
157
}
114
-
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)
115
171
}
116
172
117
173
async function doCheck() {
118
174
isUpdating = true
119
175
canRefresh = false
120
176
for (const i of instances) {
121
-
i.status = undefined
177
+
if (i.status && 'ok' in i.status) {
178
+
i.status.ok = false
179
+
}
122
180
}
123
181
124
182
await Promise.all(instances.map(async (instance) => {
125
183
const status = await getStatus(instance)
184
+
if (!status) {
185
+
return false
186
+
}
187
+
126
188
instance.status = status
127
-
if (status?.bundles?.last_bundle && status.bundles.last_bundle > lastKnownBundle.number) {
128
-
lastKnownBundle.number = status.bundles.last_bundle
129
-
lastKnownBundle.hash = status.bundles.head_hash
130
-
lastKnownBundle.time = status.bundles.end_time
189
+
if ('ok' in status && status.ok) {
131
190
132
-
if (status?.mempool?.count && (!lastKnownBundle.mempool || status.mempool.count > lastKnownBundle.mempool)) {
133
-
lastKnownBundle.mempool = status.mempool.count
134
-
lastKnownBundle.mempoolPercent = Math.round((lastKnownBundle.mempool/100)*100)/100
135
-
lastKnownBundle.etaNext = addSeconds(new Date(), status.mempool.eta_next_bundle_seconds)
136
-
lastKnownBundle.totalSize = status.bundles.total_size
137
-
lastKnownBundle.totalSizeUncompressed = status.bundles.uncompressed_size
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
+
}
138
205
}
139
206
}
207
+
140
208
lastUpdated = new Date()
141
209
142
210
recalculateHead()
···
146
214
setTimeout(() => { canRefresh = true }, 500)
147
215
}
148
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
+
149
225
function updateTitle() {
150
226
const arr: string[] = []
151
227
if (lastUpdated) {
152
-
const upCount = instances.filter(i => i._head)
228
+
const upCount = instances.filter(i => i._head && !i._conflict)
153
229
arr.push(`${isConflict ? 'โ ๏ธ' : 'โ
'} [${upCount.length}/${instances.length}]`)
154
230
}
155
231
document.title = [...arr, APP_TITLE].join(' ')
156
232
return true
157
233
}
158
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
+
159
248
let autoRefreshTimer: ReturnType<typeof setTimeout> | null = null;
160
249
161
-
onMount(async () => {
162
-
await doCheck()
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()
163
262
164
-
const scheduleRefresh = () => {
165
-
autoRefreshTimer = setTimeout(() => {
166
-
if (autoRefreshEnabled) {
167
-
doCheck()
168
-
}
169
-
scheduleRefresh()
170
-
}, AUTO_REFRESH_INTERVAL * 1000)
171
-
}
172
-
173
-
scheduleRefresh()
263
+
})
174
264
175
265
return () => {
176
266
if (autoRefreshTimer) {
···
180
270
})
181
271
</script>
182
272
183
-
<main class="w-full mt-10">
273
+
<main class="w-full mt-10 mb-16">
184
274
<div class="max-w-5xl mx-auto px-3">
185
275
186
276
<header class="flex items-center gap-10 flex-wrap">
···
189
279
</div>
190
280
<div class="flex items-center gap-6">
191
281
<Switch class="opacity-75" checked={autoRefreshEnabled} onCheckedChange={(x) => autoRefreshEnabled = x.checked} disabled={isUpdating}>
192
-
<Switch.Control className="data-[state=checked]:preset-filled-success-500">
282
+
<Switch.Control class="data-[state=checked]:preset-filled-success-500">
193
283
<Switch.Thumb />
194
284
</Switch.Control>
195
285
<Switch.Label>Auto-refresh ({AUTO_REFRESH_INTERVAL}s)</Switch.Label>
···
199
289
</div>
200
290
</header>
201
291
202
-
<div class="gap-10 mt-6 grid grid-cols-3">
292
+
<div class="gap-10 mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
203
293
<div>
204
294
<h2 class="opacity-75 text-sm">Last known bundle</h2>
205
295
<div>
···
214
304
<div>
215
305
<span class="opacity-50">{#if lastKnownBundle?.time} {formatDistanceToNow(lastKnownBundle.time, { addSuffix: true })}{/if}</span>
216
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>
217
314
</div>
218
315
</div>
219
316
<div>
···
223
320
<div class="flex gap-4">
224
321
<div class="mt-4">
225
322
<Progress value={lastKnownBundle.mempoolPercent} class="items-center">
226
-
<Progress.Circle style="--size: 48px; --thickness: 6px;">
323
+
<Progress.Circle style="--size: 64px; --thickness: 10px;" class={lastKnownBundle.mempoolPercent > 95 ? 'animate-pulse' : ''}>
227
324
<Progress.CircleTrack />
228
325
<Progress.CircleRange />
229
326
</Progress.Circle>
···
233
330
{#if lastKnownBundle.number > 0}
234
331
<div>
235
332
<div class="font-semibold text-2xl animate-pulse">{lastKnownBundle.number + 1}</div>
236
-
<div>{formatNumber(lastKnownBundle.mempool)} / {formatNumber(BUNDLE_OPS)} <span class="opacity-50">({lastKnownBundle.mempoolPercent}%)</span></div>
333
+
<div>{formatNumber(lastKnownBundle.mempool || 0)} / {formatNumber(BUNDLE_OPS)} <span class="opacity-50">({lastKnownBundle.mempoolPercent}%)</span></div>
237
334
{#if lastKnownBundle.etaNext}
238
335
<div class="mt-1 opacity-50">ETA: {formatDistanceToNow(lastKnownBundle.etaNext)}</div>
239
336
{/if}
···
247
344
<h2 class="opacity-75 text-sm">Statistics</h2>
248
345
</div>
249
346
<div class="mt-2 grid grid-cols-1 gap-1">
250
-
<div><span class="opacity-50">Instances:</span> {instances.filter(i => i._head).length} latest / {instances.length} total</div>
251
-
<div><span class="opacity-50">Bundles Size:</span> {filesize(lastKnownBundle.totalSize)}</div>
252
-
<div><span class="opacity-50">Uncompressed:</span> {filesize(lastKnownBundle.totalSizeUncompressed)}</div>
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>
253
351
</div>
254
352
</div>
255
353
{/if}
···
266
364
<th>head</th>
267
365
<th>root</th>
268
366
<th>version</th>
367
+
<th>rsv?</th>
269
368
<th>ws?</th>
270
369
<th>uptime</th>
271
370
<th>latency</th>
···
275
374
{#each orderBy(instances, ...instanceOrderBy) as instance}
276
375
<tr>
277
376
<td><a href={instance.url} target="_blank" class="font-semibold">{instance.url.replace("https://", "")}</a></td>
278
-
<td>{#if instance._head}{#if isConflict}โ ๏ธ{:else}โ
{/if}{:else if instance.status}๐{:else}โ{/if}</td>
279
-
<td>{#if instance.status?.bundles?.last_bundle}{instance.status?.bundles?.last_bundle}{/if}</td>
280
-
<td>{#if instance.status?.mempool && instance._head}{formatNumber(instance.status?.mempool.count)}{:else if instance.status}<span class="opacity-25 text-xs">syncing</span>{/if}</td>
281
-
<td class="text-xs opacity-50">{#if instance.status?.mempool && instance._head}{instance.status?.mempool.last_op_age_seconds || 0}s{/if}</td>
282
-
<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>
283
-
<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>
284
-
<td class="text-xs">{#if instance.status?.server?.version}<a href="{instance.url}/status">{instance.status?.server?.version}</a>{/if}</td>
285
-
<td class="text-xs">{#if instance.status?.server?.websocket_enabled}โ๏ธ{:else if instance.status}<span class="opacity-25">-</span>{/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>
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}
286
400
<td class="text-xs">{#if instance.status?.server?.uptime_seconds}{formatUptime(instance.status?.server?.uptime_seconds)}{/if}</td>
287
-
<td class="text-xs opacity-50">{#if instance.status?.latency}{Math.round(instance.status?.latency)}ms{/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>
288
402
</tr>
289
403
{/each}
290
404
</tbody>
···
296
410
<span class="opacity-75">PLC Directory:</span> <a href="https://{PLC_DIRECTORY}">{PLC_DIRECTORY}</a> <span class="opacity-50">(origin)</span>
297
411
</div>
298
412
<div class="mt-2">
299
-
<span class="opacity-75">Root:</span> <span class="font-mono text-xs">{ROOT.slice(0)}</span>
413
+
<span class="opacity-75">First hash (root):</span> <span class="font-mono text-xs">{ROOT.slice(0)}</span>
300
414
</div>
301
-
</div>
302
415
303
-
<hr class="hr mt-6" />
304
-
<div class="mt-2 opacity-50">
305
-
<div>
416
+
<div class="mt-6 opacity-50">
306
417
Last updated: {formatISO9075(lastUpdated)}
307
418
</div>
308
-
<div class="mt-4">
309
-
Source: <a href="https://tangled.org/@tree.fail/plcbundle-watch">https://tangled.org/@tree.fail/plcbundle-watch</a>
310
-
</div>
311
419
</div>
312
-
420
+
<!--hr class="hr my-10" /-->
421
+
<!--BundleDownloader instances={instances} /-->
313
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
+
314
430
315
431
</div>
316
432
</main>
+508
src/BundleDownloader.svelte
+508
src/BundleDownloader.svelte
···
1
+
<script lang="ts">
2
+
import { Progress } from '@skeletonlabs/skeleton-svelte';
3
+
import { tick } from 'svelte';
4
+
5
+
type Instance = {
6
+
url: string;
7
+
name?: string;
8
+
}
9
+
10
+
type InstanceStatus = {
11
+
url: string;
12
+
lastBundle: number;
13
+
}
14
+
15
+
type DownloadedBundle = {
16
+
number: number;
17
+
status: 'downloading' | 'success' | 'error' | 'cancelled';
18
+
size?: number;
19
+
error?: string;
20
+
source?: string;
21
+
}
22
+
23
+
let { instances = [] }: { instances: Instance[] } = $props();
24
+
25
+
let selectedInstance = $state('random');
26
+
let bundlesInput = $state('');
27
+
let isDownloading = $state(false);
28
+
let downloadedBundles = $state<DownloadedBundle[]>([]);
29
+
let progress = $state(0);
30
+
let totalBundles = $state(0);
31
+
let abortController: AbortController | null = null;
32
+
let isStopping = $state(false);
33
+
let instanceStatuses = $state<InstanceStatus[]>([]);
34
+
let useDirectory = $state(true);
35
+
let directoryHandle: FileSystemDirectoryHandle | null = null;
36
+
let hasFileSystemAccess = $state(false);
37
+
38
+
// Check if File System Access API is available
39
+
$effect(() => {
40
+
hasFileSystemAccess = 'showDirectoryPicker' in window;
41
+
if (!hasFileSystemAccess) {
42
+
useDirectory = false;
43
+
}
44
+
});
45
+
46
+
async function pickDirectory(): Promise<boolean> {
47
+
if (!hasFileSystemAccess) {
48
+
return false;
49
+
}
50
+
51
+
try {
52
+
directoryHandle = await (window as any).showDirectoryPicker({
53
+
mode: 'readwrite'
54
+
});
55
+
return true;
56
+
} catch (e) {
57
+
if ((e as Error).name !== 'AbortError') {
58
+
console.error('Failed to pick directory:', e);
59
+
}
60
+
return false;
61
+
}
62
+
}
63
+
64
+
async function fetchInstanceStatuses() {
65
+
const statuses: InstanceStatus[] = [];
66
+
67
+
await Promise.all(instances.map(async (instance) => {
68
+
try {
69
+
const response = await fetch(`${instance.url}/status`, {
70
+
signal: abortController?.signal
71
+
});
72
+
const data = await response.json();
73
+
statuses.push({
74
+
url: instance.url,
75
+
lastBundle: data.bundles.last_bundle
76
+
});
77
+
} catch (e) {
78
+
console.warn(`Failed to fetch status from ${instance.url}`, e);
79
+
}
80
+
}));
81
+
82
+
return statuses;
83
+
}
84
+
85
+
function getAvailableInstancesForBundle(bundleNumber: number): string[] {
86
+
return instanceStatuses
87
+
.filter(s => s.lastBundle >= bundleNumber)
88
+
.map(s => s.url);
89
+
}
90
+
91
+
function getRandomInstance(bundleNumber?: number): Instance | null {
92
+
let availableUrls: string[];
93
+
94
+
if (bundleNumber !== undefined && instanceStatuses.length > 0) {
95
+
availableUrls = getAvailableInstancesForBundle(bundleNumber);
96
+
if (availableUrls.length === 0) {
97
+
return null;
98
+
}
99
+
} else {
100
+
availableUrls = instances.map(i => i.url);
101
+
}
102
+
103
+
const randomUrl = availableUrls[Math.floor(Math.random() * availableUrls.length)];
104
+
return instances.find(i => i.url === randomUrl) || null;
105
+
}
106
+
107
+
function getInstanceUrl(bundleNumber?: number): string | null {
108
+
if (selectedInstance === 'random') {
109
+
const instance = getRandomInstance(bundleNumber);
110
+
return instance?.url || null;
111
+
}
112
+
return selectedInstance;
113
+
}
114
+
115
+
function getInstanceName(url: string): string {
116
+
const instance = instances.find(i => i.url === url);
117
+
return instance?.name || new URL(url).hostname;
118
+
}
119
+
120
+
function parseBundlesInput(input: string): number[] | 'all' {
121
+
const trimmed = input.trim();
122
+
123
+
if (!trimmed) {
124
+
return 'all';
125
+
}
126
+
127
+
if (trimmed.includes('-')) {
128
+
const [start, end] = trimmed.split('-').map(s => parseInt(s.trim()));
129
+
if (isNaN(start) || isNaN(end) || start > end) {
130
+
throw new Error('Invalid range format');
131
+
}
132
+
const bundles = [];
133
+
for (let i = start; i <= end; i++) {
134
+
bundles.push(i);
135
+
}
136
+
return bundles;
137
+
}
138
+
139
+
const num = parseInt(trimmed);
140
+
if (isNaN(num)) {
141
+
throw new Error('Invalid bundle number');
142
+
}
143
+
return [num];
144
+
}
145
+
146
+
async function getLastBundle(instanceUrl: string): Promise<number> {
147
+
const response = await fetch(`${instanceUrl}/status`, {
148
+
signal: abortController?.signal
149
+
});
150
+
const data = await response.json();
151
+
return data.bundles.last_bundle;
152
+
}
153
+
154
+
async function downloadBundle(instanceUrl: string, bundleNumber: number): Promise<Blob> {
155
+
const response = await fetch(`${instanceUrl}/data/${bundleNumber}`, {
156
+
signal: abortController?.signal
157
+
});
158
+
if (!response.ok) {
159
+
throw new Error(`HTTP ${response.status}`);
160
+
}
161
+
return await response.blob();
162
+
}
163
+
164
+
function padBundleNumber(num: number): string {
165
+
return num.toString().padStart(6, '0');
166
+
}
167
+
168
+
async function saveFileToDirectory(blob: Blob, bundleNumber: number) {
169
+
if (!directoryHandle) {
170
+
throw new Error('No directory selected');
171
+
}
172
+
173
+
const fileName = `${padBundleNumber(bundleNumber)}.jsonl.zst`;
174
+
const fileHandle = await directoryHandle.getFileHandle(fileName, { create: true });
175
+
const writable = await fileHandle.createWritable();
176
+
await writable.write(blob);
177
+
await writable.close();
178
+
}
179
+
180
+
function saveFileBrowser(blob: Blob, bundleNumber: number) {
181
+
const url = URL.createObjectURL(blob);
182
+
const link = document.createElement('a');
183
+
link.href = url;
184
+
link.download = `${padBundleNumber(bundleNumber)}.jsonl.zst`;
185
+
link.click();
186
+
URL.revokeObjectURL(url);
187
+
}
188
+
189
+
function stopDownload() {
190
+
if (abortController) {
191
+
isStopping = true;
192
+
abortController.abort();
193
+
}
194
+
}
195
+
196
+
async function handleDownload() {
197
+
if (!selectedInstance) {
198
+
alert('Please select an instance');
199
+
return;
200
+
}
201
+
202
+
// If using directory mode, pick directory first
203
+
if (useDirectory && hasFileSystemAccess) {
204
+
const picked = await pickDirectory();
205
+
if (!picked) {
206
+
return; // User cancelled
207
+
}
208
+
}
209
+
210
+
let bundleNumbers: number[];
211
+
abortController = new AbortController();
212
+
isStopping = false;
213
+
214
+
try {
215
+
if (selectedInstance === 'random') {
216
+
instanceStatuses = await fetchInstanceStatuses();
217
+
if (instanceStatuses.length === 0) {
218
+
alert('No instances available');
219
+
return;
220
+
}
221
+
}
222
+
223
+
const parsed = parseBundlesInput(bundlesInput);
224
+
225
+
if (parsed === 'all') {
226
+
let lastBundle: number;
227
+
228
+
if (selectedInstance === 'random') {
229
+
lastBundle = Math.max(...instanceStatuses.map(s => s.lastBundle));
230
+
} else {
231
+
lastBundle = await getLastBundle(selectedInstance);
232
+
}
233
+
234
+
bundleNumbers = [];
235
+
for (let i = 1; i <= lastBundle; i++) {
236
+
bundleNumbers.push(i);
237
+
}
238
+
} else {
239
+
bundleNumbers = parsed;
240
+
}
241
+
} catch (e) {
242
+
if (e instanceof Error && e.name === 'AbortError') {
243
+
return;
244
+
}
245
+
alert(e instanceof Error ? e.message : 'Invalid input');
246
+
return;
247
+
}
248
+
249
+
isDownloading = true;
250
+
downloadedBundles = [];
251
+
progress = 0;
252
+
totalBundles = bundleNumbers.length;
253
+
254
+
for (let i = 0; i < bundleNumbers.length; i++) {
255
+
if (abortController?.signal.aborted) {
256
+
break;
257
+
}
258
+
259
+
const bundleNum = bundleNumbers[i];
260
+
const instanceUrl = getInstanceUrl(bundleNum);
261
+
262
+
if (!instanceUrl) {
263
+
downloadedBundles = [...downloadedBundles, {
264
+
number: bundleNum,
265
+
status: 'error',
266
+
error: 'No instance has this bundle',
267
+
}];
268
+
progress = Math.round(((i + 1) / totalBundles) * 100);
269
+
await tick();
270
+
continue;
271
+
}
272
+
273
+
downloadedBundles = [...downloadedBundles, {
274
+
number: bundleNum,
275
+
status: 'downloading',
276
+
source: instanceUrl,
277
+
}];
278
+
279
+
await tick();
280
+
281
+
try {
282
+
const blob = await downloadBundle(instanceUrl, bundleNum);
283
+
284
+
if (abortController?.signal.aborted) {
285
+
downloadedBundles = downloadedBundles.map(b =>
286
+
b.number === bundleNum ? { ...b, status: 'cancelled' as const } : b
287
+
);
288
+
break;
289
+
}
290
+
291
+
// Save file
292
+
if (useDirectory && directoryHandle) {
293
+
await saveFileToDirectory(blob, bundleNum);
294
+
} else {
295
+
saveFileBrowser(blob, bundleNum);
296
+
}
297
+
298
+
downloadedBundles = downloadedBundles.map(b =>
299
+
b.number === bundleNum
300
+
? { ...b, status: 'success' as const, size: blob.size }
301
+
: b
302
+
);
303
+
304
+
} catch (e) {
305
+
if (e instanceof Error && e.name === 'AbortError') {
306
+
downloadedBundles = downloadedBundles.map(b =>
307
+
b.number === bundleNum ? { ...b, status: 'cancelled' as const } : b
308
+
);
309
+
break;
310
+
}
311
+
312
+
downloadedBundles = downloadedBundles.map(b =>
313
+
b.number === bundleNum
314
+
? { ...b, status: 'error' as const, error: e instanceof Error ? e.message : 'Unknown error' }
315
+
: b
316
+
);
317
+
}
318
+
319
+
progress = Math.round(((i + 1) / totalBundles) * 100);
320
+
await tick();
321
+
}
322
+
323
+
isDownloading = false;
324
+
isStopping = false;
325
+
abortController = null;
326
+
directoryHandle = null;
327
+
}
328
+
329
+
function formatBytes(bytes: number): string {
330
+
if (bytes === 0) return '0 B';
331
+
const k = 1024;
332
+
const sizes = ['B', 'KB', 'MB', 'GB'];
333
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
334
+
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
335
+
}
336
+
337
+
function clearResults() {
338
+
downloadedBundles = [];
339
+
progress = 0;
340
+
totalBundles = 0;
341
+
}
342
+
343
+
let successCount = $derived(downloadedBundles.filter(b => b.status === 'success').length);
344
+
let errorCount = $derived(downloadedBundles.filter(b => b.status === 'error').length);
345
+
let cancelledCount = $derived(downloadedBundles.filter(b => b.status === 'cancelled').length);
346
+
let totalSize = $derived(downloadedBundles.reduce((sum, b) => sum + (b.size || 0), 0));
347
+
</script>
348
+
349
+
<div class="bundle-downloader card space-y-4">
350
+
<h2 class="text-2xl">Bundle Downloader</h2>
351
+
352
+
<div class="space-y-3">
353
+
<label class="label">
354
+
<span>Instance</span>
355
+
<select
356
+
class="select p-3 text-sm"
357
+
bind:value={selectedInstance}
358
+
disabled={isDownloading}
359
+
>
360
+
<option value="random">๐ฒ Random (each bundle from different source)</option>
361
+
{#each instances as instance}
362
+
<option value={instance.url}>
363
+
{instance.name || instance.url}
364
+
</option>
365
+
{/each}
366
+
</select>
367
+
</label>
368
+
369
+
<label class="label">
370
+
<span>Bundles</span>
371
+
<input
372
+
class="input text-sm"
373
+
type="text"
374
+
bind:value={bundlesInput}
375
+
disabled={isDownloading}
376
+
placeholder="empty = all, 5 = single, 1-10 = range"
377
+
/>
378
+
<p class="text-xs opacity-75 mt-1">
379
+
Leave empty for all bundles, enter a number (e.g., <code>5</code>) or range (e.g., <code>1-10</code>)
380
+
</p>
381
+
</label>
382
+
383
+
{#if hasFileSystemAccess}
384
+
<label class="flex items-center space-x-2">
385
+
<input
386
+
type="checkbox"
387
+
class="checkbox"
388
+
bind:checked={useDirectory}
389
+
disabled={isDownloading}
390
+
/>
391
+
<span class="text-sm">
392
+
๐ Save to directory (recommended for multiple files)
393
+
</span>
394
+
</label>
395
+
{:else}
396
+
<div class="alert variant-ghost-warning p-2 text-xs">
397
+
<span>โ ๏ธ Directory mode not available in this browser. Files will download individually.</span>
398
+
</div>
399
+
{/if}
400
+
401
+
<div class="flex gap-2">
402
+
{#if !isDownloading}
403
+
<button
404
+
class="btn preset-tonal-primary flex-1"
405
+
onclick={handleDownload}
406
+
>
407
+
{useDirectory && hasFileSystemAccess ? '๐ Choose Directory & Download' : '๐ฅ Download'}
408
+
</button>
409
+
{#if downloadedBundles.length > 0}
410
+
<button
411
+
class="btn preset-tonal-surface"
412
+
onclick={clearResults}
413
+
>
414
+
๐๏ธ Clear
415
+
</button>
416
+
{/if}
417
+
{:else}
418
+
<button
419
+
class="btn preset-filled-error-500 flex-1"
420
+
onclick={stopDownload}
421
+
disabled={isStopping}
422
+
>
423
+
{isStopping ? 'โณ Stopping...' : 'โ Stop'}
424
+
</button>
425
+
{/if}
426
+
</div>
427
+
428
+
{#if isDownloading}
429
+
<div class="space-y-2">
430
+
<Progress value={progress} max={100} />
431
+
<p class="text-sm text-center font-semibold">
432
+
{progress}% ({successCount}/{totalBundles})
433
+
</p>
434
+
</div>
435
+
{/if}
436
+
</div>
437
+
438
+
{#if downloadedBundles.length > 0}
439
+
<div class="space-y-2">
440
+
<h3 class="text-2xl">
441
+
{isDownloading ? 'Downloading...' : 'Results'}
442
+
({successCount}/{downloadedBundles.length})
443
+
</h3>
444
+
445
+
<div class="table-container max-h-64 overflow-y-auto">
446
+
<table class="table table-compact table-hover">
447
+
<thead>
448
+
<tr>
449
+
<th>File</th>
450
+
<th>Source</th>
451
+
<th>Status</th>
452
+
<th class="text-right">Size</th>
453
+
</tr>
454
+
</thead>
455
+
<tbody>
456
+
{#each downloadedBundles as bundle (bundle.number)}
457
+
<tr>
458
+
<td class="font-mono text-xs">{padBundleNumber(bundle.number)}.jsonl.zst</td>
459
+
<td class="text-xs" title={bundle.source}>
460
+
{bundle.source ? getInstanceName(bundle.source) : '-'}
461
+
</td>
462
+
<td>
463
+
{#if bundle.status === 'downloading'}
464
+
<span class="badge variant-filled text-xs">โณ Downloading</span>
465
+
{:else if bundle.status === 'success'}
466
+
<span class="badge variant-filled-success text-xs">โ
Success</span>
467
+
{:else if bundle.status === 'cancelled'}
468
+
<span class="badge variant-filled-warning text-xs">โ ๏ธ Cancelled</span>
469
+
{:else}
470
+
<span class="badge variant-filled-error text-xs" title={bundle.error}>โ Error</span>
471
+
{/if}
472
+
</td>
473
+
<td class="text-sm text-right">{bundle.size ? formatBytes(bundle.size) : '-'}</td>
474
+
</tr>
475
+
{/each}
476
+
</tbody>
477
+
</table>
478
+
</div>
479
+
480
+
<div class="card p-3 variant-ghost-surface grid grid-cols-4 gap-2 text-sm">
481
+
<div>
482
+
<div class="font-bold text-success-500">
483
+
{successCount}
484
+
</div>
485
+
<div class="opacity-75">Success</div>
486
+
</div>
487
+
<div>
488
+
<div class="font-bold text-error-500">
489
+
{errorCount}
490
+
</div>
491
+
<div class="opacity-75">Failed</div>
492
+
</div>
493
+
<div>
494
+
<div class="font-bold text-warning-500">
495
+
{cancelledCount}
496
+
</div>
497
+
<div class="opacity-75">Cancelled</div>
498
+
</div>
499
+
<div>
500
+
<div class="font-bold">
501
+
{formatBytes(totalSize)}
502
+
</div>
503
+
<div class="opacity-75">Total</div>
504
+
</div>
505
+
</div>
506
+
</div>
507
+
{/if}
508
+
</div>
+50
-18
src/instances.json
+50
-18
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
-
]
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
+
]
+1
src/types/lodash.d.ts
+1
src/types/lodash.d.ts
···
1
+
declare module 'lodash/orderBy';