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