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