diff --git a/app/node/page.tsx b/app/node/page.tsx index 4ffbca8..0e95cc0 100644 --- a/app/node/page.tsx +++ b/app/node/page.tsx @@ -2,14 +2,39 @@ import { useSearchParams } from "next/navigation"; import State from "@/components/state"; +import { useEffect, useState } from "react"; +import { sendRequest } from "@/lib/ws"; +import { toast } from "sonner"; export default function NodePage() { const searchParams = useSearchParams(); const endpoint = decodeURIComponent(searchParams.get("endpoint") || ""); + const [nodeName, setNodeName] = useState(""); + + useEffect(() => { + const fetchNodeName = async () => { + try { + const name = await sendRequest(endpoint, "system_name"); + setNodeName(name as string); + } catch (error) { + toast.error( + `Failed to fetch node name: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } + }; + + if (endpoint) { + fetchNodeName(); + } + }, [endpoint]); return (
-

{endpoint}

+

+ {nodeName ? `${nodeName} (${endpoint})` : endpoint} +

); diff --git a/components/state.tsx b/components/state.tsx index 1a256ff..915cfb2 100644 --- a/components/state.tsx +++ b/components/state.tsx @@ -1,36 +1,50 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { connectToNode, sendRequest, disconnectFromNode } from "@/lib/ws"; +import { JSONObject, sendRequest } from "@/lib/ws"; import { toast } from "sonner"; +import { X } from "lucide-react"; -type KeyValuePair = { - blockHash: string; - stateRoot: string; +type Result = { + type: "block" | "state"; + data: JSONObject; }; export default function State({ endpoint }: { endpoint: string }) { - const [hashInput, setHashInput] = useState(""); - const [stateRoots, setStateRoots] = useState([]); + const [blockHashInput, setBlockHashInput] = useState(""); + const [stateHashInput, setStateHashInput] = useState(""); + const [results, setResults] = useState([]); - useEffect(() => { - connectToNode(endpoint).catch((error: unknown) => + const fetchBlock = useCallback(async () => { + if (blockHashInput.length !== 66 && blockHashInput.length !== 0) { toast.error( - `Failed to connect: ${ + "Hash must be a valid 32 byte hex string `0x{string}` or empty for best block" + ); + return; + } + + try { + const method = "chain_getBlock"; + const params = { hash: blockHashInput }; + const result = await sendRequest(endpoint, method, params); + setResults((prev) => [ + { type: "block", data: result as JSONObject }, + ...prev, + ]); + setBlockHashInput(""); + } catch (error: unknown) { + toast.error( + `Failed to fetch block: ${ error instanceof Error ? error.message : "Unknown error" }` - ) - ); - - return () => { - disconnectFromNode(endpoint); - }; - }, [endpoint]); + ); + } + }, [endpoint, blockHashInput]); const fetchState = useCallback(async () => { - if (hashInput.length !== 66 && hashInput.length !== 0) { + if (stateHashInput.length !== 66 && stateHashInput.length !== 0) { toast.error( "Hash must be a valid 32 byte hex string `0x{string}` or empty for best block" ); @@ -39,52 +53,80 @@ export default function State({ endpoint }: { endpoint: string }) { try { const method = "chain_getState"; - const params = hashInput ? { hash: hashInput } : {}; - const result = (await sendRequest(endpoint, method, params)) as { - [key: string]: string; - }; - setStateRoots((prev) => [ - { - blockHash: result?.blockHash, - stateRoot: result?.stateRoot, - }, + const params = { hash: stateHashInput }; + const result = await sendRequest(endpoint, method, params); + setResults((prev) => [ + { type: "state", data: result as JSONObject }, ...prev, ]); - setHashInput(""); + setStateHashInput(""); } catch (error: unknown) { toast.error( - `Failed to fetch state root: ${ + `Failed to fetch state: ${ error instanceof Error ? error.message : "Unknown error" }` ); } - }, [endpoint, hashInput]); + }, [endpoint, stateHashInput]); + + const removeResult = (index: number) => { + setResults((prev) => prev.filter((_, i) => i !== index)); + }; + + const renderJSON = (json: JSONObject) => { + return ( +
+        {JSON.stringify(json, null, 2)}
+      
+ ); + }; return ( -
+
-

Fetch State

+

Fetch Block

setHashInput(e.target.value)} + value={blockHashInput} + onChange={(e) => setBlockHashInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && fetchBlock()} + /> + +
+
+
+

Fetch State

+
+ setStateHashInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && fetchState()} />
- {stateRoots.map((pair, index) => ( -
-

Block Hash

-

{pair.blockHash}

-

State Root

-

- {pair.stateRoot || "No state root found"} -

-
- ))} +
+ {results.map((result, index) => ( +
+ +

+ {result.type === "block" ? "Block" : "State"} +

+ {renderJSON(result.data)} +
+ ))} +
); } diff --git a/components/telemetry-dashboard.tsx b/components/telemetry-dashboard.tsx index 1d37152..70ccd81 100644 --- a/components/telemetry-dashboard.tsx +++ b/components/telemetry-dashboard.tsx @@ -19,7 +19,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { Hash, Cpu, Blocks, Users, X, RefreshCw } from "lucide-react"; -import { connectToNode, sendRequest, disconnectFromNode } from "@/lib/ws"; +import { sendRequest, disconnectFromNode } from "@/lib/ws"; import { toast } from "sonner"; type NodeInfo = { @@ -85,46 +85,22 @@ export default function TelemetryDashboard() { [setNodeConnected] ); - const connectAndSubscribe = useCallback( + // TODO: use real subscription + const startPolling = useCallback( (endpoint: string) => { - const connect = async () => { - try { - await connectToNode(endpoint); - setNodeConnected(endpoint, true); - await updateNodeInfo(endpoint); - } catch (error: unknown) { - setNodeConnected(endpoint, false); - toast.error( - `Failed to connect to ${endpoint}: ${ - error instanceof Error ? error.message : "Unknown error" - }` - ); - } - }; + if (intervalsRef.current[endpoint]) { + clearInterval(intervalsRef.current[endpoint]); + } - // Initial connection attempt - connect(); + updateNodeInfo(endpoint); - // Set up interval for periodic updates and reconnection attempts - const interval = setInterval(async () => { - if (!nodeInfo.find((node) => node.endpoint === endpoint)?.connected) { - // If not connected, try to reconnect - await connect(); - } else { - // If connected, update node info - await updateNodeInfo(endpoint); - } + const interval = setInterval(() => { + updateNodeInfo(endpoint); }, 4000); intervalsRef.current[endpoint] = interval; - - return () => { - clearInterval(interval); - delete intervalsRef.current[endpoint]; - }; }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [setNodeConnected, updateNodeInfo] + [updateNodeInfo] ); useEffect(() => { @@ -140,7 +116,7 @@ export default function TelemetryDashboard() { ); endpoints.forEach((endpoint) => { - connectAndSubscribe(endpoint); + startPolling(endpoint); }); } }; @@ -150,10 +126,9 @@ export default function TelemetryDashboard() { return () => { const currentIntervals = intervalsRef.current; Object.values(currentIntervals).forEach(clearInterval); - nodeInfo.forEach((node) => disconnectFromNode(node.endpoint)); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [connectAndSubscribe]); + }, []); const validateUrl = (url: string) => { const pattern = /^(wss?:\/\/).+$/; @@ -171,7 +146,7 @@ export default function TelemetryDashboard() { ...prev, { endpoint: rpcInput, connected: false }, ]); // Add as disconnected - connectAndSubscribe(rpcInput); + startPolling(rpcInput); setRpcInput(""); const savedEndpoints = JSON.parse( @@ -180,7 +155,7 @@ export default function TelemetryDashboard() { savedEndpoints.push(rpcInput); localStorage.setItem(STORAGE_KEY, JSON.stringify(savedEndpoints)); } - }, [rpcInput, nodeInfo, connectAndSubscribe]); + }, [rpcInput, nodeInfo, startPolling]); const removeRpc = useCallback((endpoint: string) => { disconnectFromNode(endpoint); @@ -212,9 +187,9 @@ export default function TelemetryDashboard() { // Reconnect to all endpoints nodeInfo.forEach((node) => { - connectAndSubscribe(node.endpoint); + startPolling(node.endpoint); }); - }, [nodeInfo, connectAndSubscribe]); + }, [nodeInfo, startPolling]); return (
@@ -233,7 +208,7 @@ export default function TelemetryDashboard() {
-
+
@@ -274,7 +249,7 @@ export default function TelemetryDashboard() { className={`cursor-pointer hover:bg-muted/50 ${ node.connected ? "" - : "bg-red-100 dark:bg-red-500 text-black dark:text-white" + : "bg-red-200 dark:bg-red-500 text-black dark:text-white hover:bg-red-100" }`} onClick={() => { router.push( diff --git a/lib/ws.ts b/lib/ws.ts index 6f5891b..1d15dc5 100644 --- a/lib/ws.ts +++ b/lib/ws.ts @@ -1,11 +1,8 @@ -type JSONValue = - | string - | number - | boolean - | null - | undefined - | JSONValue[] - | { [key: string]: JSONValue }; +type JSONValue = string | number | boolean | null; + +export type JSONObject = + | JSONObject[] + | { [key: string]: JSONValue | JSONObject }; type WebSocketMessage = { jsonrpc: string; @@ -16,9 +13,11 @@ type WebSocketMessage = { type WebSocketSubscribeMessage = Omit; +type RPCResult = JSONValue | JSONObject; + type WebSocketResponse = { jsonrpc: string; - result?: JSONValue; + result?: RPCResult; error?: { code: number; message: string; @@ -30,7 +29,7 @@ type WebSocketSubscriptionResponse = { jsonrpc: string; method: string; params: { - result: JSONValue; + result: JSONObject; subscription: string; // id }; }; @@ -40,11 +39,11 @@ type Connection = { pendingRequests: Map< number, { - resolve: (value: JSONValue) => void; + resolve: (value: RPCResult) => void; reject: (reason: unknown) => void; } >; - subscriptionCallbacks: Map void>; + subscriptionCallbacks: Map void>; }; const connections: Map = new Map(); @@ -81,7 +80,7 @@ export function connectToNode(url: string): Promise { if (data.error) { request.reject(new Error(data.error.message)); } else { - request.resolve(data.result); + request.resolve(data.result || {}); } connection.pendingRequests.delete(data.id); } @@ -118,8 +117,22 @@ export function sendRequest( url: string, method: string, params: Record = {} -): Promise { - return new Promise((resolve, reject) => { +): Promise { + return new Promise(async (resolve, reject) => { + if (!connections.has(url)) { + try { + await connectToNode(url); + } catch (error) { + reject( + new Error( + `Failed to connect to ${url}: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ) + ); + } + } + const connection = connections.get(url); if (!connection) { reject(new Error(`No active connection to ${url}`)); @@ -143,7 +156,7 @@ export function subscribe( url: string, method: string, params: Record = {}, - callback: (result: JSONValue) => void + callback: (result: JSONObject) => void ): Promise { return new Promise((resolve, reject) => { const connection = connections.get(url);