+3
bun.lock
+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
+1
package.json
+77
-47
src/App.svelte
+77
-47
src/App.svelte
···
1
1
<script lang="ts">
2
+
2
3
import { onMount } from 'svelte';
3
4
import { filesize } from 'filesize';
4
5
import { formatDistanceToNow, addSeconds, subSeconds, formatDate, formatISO9075 } from 'date-fns';
5
6
import { Progress, Switch } from '@skeletonlabs/skeleton-svelte';
6
-
import orderBy from "lodash/orderBy";
7
+
import orderBy from 'lodash/orderBy';
7
8
import BundleDownloader from './BundleDownloader.svelte';
8
9
import { formatNumber, formatUptime } from './lib/utils';
9
10
import instancesData from './instances.json';
···
21
22
root_hash: string;
22
23
head_hash: string;
23
24
end_time?: string;
24
-
total_size: number;
25
-
uncompressed_size: number;
25
+
total_size?: number;
26
+
uncompressed_size?: number;
26
27
};
27
28
server: {
28
29
uptime: number;
···
34
35
latency?: number;
35
36
}
36
37
38
+
type StatusResponseError = {
39
+
error: string;
40
+
}
41
+
37
42
type Instance = {
38
43
url: string;
39
44
cors?: boolean;
40
-
status?: StatusResponse;
45
+
status?: StatusResponse | StatusResponseError;
41
46
modern?: boolean;
42
47
_head?: boolean;
43
48
}
···
50
55
mempoolBundle: number;
51
56
time?: string;
52
57
etaNext?: Date | null;
53
-
totalSize: number;
54
-
totalSizeUncompressed: number;
58
+
totalSize?: number | null;
59
+
totalSizeUncompressed?: number | null;
55
60
}
56
61
57
62
let lastKnownBundle = $state<LastKnownBundle>({
58
63
number: 0,
59
64
hash: null,
60
65
mempool: null,
66
+
mempoolBundle: 0,
61
67
mempoolPercent: 0,
62
68
})
63
69
···
68
74
let autoRefreshEnabled = $state(true)
69
75
let instances = $state<Instance[]>(instancesData.sort(() => Math.random() - 0.5))
70
76
71
-
const instanceOrderBy = [['_head', 'status.bundles.last_bundle', 'status.latency'], ['desc', 'desc', 'asc']]
77
+
const instanceOrderBy = [['status.error', '_head', 'status.bundles.last_bundle', 'status.latency'], ['desc', 'desc', 'desc', 'asc']]
72
78
73
-
async function getStatus(instance: Instance): Promise<StatusResponse | undefined> {
79
+
async function getStatus(instance: Instance): Promise<StatusResponse | StatusResponseError> {
74
80
let statusResp: StatusResponse | undefined;
75
81
let url: string = instance.url;
82
+
let lastError: string | undefined;
76
83
const start = performance.now();
77
84
try {
78
85
statusResp = await (await fetch(`${url}/status?${Number(new Date())}`)).json()
79
-
} catch (e) {}
86
+
} catch (e: any) {
87
+
lastError = e.message;
88
+
}
80
89
if (!statusResp) {
81
90
url = `https://keyoxide.org/api/3/get/http?url=${encodeURIComponent(url)}&format=text&time=${Date.now()}`
82
-
const indexResp = await (await fetch(url)).text()
91
+
92
+
let indexResp: string | undefined;
93
+
try {
94
+
indexResp = await (await fetch(url)).text()
95
+
} catch(e: any) {
96
+
lastError = e.message;
97
+
}
83
98
const match = indexResp?.match(/Range:\s+(\d{6}) - (\d{6})/)
84
99
if (match) {
85
100
const [, from, to] = match
···
87
102
const headMatch = indexResp?.match(/Head: ([a-f0-9]{64})/)
88
103
89
104
statusResp = {
105
+
ok: true,
90
106
bundles: {
91
107
last_bundle: Number(to),
92
108
root_hash: rootMatch ? rootMatch[1] : '',
···
99
115
}
100
116
}
101
117
if (statusResp) {
118
+
statusResp.ok = true
102
119
statusResp.latency = performance.now() - start;
103
120
}
104
121
//if (instance.url === 'https://plc.j4ck.xyz') { statusResp.bundles.head_hash = 'f3ad3544452b2c078cba24990486bb9c277a1155'; }
105
-
return statusResp
122
+
return statusResp ?? { error: lastError || 'unknown error' }
106
123
}
107
124
108
125
function recalculateHead() {
109
126
isConflict = false
110
127
const headHashes: string[] = []
111
128
for (const instance of instances) {
129
+
if (instance.status && 'error' in instance.status) {
130
+
continue
131
+
}
112
132
instance._head = instance.status?.bundles?.last_bundle === lastKnownBundle.number
113
133
if (instance._head && instance.status?.bundles?.head_hash) {
114
134
headHashes.push(instance.status.bundles.head_hash)
···
121
141
isUpdating = true
122
142
canRefresh = false
123
143
for (const i of instances) {
124
-
if (i.status) {
144
+
if (i.status && 'ok' in i.status) {
125
145
i.status.ok = false
126
146
}
127
147
}
128
148
129
149
await Promise.all(instances.map(async (instance) => {
130
150
const status = await getStatus(instance)
131
-
instance.status = status
132
-
if (instance.status) {
133
-
instance.status.ok = true
151
+
if (!status) {
152
+
return false
134
153
}
135
154
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
155
+
instance.status = status
156
+
if ('ok' in status && status.ok) {
140
157
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
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
+
}
148
171
}
149
172
}
173
+
150
174
lastUpdated = new Date()
151
175
152
176
recalculateHead()
···
168
192
169
193
let autoRefreshTimer: ReturnType<typeof setTimeout> | null = null;
170
194
171
-
onMount(async () => {
172
-
await doCheck()
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()
173
207
174
-
const scheduleRefresh = () => {
175
-
autoRefreshTimer = setTimeout(() => {
176
-
if (autoRefreshEnabled) {
177
-
doCheck()
178
-
}
179
-
scheduleRefresh()
180
-
}, AUTO_REFRESH_INTERVAL * 1000)
181
-
}
182
-
183
-
scheduleRefresh()
208
+
})
184
209
185
210
return () => {
186
211
if (autoRefreshTimer) {
···
199
224
</div>
200
225
<div class="flex items-center gap-6">
201
226
<Switch class="opacity-75" checked={autoRefreshEnabled} onCheckedChange={(x) => autoRefreshEnabled = x.checked} disabled={isUpdating}>
202
-
<Switch.Control className="data-[state=checked]:preset-filled-success-500">
227
+
<Switch.Control class="data-[state=checked]:preset-filled-success-500">
203
228
<Switch.Thumb />
204
229
</Switch.Control>
205
230
<Switch.Label>Auto-refresh ({AUTO_REFRESH_INTERVAL}s)</Switch.Label>
···
259
284
<div class="mt-2 grid grid-cols-1 gap-1">
260
285
<div><span class="opacity-50">Instances:</span> {instances.filter(i => i._head).length} latest / {instances.length} total</div>
261
286
<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>
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>
264
289
</div>
265
290
</div>
266
291
{/if}
···
286
311
{#each orderBy(instances, ...instanceOrderBy) as instance}
287
312
<tr>
288
313
<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>
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
+
295
325
<td class="text-xs">{#if instance.status?.server?.version}{instance.status?.server?.version}{/if}</td>
296
326
<td class="text-xs">{#if instance.status?.server?.websocket_enabled}✔︎{:else if instance.status}<span class="opacity-25">-</span>{/if}</td>
297
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';