+3
bun.lock
+3
bun.lock
···
16
"@skeletonlabs/skeleton-svelte": "^4.2.2",
17
"@sveltejs/vite-plugin-svelte": "^6.2.1",
18
"@tsconfig/svelte": "^5.0.5",
19
"@types/node": "^24.6.0",
20
"svelte": "^5.39.6",
21
"svelte-check": "^4.3.2",
···
186
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="],
187
188
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
189
190
"@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="],
191
···
16
"@skeletonlabs/skeleton-svelte": "^4.2.2",
17
"@sveltejs/vite-plugin-svelte": "^6.2.1",
18
"@tsconfig/svelte": "^5.0.5",
19
+
"@types/lodash": "^4.17.20",
20
"@types/node": "^24.6.0",
21
"svelte": "^5.39.6",
22
"svelte-check": "^4.3.2",
···
187
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="],
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=="],
192
193
"@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="],
194
+1
package.json
+1
package.json
+77
-47
src/App.svelte
+77
-47
src/App.svelte
···
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';
···
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;
···
34
latency?: number;
35
}
36
37
type Instance = {
38
url: string;
39
cors?: boolean;
40
-
status?: StatusResponse;
41
modern?: boolean;
42
_head?: boolean;
43
}
···
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
···
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
···
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] : '',
···
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)
···
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()
···
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) {
···
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>
···
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}
···
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>
···
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';
···
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;
···
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
}
···
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
···
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
···
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] : '',
···
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)
···
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()
···
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) {
···
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>
···
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}
···
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>
+1
src/types/lodash.d.ts
+1
src/types/lodash.d.ts
···
···
1
+
declare module 'lodash/orderBy';