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