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