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