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