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