Compare changes

Choose any two refs to compare.

+3
bun.lock
··· 16 16 "@skeletonlabs/skeleton-svelte": "^4.2.2", 17 17 "@sveltejs/vite-plugin-svelte": "^6.2.1", 18 18 "@tsconfig/svelte": "^5.0.5", 19 + "@types/lodash": "^4.17.20", 19 20 "@types/node": "^24.6.0", 20 21 "svelte": "^5.39.6", 21 22 "svelte-check": "^4.3.2", ··· 186 187 "@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="], 187 188 188 189 "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], 190 + 191 + "@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="], 189 192 190 193 "@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], 191 194
+1
package.json
··· 16 16 "@sveltejs/vite-plugin-svelte": "^6.2.1", 17 17 "@tsconfig/svelte": "^5.0.5", 18 18 "@types/node": "^24.6.0", 19 + "@types/lodash": "^4.17.20", 19 20 "svelte": "^5.39.6", 20 21 "svelte-check": "^4.3.2", 21 22 "typescript": "~5.9.3",
+185 -69
src/App.svelte
··· 1 1 <script lang="ts"> 2 + 2 3 import { onMount } from 'svelte'; 3 4 import { filesize } from 'filesize'; 4 - import { formatDistanceToNow, addSeconds, subSeconds, formatDate, formatISO9075 } from 'date-fns'; 5 + import { formatDistanceToNow, addSeconds, subSeconds, formatDate, formatISO9075, differenceInSeconds } from 'date-fns'; 5 6 import { Progress, Switch } from '@skeletonlabs/skeleton-svelte'; 6 - import orderBy from "lodash/orderBy"; 7 + import orderBy from 'lodash/orderBy'; 8 + import BundleDownloader from './BundleDownloader.svelte'; 7 9 import { formatNumber, formatUptime } from './lib/utils'; 8 10 import instancesData from './instances.json'; 9 11 10 12 const APP_TITLE = 'plcbundle instances' 11 13 const PLC_DIRECTORY = 'plc.directory' 12 - const ROOT = 'cbab6809a136d6a621906ee11199d3b0faf85b422fe0d0d2c346ce8e9dcd7485' 14 + const ROOT = 'f743c3ae1e3f6023e89e492bce63b52a9ed03ee46a163c2f4a3b997eaf2aaf85' 13 15 const AUTO_REFRESH_INTERVAL = 10 // in seconds 14 16 const BUNDLE_OPS = 10_000 17 + const PAST_ROOTS = [ 18 + // November 2025: https://bsky.app/profile/atproto.com/post/3m4e3mnxb7s2p 19 + "cbab6809a136d6a621906ee11199d3b0faf85b422fe0d0d2c346ce8e9dcd7485" 20 + ] 15 21 16 22 type StatusResponse = { 23 + ok: boolean; 17 24 bundles: { 18 25 last_bundle: number; 19 26 root_hash: string; 20 27 head_hash: string; 21 28 end_time?: string; 22 - total_size: number; 23 - uncompressed_size: number; 29 + total_size?: number; 30 + uncompressed_size?: number; 31 + updated_at?: string; 24 32 }; 25 33 server: { 26 34 uptime: number; ··· 28 36 mempool?: { 29 37 count: number; 30 38 eta_next_bundle_seconds: number; 39 + last_time: Date; 31 40 }; 32 41 latency?: number; 33 42 } 34 43 44 + type StatusResponseError = { 45 + error: string; 46 + } 47 + 35 48 type Instance = { 36 49 url: string; 37 50 cors?: boolean; 38 - status?: StatusResponse; 51 + status?: StatusResponse | StatusResponseError; 39 52 modern?: boolean; 40 53 _head?: boolean; 54 + _oldRoot?: boolean; 55 + _conflict?: boolean; 41 56 } 42 57 43 58 type LastKnownBundle = { ··· 45 60 hash: string | null; 46 61 mempool: number | null; 47 62 mempoolPercent: number; 63 + mempoolBundle: number; 64 + lastTime?: Date; 48 65 time?: string; 49 - etaNext?: Date; 50 - totalSize: number; 51 - totalSizeUncompressed: number; 66 + etaNext?: Date | null; 67 + totalSize?: number | null; 68 + totalSizeUncompressed?: number | null; 52 69 } 53 70 54 71 let lastKnownBundle = $state<LastKnownBundle>({ 55 72 number: 0, 56 73 hash: null, 57 74 mempool: null, 75 + mempoolBundle: 0, 58 76 mempoolPercent: 0, 59 77 }) 60 78 61 79 let isUpdating = $state(false) 62 80 let canRefresh = $state(true) 63 - let isConflict = $state(false) 81 + let consensus = $state({}) 82 + let isConflict = $state(consensus) 83 + let instancesInConflict = $state<string[]>([]) 64 84 let lastUpdated = $state(new Date()) 65 85 let autoRefreshEnabled = $state(true) 66 86 let instances = $state<Instance[]>(instancesData.sort(() => Math.random() - 0.5)) 67 87 68 - const instanceOrderBy = [['_head', 'status.bundles.last_bundle', 'status.latency'], ['desc', 'asc']] 88 + const instanceOrderBy = [ 89 + ['status.error', '_head', '_oldRoot' , 'status.bundles.last_bundle', 'status.latency'], 90 + ['desc', 'desc', 'asc', 'desc', 'asc'] 91 + ] 69 92 70 - async function getStatus(instance: Instance): Promise<StatusResponse | undefined> { 93 + async function getStatus(instance: Instance): Promise<StatusResponse | StatusResponseError> { 71 94 let statusResp: StatusResponse | undefined; 72 95 let url: string = instance.url; 96 + let lastError: string | undefined; 73 97 const start = performance.now(); 74 98 try { 75 99 statusResp = await (await fetch(`${url}/status?${Number(new Date())}`)).json() 76 - } catch (e) {} 100 + } catch (e: any) { 101 + lastError = e.message; 102 + } 77 103 if (!statusResp) { 78 104 url = `https://keyoxide.org/api/3/get/http?url=${encodeURIComponent(url)}&format=text&time=${Date.now()}` 79 - const indexResp = await (await fetch(url)).text() 105 + 106 + let indexResp: string | undefined; 107 + try { 108 + indexResp = await (await fetch(url)).text() 109 + } catch(e: any) { 110 + lastError = e.message; 111 + } 80 112 const match = indexResp?.match(/Range:\s+(\d{6}) - (\d{6})/) 81 113 if (match) { 82 114 const [, from, to] = match ··· 84 116 const headMatch = indexResp?.match(/Head: ([a-f0-9]{64})/) 85 117 86 118 statusResp = { 119 + ok: true, 87 120 bundles: { 88 121 last_bundle: Number(to), 89 122 root_hash: rootMatch ? rootMatch[1] : '', ··· 96 129 } 97 130 } 98 131 if (statusResp) { 132 + statusResp.ok = true 99 133 statusResp.latency = performance.now() - start; 100 134 } 101 135 //if (instance.url === 'https://plc.j4ck.xyz') { statusResp.bundles.head_hash = 'f3ad3544452b2c078cba24990486bb9c277a1155'; } 102 - return statusResp 136 + return statusResp ?? { error: lastError || 'unknown error' } 103 137 } 104 138 105 139 function recalculateHead() { 106 140 isConflict = false 107 - const headHashes: string[] = [] 141 + instancesInConflict = [] 142 + const headHashes: any = {} 108 143 for (const instance of instances) { 109 - instance._head = instance.status?.bundles?.last_bundle === lastKnownBundle.number 110 - if (instance._head && instance.status?.bundles?.head_hash) { 111 - headHashes.push(instance.status.bundles.head_hash) 144 + if ((instance.status && 'error' in instance.status) || !instance.status) { 145 + continue 146 + } 147 + const { head_hash, root_hash, last_bundle } = instance.status.bundles 148 + instance._oldRoot = PAST_ROOTS.includes(root_hash) 149 + instance._head = !instance._oldRoot && (last_bundle === lastKnownBundle.number) 150 + 151 + if (instance._head && head_hash) { 152 + if (!headHashes[head_hash]) { 153 + headHashes[head_hash] = [] 154 + } 155 + headHashes[head_hash].push(instance.url) 112 156 } 113 157 } 114 - isConflict = [...new Set(headHashes)].length > 1 158 + console.log(headHashes) 159 + // second pass 160 + const sorted: any = Object.fromEntries( 161 + Object.entries(headHashes).sort(([, a]: any, [, b]: any) => b.length - a.length) 162 + ) 163 + for (const instance of instances) { 164 + if (Object.keys(sorted).length > 1 && Object.keys(sorted)[0] && !sorted[Object.keys(sorted)[0]].includes(instance.url)) { 165 + instance._conflict = true 166 + instancesInConflict.push(instance.url) 167 + } 168 + } 169 + //const uniq = [...new Set(headHashes)] 170 + isConflict = instancesInConflict.length > Math.ceil(instances.length/2) 115 171 } 116 172 117 173 async function doCheck() { 118 174 isUpdating = true 119 175 canRefresh = false 120 176 for (const i of instances) { 121 - i.status = undefined 177 + if (i.status && 'ok' in i.status) { 178 + i.status.ok = false 179 + } 122 180 } 123 181 124 182 await Promise.all(instances.map(async (instance) => { 125 183 const status = await getStatus(instance) 184 + if (!status) { 185 + return false 186 + } 187 + 126 188 instance.status = status 127 - if (status?.bundles?.last_bundle && status.bundles.last_bundle > lastKnownBundle.number) { 128 - lastKnownBundle.number = status.bundles.last_bundle 129 - lastKnownBundle.hash = status.bundles.head_hash 130 - lastKnownBundle.time = status.bundles.end_time 189 + if ('ok' in status && status.ok) { 190 + 191 + if (status?.bundles?.last_bundle && status.bundles.last_bundle >= lastKnownBundle.number && !PAST_ROOTS.includes(status.bundles.root_hash)) { 192 + lastKnownBundle.number = status.bundles.last_bundle 193 + lastKnownBundle.hash = status.bundles.head_hash 194 + lastKnownBundle.time = status.bundles.end_time 131 195 132 - if (status?.mempool?.count && (!lastKnownBundle.mempool || status.mempool.count > lastKnownBundle.mempool)) { 133 - lastKnownBundle.mempool = status.mempool.count 134 - lastKnownBundle.mempoolPercent = Math.round((lastKnownBundle.mempool/100)*100)/100 135 - lastKnownBundle.etaNext = addSeconds(new Date(), status.mempool.eta_next_bundle_seconds) 136 - lastKnownBundle.totalSize = status.bundles.total_size 137 - lastKnownBundle.totalSizeUncompressed = status.bundles.uncompressed_size 196 + if (status?.mempool?.count && (!lastKnownBundle.mempool || status.mempool.count > lastKnownBundle.mempool || status.bundles.last_bundle > lastKnownBundle.mempoolBundle)) { 197 + lastKnownBundle.mempoolBundle = status.bundles.last_bundle 198 + lastKnownBundle.mempool = status.mempool.count 199 + lastKnownBundle.lastTime = status.mempool.last_time 200 + lastKnownBundle.mempoolPercent = Math.round((lastKnownBundle.mempool/100)*100)/100 201 + lastKnownBundle.etaNext = status.mempool.eta_next_bundle_seconds ? addSeconds(new Date(), status.mempool.eta_next_bundle_seconds) : null 202 + lastKnownBundle.totalSize = status.bundles.total_size 203 + lastKnownBundle.totalSizeUncompressed = status.bundles.uncompressed_size 204 + } 138 205 } 139 206 } 207 + 140 208 lastUpdated = new Date() 141 209 142 210 recalculateHead() ··· 146 214 setTimeout(() => { canRefresh = true }, 500) 147 215 } 148 216 217 + function normalizedVersion(version: string) { 218 + const m = version.trim().match(/^([^\s]+)\.\d+\.\d+\-[0-9a-f]+(\+dirty|)$/) 219 + if (m) { 220 + return `${m[1]}+dirty` 221 + } 222 + return version 223 + } 224 + 149 225 function updateTitle() { 150 226 const arr: string[] = [] 151 227 if (lastUpdated) { 152 - const upCount = instances.filter(i => i._head) 228 + const upCount = instances.filter(i => i._head && !i._conflict) 153 229 arr.push(`${isConflict ? 'โš ๏ธ' : 'โœ…'} [${upCount.length}/${instances.length}]`) 154 230 } 155 231 document.title = [...arr, APP_TITLE].join(' ') 156 232 return true 157 233 } 158 234 235 + function bundleAge(instance: Instance) { 236 + if (!instance.status || !('bundles' in instance.status) || !instance.status.bundles?.updated_at) { 237 + return '-' 238 + } 239 + const str = instance.status?.bundles.updated_at ? instance.status?.bundles.updated_at : instance.status?.bundles.end_time || '' 240 + const diff = differenceInSeconds(new Date(), new Date(str)) 241 + return diff > 300 ? '>5m' : diff + 's' 242 + } 243 + 244 + function secondsAge(seconds: number) { 245 + return seconds > 300 ? '>5m' : seconds + 's' 246 + } 247 + 159 248 let autoRefreshTimer: ReturnType<typeof setTimeout> | null = null; 160 249 161 - onMount(async () => { 162 - await doCheck() 250 + onMount(() => { 251 + doCheck().then(() => { 252 + const scheduleRefresh = () => { 253 + autoRefreshTimer = setTimeout(() => { 254 + if (autoRefreshEnabled) { 255 + doCheck() 256 + } 257 + scheduleRefresh() 258 + }, AUTO_REFRESH_INTERVAL * 1000) 259 + } 260 + 261 + scheduleRefresh() 163 262 164 - const scheduleRefresh = () => { 165 - autoRefreshTimer = setTimeout(() => { 166 - if (autoRefreshEnabled) { 167 - doCheck() 168 - } 169 - scheduleRefresh() 170 - }, AUTO_REFRESH_INTERVAL * 1000) 171 - } 172 - 173 - scheduleRefresh() 263 + }) 174 264 175 265 return () => { 176 266 if (autoRefreshTimer) { ··· 180 270 }) 181 271 </script> 182 272 183 - <main class="w-full mt-10"> 273 + <main class="w-full mt-10 mb-16"> 184 274 <div class="max-w-5xl mx-auto px-3"> 185 275 186 276 <header class="flex items-center gap-10 flex-wrap"> ··· 189 279 </div> 190 280 <div class="flex items-center gap-6"> 191 281 <Switch class="opacity-75" checked={autoRefreshEnabled} onCheckedChange={(x) => autoRefreshEnabled = x.checked} disabled={isUpdating}> 192 - <Switch.Control className="data-[state=checked]:preset-filled-success-500"> 282 + <Switch.Control class="data-[state=checked]:preset-filled-success-500"> 193 283 <Switch.Thumb /> 194 284 </Switch.Control> 195 285 <Switch.Label>Auto-refresh ({AUTO_REFRESH_INTERVAL}s)</Switch.Label> ··· 199 289 </div> 200 290 </header> 201 291 202 - <div class="gap-10 mt-6 grid grid-cols-3"> 292 + <div class="gap-10 mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> 203 293 <div> 204 294 <h2 class="opacity-75 text-sm">Last known bundle</h2> 205 295 <div> ··· 213 303 </div> 214 304 <div> 215 305 <span class="opacity-50">{#if lastKnownBundle?.time} {formatDistanceToNow(lastKnownBundle.time, { addSuffix: true })}{/if}</span> 306 + </div> 307 + <div class="mt-1"> 308 + {#if instancesInConflict.length > 0} 309 + โš ๏ธ Fork alert on {instancesInConflict.length} instances! 310 + {:else if !isConflict} 311 + โœ… Everything fine! 312 + {/if} 216 313 </div> 217 314 </div> 218 315 </div> ··· 223 320 <div class="flex gap-4"> 224 321 <div class="mt-4"> 225 322 <Progress value={lastKnownBundle.mempoolPercent} class="items-center"> 226 - <Progress.Circle style="--size: 64px; --thickness: 10px;"> 323 + <Progress.Circle style="--size: 64px; --thickness: 10px;" class={lastKnownBundle.mempoolPercent > 95 ? 'animate-pulse' : ''}> 227 324 <Progress.CircleTrack /> 228 325 <Progress.CircleRange /> 229 326 </Progress.Circle> ··· 247 344 <h2 class="opacity-75 text-sm">Statistics</h2> 248 345 </div> 249 346 <div class="mt-2 grid grid-cols-1 gap-1"> 250 - <div><span class="opacity-50">Instances:</span> {instances.filter(i => i._head).length} latest / {instances.length} total</div> 251 - <div><span class="opacity-50">Bundles Size:</span> {filesize(lastKnownBundle.totalSize)}</div> 252 - <div><span class="opacity-50">Uncompressed:</span> {filesize(lastKnownBundle.totalSizeUncompressed)}</div> 347 + <div><span class="opacity-50">Instances:</span> {instances.filter(i => i._head && !i._conflict).length} latest / {instances.length} total</div> 348 + <div><span class="opacity-50">PLC Operations:</span> {formatNumber((lastKnownBundle.number * BUNDLE_OPS) + (lastKnownBundle.mempool || 0))}</div> 349 + <div><span class="opacity-50">Bundles Size:</span> {#if lastKnownBundle.totalSize}{filesize(lastKnownBundle.totalSize)}{/if}</div> 350 + <div><span class="opacity-50">Uncompressed:</span> {#if lastKnownBundle.totalSizeUncompressed}{filesize(lastKnownBundle.totalSizeUncompressed)}{/if}</div> 253 351 </div> 254 352 </div> 255 353 {/if} ··· 266 364 <th>head</th> 267 365 <th>root</th> 268 366 <th>version</th> 367 + <th>rsv?</th> 269 368 <th>ws?</th> 270 369 <th>uptime</th> 271 370 <th>latency</th> ··· 275 374 {#each orderBy(instances, ...instanceOrderBy) as instance} 276 375 <tr> 277 376 <td><a href={instance.url} target="_blank" class="font-semibold">{instance.url.replace("https://", "")}</a></td> 278 - <td>{#if instance._head}{#if isConflict}โš ๏ธ{:else}โœ…{/if}{:else if instance.status}๐Ÿ”„{:else}โŒ›{/if}</td> 279 - <td>{#if instance.status?.bundles?.last_bundle}{instance.status?.bundles?.last_bundle}{/if}</td> 280 - <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> 281 - <td class="text-xs opacity-50">{#if instance.status?.mempool && instance._head}{instance.status?.mempool.last_op_age_seconds || 0}s{/if}</td> 282 - <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> 283 - <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> 284 - <td class="text-xs">{#if instance.status?.server?.version}<a href="{instance.url}/status">{instance.status?.server?.version}</a>{/if}</td> 285 - <td class="text-xs">{#if instance.status?.server?.websocket_enabled}โœ”๏ธŽ{:else if instance.status}<span class="opacity-25">-</span>{/if}</td> 377 + <td>{#if instance._head && instance.status?.ok}{#if instance._conflict}โš ๏ธ{:else}โœ…{/if}{:else if instance.status?.ok && instance._oldRoot}๐Ÿฆ‡{:else if instance.status?.ok}๐Ÿ”„{:else if instance.status?.error}โŒ{:else}โŒ›{/if}</td> 378 + {#if instance.status?.error} 379 + <td colspan="8" class="opacity-50 text-xs">Error: {instance.status?.error}</td> 380 + {:else} 381 + <td>{#if instance.status?.bundles?.last_bundle}<span class="{instance._conflict ? 'text-error-600' : ''}">{instance.status?.bundles?.last_bundle}</span>{/if}</td> 382 + <td>{#if instance.status?.mempool && (instance._head || instance._oldRoot)}<span class="{instance._conflict ? 'text-error-600' : (instance._oldRoot ? 'text-warning-500 opacity-50' : '')}">{formatNumber(instance.status?.mempool.count)}</span>{: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> 383 + <td> 384 + {#if instance.status?.mempool} 385 + <span class="text-xs opacity-50 {instance._conflict ? 'text-error-600' : (instance._oldRoot ? 'text-warning-500' : '')}"> 386 + {#if instance._head || instance._oldRoot} 387 + {secondsAge(instance.status?.mempool.last_op_age_seconds || 0)} 388 + {:else} 389 + {bundleAge(instance)} 390 + {/if} 391 + </span> 392 + {/if} 393 + </td> 394 + <td><span class="font-mono text-xs {instance._head ? (instance._conflict ? 'text-error-600' : 'text-success-600') : (instance._oldRoot ? 'text-warning-500 opacity-50' : 'opacity-50')}">{#if instance.status?.bundles?.head_hash}{instance.status?.bundles?.head_hash.slice(0, 7)}{/if}</span></td> 395 + <td><span class="font-mono text-xs {instance.status ? (instance.status?.bundles?.root_hash === ROOT ? 'text-success-600' : (PAST_ROOTS.includes(instance.status?.bundles?.root_hash) ? 'text-warning-500' : 'text-error-600')) : ''}">{#if instance.status?.bundles?.root_hash}{instance.status?.bundles?.root_hash.slice(0, 7)}{/if}</span></td> 396 + <td class="text-xs">{#if instance.status?.server?.version}<span title={instance.status?.server?.version}>{normalizedVersion(instance.status?.server?.version)}</span>{/if}</td> 397 + <td class="text-xs">{#if instance.status?.server?.resolver_enabled}โœ”๏ธŽ{:else if instance.status}<span class="opacity-25">-</span>{/if}</td> 398 + <td class="text-xs">{#if instance.status?.server?.websocket_enabled}โœ”๏ธŽ{:else if instance.status}<span class="opacity-25">-</span>{/if}</td> 399 + {/if} 286 400 <td class="text-xs">{#if instance.status?.server?.uptime_seconds}{formatUptime(instance.status?.server?.uptime_seconds)}{/if}</td> 287 - <td class="text-xs opacity-50">{#if instance.status?.latency}{Math.round(instance.status?.latency)}ms{/if}</td> 401 + <td class="text-xs opacity-50">{#if instance.status?.latency}<a href="{instance.url}/status">{Math.round(instance.status?.latency)}ms</a>{/if}</td> 288 402 </tr> 289 403 {/each} 290 404 </tbody> ··· 296 410 <span class="opacity-75">PLC Directory:</span> <a href="https://{PLC_DIRECTORY}">{PLC_DIRECTORY}</a> <span class="opacity-50">(origin)</span> 297 411 </div> 298 412 <div class="mt-2"> 299 - <span class="opacity-75">Root:</span> <span class="font-mono text-xs">{ROOT.slice(0)}</span> 413 + <span class="opacity-75">First hash (root):</span> <span class="font-mono text-xs">{ROOT.slice(0)}</span> 300 414 </div> 301 - </div> 302 415 303 - <hr class="hr mt-6" /> 304 - <div class="mt-2 opacity-50"> 305 - <div> 416 + <div class="mt-6 opacity-50"> 306 417 Last updated: {formatISO9075(lastUpdated)} 307 418 </div> 308 - <div class="mt-4"> 309 - Source: <a href="https://tangled.org/@tree.fail/plcbundle-watch">https://tangled.org/@tree.fail/plcbundle-watch</a> 419 + </div> 420 + <!--hr class="hr my-10" /--> 421 + <!--BundleDownloader instances={instances} /--> 422 + 423 + <hr class="hr mb-6 mt-12" /> 424 + <div class="opacity-50"> 425 + <div class="mt-4 text-sm"> 426 + <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> 310 427 </div> 311 428 </div> 312 - 313 - 429 + 314 430 315 431 </div> 316 432 </main>
+508
src/BundleDownloader.svelte
··· 1 + <script lang="ts"> 2 + import { Progress } from '@skeletonlabs/skeleton-svelte'; 3 + import { tick } from 'svelte'; 4 + 5 + type Instance = { 6 + url: string; 7 + name?: string; 8 + } 9 + 10 + type InstanceStatus = { 11 + url: string; 12 + lastBundle: number; 13 + } 14 + 15 + type DownloadedBundle = { 16 + number: number; 17 + status: 'downloading' | 'success' | 'error' | 'cancelled'; 18 + size?: number; 19 + error?: string; 20 + source?: string; 21 + } 22 + 23 + let { instances = [] }: { instances: Instance[] } = $props(); 24 + 25 + let selectedInstance = $state('random'); 26 + let bundlesInput = $state(''); 27 + let isDownloading = $state(false); 28 + let downloadedBundles = $state<DownloadedBundle[]>([]); 29 + let progress = $state(0); 30 + let totalBundles = $state(0); 31 + let abortController: AbortController | null = null; 32 + let isStopping = $state(false); 33 + let instanceStatuses = $state<InstanceStatus[]>([]); 34 + let useDirectory = $state(true); 35 + let directoryHandle: FileSystemDirectoryHandle | null = null; 36 + let hasFileSystemAccess = $state(false); 37 + 38 + // Check if File System Access API is available 39 + $effect(() => { 40 + hasFileSystemAccess = 'showDirectoryPicker' in window; 41 + if (!hasFileSystemAccess) { 42 + useDirectory = false; 43 + } 44 + }); 45 + 46 + async function pickDirectory(): Promise<boolean> { 47 + if (!hasFileSystemAccess) { 48 + return false; 49 + } 50 + 51 + try { 52 + directoryHandle = await (window as any).showDirectoryPicker({ 53 + mode: 'readwrite' 54 + }); 55 + return true; 56 + } catch (e) { 57 + if ((e as Error).name !== 'AbortError') { 58 + console.error('Failed to pick directory:', e); 59 + } 60 + return false; 61 + } 62 + } 63 + 64 + async function fetchInstanceStatuses() { 65 + const statuses: InstanceStatus[] = []; 66 + 67 + await Promise.all(instances.map(async (instance) => { 68 + try { 69 + const response = await fetch(`${instance.url}/status`, { 70 + signal: abortController?.signal 71 + }); 72 + const data = await response.json(); 73 + statuses.push({ 74 + url: instance.url, 75 + lastBundle: data.bundles.last_bundle 76 + }); 77 + } catch (e) { 78 + console.warn(`Failed to fetch status from ${instance.url}`, e); 79 + } 80 + })); 81 + 82 + return statuses; 83 + } 84 + 85 + function getAvailableInstancesForBundle(bundleNumber: number): string[] { 86 + return instanceStatuses 87 + .filter(s => s.lastBundle >= bundleNumber) 88 + .map(s => s.url); 89 + } 90 + 91 + function getRandomInstance(bundleNumber?: number): Instance | null { 92 + let availableUrls: string[]; 93 + 94 + if (bundleNumber !== undefined && instanceStatuses.length > 0) { 95 + availableUrls = getAvailableInstancesForBundle(bundleNumber); 96 + if (availableUrls.length === 0) { 97 + return null; 98 + } 99 + } else { 100 + availableUrls = instances.map(i => i.url); 101 + } 102 + 103 + const randomUrl = availableUrls[Math.floor(Math.random() * availableUrls.length)]; 104 + return instances.find(i => i.url === randomUrl) || null; 105 + } 106 + 107 + function getInstanceUrl(bundleNumber?: number): string | null { 108 + if (selectedInstance === 'random') { 109 + const instance = getRandomInstance(bundleNumber); 110 + return instance?.url || null; 111 + } 112 + return selectedInstance; 113 + } 114 + 115 + function getInstanceName(url: string): string { 116 + const instance = instances.find(i => i.url === url); 117 + return instance?.name || new URL(url).hostname; 118 + } 119 + 120 + function parseBundlesInput(input: string): number[] | 'all' { 121 + const trimmed = input.trim(); 122 + 123 + if (!trimmed) { 124 + return 'all'; 125 + } 126 + 127 + if (trimmed.includes('-')) { 128 + const [start, end] = trimmed.split('-').map(s => parseInt(s.trim())); 129 + if (isNaN(start) || isNaN(end) || start > end) { 130 + throw new Error('Invalid range format'); 131 + } 132 + const bundles = []; 133 + for (let i = start; i <= end; i++) { 134 + bundles.push(i); 135 + } 136 + return bundles; 137 + } 138 + 139 + const num = parseInt(trimmed); 140 + if (isNaN(num)) { 141 + throw new Error('Invalid bundle number'); 142 + } 143 + return [num]; 144 + } 145 + 146 + async function getLastBundle(instanceUrl: string): Promise<number> { 147 + const response = await fetch(`${instanceUrl}/status`, { 148 + signal: abortController?.signal 149 + }); 150 + const data = await response.json(); 151 + return data.bundles.last_bundle; 152 + } 153 + 154 + async function downloadBundle(instanceUrl: string, bundleNumber: number): Promise<Blob> { 155 + const response = await fetch(`${instanceUrl}/data/${bundleNumber}`, { 156 + signal: abortController?.signal 157 + }); 158 + if (!response.ok) { 159 + throw new Error(`HTTP ${response.status}`); 160 + } 161 + return await response.blob(); 162 + } 163 + 164 + function padBundleNumber(num: number): string { 165 + return num.toString().padStart(6, '0'); 166 + } 167 + 168 + async function saveFileToDirectory(blob: Blob, bundleNumber: number) { 169 + if (!directoryHandle) { 170 + throw new Error('No directory selected'); 171 + } 172 + 173 + const fileName = `${padBundleNumber(bundleNumber)}.jsonl.zst`; 174 + const fileHandle = await directoryHandle.getFileHandle(fileName, { create: true }); 175 + const writable = await fileHandle.createWritable(); 176 + await writable.write(blob); 177 + await writable.close(); 178 + } 179 + 180 + function saveFileBrowser(blob: Blob, bundleNumber: number) { 181 + const url = URL.createObjectURL(blob); 182 + const link = document.createElement('a'); 183 + link.href = url; 184 + link.download = `${padBundleNumber(bundleNumber)}.jsonl.zst`; 185 + link.click(); 186 + URL.revokeObjectURL(url); 187 + } 188 + 189 + function stopDownload() { 190 + if (abortController) { 191 + isStopping = true; 192 + abortController.abort(); 193 + } 194 + } 195 + 196 + async function handleDownload() { 197 + if (!selectedInstance) { 198 + alert('Please select an instance'); 199 + return; 200 + } 201 + 202 + // If using directory mode, pick directory first 203 + if (useDirectory && hasFileSystemAccess) { 204 + const picked = await pickDirectory(); 205 + if (!picked) { 206 + return; // User cancelled 207 + } 208 + } 209 + 210 + let bundleNumbers: number[]; 211 + abortController = new AbortController(); 212 + isStopping = false; 213 + 214 + try { 215 + if (selectedInstance === 'random') { 216 + instanceStatuses = await fetchInstanceStatuses(); 217 + if (instanceStatuses.length === 0) { 218 + alert('No instances available'); 219 + return; 220 + } 221 + } 222 + 223 + const parsed = parseBundlesInput(bundlesInput); 224 + 225 + if (parsed === 'all') { 226 + let lastBundle: number; 227 + 228 + if (selectedInstance === 'random') { 229 + lastBundle = Math.max(...instanceStatuses.map(s => s.lastBundle)); 230 + } else { 231 + lastBundle = await getLastBundle(selectedInstance); 232 + } 233 + 234 + bundleNumbers = []; 235 + for (let i = 1; i <= lastBundle; i++) { 236 + bundleNumbers.push(i); 237 + } 238 + } else { 239 + bundleNumbers = parsed; 240 + } 241 + } catch (e) { 242 + if (e instanceof Error && e.name === 'AbortError') { 243 + return; 244 + } 245 + alert(e instanceof Error ? e.message : 'Invalid input'); 246 + return; 247 + } 248 + 249 + isDownloading = true; 250 + downloadedBundles = []; 251 + progress = 0; 252 + totalBundles = bundleNumbers.length; 253 + 254 + for (let i = 0; i < bundleNumbers.length; i++) { 255 + if (abortController?.signal.aborted) { 256 + break; 257 + } 258 + 259 + const bundleNum = bundleNumbers[i]; 260 + const instanceUrl = getInstanceUrl(bundleNum); 261 + 262 + if (!instanceUrl) { 263 + downloadedBundles = [...downloadedBundles, { 264 + number: bundleNum, 265 + status: 'error', 266 + error: 'No instance has this bundle', 267 + }]; 268 + progress = Math.round(((i + 1) / totalBundles) * 100); 269 + await tick(); 270 + continue; 271 + } 272 + 273 + downloadedBundles = [...downloadedBundles, { 274 + number: bundleNum, 275 + status: 'downloading', 276 + source: instanceUrl, 277 + }]; 278 + 279 + await tick(); 280 + 281 + try { 282 + const blob = await downloadBundle(instanceUrl, bundleNum); 283 + 284 + if (abortController?.signal.aborted) { 285 + downloadedBundles = downloadedBundles.map(b => 286 + b.number === bundleNum ? { ...b, status: 'cancelled' as const } : b 287 + ); 288 + break; 289 + } 290 + 291 + // Save file 292 + if (useDirectory && directoryHandle) { 293 + await saveFileToDirectory(blob, bundleNum); 294 + } else { 295 + saveFileBrowser(blob, bundleNum); 296 + } 297 + 298 + downloadedBundles = downloadedBundles.map(b => 299 + b.number === bundleNum 300 + ? { ...b, status: 'success' as const, size: blob.size } 301 + : b 302 + ); 303 + 304 + } catch (e) { 305 + if (e instanceof Error && e.name === 'AbortError') { 306 + downloadedBundles = downloadedBundles.map(b => 307 + b.number === bundleNum ? { ...b, status: 'cancelled' as const } : b 308 + ); 309 + break; 310 + } 311 + 312 + downloadedBundles = downloadedBundles.map(b => 313 + b.number === bundleNum 314 + ? { ...b, status: 'error' as const, error: e instanceof Error ? e.message : 'Unknown error' } 315 + : b 316 + ); 317 + } 318 + 319 + progress = Math.round(((i + 1) / totalBundles) * 100); 320 + await tick(); 321 + } 322 + 323 + isDownloading = false; 324 + isStopping = false; 325 + abortController = null; 326 + directoryHandle = null; 327 + } 328 + 329 + function formatBytes(bytes: number): string { 330 + if (bytes === 0) return '0 B'; 331 + const k = 1024; 332 + const sizes = ['B', 'KB', 'MB', 'GB']; 333 + const i = Math.floor(Math.log(bytes) / Math.log(k)); 334 + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; 335 + } 336 + 337 + function clearResults() { 338 + downloadedBundles = []; 339 + progress = 0; 340 + totalBundles = 0; 341 + } 342 + 343 + let successCount = $derived(downloadedBundles.filter(b => b.status === 'success').length); 344 + let errorCount = $derived(downloadedBundles.filter(b => b.status === 'error').length); 345 + let cancelledCount = $derived(downloadedBundles.filter(b => b.status === 'cancelled').length); 346 + let totalSize = $derived(downloadedBundles.reduce((sum, b) => sum + (b.size || 0), 0)); 347 + </script> 348 + 349 + <div class="bundle-downloader card space-y-4"> 350 + <h2 class="text-2xl">Bundle Downloader</h2> 351 + 352 + <div class="space-y-3"> 353 + <label class="label"> 354 + <span>Instance</span> 355 + <select 356 + class="select p-3 text-sm" 357 + bind:value={selectedInstance} 358 + disabled={isDownloading} 359 + > 360 + <option value="random">๐ŸŽฒ Random (each bundle from different source)</option> 361 + {#each instances as instance} 362 + <option value={instance.url}> 363 + {instance.name || instance.url} 364 + </option> 365 + {/each} 366 + </select> 367 + </label> 368 + 369 + <label class="label"> 370 + <span>Bundles</span> 371 + <input 372 + class="input text-sm" 373 + type="text" 374 + bind:value={bundlesInput} 375 + disabled={isDownloading} 376 + placeholder="empty = all, 5 = single, 1-10 = range" 377 + /> 378 + <p class="text-xs opacity-75 mt-1"> 379 + Leave empty for all bundles, enter a number (e.g., <code>5</code>) or range (e.g., <code>1-10</code>) 380 + </p> 381 + </label> 382 + 383 + {#if hasFileSystemAccess} 384 + <label class="flex items-center space-x-2"> 385 + <input 386 + type="checkbox" 387 + class="checkbox" 388 + bind:checked={useDirectory} 389 + disabled={isDownloading} 390 + /> 391 + <span class="text-sm"> 392 + ๐Ÿ“ Save to directory (recommended for multiple files) 393 + </span> 394 + </label> 395 + {:else} 396 + <div class="alert variant-ghost-warning p-2 text-xs"> 397 + <span>โš ๏ธ Directory mode not available in this browser. Files will download individually.</span> 398 + </div> 399 + {/if} 400 + 401 + <div class="flex gap-2"> 402 + {#if !isDownloading} 403 + <button 404 + class="btn preset-tonal-primary flex-1" 405 + onclick={handleDownload} 406 + > 407 + {useDirectory && hasFileSystemAccess ? '๐Ÿ“ Choose Directory & Download' : '๐Ÿ“ฅ Download'} 408 + </button> 409 + {#if downloadedBundles.length > 0} 410 + <button 411 + class="btn preset-tonal-surface" 412 + onclick={clearResults} 413 + > 414 + ๐Ÿ—‘๏ธ Clear 415 + </button> 416 + {/if} 417 + {:else} 418 + <button 419 + class="btn preset-filled-error-500 flex-1" 420 + onclick={stopDownload} 421 + disabled={isStopping} 422 + > 423 + {isStopping ? 'โณ Stopping...' : 'โ›” Stop'} 424 + </button> 425 + {/if} 426 + </div> 427 + 428 + {#if isDownloading} 429 + <div class="space-y-2"> 430 + <Progress value={progress} max={100} /> 431 + <p class="text-sm text-center font-semibold"> 432 + {progress}% ({successCount}/{totalBundles}) 433 + </p> 434 + </div> 435 + {/if} 436 + </div> 437 + 438 + {#if downloadedBundles.length > 0} 439 + <div class="space-y-2"> 440 + <h3 class="text-2xl"> 441 + {isDownloading ? 'Downloading...' : 'Results'} 442 + ({successCount}/{downloadedBundles.length}) 443 + </h3> 444 + 445 + <div class="table-container max-h-64 overflow-y-auto"> 446 + <table class="table table-compact table-hover"> 447 + <thead> 448 + <tr> 449 + <th>File</th> 450 + <th>Source</th> 451 + <th>Status</th> 452 + <th class="text-right">Size</th> 453 + </tr> 454 + </thead> 455 + <tbody> 456 + {#each downloadedBundles as bundle (bundle.number)} 457 + <tr> 458 + <td class="font-mono text-xs">{padBundleNumber(bundle.number)}.jsonl.zst</td> 459 + <td class="text-xs" title={bundle.source}> 460 + {bundle.source ? getInstanceName(bundle.source) : '-'} 461 + </td> 462 + <td> 463 + {#if bundle.status === 'downloading'} 464 + <span class="badge variant-filled text-xs">โณ Downloading</span> 465 + {:else if bundle.status === 'success'} 466 + <span class="badge variant-filled-success text-xs">โœ… Success</span> 467 + {:else if bundle.status === 'cancelled'} 468 + <span class="badge variant-filled-warning text-xs">โš ๏ธ Cancelled</span> 469 + {:else} 470 + <span class="badge variant-filled-error text-xs" title={bundle.error}>โŒ Error</span> 471 + {/if} 472 + </td> 473 + <td class="text-sm text-right">{bundle.size ? formatBytes(bundle.size) : '-'}</td> 474 + </tr> 475 + {/each} 476 + </tbody> 477 + </table> 478 + </div> 479 + 480 + <div class="card p-3 variant-ghost-surface grid grid-cols-4 gap-2 text-sm"> 481 + <div> 482 + <div class="font-bold text-success-500"> 483 + {successCount} 484 + </div> 485 + <div class="opacity-75">Success</div> 486 + </div> 487 + <div> 488 + <div class="font-bold text-error-500"> 489 + {errorCount} 490 + </div> 491 + <div class="opacity-75">Failed</div> 492 + </div> 493 + <div> 494 + <div class="font-bold text-warning-500"> 495 + {cancelledCount} 496 + </div> 497 + <div class="opacity-75">Cancelled</div> 498 + </div> 499 + <div> 500 + <div class="font-bold"> 501 + {formatBytes(totalSize)} 502 + </div> 503 + <div class="opacity-75">Total</div> 504 + </div> 505 + </div> 506 + </div> 507 + {/if} 508 + </div>
+50 -18
src/instances.json
··· 1 1 [ 2 - { 3 - "url": "https://plcbundle.atscan.net", 4 - "country": "AT", 5 - "maintainer": "@tree.fail" 6 - }, 7 - { 8 - "url": "https://plc.j4ck.xyz" 9 - }, 10 - { 11 - "url": "https://plc.indexx.dev" 12 - }, 13 - { 14 - "url": "https://plc.nyxt.dev" 15 - }, 16 - { 17 - "url": "https://plc.madebydanny.uk" 18 - } 19 - ] 2 + { 3 + "url": "https://plcbundle.atscan.net", 4 + "country": "AT", 5 + "maintainer": "@tree.fail" 6 + }, 7 + { 8 + "url": "https://plcbundle2.atscan.net", 9 + "country": "CZ", 10 + "maintainer": "@tree.fail" 11 + }, 12 + { 13 + "url": "https://plcbundle3.atscan.net", 14 + "country": "CZ", 15 + "maintainer": "@tree.fail" 16 + }, 17 + { 18 + "url": "https://plc.j4ck.xyz", 19 + "country": "UK", 20 + "maintainer": "@j4ck.xyz" 21 + }, 22 + { 23 + "url": "https://plc.indexx.dev", 24 + "country": "US", 25 + "maintainer": "@indexx.dev" 26 + }, 27 + { 28 + "url": "https://plc.nyxt.dev" 29 + }, 30 + { 31 + "url": "https://plc.madebydanny.uk", 32 + "country": "US", 33 + "maintainer": "@madebydanny.uk" 34 + }, 35 + { 36 + "url": "https://plc.tartarus.us" 37 + }, 38 + { 39 + "url": "https://plcbundle.snek.cc", 40 + "country": "US", 41 + "maintainer": "@jackvalinsky.com" 42 + }, 43 + { 44 + "url": "https://plc.dane.computer", 45 + "country": "CA", 46 + "maintainer": "@dane.is.extraordinarily.cool" 47 + }, 48 + { 49 + "url": "https://plc.witchcraft.systems" 50 + } 51 + ]
+1
src/types/lodash.d.ts
··· 1 + declare module 'lodash/orderBy';