From a241544b4b1ff090dee132dd0e477f616e07ddf3 Mon Sep 17 00:00:00 2001 From: Qiwei Yang Date: Wed, 23 Oct 2024 22:01:48 +0800 Subject: [PATCH] update dashboard (#3) * update * update --- components/state.tsx | 62 +++++----- components/telemetry-dashboard.tsx | 188 +++++++++++++++++++---------- lib/ws.ts | 29 +++-- 3 files changed, 180 insertions(+), 99 deletions(-) diff --git a/components/state.tsx b/components/state.tsx index 19b7e47..1a256ff 100644 --- a/components/state.tsx +++ b/components/state.tsx @@ -1,20 +1,19 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { connectToNode, sendRequest, disconnectFromNode } from "@/lib/ws"; import { toast } from "sonner"; -import { useEffect } from "react"; type KeyValuePair = { - key: string; - value: string; + blockHash: string; + stateRoot: string; }; export default function State({ endpoint }: { endpoint: string }) { - const [keyInput, setKeyInput] = useState(""); - const [keyValuePairs, setKeyValuePairs] = useState([]); + const [hashInput, setHashInput] = useState(""); + const [stateRoots, setStateRoots] = useState([]); useEffect(() => { connectToNode(endpoint).catch((error: unknown) => @@ -30,52 +29,59 @@ export default function State({ endpoint }: { endpoint: string }) { }; }, [endpoint]); - const fetchKeyValue = useCallback(async () => { - if (keyInput.length !== 66 || !keyInput.startsWith("0x")) { - toast.error("Key must be a valid hex string `0x{string}`"); + const fetchState = useCallback(async () => { + if (hashInput.length !== 66 && hashInput.length !== 0) { + toast.error( + "Hash must be a valid 32 byte hex string `0x{string}` or empty for best block" + ); return; } try { - const result = await sendRequest(endpoint, "chain_getState", { - key: keyInput, - }); - setKeyValuePairs((prev) => [ - { key: keyInput, value: result as string }, + 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, + }, ...prev, ]); - setKeyInput(""); + setHashInput(""); } catch (error: unknown) { toast.error( - `Failed to fetch key value: ${ + `Failed to fetch state root: ${ error instanceof Error ? error.message : "Unknown error" }` ); } - }, [endpoint, keyInput]); + }, [endpoint, hashInput]); return (
-

Fetch Value

+

Fetch State

setKeyInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && fetchKeyValue()} + placeholder="0x... (optional 32 byte block hash)" + value={hashInput} + onChange={(e) => setHashInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && fetchState()} /> - +
- {keyValuePairs.map((pair, index) => ( + {stateRoots.map((pair, index) => (
-

Key

-

{pair.key}

-

Value

+

Block Hash

+

{pair.blockHash}

+

State Root

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

))} diff --git a/components/telemetry-dashboard.tsx b/components/telemetry-dashboard.tsx index 357acde..138150d 100644 --- a/components/telemetry-dashboard.tsx +++ b/components/telemetry-dashboard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { @@ -17,10 +17,9 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { Hash, Cpu, Blocks, Users, X } from "lucide-react"; +import { Hash, Cpu, Blocks, Users, X, RefreshCw } from "lucide-react"; import { connectToNode, sendRequest, disconnectFromNode } from "@/lib/ws"; import { toast } from "sonner"; -import { useRouter } from "next/navigation"; type NodeInfo = { endpoint: string; @@ -36,7 +35,94 @@ const STORAGE_KEY = "telemetry-endpoints"; export default function TelemetryDashboard() { const [rpcInput, setRpcInput] = useState(""); const [nodeInfo, setNodeInfo] = useState([]); - const router = useRouter(); + const intervalsRef = useRef<{ [key: string]: NodeJS.Timeout }>({}); + + const setNodeConnected = useCallback( + (endpoint: string, connected: boolean) => { + setNodeInfo((prev) => + prev.map((node) => + node.endpoint === endpoint ? { ...node, connected } : node + ) + ); + }, + [] + ); + + const updateNodeInfo = useCallback( + async (endpoint: string) => { + try { + const data = await sendRequest(endpoint, "telemetry_getUpdate"); + setNodeInfo((prev) => { + const index = prev.findIndex((node) => node.endpoint === endpoint); + if (index !== -1) { + const newNodeInfo = [...prev]; + newNodeInfo[index] = { + ...newNodeInfo[index], + endpoint, + ...(data as Partial), + connected: true, + }; + return newNodeInfo; + } else { + return [ + ...prev, + { endpoint, ...(data as Partial), connected: true }, + ]; + } + }); + } catch (error: unknown) { + setNodeConnected(endpoint, false); + toast.error( + `Failed to update info for ${endpoint}: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } + }, + [setNodeConnected] + ); + + const connectAndSubscribe = 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" + }` + ); + } + }; + + // Initial connection attempt + connect(); + + // 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); + } + }, 4000); + + intervalsRef.current[endpoint] = interval; + + return () => { + clearInterval(interval); + delete intervalsRef.current[endpoint]; + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [setNodeConnected, updateNodeInfo] + ); useEffect(() => { const loadSavedEndpoints = () => { @@ -59,60 +145,12 @@ export default function TelemetryDashboard() { loadSavedEndpoints(); return () => { + const currentIntervals = intervalsRef.current; + Object.values(currentIntervals).forEach(clearInterval); nodeInfo.forEach((node) => disconnectFromNode(node.endpoint)); }; - }, []); - - const updateNodeInfo = useCallback(async (endpoint: string) => { - try { - const data = await sendRequest(endpoint, "telemetry_getUpdate"); - setNodeInfo((prev) => { - const index = prev.findIndex((node) => node.endpoint === endpoint); - if (index !== -1) { - const newNodeInfo = [...prev]; - newNodeInfo[index] = { - ...newNodeInfo[index], - endpoint, - ...(data as Partial), - connected: true, - }; - return newNodeInfo; - } else { - return [ - ...prev, - { endpoint, ...(data as Partial), connected: true }, - ]; - } - }); - } catch (error: unknown) { - toast.error( - `Failed to update info for ${endpoint}: ${ - error instanceof Error ? error.message : "Unknown error" - }` - ); - } - }, []); - - const connectAndSubscribe = useCallback( - async (endpoint: string) => { - try { - await connectToNode(endpoint); - updateNodeInfo(endpoint); - setNodeInfo((prev) => - prev.map((node) => - node.endpoint === endpoint ? { ...node, connected: true } : node - ) - ); - } catch (error: unknown) { - toast.error( - `Failed to connect to ${endpoint}: ${ - error instanceof Error ? error.message : "Unknown error" - }` - ); - } - }, - [updateNodeInfo] - ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connectAndSubscribe]); const validateUrl = (url: string) => { const pattern = /^(wss?:\/\/).+$/; @@ -152,14 +190,28 @@ export default function TelemetryDashboard() { (savedEndpoint: string) => savedEndpoint !== endpoint ); localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedEndpoints)); + + if (intervalsRef.current[endpoint]) { + clearInterval(intervalsRef.current[endpoint]); + delete intervalsRef.current[endpoint]; + } }, []); - const handleRowClick = useCallback( - (endpoint: string) => { - router.push(`/node?endpoint=${encodeURIComponent(endpoint)}`); - }, - [router] - ); + const refreshAll = useCallback(() => { + // Disconnect from all endpoints + nodeInfo.forEach((node) => { + disconnectFromNode(node.endpoint); + if (intervalsRef.current[node.endpoint]) { + clearInterval(intervalsRef.current[node.endpoint]); + delete intervalsRef.current[node.endpoint]; + } + }); + + // Reconnect to all endpoints + nodeInfo.forEach((node) => { + connectAndSubscribe(node.endpoint); + }); + }, [nodeInfo, connectAndSubscribe]); return (
@@ -174,6 +226,9 @@ export default function TelemetryDashboard() { className="flex-grow" /> +
@@ -218,7 +273,14 @@ export default function TelemetryDashboard() { ? "" : "bg-red-100 dark:bg-red-500 text-black dark:text-white" }`} - onClick={() => handleRowClick(node.endpoint)} + onClick={() => + window.open( + `jam-dashboard/node/?endpoint=${encodeURIComponent( + node.endpoint + )}`, + "_blank" + ) + } > {node.name || "-"} diff --git a/lib/ws.ts b/lib/ws.ts index 1ccba07..6f5891b 100644 --- a/lib/ws.ts +++ b/lib/ws.ts @@ -14,6 +14,8 @@ type WebSocketMessage = { params: Record; }; +type WebSocketSubscribeMessage = Omit; + type WebSocketResponse = { jsonrpc: string; result?: JSONValue; @@ -84,11 +86,10 @@ export function connectToNode(url: string): Promise { connection.pendingRequests.delete(data.id); } } else if ("method" in data && "params" in data) { - // if subscription updates - const { result, subscription } = data.params; - const callback = connection.subscriptionCallbacks.get(subscription); + // subscription updates + const callback = connection.subscriptionCallbacks.get(data.method); if (callback) { - callback(result); + callback(data.params); } } } @@ -151,17 +152,29 @@ export function subscribe( return; } - const id = nextRequestId++; - const message: WebSocketMessage = { + const message: WebSocketSubscribeMessage = { jsonrpc: "2.0", method, - id, params, }; - connection.subscriptionCallbacks.set(id.toString(), callback); + connection.subscriptionCallbacks.set(method, callback); connection.ws.send(JSON.stringify(message)); resolve(); }); } + +export function unsubscribe(url: string, method: string): Promise { + return new Promise((resolve, reject) => { + const connection = connections.get(url); + if (!connection) { + reject(new Error(`No active connection to ${url}`)); + return; + } + + connection.subscriptionCallbacks.delete(method); + + resolve(); + }); +}