diff --git a/packages/admin-ui/src/__mock-backend__/index.ts b/packages/admin-ui/src/__mock-backend__/index.ts index 6b51f2836e..c0bb24001d 100644 --- a/packages/admin-ui/src/__mock-backend__/index.ts +++ b/packages/admin-ui/src/__mock-backend__/index.ts @@ -479,20 +479,26 @@ export const otherCalls: Omit = { gnosis: null }), - validatorsFilterAttestingByNetwork: async () => ({ - mainnet: { validators: ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"] }, - hoodi: { validators: [] }, - gnosis: null - }), - - validatorsBalancesByNetwork: async () => ({ + validatorsDataByNetwork: async () => ({ mainnet: { + active: { validators: ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"] }, + attesting: { validators: ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"] }, balances: { - "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef": "32000000000" + balances: { + "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef": "32000000000" + } } }, - hoodi: { balances: {} }, - gnosis: null + hoodi: { + active: { validators: [] }, + attesting: { validators: [] }, + balances: { balances: {} } + }, + gnosis: { + active: null, + attesting: null, + balances: null + } }), signerByNetworkGet: async () => ({ diff --git a/packages/admin-ui/src/hooks/PWA/useSystemHealth.ts b/packages/admin-ui/src/hooks/PWA/useSystemHealth.ts deleted file mode 100644 index d4873dfb68..0000000000 --- a/packages/admin-ui/src/hooks/PWA/useSystemHealth.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useApi } from "api"; -import { useEffect } from "react"; -import humanFileSize from "utils/humanFileSize"; - -export function useSystemHealth() { - const cpuStats = useApi.statsCpuGet(); - const memoryStats = useApi.statsMemoryGet(); - const diskStats = useApi.statsDiskGet(); - const hostUptime = useApi.getHostUptime(); - - // Just showing "loading" for the first load - const isLoading = - (cpuStats.isValidating && !cpuStats.data) || - (memoryStats.isValidating && !memoryStats.data) || - (diskStats.isValidating && !diskStats.data) || - (hostUptime.isValidating && !hostUptime.data); - - useEffect(() => { - const interval = setInterval(() => { - cpuStats.revalidate(); - diskStats.revalidate(); - memoryStats.revalidate(); - }, 5 * 1000); // every 5 seconds - return () => { - clearInterval(interval); - }; - }, [cpuStats, diskStats, memoryStats, hostUptime]); - - useEffect(() => { - const interval = setInterval( - () => { - hostUptime.revalidate(); - }, - 60 * 5 * 1000 // every 5 minutes - ); - return () => { - clearInterval(interval); - }; - }, [hostUptime]); - - return { - cpuUsage: cpuStats.data ? Math.floor(cpuStats.data.usedPercentage) : 0, - cpuTemp: cpuStats.data ? Math.floor(cpuStats.data.temperatureAverage) : 0, - memoryUsed: memoryStats.data ? humanFileSize(memoryStats.data.used, false, 0) : null, - memoryTotal: memoryStats.data ? humanFileSize(memoryStats.data.total) : null, - memoryPercentage: memoryStats.data ? Math.floor(memoryStats.data.usedPercentage) : 0, - diskUsed: diskStats.data ? humanFileSize(diskStats.data.used, false) : null, - diskTotal: diskStats.data ? humanFileSize(diskStats.data.total) : null, - diskPercentage: diskStats.data ? Math.floor(diskStats.data.usedPercentage) : 0, - uptime: hostUptime.data ? formatUptime(hostUptime.data) : null, - isLoading - }; -} - -// Formats uptime string from "up 1 week, 2 days, 3 hours, 4 minutes" to "up 1w 2d 3h 4m" -function formatUptime(uptime: string): string { - if (!uptime) return ""; - const clean = uptime.replace(/^up\s*/, ""); - return clean; -} diff --git a/packages/admin-ui/src/hooks/useNetworkStats.ts b/packages/admin-ui/src/hooks/useNetworkStats.ts index 413b732c8f..738f73d022 100644 --- a/packages/admin-ui/src/hooks/useNetworkStats.ts +++ b/packages/admin-ui/src/hooks/useNetworkStats.ts @@ -1,5 +1,16 @@ -import { DashboardSupportedNetwork, Network, NetworkStats, NodeStatus } from "@dappnode/types"; -import { useApi } from "api"; +import { useState, useEffect, useCallback, useRef } from "react"; +import { + ClientError, + ClientResult, + DashboardSupportedNetwork, + Network, + NetworkStats, + NodeStatus, + NodeStatusByNetwork, + SignerStatus, + ValidatorsDataByNetwork +} from "@dappnode/types"; +import { api, useApi } from "api"; import EthLogo from "img/logos/eth-logo.svg?react"; import GnosisLogo from "img/logos/gnosis-logo.svg?react"; import LuksoLogo from "img/logos/lukso-logo.svg?react"; @@ -43,31 +54,151 @@ const networkFeatures: Record = { [Network.Sepolia]: { hasValidators: false, logo: EthLogo } }; +/** + * Collects all non-null dnpNames from the consensus and execution client maps. + */ +function collectDnpNames( + consensusClients: Partial> | undefined, + executionClients: Partial> | undefined +): Set { + const names = new Set(); + if (consensusClients) { + for (const dnpName of Object.values(consensusClients)) { + if (dnpName) names.add(dnpName); + } + } + if (executionClients) { + for (const dnpName of Object.values(executionClients)) { + if (dnpName) names.add(dnpName); + } + } + return names; +} + +/** + * Determines which supported networks have at least one installed client package + * (either consensus or execution) based on the installed packages. + */ +function getNetworksWithInstalledClients( + consensusClients: Partial> | undefined, + executionClients: Partial> | undefined, + installedDnpNames: Set +): DashboardSupportedNetwork[] { + const networks: DashboardSupportedNetwork[] = []; + for (const network of supportedNetworks) { + const cc = consensusClients?.[network]; + const ec = executionClients?.[network]; + const ccInstalled = cc ? installedDnpNames.has(cc) : false; + const ecInstalled = ec ? installedDnpNames.has(ec) : false; + if (ccInstalled || ecInstalled) { + networks.push(network); + } + } + return networks; +} + +type SignersStatusByNetwork = Partial>; + export function useNetworkStats() { - const nodesStatusReq = useApi.nodeStatusGetByNetwork({ networks: supportedNetworks }); + // Step 1: Fetch consensus and execution clients for all supported networks const consensusClientsReq = useApi.consensusClientsGetByNetworks({ networks: supportedNetworks }); const executionClientsReq = useApi.executionClientsGetByNetworks({ networks: supportedNetworks }); - const validatorsFilterActiveReq = useApi.validatorsFilterActiveByNetwork({ networks: supportedNetworks }); - const validatorsFilterAttestingReq = useApi.validatorsFilterAttestingByNetwork({ networks: supportedNetworks }); - const validatorsBalancesReq = useApi.validatorsBalancesByNetwork({ networks: supportedNetworks }); - const signersStatusReq = useApi.signerByNetworkGet({ networks: supportedNetworks }); - const nodesStatusByNetwork = nodesStatusReq.data; const consensusClientsByNetwork = consensusClientsReq.data; const executionClientsByNetwork = executionClientsReq.data; - const validatorsActiveByNetwork = validatorsFilterActiveReq.data; - const validatorsAttestingByNetwork = validatorsFilterAttestingReq.data; - const validatorsBalancesByNetwork = validatorsBalancesReq.data; - const signersStatusByNetwork = signersStatusReq.data; - const clientsLoading = - nodesStatusReq.isValidating || consensusClientsReq.isValidating || executionClientsReq.isValidating; + // States fetched only for networks with installed clients + const [nodesStatusByNetwork, setNodesStatusByNetwork] = useState(undefined); + const [nodesStatusLoading, setNodesStatusLoading] = useState(false); + const [validatorsData, setValidatorsData] = useState(undefined); + const [signersStatusByNetwork, setSignersStatusByNetwork] = useState(undefined); + const [validatorsLoading, setValidatorsLoading] = useState(false); + const [installedDnpNames, setInstalledDnpNames] = useState>(new Set()); + + const lastFetchedNetworksKey = useRef(""); + + const fetchNetworkData = useCallback(async () => { + if (!consensusClientsByNetwork || !executionClientsByNetwork) return; - const validatorsLoading = - validatorsFilterActiveReq.isValidating || - validatorsFilterAttestingReq.isValidating || - validatorsBalancesReq.isValidating || - signersStatusReq.isValidating; + // Step 2: Collect all dnpNames from both client responses + const dnpNames = collectDnpNames(consensusClientsByNetwork, executionClientsByNetwork); + if (dnpNames.size === 0) { + setNodesStatusByNetwork({}); + setValidatorsData({}); + setSignersStatusByNetwork({}); + return; + } + + // Step 3: Verify which packages are installed + let installedDnpNames: Set; + try { + const installedPackages = await api.packagesGet(); + const allInstalledNames = new Set(installedPackages.map((pkg) => pkg.dnpName)); + installedDnpNames = new Set([...dnpNames].filter((name) => allInstalledNames.has(name))); + setInstalledDnpNames(installedDnpNames); + } catch (e) { + console.error("Error fetching installed packages for node status", e); + return; + } + + if (installedDnpNames.size === 0) { + setNodesStatusByNetwork({}); + setValidatorsData({}); + setSignersStatusByNetwork({}); + return; + } + + const networksWithClients = getNetworksWithInstalledClients( + consensusClientsByNetwork, + executionClientsByNetwork, + installedDnpNames + ); + + if (networksWithClients.length === 0) { + setNodesStatusByNetwork({}); + setValidatorsData({}); + setSignersStatusByNetwork({}); + return; + } + + // Avoid re-fetching if the networks list hasn't changed + const networksKey = JSON.stringify(networksWithClients); + if (networksKey === lastFetchedNetworksKey.current) return; + lastFetchedNetworksKey.current = networksKey; + + // Step 4: Fetch node status, combined validators data, and signer data + // validatorsDataByNetwork combines active, attesting, and balances in a single + // backend call, reducing beacon chain API requests + try { + setNodesStatusLoading(true); + setValidatorsLoading(true); + + const [nodeStatus, validatorsResult, signersStatus] = await Promise.all([ + api.nodeStatusGetByNetwork({ networks: networksWithClients }), + api.validatorsDataByNetwork({ networks: networksWithClients }), + api.signerByNetworkGet({ networks: networksWithClients }) + ]); + + setNodesStatusByNetwork(nodeStatus); + setValidatorsData(validatorsResult); + setSignersStatusByNetwork(signersStatus); + } catch (e) { + console.error("Error fetching network data", e); + } finally { + setNodesStatusLoading(false); + setValidatorsLoading(false); + } + }, [consensusClientsByNetwork, executionClientsByNetwork]); + + useEffect(() => { + fetchNetworkData(); + }, [fetchNetworkData]); + + const clientsLoading = + nodesStatusLoading || + consensusClientsReq.isValidating || + executionClientsReq.isValidating || + nodesStatusByNetwork === undefined; const networkStats: NetworkStats = {}; @@ -76,9 +207,21 @@ export function useNetworkStats() { const nodeStatusData: NodeStatus | undefined = nodesStatusByNetwork?.[network]; const consensusClientDnp = consensusClientsByNetwork?.[network]; const executionClientDnp = executionClientsByNetwork?.[network]; - const validatorsActive = validatorsActiveByNetwork?.[network]; - const validatorsAttesting = validatorsAttestingByNetwork?.[network]; - const balancesObj = validatorsBalancesByNetwork?.[network]?.balances; + + // Only include client results if the corresponding package is actually installed + const ecInstalled = executionClientDnp ? installedDnpNames.has(executionClientDnp) : false; + const ccInstalled = consensusClientDnp ? installedDnpNames.has(consensusClientDnp) : false; + + const filteredNodeStatus: NodeStatus | undefined = nodeStatusData + ? { + ec: ecInstalled ? nodeStatusData.ec : null, + cc: ccInstalled ? nodeStatusData.cc : null + } + : undefined; + + const validatorsActive = validatorsData?.[network]?.active; + const validatorsAttesting = validatorsData?.[network]?.attesting; + const balancesObj = validatorsData?.[network]?.balances?.balances; const signerStatus = signersStatusByNetwork?.[network] ?? { isInstalled: false, brainRunning: false }; let total = 0; @@ -90,7 +233,7 @@ export function useNetworkStats() { total = validatorsActive.validators.length; beaconError = validatorsActive.beaconError; } - if (features.hasValidators && validatorsAttesting) { + if (features.hasValidators && validatorsAttesting && !validatorsAttesting.beaconError) { attesting = validatorsAttesting.validators.length; } if (features.hasValidators && balancesObj && validatorsActive) { @@ -98,7 +241,7 @@ export function useNetworkStats() { balance = validatorsActive.validators.reduce((acc, pk) => acc + (parseFloat(balancesObj[pk]) || 0), 0); } - const validatorsData = features.hasValidators + const validatorsInfo = features.hasValidators ? { validators: { total, @@ -111,15 +254,17 @@ export function useNetworkStats() { } : undefined; - // Remove network where no node data is available - if (nodeStatusData && (nodeStatusData.ec || nodeStatusData.cc)) { + // Show network when any client has data or an error (but not when both are null) + const hasEc = filteredNodeStatus?.ec !== null && filteredNodeStatus?.ec !== undefined; + const hasCc = filteredNodeStatus?.cc !== null && filteredNodeStatus?.cc !== undefined; + if (filteredNodeStatus && (hasEc || hasCc)) { networkStats[network] = { - nodeStatus: nodeStatusData, + nodeStatus: filteredNodeStatus, clientsDnps: { - ecDnp: executionClientDnp || null, - ccDnp: consensusClientDnp || null + ecDnp: ecInstalled ? executionClientDnp || null : null, + ccDnp: ccInstalled ? consensusClientDnp || null : null }, - ...validatorsData, + ...validatorsInfo, hasValidators: features.hasValidators, beaconExplorer: features.beaconExplorer || undefined }; @@ -135,3 +280,7 @@ export function useNetworkStats() { return { networkStats, clientsLoading, getNetworkLogo, validatorsLoading }; } + +export function isClientError(result: ClientResult): result is ClientError { + return result !== null && typeof result === "object" && "error" in result; +} diff --git a/packages/admin-ui/src/hooks/useSystemHealth.ts b/packages/admin-ui/src/hooks/useSystemHealth.ts index d4873dfb68..e717db6df2 100644 --- a/packages/admin-ui/src/hooks/useSystemHealth.ts +++ b/packages/admin-ui/src/hooks/useSystemHealth.ts @@ -41,10 +41,10 @@ export function useSystemHealth() { return { cpuUsage: cpuStats.data ? Math.floor(cpuStats.data.usedPercentage) : 0, cpuTemp: cpuStats.data ? Math.floor(cpuStats.data.temperatureAverage) : 0, - memoryUsed: memoryStats.data ? humanFileSize(memoryStats.data.used, false, 0) : null, + memoryUsed: memoryStats.data ? humanFileSize(memoryStats.data.used, true, 0) : null, memoryTotal: memoryStats.data ? humanFileSize(memoryStats.data.total) : null, memoryPercentage: memoryStats.data ? Math.floor(memoryStats.data.usedPercentage) : 0, - diskUsed: diskStats.data ? humanFileSize(diskStats.data.used, false) : null, + diskUsed: diskStats.data ? humanFileSize(diskStats.data.used) : null, diskTotal: diskStats.data ? humanFileSize(diskStats.data.total) : null, diskPercentage: diskStats.data ? Math.floor(diskStats.data.usedPercentage) : 0, uptime: hostUptime.data ? formatUptime(hostUptime.data) : null, diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/NetworkCards.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/NetworkCards.tsx index afb8110762..7d9fde833e 100644 --- a/packages/admin-ui/src/pages/dashboard_v2/components/NetworkCards.tsx +++ b/packages/admin-ui/src/pages/dashboard_v2/components/NetworkCards.tsx @@ -4,6 +4,7 @@ import { Link } from "react-router-dom"; import { basePath as stakersBasePath, relativePath as stakersPath } from "pages/stakers"; import { relativePath as packagesRelativePath } from "pages/packages"; import { Network, NetworkStatus, NodeStatus } from "@dappnode/types"; +import { isClientError } from "hooks/useNetworkStats"; import newTabProps from "utils/newTabProps"; import { gweiToToken } from "utils/gweiToToken"; import { capitalize } from "utils/strings"; @@ -49,10 +50,17 @@ export const StatusCard = ({ }; }) => { const navigate = useNavigate(); - const execution = data && data.ec; - const consensus = data && data.cc; - const consensusSynced = consensus ? consensus.isSynced : false; // Used to determine if we can show execution sync progress to avoid synced false positives + const ecResult = data?.ec ?? null; + const ccResult = data?.cc ?? null; + + const ecError = ecResult && isClientError(ecResult) ? ecResult : null; + const ccError = ccResult && isClientError(ccResult) ? ccResult : null; + + const execution = ecResult && !isClientError(ecResult) ? ecResult : null; + const consensus = ccResult && !isClientError(ccResult) ? ccResult : null; + + const consensusSynced = consensus?.isSynced ?? false; return ( }> @@ -60,91 +68,30 @@ export const StatusCard = ({ ) : data ? (
- {execution && ( -
-
-
-
EXECUTION
- - {clientsDnps?.ecDnp ? ( - - {capitalize(execution.name ?? "-")} - - ) : ( - capitalize(execution.name ?? "-") - )} - -
-
-
-
PEERS
- {execution.peers} -
-
- {consensusSynced ? ( - <> -
#{execution.currentBlock}
-
- {execution.isSynced ? "synced" : "syncing"} -
- - ) : ( - - Execution client status will be available once the consensus client finishes syncing. - - } - placement="top" - > -
-
- -
-
Waiting
-
-
- )} -
-
-
- {consensusSynced && !execution.isSynced && } -
- )} + +
- {consensus && ( -
-
- <> -
-
CONSENSUS
- - {clientsDnps?.ccDnp ? ( - - {capitalize(consensus.name ?? "-")} - - ) : ( - capitalize(consensus.name ?? "-") - )} - -
-
-
-
PEERS
- {consensus.peers} -
-
-
#{consensus.currentBlock}
-
- {consensus.isSynced ? "synced" : "syncing"} -
-
-
- -
- {!consensus.isSynced && } -
- )} + +
) : (
Data could not be fetched
@@ -161,6 +108,144 @@ export const StatusCard = ({ ); }; +/** + * Renders a single client row (execution or consensus) with one of four states: + * - Not installed + * - Error (with tooltip) + * - Syncing/synced (with optional progress bar) + * - Waiting (execution waiting for consensus to sync) + */ +const ClientSection = ({ + label, + network, + dnpName, + clientData, + clientError, + isInstalled, + showWaiting = false, + showProgress = false, + progress +}: { + label: string; + network: string; + dnpName: string | null; + clientData: { name: string; isSynced: boolean; currentBlock: number; peers: number; progress: number } | null; + clientError: { error: string } | null; + isInstalled: boolean; + showWaiting?: boolean; + showProgress?: boolean; + progress?: number; +}) => { + const parseClientName = (name: string) => capitalize(name.split(".")[0].split("-")[0] ?? "-"); + + const clientLink = dnpName ? ( + + {clientData ? capitalize(clientData.name ?? "-") : parseClientName(dnpName)} + + ) : clientData ? ( + capitalize(clientData.name ?? "-") + ) : ( + "-" + ); + + if (!isInstalled) { + return ( +
+
+
+
{label}
+ - +
+
+
+
+
Not installed
+
+
+
+
+ ); + } + + if (clientError) { + return ( +
+
+
+
{label}
+ {clientLink} +
+
+
+
+ {clientError.error}} + placement="top" + > +
+
+ +
+
Error
+
+
+
+
+
+
+ ); + } + + if (clientData) { + return ( +
+
+
+
{label}
+ {clientLink} +
+
+
+
PEERS
+ {clientData.peers} +
+
+ {showWaiting ? ( + + {label.charAt(0) + label.slice(1).toLowerCase()} client status will be available once the + consensus client finishes syncing. + + } + placement="top" + > +
+
+ +
+
Waiting
+
+
+ ) : ( + <> +
#{clientData.currentBlock}
+
+ {clientData.isSynced ? "synced" : "syncing"} +
+ + )} +
+
+
+ {showProgress && progress !== undefined && } +
+ ); + } + + return null; +}; + export const ValidatorsCard = ({ network, validatorsLoading, @@ -299,7 +384,7 @@ export const RewardsCard = ({ export const NoNodesCard = () => { return ( -
+
No nodes configured yet!
You haven't set up a node on any network.
diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/SystemHealth.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/SystemHealth.tsx index 04096e6df2..f9436bb2df 100644 --- a/packages/admin-ui/src/pages/dashboard_v2/components/SystemHealth.tsx +++ b/packages/admin-ui/src/pages/dashboard_v2/components/SystemHealth.tsx @@ -54,10 +54,10 @@ export default function SystemHealth() { warning={85} /> - } data={`${memoryUsed} /${memoryTotal}`}> + } data={`${memoryUsed} / ${memoryTotal}`}> - } data={`${diskUsed} /${diskTotal}`}> + } data={`${diskUsed} / ${diskTotal}`}>
diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/networkStats.scss b/packages/admin-ui/src/pages/dashboard_v2/components/networkStats.scss index 9e5a364430..6477f152bb 100644 --- a/packages/admin-ui/src/pages/dashboard_v2/components/networkStats.scss +++ b/packages/admin-ui/src/pages/dashboard_v2/components/networkStats.scss @@ -191,6 +191,7 @@ align-items: center; justify-content: center; gap: 5px; + text-align: center; > div { font-size: 16px; diff --git a/packages/admin-ui/src/utils/gweiToToken.ts b/packages/admin-ui/src/utils/gweiToToken.ts index 60f796ead5..b36f279f7f 100644 --- a/packages/admin-ui/src/utils/gweiToToken.ts +++ b/packages/admin-ui/src/utils/gweiToToken.ts @@ -1,4 +1,5 @@ import { Network } from "@dappnode/types"; +import { formatUnits } from "ethers"; /** * Converts a gwei value (string or number) to the main token unit for the given network. @@ -7,10 +8,10 @@ import { Network } from "@dappnode/types"; * @param decimals - Number of decimals to display (default: 4) * @returns The value in the correct token unit as a string */ -export function gweiToToken(gwei: string | number, network: Network, decimals = 4): string { - const n = typeof gwei === "string" ? parseFloat(gwei) : gwei; - if (isNaN(n)) return "-"; - let value = n / 1e9; +export function gweiToToken(gwei: number, network: Network, decimals = 4): string { + if (isNaN(gwei)) return "-"; + // Convert from gwei to ETH + let valueInEth = parseFloat(formatUnits(gwei, "gwei")); let symbol = "ETH"; switch (network) { case Network.Lukso: @@ -19,12 +20,14 @@ export function gweiToToken(gwei: string | number, network: Network, decimals = case Network.Gnosis: // Gnosis validators stake 1 GNO which equals 32 ETH in the beacon chain // So we need to divide by 32 to convert the ETH-denominated balance to GNO - value = value / 32; + valueInEth = valueInEth / 32; symbol = "GNO"; break; // Add more networks and their symbols as needed default: symbol = "ETH"; } - return `${value.toFixed(decimals)} ${symbol}`; + + // Format with specified decimals + return `${valueInEth.toFixed(decimals)} ${symbol}`; } diff --git a/packages/dappmanager/src/calls/index.ts b/packages/dappmanager/src/calls/index.ts index 7782a2485e..7833d38934 100644 --- a/packages/dappmanager/src/calls/index.ts +++ b/packages/dappmanager/src/calls/index.ts @@ -23,11 +23,7 @@ export { getUserActionLogs } from "./getUserActionLogs.js"; export { getHostUptime } from "./getHostUptime.js"; export { pwaUrlGet, pwaRequirementsGet } from "./pwaRequirementsGet.js"; export { keystoresGetByNetwork } from "./keystoresGet.js"; -export { - validatorsFilterActiveByNetwork, - validatorsBalancesByNetwork, - validatorsFilterAttestingByNetwork -} from "./validatorsFilterActive.js"; +export { validatorsFilterActiveByNetwork, validatorsDataByNetwork } from "./validatorsFilterActive.js"; export { notificationsSendCustom, notificationsGetAllEndpoints, diff --git a/packages/dappmanager/src/calls/nodeStatusGet.ts b/packages/dappmanager/src/calls/nodeStatusGet.ts index f7dda97a5a..a644388fac 100644 --- a/packages/dappmanager/src/calls/nodeStatusGet.ts +++ b/packages/dappmanager/src/calls/nodeStatusGet.ts @@ -1,200 +1,192 @@ -import { DashboardSupportedNetwork, NodeStatusByNetwork } from "@dappnode/types"; +import { logs } from "@dappnode/logger"; +import { ClientResult, DashboardSupportedNetwork, NodeStatusByNetwork } from "@dappnode/types"; + +/** Timeout in milliseconds for each individual client request group (EC or CC) */ +const CLIENT_TIMEOUT_MS = 3_000; const ecBaseUrl = (network: DashboardSupportedNetwork) => `http://execution.${network}.dncore.dappnode:8545`; const ccBaseUrl = (network: DashboardSupportedNetwork) => `http://beacon-chain.${network}.dncore.dappnode:3500`; const getEcName = async (network: DashboardSupportedNetwork) => { - try { - const versionResponse = await fetch(ecBaseUrl(network), { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "web3_clientVersion", - params: [], - id: 1 - }) - }); - - if (!versionResponse.ok) { - throw new Error(`HTTP error! Status: ${versionResponse.status}`); - } - const versionData = await versionResponse.json(); - const clientName = versionData.result.split("/")[0]; - - return { name: clientName }; - } catch (e) { - console.error(`Error getting EC version: ${e}`); - return null; + const versionResponse = await fetch(ecBaseUrl(network), { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "web3_clientVersion", + params: [], + id: 1 + }) + }); + + if (!versionResponse.ok) { + throw new Error(`HTTP error! Status: ${versionResponse.status}`); } + const versionData = await versionResponse.json(); + const clientName = versionData.result.split("/")[0]; + + return { name: clientName }; }; const getEcSyncStatus = async (network: DashboardSupportedNetwork) => { - try { - const syncResponse = await fetch(`${ecBaseUrl(network)}`, { + const syncResponse = await fetch(`${ecBaseUrl(network)}`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_syncing", + params: [], + id: 0 + }) + }); + if (!syncResponse.ok) { + throw new Error(`HTTP error! Status: ${syncResponse.status}`); + } + + const syncData = await syncResponse.json(); + const syncing = syncData.result; + + let isSynced = false; + let currentBlock = null; + let startingBlock = null; + let highestBlock = null; + let progress = null; + + if (syncing === false) { + // Node is fully synced + isSynced = true; + // Get current block number + const blockResponse = await fetch(`${ecBaseUrl(network)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", - method: "eth_syncing", + method: "eth_blockNumber", params: [], - id: 0 + id: 1 }) }); - if (!syncResponse.ok) { - throw new Error(`HTTP error! Status: ${syncResponse.status}`); - } - - const syncData = await syncResponse.json(); - const syncing = syncData.result; - - let isSynced = false; - let currentBlock = null; - let startingBlock = null; - let highestBlock = null; - let progress = null; - - if (syncing === false) { - // Node is fully synced - isSynced = true; - // Get current block number - const blockResponse = await fetch(`${ecBaseUrl(network)}`, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_blockNumber", - params: [], - id: 1 - }) - }); - const blockData = await blockResponse.json(); - currentBlock = parseInt(blockData.result, 16); - startingBlock = currentBlock; - highestBlock = currentBlock; - progress = 100; - } else { - // Node is syncing - isSynced = false; - startingBlock = parseInt(syncing.startingBlock, 16); - currentBlock = parseInt(syncing.currentBlock, 16); - highestBlock = parseInt(syncing.highestBlock, 16); + const blockData = await blockResponse.json(); + currentBlock = parseInt(blockData.result, 16); + startingBlock = currentBlock; + highestBlock = currentBlock; + progress = 100; + } else { + // Node is syncing + isSynced = false; + startingBlock = parseInt(syncing.startingBlock, 16); + currentBlock = parseInt(syncing.currentBlock, 16); + highestBlock = parseInt(syncing.highestBlock, 16); + + if ( + Number.isFinite(currentBlock) && + Number.isFinite(startingBlock) && + Number.isFinite(highestBlock) && + highestBlock !== startingBlock + ) { progress = ((currentBlock - startingBlock) / (highestBlock - startingBlock)) * 100; progress = Math.max(0, Math.min(100, Math.round(progress))); + } else { + progress = 0; } - - return { isSynced, currentBlock, progress }; - } catch (e) { - console.error(`Error getting EC data: ${e}`); - return null; } + + return { isSynced, currentBlock, progress }; }; const getEcPeers = async (network: DashboardSupportedNetwork) => { - try { - const peersResponse = await fetch(ecBaseUrl(network), { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "net_peerCount", - params: [], - id: 1 - }) - }); - if (!peersResponse.ok) { - throw new Error(`HTTP error! Status: ${peersResponse.status}`); - } - const peersData = await peersResponse.json(); - return peersData.result ? parseInt(peersData.result, 16) : 0; - } catch (error) { - console.error(`Error getting EC peers: ${error}`); - return null; + const peersResponse = await fetch(ecBaseUrl(network), { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "net_peerCount", + params: [], + id: 1 + }) + }); + if (!peersResponse.ok) { + throw new Error(`HTTP error! Status: ${peersResponse.status}`); } + const peersData = await peersResponse.json(); + return peersData.result ? parseInt(peersData.result, 16) : 0; }; const getCcName = async (network: DashboardSupportedNetwork) => { - try { - const versionResponse = await fetch(`${ccBaseUrl(network)}/eth/v1/node/version`, { - method: "GET", - headers: { - "Content-Type": "application/json" - } - }); - if (!versionResponse.ok) { - throw new Error(`HTTP error! Status: ${versionResponse.status}`); + const versionResponse = await fetch(`${ccBaseUrl(network)}/eth/v1/node/version`, { + method: "GET", + headers: { + "Content-Type": "application/json" } - const versionData = await versionResponse.json(); + }); + if (!versionResponse.ok) { + throw new Error(`HTTP error! Status: ${versionResponse.status}`); + } + const versionData = await versionResponse.json(); - const clientName = versionData.data.version.split("/")[0]; + const clientName = versionData.data.version.split("/")[0]; - return { name: clientName }; - } catch (e) { - console.error(`Error getting CC data: ${e}`); - return null; - } + return { name: clientName }; }; // get also peers for the consensus clients const getCcPeers = async (network: DashboardSupportedNetwork) => { - try { - const peersResponse = await fetch(`${ccBaseUrl(network)}/eth/v1/node/peer_count`); - if (!peersResponse.ok) { - throw new Error(`HTTP error! Status: ${peersResponse.status}`); - } - const peersData = await peersResponse.json(); - return peersData.data.connected; - } catch (error) { - console.error(`Error getting CC peers: ${error}`); - return null; + const peersResponse = await fetch(`${ccBaseUrl(network)}/eth/v1/node/peer_count`); + if (!peersResponse.ok) { + throw new Error(`HTTP error! Status: ${peersResponse.status}`); } + const peersData = await peersResponse.json(); + return peersData.data.connected; }; const getCcSyncStatus = async (network: DashboardSupportedNetwork) => { - try { - // Standard endpoint for consensus client sync status (REST API, not JSON-RPC) - const syncResponse = await fetch(`${ccBaseUrl(network)}/eth/v1/node/syncing`); - if (!syncResponse.ok) { - throw new Error(`HTTP error! Status: ${syncResponse.status}`); - } - const syncData = await syncResponse.json(); - const syncing = syncData.data; - - const isSynced = syncing.is_syncing === false; - const currentBlock = parseInt(syncing.head_slot, 10); - - let progress = null; - - if (syncing.is_syncing === false) { - progress = 100; + // Standard endpoint for consensus client sync status (REST API, not JSON-RPC) + const syncResponse = await fetch(`${ccBaseUrl(network)}/eth/v1/node/syncing`); + if (!syncResponse.ok) { + throw new Error(`HTTP error! Status: ${syncResponse.status}`); + } + const syncData = await syncResponse.json(); + const syncing = syncData.data; + + const isSynced = syncing.is_syncing === false; + const headSlot = parseInt(syncing.head_slot, 10); + const syncDistance = parseInt(syncing.sync_distance, 10); + + let progress = null; + + if (syncing.is_syncing === false) { + progress = 100; + } else { + const highestSlot = headSlot + syncDistance; + + if ( + Number.isFinite(headSlot) && + Number.isFinite(syncDistance) && + Number.isFinite(highestSlot) && + highestSlot !== 0 + ) { + progress = (headSlot / highestSlot) * 100; + progress = Math.max(0, Math.min(100, Math.round(progress))); } else { - // Calculate progress based on slots - progress = 100; + progress = 0; } - - return { isSynced, currentBlock, progress }; - } catch (e) { - console.error(`Error getting CC data: ${e}`); - return null; } + + return { isSynced, currentBlock: headSlot, progress }; }; -const getEcData = async (network: DashboardSupportedNetwork) => { +const getEcData = async (network: DashboardSupportedNetwork): Promise => { const ecName = await getEcName(network); const ecSync = await getEcSyncStatus(network); const ecPeers = await getEcPeers(network); - if (!ecName || !ecSync || ecPeers === null) { - return null; - } - return { name: ecName.name, isSynced: ecSync.isSynced, @@ -204,15 +196,11 @@ const getEcData = async (network: DashboardSupportedNetwork) => { }; }; -const getCcData = async (network: DashboardSupportedNetwork) => { +const getCcData = async (network: DashboardSupportedNetwork): Promise => { const ccVersion = await getCcName(network); const ccSync = await getCcSyncStatus(network); const ccPeers = await getCcPeers(network); - if (!ccVersion || !ccSync || ccPeers === null) { - return null; - } - return { name: ccVersion.name, isSynced: ccSync.isSynced, @@ -222,14 +210,41 @@ const getCcData = async (network: DashboardSupportedNetwork) => { }; }; +/** + * Wraps a client data fetch with a timeout. If the request exceeds + * CLIENT_TIMEOUT_MS, it returns a ClientError instead of blocking. + */ +async function fetchClientWithTimeout( + fetchFn: () => Promise, + network: DashboardSupportedNetwork +): Promise { + try { + const result = await Promise.race([ + fetchFn(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timeout after ${CLIENT_TIMEOUT_MS}ms`)), CLIENT_TIMEOUT_MS) + ) + ]); + return result; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + logs.error(`Error fetching data for ${network}: ${message}`); + return { error: `Failed to fetch RPC: ${message}` }; + } +} + export async function nodeStatusGetByNetwork({ networks }: { networks: DashboardSupportedNetwork[] }) { const resultsByNetwork: NodeStatusByNetwork = {}; - for (const network of networks) { - const ec = await getEcData(network); - const cc = await getCcData(network); - resultsByNetwork[network] = { ec, cc }; - } + await Promise.all( + networks.map(async (network) => { + const [ec, cc] = await Promise.all([ + fetchClientWithTimeout(() => getEcData(network), network), + fetchClientWithTimeout(() => getCcData(network), network) + ]); + resultsByNetwork[network] = { ec, cc }; + }) + ); return resultsByNetwork; } diff --git a/packages/dappmanager/src/calls/validatorsFilterActive.ts b/packages/dappmanager/src/calls/validatorsFilterActive.ts index 2515a63544..22d6fafb79 100644 --- a/packages/dappmanager/src/calls/validatorsFilterActive.ts +++ b/packages/dappmanager/src/calls/validatorsFilterActive.ts @@ -1,130 +1,98 @@ -import { Network } from "@dappnode/types"; +import { Network, ValidatorsNetworkData } from "@dappnode/types"; import { keystoresGetByNetwork } from "./keystoresGet.js"; type KeystoresByProtocol = Record; const ACTIVE_STATUSES = new Set(["active_ongoing", "active_exiting", "active_slashed"]); -/** - * Query the beacon API for a batch of pubkeys and return the attesting ones (liveness endpoint). - * Uses (current epoch -1) and converts pubkeys -> indices since liveness endpoint requires indices. - */ + +type ValidatorEntry = { + index: string; + validator: { pubkey: string }; + balance: string; + status: string; +}; /** - * For each network, return the attesting validators (using liveness endpoint). + * calls /eth/v1/beacon/states/head/validators ONCE for a batch + * and returns all data needed by active, balances, and attesting */ -export async function validatorsFilterAttestingByNetwork({ - networks -}: { - networks: Network[]; -}): Promise>> { - const result: Partial> = {}; - const keystoresByNetwork = await keystoresGetByNetwork({ networks }); - const BATCH_SIZE = 100; - for (const network of networks) { - const keystores = keystoresByNetwork[network]; - const pubkeys = extractPubkeys(keystores); - if (pubkeys.length === 0) { - result[network] = null; - continue; - } - try { - const batches = chunk(pubkeys, BATCH_SIZE); - const attestingSets = await Promise.all(batches.map((b) => fetchAttestingPubkeysForBatch(network, b))); - const attestingSet = new Set(attestingSets.flat()); - const attestingInInputOrder = pubkeys.filter((pk) => attestingSet.has(pk)); - result[network] = { validators: attestingInInputOrder }; - } catch (err) { - result[network] = { validators: pubkeys, beaconError: err as Error }; - } +async function fetchValidatorsBatch( + network: Network, + pubkeys: string[] +): Promise<{ + activePubkeys: string[]; + balances: Record; + pubkeyToIndex: Map; + indexToPubkey: Map; +}> { + const base = `http://beacon-chain.${network}.dncore.dappnode:3500`; + const normPubkeys = pubkeys.map((p) => p.toLowerCase()); + const url = new URL(`/eth/v1/beacon/states/head/validators`, base); + const params = new URLSearchParams(); + for (const pk of normPubkeys) params.append("id", pk); + url.search = params.toString(); + + const res = await fetch(url.toString(), { headers: { Accept: "application/json" } }); + if (!res.ok) { + throw new Error(`Beacon API ${network} validators responded ${res.status} ${res.statusText}`); } - return result; -} -/** - * For each network, return the balances for all validators (as a map pubkey -> balance). - */ -export async function validatorsBalancesByNetwork({ - networks -}: { - networks: Network[]; -}): Promise; beaconError?: Error } | null>>> { - const result: Partial; beaconError?: Error } | null>> = {}; - const keystoresByNetwork = await keystoresGetByNetwork({ networks }); - const BATCH_SIZE = 100; - for (const network of networks) { - const keystores = keystoresByNetwork[network]; - const pubkeys = extractPubkeys(keystores); - if (pubkeys.length === 0) { - result[network] = null; - continue; + const json: { data?: ValidatorEntry[] } = await res.json(); + const entries = json.data ?? []; + + const activePubkeys: string[] = []; + const balances: Record = {}; + const pubkeyToIndex = new Map(); + const indexToPubkey = new Map(); + + for (const row of entries) { + const pk = row.validator?.pubkey?.toLowerCase(); + const idx = row.index; + if (pk && idx) { + pubkeyToIndex.set(pk, idx); + indexToPubkey.set(idx, pk); } - try { - const batches = chunk(pubkeys, BATCH_SIZE); - const balancesArr = await Promise.all(batches.map((b) => fetchBalancesForBatch(network, b))); - // Merge all batch results - const balances: Record = {}; - for (const b of balancesArr) Object.assign(balances, b); - result[network] = { balances }; - } catch (err) { - result[network] = { balances: {}, beaconError: err as Error }; + if (pk) { + balances[pk] = row.balance; + if (ACTIVE_STATUSES.has(row.status)) { + activePubkeys.push(pk); + } } } - return result; + + return { activePubkeys, balances, pubkeyToIndex, indexToPubkey }; } -async function fetchAttestingPubkeysForBatch(network: Network, pubkeys: string[]): Promise { +/** + * Query the liveness endpoint using precomputed index mappings. + * Returns attesting pubkeys. + */ +async function fetchAttestingFromLiveness( + network: Network, + pubkeys: string[], + pubkeyToIndex: Map, + indexToPubkey: Map +): Promise { const base = `http://beacon-chain.${network}.dncore.dappnode:3500`; const normPubkeys = pubkeys.map((p) => p.toLowerCase()); - // Get current epoch from head. Use last completed epoch = currentEpoch - 1 + // Get current epoch from head const headHeaderUrl = new URL(`/eth/v1/beacon/headers/head`, base); - console.log(`[fetchAttestingPubkeysForBatch] headHeaderUrl:`, headHeaderUrl.toString()); - const headRes = await fetch(headHeaderUrl.toString(), { headers: { Accept: "application/json" } }); if (!headRes.ok) { throw new Error(`Beacon API ${network} headers/head responded ${headRes.status} ${headRes.statusText}`); } const headJson: { data?: { header?: { message?: { slot?: string } } } } = await headRes.json(); - const slotStr = headJson?.data?.header?.message?.slot; if (!slotStr) throw new Error(`Beacon API ${network} headers/head missing data.header.message.slot`); - fetchAttestingPubkeysForBatch; const currentEpoch = Math.floor(Number(slotStr) / 32); - const livenessEpoch = currentEpoch - 1; // "last epoch" and widely supported - - // Query validators endpoint to convert pubkeys -> indices - const validatorsUrl = new URL(`/eth/v1/beacon/states/head/validators`, base); - const params = new URLSearchParams(); - for (const pk of normPubkeys) params.append("id", pk); - validatorsUrl.search = params.toString(); - - const vRes = await fetch(validatorsUrl.toString(), { headers: { Accept: "application/json" } }); - if (!vRes.ok) { - throw new Error(`Beacon API ${network} validators responded ${vRes.status} ${vRes.statusText}`); - } - - type ValidatorEntry = { index: string; validator: { pubkey: string } }; - const vJson: { data?: ValidatorEntry[] } = await vRes.json(); - - const pubkeyToIndex = new Map(); - const indexToPubkey = new Map(); - for (const row of vJson.data ?? []) { - const pk = row.validator?.pubkey?.toLowerCase(); - const idx = row.index; - if (pk && idx) { - pubkeyToIndex.set(pk, idx); - indexToPubkey.set(idx, pk); - } - } + const livenessEpoch = currentEpoch - 1; const indices = normPubkeys.map((pk) => pubkeyToIndex.get(pk)).filter((x): x is string => !!x); - if (indices.length === 0) return []; - // Query liveness endpoint with indices to get attesting validators const livenessUrl = new URL(`/eth/v1/validator/liveness/${livenessEpoch}`, base); - const res = await fetch(livenessUrl.toString(), { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, @@ -133,7 +101,6 @@ async function fetchAttestingPubkeysForBatch(network: Network, pubkeys: string[] if (!res.ok) { const text = await res.text().catch(() => ""); - throw new Error( `Beacon API ${network} liveness responded ${res.status} ${res.statusText}${text ? `: ${text}` : ""}` ); @@ -144,69 +111,10 @@ async function fetchAttestingPubkeysForBatch(network: Network, pubkeys: string[] const live = new Set((json.data ?? []).filter((x) => x.is_live).map((x) => x.index)); - // Return pubkeys that are attesting - const result = indices + return indices .filter((idx) => live.has(idx)) .map((idx) => indexToPubkey.get(idx)) .filter((pk): pk is string => !!pk); - - return result; -} - -/** - * Query the beacon API for a batch of pubkeys and return their balances. - */ -async function fetchBalancesForBatch(network: Network, pubkeys: string[]): Promise> { - const base = `http://beacon-chain.${network}.dncore.dappnode:3500`; - const url = new URL(`/eth/v1/beacon/states/head/validators`, base); - const params = new URLSearchParams(); - for (const pk of pubkeys) params.append("id", pk); - url.search = params.toString(); - - const res = await fetch(url.toString()); - if (!res.ok) { - throw new Error(`Beacon API ${network} responded ${res.status} ${res.statusText}`); - } - - type ValidatorEntry = { - validator: { pubkey: string }; - balance: string; - status: string; - index?: string; - }; - - const json: { data: ValidatorEntry[] } = await res.json(); - const out: Record = {}; - for (const v of json.data ?? []) { - out[v.validator.pubkey.toLowerCase()] = v.balance; - } - return out; -} - -/** - * Query the beacon API for a batch of pubkeys and return the active ones. - */ -async function fetchActivePubkeysForBatch(network: Network, pubkeys: string[]): Promise { - const base = `http://beacon-chain.${network}.dncore.dappnode:3500`; - const url = new URL(`/eth/v1/beacon/states/head/validators`, base); - const params = new URLSearchParams(); - for (const pk of pubkeys) params.append("id", pk); - url.search = params.toString(); - - const res = await fetch(url.toString()); - if (!res.ok) { - throw new Error(`Beacon API ${network} responded ${res.status} ${res.statusText}`); - } - - type ValidatorEntry = { - validator: { pubkey: string }; - status: string; - index?: string; - }; - - const json: { data: ValidatorEntry[] } = await res.json(); - - return (json.data ?? []).filter((v) => ACTIVE_STATUSES.has(v.status)).map((v) => v.validator.pubkey.toLowerCase()); } /** @@ -245,25 +153,111 @@ export async function validatorsFilterActiveByNetwork({ const pubkeys = extractPubkeys(keystores); if (pubkeys.length === 0) { - // No keystores for this network result[network] = null; continue; } try { const batches = chunk(pubkeys, BATCH_SIZE); - const activeSets = await Promise.all(batches.map((b) => fetchActivePubkeysForBatch(network, b))); + const activeSets = await Promise.all( + batches.map(async (b) => { + const { activePubkeys } = await fetchValidatorsBatch(network, b); + return activePubkeys; + }) + ); - // Deduplicate while preserving original input order const activeSet = new Set(activeSets.flat()); const activeInInputOrder = pubkeys.filter((pk) => activeSet.has(pk)); - result[network] = { validators: activeInInputOrder }; // could be [] + result[network] = { validators: activeInInputOrder }; } catch (err) { - // If the beacon request fails for this network returns all the imported keystores result[network] = { validators: pubkeys, beaconError: err as Error }; } } return result; } + +/** + * Combined endpoint: for each network, fetches active validators, balances, + * and attesting validators in a single pass. + * + */ +export async function validatorsDataByNetwork({ + networks +}: { + networks: Network[]; +}): Promise>> { + const result: Partial> = {}; + const keystoresByNetwork = await keystoresGetByNetwork({ networks }); + const BATCH_SIZE = 100; + + for (const network of networks) { + const keystores = keystoresByNetwork[network]; + const pubkeys = extractPubkeys(keystores); + + if (pubkeys.length === 0) { + result[network] = { + active: null, + attesting: null, + balances: null + }; + continue; + } + + try { + const batches = chunk(pubkeys, BATCH_SIZE); + + // For each batch, call fetchValidatorsBatch ONCE and reuse results for all three concerns + const batchResults = await Promise.all( + batches.map(async (batch) => { + const { activePubkeys, balances, pubkeyToIndex, indexToPubkey } = await fetchValidatorsBatch(network, batch); + + let attestingPubkeys: string[] = []; + let attestingError: Error | undefined; + try { + attestingPubkeys = await fetchAttestingFromLiveness(network, batch, pubkeyToIndex, indexToPubkey); + } catch (err) { + attestingError = err as Error; + } + + return { activePubkeys, balances, attestingPubkeys, attestingError }; + }) + ); + + // Merge batch results + const allActive: string[] = []; + const mergedBalances: Record = {}; + const allAttesting: string[] = []; + let attestingBeaconError: Error | undefined; + + for (const br of batchResults) { + allActive.push(...br.activePubkeys); + Object.assign(mergedBalances, br.balances); + allAttesting.push(...br.attestingPubkeys); + if (br.attestingError) attestingBeaconError = br.attestingError; + } + + const activeSet = new Set(allActive); + const attestingSet = new Set(allAttesting); + + result[network] = { + active: { validators: pubkeys.filter((pk) => activeSet.has(pk)) }, + attesting: attestingBeaconError + ? { validators: pubkeys, beaconError: attestingBeaconError } + : { validators: pubkeys.filter((pk) => attestingSet.has(pk)) }, + balances: { balances: mergedBalances } + }; + } catch (err) { + // If the shared fetch itself fails, all three concerns get the error + const beaconError = err as Error; + result[network] = { + active: { validators: pubkeys, beaconError }, + attesting: { validators: pubkeys, beaconError }, + balances: { balances: {}, beaconError } + }; + } + } + + return result; +} diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index 78fc770690..d63a97d5b5 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -56,7 +56,12 @@ import { TrustedReleaseKey } from "./pkg.js"; import { OptimismConfigSet, OptimismConfigGet } from "./rollups.js"; import { Network, StakerConfigGet, StakerConfigSet } from "./stakers.js"; import { BeaconBackupActivationParams, BeaconBackupNetworkStatus } from "./beaconBackup.js"; -import { NodeStatusByNetwork, SignerStatus } from "./stakingDashboard.js"; +import { + DashboardSupportedNetwork, + NodeStatusByNetwork, + SignerStatus, + ValidatorsNetworkData +} from "./stakingDashboard.js"; export interface Routes { /** @@ -271,7 +276,11 @@ export interface Routes { subscriptionEndpoint?: string; }): Promise; - nodeStatusGetByNetwork(kwargs: { networks: Network[] }): Promise; + /** + * Returns the node status for the given networks + */ + nodeStatusGetByNetwork(kwargs: { networks: DashboardSupportedNetwork[] }): Promise; + /** * Get all the notifications */ @@ -812,21 +821,13 @@ export interface Routes { }): Promise>>; /** - * Returns the attesting validators for each network (using beacon liveness endpoint) - * @param networks List of networks - * @param epoch (optional) Beacon epoch to check liveness for (default: head) - */ - validatorsFilterAttestingByNetwork: (kwargs: { - networks: Network[]; - }) => Promise>>; - - /** - * Returns the balances for all validators for each network (as a map pubkey -> balance) + * Combined endpoint: returns active validators, attesting validators, and balances + * in a single call per network, minimizing beacon chain API requests. * @param networks List of networks */ - validatorsBalancesByNetwork: (kwargs: { + validatorsDataByNetwork: (kwargs: { networks: Network[]; - }) => Promise; beaconError?: Error } | null>>>; + }) => Promise>>; /** * Removes a docker volume by name @@ -1000,8 +1001,7 @@ export const routesData: { [P in keyof Routes]: RouteData } = { natRenewalEnable: {}, natRenewalIsEnabled: {}, validatorsFilterActiveByNetwork: {}, - validatorsFilterAttestingByNetwork: {}, - validatorsBalancesByNetwork: {}, + validatorsDataByNetwork: {}, volumeRemove: { log: true }, volumesGet: {}, ipPublicGet: {}, diff --git a/packages/types/src/stakingDashboard.ts b/packages/types/src/stakingDashboard.ts index f220d2a303..f26dd36624 100644 --- a/packages/types/src/stakingDashboard.ts +++ b/packages/types/src/stakingDashboard.ts @@ -15,7 +15,13 @@ export type ClientData = { peers: number; } | null; -export type NodeStatus = { ec: ClientData; cc: ClientData }; +export type ClientError = { + error: string; +}; + +export type ClientResult = ClientData | ClientError; + +export type NodeStatus = { ec: ClientResult; cc: ClientResult }; export type NetworkStatus = { nodeStatus: NodeStatus | undefined; @@ -43,3 +49,11 @@ export type SignerStatus = { export type NetworkStats = Partial>; export type NodeStatusByNetwork = Partial>; + +export type ValidatorsNetworkData = { + active: { validators: string[]; beaconError?: Error } | null; + attesting: { validators: string[]; beaconError?: Error } | null; + balances: { balances: Record; beaconError?: Error } | null; +}; + +export type ValidatorsDataByNetwork = Partial>;