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 } 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 = 'cbab6809a136d6a621906ee11199d3b0faf85b422fe0d0d2c346ce8e9dcd7485'
15 const AUTO_REFRESH_INTERVAL = 10 // in seconds
16 const BUNDLE_OPS = 10_000
17
18 type StatusResponse = {
19 ok: boolean;
20 bundles: {
21 last_bundle: number;
22 root_hash: string;
23 head_hash: string;
24 end_time?: string;
25 total_size?: number;
26 uncompressed_size?: number;
27 };
28 server: {
29 uptime: number;
30 };
31 mempool?: {
32 count: number;
33 eta_next_bundle_seconds: number;
34 };
35 latency?: number;
36 }
37
38 type StatusResponseError = {
39 error: string;
40 }
41
42 type Instance = {
43 url: string;
44 cors?: boolean;
45 status?: StatusResponse | StatusResponseError;
46 modern?: boolean;
47 _head?: boolean;
48 }
49
50 type LastKnownBundle = {
51 number: number;
52 hash: string | null;
53 mempool: number | null;
54 mempoolPercent: number;
55 mempoolBundle: number;
56 time?: string;
57 etaNext?: Date | null;
58 totalSize?: number | null;
59 totalSizeUncompressed?: number | null;
60 }
61
62 let lastKnownBundle = $state<LastKnownBundle>({
63 number: 0,
64 hash: null,
65 mempool: null,
66 mempoolBundle: 0,
67 mempoolPercent: 0,
68 })
69
70 let isUpdating = $state(false)
71 let canRefresh = $state(true)
72 let isConflict = $state(false)
73 let lastUpdated = $state(new Date())
74 let autoRefreshEnabled = $state(true)
75 let instances = $state<Instance[]>(instancesData.sort(() => Math.random() - 0.5))
76
77 const instanceOrderBy = [['status.error', '_head', 'status.bundles.last_bundle', 'status.latency'], ['desc', 'desc', 'desc', 'asc']]
78
79 async function getStatus(instance: Instance): Promise<StatusResponse | StatusResponseError> {
80 let statusResp: StatusResponse | undefined;
81 let url: string = instance.url;
82 let lastError: string | undefined;
83 const start = performance.now();
84 try {
85 statusResp = await (await fetch(`${url}/status?${Number(new Date())}`)).json()
86 } catch (e: any) {
87 lastError = e.message;
88 }
89 if (!statusResp) {
90 url = `https://keyoxide.org/api/3/get/http?url=${encodeURIComponent(url)}&format=text&time=${Date.now()}`
91
92 let indexResp: string | undefined;
93 try {
94 indexResp = await (await fetch(url)).text()
95 } catch(e: any) {
96 lastError = e.message;
97 }
98 const match = indexResp?.match(/Range:\s+(\d{6}) - (\d{6})/)
99 if (match) {
100 const [, from, to] = match
101 const rootMatch = indexResp?.match(/Root: ([a-f0-9]{64})/)
102 const headMatch = indexResp?.match(/Head: ([a-f0-9]{64})/)
103
104 statusResp = {
105 ok: true,
106 bundles: {
107 last_bundle: Number(to),
108 root_hash: rootMatch ? rootMatch[1] : '',
109 head_hash: headMatch ? headMatch[1] : '',
110 },
111 server: {
112 uptime: 1,
113 }
114 }
115 }
116 }
117 if (statusResp) {
118 statusResp.ok = true
119 statusResp.latency = performance.now() - start;
120 }
121 //if (instance.url === 'https://plc.j4ck.xyz') { statusResp.bundles.head_hash = 'f3ad3544452b2c078cba24990486bb9c277a1155'; }
122 return statusResp ?? { error: lastError || 'unknown error' }
123 }
124
125 function recalculateHead() {
126 isConflict = false
127 const headHashes: string[] = []
128 for (const instance of instances) {
129 if (instance.status && 'error' in instance.status) {
130 continue
131 }
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)
135 }
136 }
137 isConflict = [...new Set(headHashes)].length > 1
138 }
139
140 async function doCheck() {
141 isUpdating = true
142 canRefresh = false
143 for (const i of instances) {
144 if (i.status && 'ok' in i.status) {
145 i.status.ok = false
146 }
147 }
148
149 await Promise.all(instances.map(async (instance) => {
150 const status = await getStatus(instance)
151 if (!status) {
152 return false
153 }
154
155 instance.status = status
156 if ('ok' in status && status.ok) {
157
158 if (status?.bundles?.last_bundle && status.bundles.last_bundle >= lastKnownBundle.number) {
159 lastKnownBundle.number = status.bundles.last_bundle
160 lastKnownBundle.hash = status.bundles.head_hash
161 lastKnownBundle.time = status.bundles.end_time
162
163 if (status?.mempool?.count && (!lastKnownBundle.mempool || status.mempool.count > lastKnownBundle.mempool || status.bundles.last_bundle > lastKnownBundle.mempoolBundle)) {
164 lastKnownBundle.mempoolBundle = status.bundles.last_bundle
165 lastKnownBundle.mempool = status.mempool.count
166 lastKnownBundle.mempoolPercent = Math.round((lastKnownBundle.mempool/100)*100)/100
167 lastKnownBundle.etaNext = status.mempool.eta_next_bundle_seconds ? addSeconds(new Date(), status.mempool.eta_next_bundle_seconds) : null
168 lastKnownBundle.totalSize = status.bundles.total_size
169 lastKnownBundle.totalSizeUncompressed = status.bundles.uncompressed_size
170 }
171 }
172 }
173
174 lastUpdated = new Date()
175
176 recalculateHead()
177 }))
178 isUpdating = false
179 updateTitle()
180 setTimeout(() => { canRefresh = true }, 500)
181 }
182
183 function updateTitle() {
184 const arr: string[] = []
185 if (lastUpdated) {
186 const upCount = instances.filter(i => i._head)
187 arr.push(`${isConflict ? '⚠️' : '✅'} [${upCount.length}/${instances.length}]`)
188 }
189 document.title = [...arr, APP_TITLE].join(' ')
190 return true
191 }
192
193 let autoRefreshTimer: ReturnType<typeof setTimeout> | null = null;
194
195 onMount(() => {
196 doCheck().then(() => {
197 const scheduleRefresh = () => {
198 autoRefreshTimer = setTimeout(() => {
199 if (autoRefreshEnabled) {
200 doCheck()
201 }
202 scheduleRefresh()
203 }, AUTO_REFRESH_INTERVAL * 1000)
204 }
205
206 scheduleRefresh()
207
208 })
209
210 return () => {
211 if (autoRefreshTimer) {
212 clearTimeout(autoRefreshTimer)
213 }
214 }
215 })
216</script>
217
218<main class="w-full mt-10 mb-16">
219 <div class="max-w-5xl mx-auto px-3">
220
221 <header class="flex items-center gap-10 flex-wrap">
222 <div class="grow">
223 <h1 class="text-3xl linear-text-gradient"><a href="https://plcbundle-watch.pages.dev/" class="no-style">plcbundle instances</a></h1>
224 </div>
225 <div class="flex items-center gap-6">
226 <Switch class="opacity-75" checked={autoRefreshEnabled} onCheckedChange={(x) => autoRefreshEnabled = x.checked} disabled={isUpdating}>
227 <Switch.Control class="data-[state=checked]:preset-filled-success-500">
228 <Switch.Thumb />
229 </Switch.Control>
230 <Switch.Label>Auto-refresh ({AUTO_REFRESH_INTERVAL}s)</Switch.Label>
231 <Switch.HiddenInput />
232 </Switch>
233 <button type="button" class="btn btn-sm preset-tonal-primary" onclick={() => doCheck()} disabled={isUpdating || canRefresh === false}>Refresh</button>
234 </div>
235 </header>
236
237 <div class="gap-10 mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
238 <div>
239 <h2 class="opacity-75 text-sm">Last known bundle</h2>
240 <div>
241 <div class="flex items-center gap-5">
242 <div class="font-semibold text-3xl">{lastKnownBundle.number}</div>
243 {#if !isConflict}
244 <div class="mt-1 font-mono badge preset-outlined-primary-500 text-xs">{lastKnownBundle?.hash?.slice(0, 7)}</div>
245 {:else}
246 <div class="mt-1 badge preset-filled-error-500">⚠️ conflict!</div>
247 {/if}
248 </div>
249 <div>
250 <span class="opacity-50">{#if lastKnownBundle?.time} {formatDistanceToNow(lastKnownBundle.time, { addSuffix: true })}{/if}</span>
251 </div>
252 </div>
253 </div>
254 <div>
255 <div>
256 <h2 class="opacity-75 text-sm">Next bundle</h2>
257 </div>
258 <div class="flex gap-4">
259 <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;">
262 <Progress.CircleTrack />
263 <Progress.CircleRange />
264 </Progress.Circle>
265 <!--Progress.ValueText class="text-xs opacity-50" /-->
266 </Progress>
267 </div>
268 {#if lastKnownBundle.number > 0}
269 <div>
270 <div class="font-semibold text-2xl animate-pulse">{lastKnownBundle.number + 1}</div>
271 <div>{formatNumber(lastKnownBundle.mempool || 0)} / {formatNumber(BUNDLE_OPS)} <span class="opacity-50">({lastKnownBundle.mempoolPercent}%)</span></div>
272 {#if lastKnownBundle.etaNext}
273 <div class="mt-1 opacity-50">ETA: {formatDistanceToNow(lastKnownBundle.etaNext)}</div>
274 {/if}
275 </div>
276 {/if}
277 </div>
278 </div>
279 {#if lastKnownBundle.number > 0}
280 <div class="">
281 <div>
282 <h2 class="opacity-75 text-sm">Statistics</h2>
283 </div>
284 <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>
286 <div><span class="opacity-50">PLC Operations:</span> {formatNumber((lastKnownBundle.number * BUNDLE_OPS) + (lastKnownBundle.mempool || 0))}</div>
287 <div><span class="opacity-50">Bundles Size:</span> {#if lastKnownBundle.totalSize}{filesize(lastKnownBundle.totalSize)}{/if}</div>
288 <div><span class="opacity-50">Uncompressed:</span> {#if lastKnownBundle.totalSizeUncompressed}{filesize(lastKnownBundle.totalSizeUncompressed)}{/if}</div>
289 </div>
290 </div>
291 {/if}
292 </div>
293
294 <table class="table mt-10">
295 <thead>
296 <tr>
297 <th>endpoint</th>
298 <th>ok?</th>
299 <th>last</th>
300 <th>mempool</th>
301 <th>age</th>
302 <th>head</th>
303 <th>first</th>
304 <th>version</th>
305 <th>ws?</th>
306 <th>uptime</th>
307 <th>latency</th>
308 </tr>
309 </thead>
310 <tbody class="[&>tr]:hover:bg-primary-500/10">
311 {#each orderBy(instances, ...instanceOrderBy) as instance}
312 <tr>
313 <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>
315 {#if instance.status?.error}
316 <td colspan="5" class="opacity-50 text-xs">Error: {instance.status?.error}</td>
317 {: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>
323 {/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 <td class="text-xs">{#if instance.status?.server?.uptime_seconds}{formatUptime(instance.status?.server?.uptime_seconds)}{/if}</td>
328 <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 </tr>
330 {/each}
331 </tbody>
332 </table>
333
334
335 <div class="mt-12">
336 <div>
337 <span class="opacity-75">PLC Directory:</span> <a href="https://{PLC_DIRECTORY}">{PLC_DIRECTORY}</a> <span class="opacity-50">(origin)</span>
338 </div>
339 <div class="mt-2">
340 <span class="opacity-75">First hash (root):</span> <span class="font-mono text-xs">{ROOT.slice(0)}</span>
341 </div>
342
343 <div class="mt-6 opacity-50">
344 Last updated: {formatISO9075(lastUpdated)}
345 </div>
346 </div>
347 <hr class="hr my-10" />
348
349 <BundleDownloader instances={instances} />
350
351 <hr class="hr mb-6 mt-12" />
352 <div class="opacity-50">
353 <div class="mt-4 text-sm">
354 <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>
355 </div>
356 </div>
357
358
359 </div>
360</main>
361