this repo has no description
plcbundle-watch.pages.dev
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