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