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