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 lastKnownBundle.mempool = null
126 lastKnownBundle.mempoolPercent = 0
127
128 await Promise.all(instances.map(async (instance) => {
129 const status = await getStatus(instance)
130 instance.status = status
131
132 if (status?.bundles?.last_bundle && status.bundles.last_bundle >= lastKnownBundle.number) {
133 lastKnownBundle.number = status.bundles.last_bundle
134 lastKnownBundle.hash = status.bundles.head_hash
135 lastKnownBundle.time = status.bundles.end_time
136
137 if (status?.mempool?.count && (!lastKnownBundle.mempool || status.mempool.count > lastKnownBundle.mempool)) {
138 lastKnownBundle.mempool = status.mempool.count
139 lastKnownBundle.mempoolPercent = Math.round((lastKnownBundle.mempool/100)*100)/100
140 lastKnownBundle.etaNext = status.mempool.eta_next_bundle_seconds ? addSeconds(new Date(), status.mempool.eta_next_bundle_seconds) : null
141 lastKnownBundle.totalSize = status.bundles.total_size
142 lastKnownBundle.totalSizeUncompressed = status.bundles.uncompressed_size
143 }
144 }
145 lastUpdated = new Date()
146
147 recalculateHead()
148 }))
149 isUpdating = false
150 updateTitle()
151 setTimeout(() => { canRefresh = true }, 500)
152 }
153
154 function updateTitle() {
155 const arr: string[] = []
156 if (lastUpdated) {
157 const upCount = instances.filter(i => i._head)
158 arr.push(`${isConflict ? '⚠️' : '✅'} [${upCount.length}/${instances.length}]`)
159 }
160 document.title = [...arr, APP_TITLE].join(' ')
161 return true
162 }
163
164 let autoRefreshTimer: ReturnType<typeof setTimeout> | null = null;
165
166 onMount(async () => {
167 await doCheck()
168
169 const scheduleRefresh = () => {
170 autoRefreshTimer = setTimeout(() => {
171 if (autoRefreshEnabled) {
172 doCheck()
173 }
174 scheduleRefresh()
175 }, AUTO_REFRESH_INTERVAL * 1000)
176 }
177
178 scheduleRefresh()
179
180 return () => {
181 if (autoRefreshTimer) {
182 clearTimeout(autoRefreshTimer)
183 }
184 }
185 })
186</script>
187
188<main class="w-full mt-10 mb-16">
189 <div class="max-w-5xl mx-auto px-3">
190
191 <header class="flex items-center gap-10 flex-wrap">
192 <div class="grow">
193 <h1 class="text-3xl linear-text-gradient"><a href="https://plcbundle-watch.pages.dev/" class="no-style">plcbundle instances</a></h1>
194 </div>
195 <div class="flex items-center gap-6">
196 <Switch class="opacity-75" checked={autoRefreshEnabled} onCheckedChange={(x) => autoRefreshEnabled = x.checked} disabled={isUpdating}>
197 <Switch.Control className="data-[state=checked]:preset-filled-success-500">
198 <Switch.Thumb />
199 </Switch.Control>
200 <Switch.Label>Auto-refresh ({AUTO_REFRESH_INTERVAL}s)</Switch.Label>
201 <Switch.HiddenInput />
202 </Switch>
203 <button type="button" class="btn btn-sm preset-tonal-primary" onclick={() => doCheck()} disabled={isUpdating || canRefresh === false}>Refresh</button>
204 </div>
205 </header>
206
207 <div class="gap-10 mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
208 <div>
209 <h2 class="opacity-75 text-sm">Last known bundle</h2>
210 <div>
211 <div class="flex items-center gap-5">
212 <div class="font-semibold text-3xl">{lastKnownBundle.number}</div>
213 {#if !isConflict}
214 <div class="mt-1 font-mono badge preset-outlined-primary-500 text-xs">{lastKnownBundle?.hash?.slice(0, 7)}</div>
215 {:else}
216 <div class="mt-1 badge preset-filled-error-500">⚠️ conflict!</div>
217 {/if}
218 </div>
219 <div>
220 <span class="opacity-50">{#if lastKnownBundle?.time} {formatDistanceToNow(lastKnownBundle.time, { addSuffix: true })}{/if}</span>
221 </div>
222 </div>
223 </div>
224 <div>
225 <div>
226 <h2 class="opacity-75 text-sm">Next bundle</h2>
227 </div>
228 <div class="flex gap-4">
229 <div class="mt-4">
230 <Progress value={lastKnownBundle.mempoolPercent} class="items-center">
231 <Progress.Circle style="--size: 64px; --thickness: 10px;">
232 <Progress.CircleTrack />
233 <Progress.CircleRange />
234 </Progress.Circle>
235 <!--Progress.ValueText class="text-xs opacity-50" /-->
236 </Progress>
237 </div>
238 {#if lastKnownBundle.number > 0}
239 <div>
240 <div class="font-semibold text-2xl animate-pulse">{lastKnownBundle.number + 1}</div>
241 <div>{formatNumber(lastKnownBundle.mempool || 0)} / {formatNumber(BUNDLE_OPS)} <span class="opacity-50">({lastKnownBundle.mempoolPercent}%)</span></div>
242 {#if lastKnownBundle.etaNext}
243 <div class="mt-1 opacity-50">ETA: {formatDistanceToNow(lastKnownBundle.etaNext)}</div>
244 {/if}
245 </div>
246 {/if}
247 </div>
248 </div>
249 {#if lastKnownBundle.number > 0}
250 <div class="">
251 <div>
252 <h2 class="opacity-75 text-sm">Statistics</h2>
253 </div>
254 <div class="mt-2 grid grid-cols-1 gap-1">
255 <div><span class="opacity-50">Instances:</span> {instances.filter(i => i._head).length} latest / {instances.length} total</div>
256 <div><span class="opacity-50">PLC Operations:</span> {formatNumber((lastKnownBundle.number * BUNDLE_OPS) + lastKnownBundle.mempool)}</div>
257 <div><span class="opacity-50">Bundles Size:</span> {filesize(lastKnownBundle.totalSize)}</div>
258 <div><span class="opacity-50">Uncompressed:</span> {filesize(lastKnownBundle.totalSizeUncompressed)}</div>
259 </div>
260 </div>
261 {/if}
262 </div>
263
264 <table class="table mt-10">
265 <thead>
266 <tr>
267 <th>endpoint</th>
268 <th>ok?</th>
269 <th>last</th>
270 <th>mempool</th>
271 <th>age</th>
272 <th>head</th>
273 <th>first</th>
274 <th>version</th>
275 <th>ws?</th>
276 <th>uptime</th>
277 <th>latency</th>
278 </tr>
279 </thead>
280 <tbody class="[&>tr]:hover:bg-primary-500/10">
281 {#each orderBy(instances, ...instanceOrderBy) as instance}
282 <tr>
283 <td><a href={instance.url} target="_blank" class="font-semibold">{instance.url.replace("https://", "")}</a></td>
284 <td>{#if instance._head}{#if isConflict}⚠️{:else}✅{/if}{:else if instance.status}🔄{:else}⌛{/if}</td>
285 <td>{#if instance.status?.bundles?.last_bundle}{instance.status?.bundles?.last_bundle}{/if}</td>
286 <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>
287 <td class="text-xs opacity-50">{#if instance.status?.mempool && instance._head}{instance.status?.mempool.last_op_age_seconds || 0}s{/if}</td>
288 <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>
289 <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>
290 <td class="text-xs">{#if instance.status?.server?.version}<a href="{instance.url}/status">{instance.status?.server?.version}</a>{/if}</td>
291 <td class="text-xs">{#if instance.status?.server?.websocket_enabled}✔︎{:else if instance.status}<span class="opacity-25">-</span>{/if}</td>
292 <td class="text-xs">{#if instance.status?.server?.uptime_seconds}{formatUptime(instance.status?.server?.uptime_seconds)}{/if}</td>
293 <td class="text-xs opacity-50">{#if instance.status?.latency}{Math.round(instance.status?.latency)}ms{/if}</td>
294 </tr>
295 {/each}
296 </tbody>
297 </table>
298
299
300 <div class="mt-12">
301 <div>
302 <span class="opacity-75">PLC Directory:</span> <a href="https://{PLC_DIRECTORY}">{PLC_DIRECTORY}</a> <span class="opacity-50">(origin)</span>
303 </div>
304 <div class="mt-2">
305 <span class="opacity-75">First hash (root):</span> <span class="font-mono text-xs">{ROOT.slice(0)}</span>
306 </div>
307
308 <div class="mt-6 opacity-50">
309 Last updated: {formatISO9075(lastUpdated)}
310 </div>
311 </div>
312 <hr class="hr my-10" />
313
314 <BundleDownloader instances={instances} />
315
316 <hr class="hr mb-6 mt-12" />
317 <div class="opacity-50">
318 <div class="mt-4">
319 Source: <a href="https://tangled.org/@tree.fail/plcbundle-watch">https://tangled.org/@tree.fail/plcbundle-watch</a>
320 </div>
321 </div>
322
323
324 </div>
325</main>
326