Skip to content

Commit a75b4df

Browse files
authored
feat(app2): quick node page (#4497)
2 parents b8f5ff7 + 3cf850d commit a75b4df

File tree

1 file changed

+292
-0
lines changed

1 file changed

+292
-0
lines changed

app2/src/routes/nodes/+page.svelte

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
<script lang="ts">
2+
import { Effect, Option, Either } from "effect"
3+
import Card from "$lib/components/ui/Card.svelte"
4+
import Sections from "$lib/components/ui/Sections.svelte"
5+
import { cn } from "$lib/utils"
6+
import Skeleton from "$lib/components/ui/Skeleton.svelte"
7+
import Tooltip from "$lib/components/ui/Tooltip.svelte"
8+
import type { Chain } from "@unionlabs/sdk/schema"
9+
import { chains } from "$lib/stores/chains.svelte"
10+
import type { RpcProtocolType } from "@unionlabs/sdk/schema"
11+
12+
type RpcType = "cosmos" | "evm"
13+
14+
interface RpcStatusResult {
15+
url: string
16+
type: RpcType
17+
responseTimeMs: number
18+
status: CosmosStatus | EvmStatus
19+
}
20+
21+
interface CosmosStatus {
22+
kind: "cosmos"
23+
latestBlockHeight: number
24+
catchingUp: boolean
25+
moniker: string
26+
network: string
27+
}
28+
29+
interface EvmStatus {
30+
kind: "evm"
31+
latestBlockHex: string
32+
latestBlockNumber: number
33+
}
34+
35+
class RpcStatusError {
36+
readonly _tag = "RpcStatusError"
37+
readonly type: RpcType
38+
readonly url: string
39+
readonly message: string
40+
readonly cause?: unknown
41+
42+
constructor(type: RpcType, url: string, message: string, cause?: unknown) {
43+
this.type = type
44+
this.url = url
45+
this.message = message
46+
this.cause = cause
47+
}
48+
}
49+
50+
const withTiming = async (fn: () => Promise<any>) => {
51+
const start = performance.now()
52+
const result = await fn()
53+
const end = performance.now()
54+
return { result, duration: end - start }
55+
}
56+
57+
const checkRpcStatus = (
58+
type: RpcType,
59+
url: string
60+
): Effect.Effect<RpcStatusResult, RpcStatusError> =>
61+
Effect.tryPromise({
62+
try: async signal => {
63+
if (type === "cosmos") {
64+
const { result: res, duration } = await withTiming(async () =>
65+
fetch(url, {
66+
method: "POST",
67+
headers: { "Content-Type": "application/json" },
68+
body: JSON.stringify({
69+
jsonrpc: "2.0",
70+
id: 1,
71+
method: "status",
72+
params: []
73+
})
74+
})
75+
)
76+
if (!res.ok)
77+
throw new RpcStatusError("cosmos", url, `HTTP ${res.status} - ${res.statusText}`)
78+
const data = await res.json()
79+
if (!(data?.result?.sync_info && data?.result?.node_info)) {
80+
throw new RpcStatusError("cosmos", url, "Malformed response", data)
81+
}
82+
return {
83+
url,
84+
type: "cosmos",
85+
responseTimeMs: duration,
86+
status: {
87+
kind: "cosmos",
88+
latestBlockHeight: Number(data.result.sync_info.latest_block_height),
89+
catchingUp: data.result.sync_info.catching_up,
90+
moniker: data.result.node_info.moniker,
91+
network: data.result.node_info.network
92+
}
93+
}
94+
}
95+
if (type === "evm") {
96+
const { result: res, duration } = await withTiming(async () =>
97+
fetch(url, {
98+
method: "POST",
99+
headers: { "Content-Type": "application/json" },
100+
body: JSON.stringify({
101+
jsonrpc: "2.0",
102+
id: 1,
103+
method: "eth_blockNumber",
104+
params: []
105+
})
106+
})
107+
)
108+
if (!res.ok) throw new RpcStatusError("evm", url, `HTTP ${res.status} - ${res.statusText}`)
109+
const data = await res.json()
110+
if (!data?.result) {
111+
throw new RpcStatusError("evm", url, "Missing result field", data)
112+
}
113+
const hex = data.result as string
114+
return {
115+
url,
116+
type: "evm",
117+
responseTimeMs: duration,
118+
status: {
119+
kind: "evm",
120+
latestBlockHex: hex,
121+
latestBlockNumber: Number.parseInt(hex, 16)
122+
}
123+
}
124+
}
125+
throw new RpcStatusError(type, url, `Unsupported type: ${type}`)
126+
},
127+
catch: err =>
128+
err instanceof RpcStatusError ? err : new RpcStatusError(type, url, "Unknown error", err)
129+
})
130+
131+
type NodeStatus = {
132+
chain: Chain
133+
rpcUrl: string
134+
status: "CHECKING" | "OK" | "ERROR"
135+
responseTime?: number
136+
error?: string
137+
}
138+
139+
let nodeData: Map<string, NodeStatus> = $state(new Map())
140+
let hasInitialized = $state(false)
141+
142+
const checkNode = (chain: Chain, rpcUrl: string) =>
143+
Effect.gen(function* (_) {
144+
const key = `${chain.universal_chain_id}-${rpcUrl}`
145+
146+
nodeData = new Map(
147+
nodeData.set(key, {
148+
chain,
149+
rpcUrl,
150+
status: "CHECKING"
151+
})
152+
)
153+
154+
const result = yield* _(
155+
checkRpcStatus(chain.rpc_type === "cosmos" ? "cosmos" : "evm", rpcUrl).pipe(
156+
Effect.map(res =>
157+
Either.right({
158+
status: "OK" as const,
159+
responseTime: Math.round(res.responseTimeMs)
160+
})
161+
),
162+
Effect.catchAll((err: RpcStatusError) =>
163+
Effect.succeed(
164+
Either.left({
165+
status: "ERROR" as const,
166+
error: err.message
167+
})
168+
)
169+
)
170+
)
171+
)
172+
173+
const status = Either.match(result, {
174+
onLeft: error => error,
175+
onRight: success => success
176+
})
177+
178+
nodeData = new Map(
179+
nodeData.set(key, {
180+
chain,
181+
rpcUrl,
182+
...status
183+
})
184+
)
185+
186+
return status
187+
})
188+
189+
async function checkAllNodes() {
190+
const chainsData = Option.getOrElse(chains.data, () => [])
191+
const rpcNodes = chainsData.flatMap(chain =>
192+
chain.rpcs.filter(rpc => rpc.type === ("rpc" as RpcProtocolType)).map(rpc => ({ chain, rpc }))
193+
)
194+
195+
await Promise.all(
196+
rpcNodes.map(({ chain, rpc }) => checkNode(chain, rpc.url).pipe(Effect.runPromise))
197+
)
198+
}
199+
200+
$effect(() => {
201+
if (Option.isSome(chains.data) && !hasInitialized) {
202+
hasInitialized = true
203+
checkAllNodes()
204+
}
205+
})
206+
207+
setInterval(() => {
208+
checkAllNodes()
209+
}, 30000)
210+
</script>
211+
212+
<Sections>
213+
<Card class="overflow-auto" divided>
214+
<div class="p-3 text-sm font-medium text-zinc-400">Node Status</div>
215+
<div class="space-y-1">
216+
{#if Option.isNone(chains.data)}
217+
{#each Array(3) as _}
218+
<div class="flex justify-between gap-8 px-4 py-2 h-12 items-center">
219+
<div class="space-y-2">
220+
<Skeleton class="h-4 w-24" />
221+
<Skeleton class="h-3 w-32" />
222+
</div>
223+
<div class="flex items-center gap-2">
224+
<Skeleton class="h-5 w-16" />
225+
<Skeleton class="h-5 w-16" />
226+
</div>
227+
</div>
228+
{/each}
229+
{:else}
230+
{#each Option.getOrElse(chains.data, () => []) as chain}
231+
{#each chain.rpcs.filter((rpc) => rpc.type === ("rpc" as RpcProtocolType)) as rpc}
232+
{@const key = `${chain.universal_chain_id}-${rpc.url}`}
233+
{@const status = nodeData.get(key)}
234+
<a
235+
href={rpc.url}
236+
target="_blank"
237+
rel="noopener noreferrer"
238+
class={cn(
239+
"flex justify-between gap-8 px-4 py-2 h-12 items-center",
240+
"hover:bg-zinc-50 dark:hover:bg-zinc-900 transition-colors duration-75",
241+
"cursor-pointer"
242+
)}
243+
>
244+
<div>
245+
<h2 class="text-sm font-medium">{chain.display_name}</h2>
246+
<p class="text-xs text-zinc-400">{rpc.url}</p>
247+
</div>
248+
<div class="flex items-center gap-2">
249+
{#if status?.responseTime}
250+
<span
251+
class="px-2 py-0.5 text-xs font-medium bg-zinc-500/20 text-zinc-500 rounded-sm"
252+
>
253+
{status.responseTime}ms
254+
</span>
255+
{/if}
256+
{#if status?.error}
257+
<Tooltip>
258+
{#snippet trigger()}
259+
<span
260+
class="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-500 rounded-sm"
261+
>
262+
ERROR
263+
</span>
264+
{/snippet}
265+
{#snippet content()}
266+
<span class="text-red-500">{status.error}</span>
267+
{/snippet}
268+
</Tooltip>
269+
{:else}
270+
<span
271+
class={cn(
272+
"px-2 py-0.5 text-xs font-medium rounded-sm",
273+
!status
274+
? "bg-zinc-500/20 text-zinc-500"
275+
: status.status === "CHECKING"
276+
? "bg-accent/20 text-accent"
277+
: status.status === "OK"
278+
? "bg-emerald-500/20 text-emerald-500"
279+
: "bg-zinc-500/20 text-zinc-500"
280+
)}
281+
>
282+
{!status ? "PENDING" : status.status}
283+
</span>
284+
{/if}
285+
</div>
286+
</a>
287+
{/each}
288+
{/each}
289+
{/if}
290+
</div>
291+
</Card>
292+
</Sections>

0 commit comments

Comments
 (0)