diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index d1269d54fc..076ba796c6 100644 --- a/packages/admin-ui/package.json +++ b/packages/admin-ui/package.json @@ -64,7 +64,8 @@ "@types/react-dom": "^18.3.0", "nodemon": "^3.1.4", "prettier": "^1.16.4", - "ts-node": "^10.9.2" + "ts-node": "^10.9.2", + "vite-plugin-svgr": "^4.5.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.18.0" diff --git a/packages/admin-ui/src/__mock-backend__/index.ts b/packages/admin-ui/src/__mock-backend__/index.ts index 59e01c7638..c0bb24001d 100644 --- a/packages/admin-ui/src/__mock-backend__/index.ts +++ b/packages/admin-ui/src/__mock-backend__/index.ts @@ -56,6 +56,44 @@ export const otherCalls: Omit = { progress: 0.83027522935 } ], + nodeStatusGetByNetwork: async () => ({ + mainnet: { + ec: { + name: "Nethermind", + dnp: "nethermind.dnp.dappnode.eth", + isSynced: true, + currentBlock: 23648945, + progress: 100, + peers: 50 + }, + cc: { + name: "Nimbus", + dnp: "nimbus.dnp.dappnode.eth", + isSynced: true, + currentBlock: 12875373, + progress: 100, + peers: 40 + } + }, + hoodi: { + ec: { + name: "besu", + dnp: "besu-hoodi.dnp.dappnode.eth", + isSynced: true, + currentBlock: 1480848, + progress: 100, + peers: 30 + }, + cc: { + name: "Prysm", + dnp: "prysm-hoodi.dnp.dappnode.eth", + isSynced: false, + currentBlock: 1592925, + progress: 100, + peers: 20 + } + } + }), changeIpfsTimeout: async () => {}, cleanCache: async () => {}, cleanDb: async () => {}, @@ -411,6 +449,12 @@ export const otherCalls: Omit = { gnosis: "prysm-gnosis.dnp.dappnode.eth" }; }, + executionClientsGetByNetworks: async () => { + return { + mainnet: "geth.dnp.dappnode.eth", + gnosis: "nethermind-xdai.dnp.dappnode.eth" + }; + }, premiumBeaconBackupActivate: async () => {}, premiumBeaconBackupDeactivate: async () => {}, premiumBeaconBackupStatus: async (): Promise> => { @@ -433,6 +477,43 @@ export const otherCalls: Omit = { mainnet: { validators: ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"] }, hoodi: { validators: [] }, gnosis: null + }), + + validatorsDataByNetwork: async () => ({ + mainnet: { + active: { validators: ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"] }, + attesting: { validators: ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"] }, + balances: { + balances: { + "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef": "32000000000" + } + } + }, + hoodi: { + active: { validators: [] }, + attesting: { validators: [] }, + balances: { balances: {} } + }, + gnosis: { + active: null, + attesting: null, + balances: null + } + }), + + signerByNetworkGet: async () => ({ + mainnet: { + isInstalled: true, + brainRunning: true + }, + hoodi: { + isInstalled: true, + brainRunning: false + }, + gnosis: { + isInstalled: false, + brainRunning: false + } }) }; diff --git a/packages/admin-ui/src/components/Loading.tsx b/packages/admin-ui/src/components/Loading.tsx index a1ae25baf5..20f9724b5e 100644 --- a/packages/admin-ui/src/components/Loading.tsx +++ b/packages/admin-ui/src/components/Loading.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import logoAnimated from "img/dappNodeAnimation.gif"; import "./loading.scss"; -export default function Loading({ steps }: { steps: string[] }) { +export default function Loading({ steps, small }: { steps?: string[]; small?: boolean }) { const [index, setIndex] = useState(0); useEffect(() => { const interval = setInterval(() => setIndex((i) => i + 1), 3000); @@ -10,9 +10,9 @@ export default function Loading({ steps }: { steps: string[] }) { }, []); return ( -
+
Loading icon -

{steps[index] || steps[steps.length - 1]}...

+ {steps &&

{steps[index] || steps[steps.length - 1]}...

}
); } diff --git a/packages/admin-ui/src/components/loading.scss b/packages/admin-ui/src/components/loading.scss index b8723e5893..de0d956b35 100644 --- a/packages/admin-ui/src/components/loading.scss +++ b/packages/admin-ui/src/components/loading.scss @@ -12,4 +12,13 @@ p { opacity: 0.6; } + + &.small { + margin: 1rem 0px; + img { + width: 50px; + height: 50px; + margin: 0px; + } + } } diff --git a/packages/admin-ui/src/components/sidebar/navbarItems.ts b/packages/admin-ui/src/components/sidebar/navbarItems.ts index b48ce45e03..6ca5b55ed7 100644 --- a/packages/admin-ui/src/components/sidebar/navbarItems.ts +++ b/packages/admin-ui/src/components/sidebar/navbarItems.ts @@ -20,6 +20,7 @@ import { FaRegBell } from "react-icons/fa"; import { SiEthereum } from "react-icons/si"; // URLs import { relativePath as dashboardRelativePath } from "pages/dashboard"; +import { relativePath as dashboardv2RelativePath } from "pages/dashboard_v2"; import { relativePath as devicesRelativePath } from "pages/vpn"; import { relativePath as installerRelativePath } from "pages/installer"; import { relativePath as packagesRelativePath } from "pages/packages"; @@ -70,6 +71,12 @@ export const sidenavItems: { icon: MdDashboard, show: true }, + { + name: "DASHBOARD v2", + href: dashboardv2RelativePath, + icon: MdDashboard, + show: true + }, { name: "WI-FI", href: wifiRelativePath, diff --git a/packages/admin-ui/src/hooks/useNetworkStats.ts b/packages/admin-ui/src/hooks/useNetworkStats.ts new file mode 100644 index 0000000000..909ed3fd09 --- /dev/null +++ b/packages/admin-ui/src/hooks/useNetworkStats.ts @@ -0,0 +1,281 @@ +import { useState, useEffect, useCallback } 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"; + +const supportedNetworks: DashboardSupportedNetwork[] = [ + Network.Mainnet, + Network.Gnosis, + Network.Lukso, + Network.Hoodi, + Network.Sepolia +]; + +// Define network's logos and which ones have validators and rewards data +type NetworkFeatures = { + hasValidators: boolean; + logo: React.FC>; + beaconExplorer?: { url: string; name: string }; +}; + +const networkFeatures: Record = { + [Network.Mainnet]: { + hasValidators: true, + logo: EthLogo, + beaconExplorer: { url: "https://beaconcha.in/", name: "Beaconcha.in" } + }, + [Network.Gnosis]: { + hasValidators: true, + logo: GnosisLogo, + beaconExplorer: { url: "https://beacon.gnosisscan.io/", name: "Beacon Gnosisscan" } + }, + [Network.Lukso]: { + hasValidators: true, + logo: LuksoLogo, + beaconExplorer: { url: "https://dora.explorer.mainnet.lukso.network/", name: "Beacon Lukso Explorer" } + }, + [Network.Hoodi]: { + hasValidators: true, + logo: EthLogo, + beaconExplorer: { url: "https://hoodi.beaconcha.in/", name: "Hoodi Beaconcha.in" } + }, + [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() { + // Step 1: Fetch consensus and execution clients for all supported networks + const consensusClientsReq = useApi.consensusClientsGetByNetworks({ networks: supportedNetworks }); + const executionClientsReq = useApi.executionClientsGetByNetworks({ networks: supportedNetworks }); + + const consensusClientsByNetwork = consensusClientsReq.data; + const executionClientsByNetwork = executionClientsReq.data; + + // 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 fetchNetworkData = useCallback(async () => { + if (!consensusClientsByNetwork || !executionClientsByNetwork) return; + + // 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 resolvedInstalledDnpNames: Set; + try { + const installedPackages = await api.packagesGet(); + const allInstalledNames = new Set(installedPackages.map((pkg) => pkg.dnpName)); + resolvedInstalledDnpNames = new Set([...dnpNames].filter((name) => allInstalledNames.has(name))); + setInstalledDnpNames(resolvedInstalledDnpNames); + } catch (e) { + console.error("Error fetching installed packages for node status", e); + return; + } + + if (resolvedInstalledDnpNames.size === 0) { + setNodesStatusByNetwork({}); + setValidatorsData({}); + setSignersStatusByNetwork({}); + return; + } + + const networksWithClients = getNetworksWithInstalledClients( + consensusClientsByNetwork, + executionClientsByNetwork, + resolvedInstalledDnpNames + ); + + if (networksWithClients.length === 0) { + setNodesStatusByNetwork({}); + setValidatorsData({}); + setSignersStatusByNetwork({}); + return; + } + + // 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(); + const interval = setInterval(fetchNetworkData, 30 * 1000); + return () => clearInterval(interval); + }, [fetchNetworkData]); + + const clientsLoading = + nodesStatusLoading || + consensusClientsReq.isValidating || + executionClientsReq.isValidating || + nodesStatusByNetwork === undefined; + + const networkStats: NetworkStats = {}; + + for (const network of supportedNetworks) { + const features = networkFeatures[network]; + const nodeStatusData: NodeStatus | undefined = nodesStatusByNetwork?.[network]; + const consensusClientDnp = consensusClientsByNetwork?.[network]; + const executionClientDnp = executionClientsByNetwork?.[network]; + + // 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; + let attesting = 0; + let balance = 0; + let beaconError = undefined; + + if (features.hasValidators && validatorsActive) { + total = validatorsActive.validators.length; + beaconError = validatorsActive.beaconError; + } + if (features.hasValidators && validatorsAttesting && !validatorsAttesting.beaconError) { + attesting = validatorsAttesting.validators.length; + } + if (features.hasValidators && balancesObj && validatorsActive) { + // Sum balances for active validators + balance = validatorsActive.validators.reduce((acc, pk) => acc + (parseFloat(balancesObj[pk]) || 0), 0); + } + + const validatorsInfo = features.hasValidators + ? { + validators: { + total, + balance, + attesting, + beaconError, + signerStatus, + pubKeys: validatorsActive?.validators || [] + } + } + : undefined; + + // 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: filteredNodeStatus, + clientsDnps: { + ecDnp: ecInstalled ? executionClientDnp || null : null, + ccDnp: ccInstalled ? consensusClientDnp || null : null + }, + ...validatorsInfo, + hasValidators: features.hasValidators, + beaconExplorer: features.beaconExplorer || undefined + }; + } else { + delete networkStats[network]; + } + } + + // Provide a function to get the logo for a network + function getNetworkLogo(network: DashboardSupportedNetwork) { + return networkFeatures[network]?.logo || EthLogo; + } + + 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 new file mode 100644 index 0000000000..e717db6df2 --- /dev/null +++ b/packages/admin-ui/src/hooks/useSystemHealth.ts @@ -0,0 +1,60 @@ +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, 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) : 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/img/logos/eth-logo.svg b/packages/admin-ui/src/img/logos/eth-logo.svg new file mode 100644 index 0000000000..730a89499f --- /dev/null +++ b/packages/admin-ui/src/img/logos/eth-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/admin-ui/src/img/logos/gnosis-logo.svg b/packages/admin-ui/src/img/logos/gnosis-logo.svg new file mode 100644 index 0000000000..147f438f52 --- /dev/null +++ b/packages/admin-ui/src/img/logos/gnosis-logo.svg @@ -0,0 +1,14 @@ + + + + + + + diff --git a/packages/admin-ui/src/img/logos/lukso-logo.svg b/packages/admin-ui/src/img/logos/lukso-logo.svg new file mode 100644 index 0000000000..6bac782f7a --- /dev/null +++ b/packages/admin-ui/src/img/logos/lukso-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/ChainCard.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/ChainCard.tsx new file mode 100644 index 0000000000..b4721793ed --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/ChainCard.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { useChainData } from "hooks/chainData"; +import ProgressBar from "react-bootstrap/ProgressBar"; +import Card from "components/Card"; +import RenderMarkdown from "components/RenderMarkdown"; +import { prettyDnpName } from "utils/format"; +import { ChainData } from "@dappnode/types"; +import { HelpTo } from "components/Help"; +import { Link } from "react-router-dom"; +import { relativePath as packagesRelativePath } from "pages/packages"; + +export function ChainCards() { + const chainData = useChainData(); + + return ( +
+ {chainData.map((chain) => ( + + ))} +
+ ); +} + +function ChainCard(chain: ChainData) { + const { dnpName, name, message, help, progress, error, syncing, peers } = chain; + return ( + +
+ {name || prettyDnpName(dnpName)} + {help && } +
+ + {syncing ? ( + typeof progress === "number" && + (progress === 0 ? ( + + ) : ( + + )) + ) : error ? ( + + ) : ( + + )} + +
+ {(dnpName === "repository-source" || !syncing || (typeof progress === "number" && progress !== 0)) && ( + + )} + {peers && } + {error ? More info : null} +
+
+ ); +} diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/Dashboard.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/Dashboard.tsx new file mode 100644 index 0000000000..8bed4bb586 --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/Dashboard.tsx @@ -0,0 +1,21 @@ +import React from "react"; +// Own module +// Components +import Title from "components/Title"; +import SystemHealth from "./SystemHealth"; +import NetworkStats from "./NetworkStats"; +import "./dashboard.scss"; + +export default function Dashboard() { + return ( +
+
+ + <hr /> + </div> + + <SystemHealth /> + <NetworkStats /> + </div> + ); +} diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/HostStats.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/HostStats.tsx new file mode 100644 index 0000000000..75d04624c9 --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/HostStats.tsx @@ -0,0 +1,184 @@ +import React, { useEffect } from "react"; +import { useApi } from "api"; +import Card from "components/Card"; +import ProgressBar from "react-bootstrap/ProgressBar"; +import humanFileSize from "utils/humanFileSize"; +import Ok from "../../../components/Ok"; + +function parseVariant({ + value, + danger = 90, + warning = 75, + infoCard +}: { + value: number; + danger?: number; + warning?: number; + infoCard: boolean; +}) { + if (infoCard) return "info"; + if (value > danger) return "danger"; + if (value > warning) return "warning"; + return "success"; +} + +const StatsCardContainer: React.FunctionComponent<{ + children: React.ReactNode; + title: string; + usage?: boolean; +}> = ({ children, title }) => { + return ( + <Card className="stats-card"> + <div className="header"> + <span className="id">{title}</span> + </div> + {children} + </Card> + ); +}; + +function StatsCardOk({ + percent, + label, + text, + max, + danger, + warning, + infoCard = false +}: { + percent: number; + label: "%" | "ºC"; + text?: string; + max?: number; + danger?: number; + warning?: number; + infoCard?: boolean; +}) { + let value: number; + if (label === "%") value = Math.round(percent); + else value = percent; + + return ( + <> + <ProgressBar + variant={parseVariant({ value, danger, warning, infoCard })} + max={max || 100} + now={value} + label={value + label} + /> + {text ? <div className="text">{text}</div> : null} + </> + ); +} + +function StatsCardError({ error }: { error: Error }) { + return <Ok msg={error.message} style={{ margin: "auto" }} />; +} + +function StatsCardLoading() { + return <Ok msg={"Loading..."} loading={true} style={{ margin: "auto" }} />; +} + +export function HostStats() { + const cpuStats = useApi.statsCpuGet(); + const memoryStats = useApi.statsMemoryGet(); + const diskStats = useApi.statsDiskGet(); + const hostUptime = useApi.getHostUptime(); + + 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 ( + <div className="dashboard-cards"> + <StatsCardContainer title={"cpu usage"}> + {cpuStats.data ? ( + <StatsCardOk + percent={cpuStats.data.usedPercentage} + label={"%"} + text={`Number of cores ${cpuStats.data.numberOfCores}`} + /> + ) : cpuStats.error ? ( + <StatsCardError error={cpuStats.error} /> + ) : ( + <StatsCardLoading /> + )} + </StatsCardContainer> + + {cpuStats.data?.temperatureAverage ? ( + <StatsCardContainer title={"cpu temperature"}> + {" "} + <StatsCardOk + percent={cpuStats.data.temperatureAverage} + text="Average temperature of the CPU cores" + label={"ºC"} + max={115} // cpu temperature above 100/110 triggers automatic shutdowns in intel NUCs + danger={95} + warning={85} + /> + </StatsCardContainer> + ) : cpuStats.error ? ( + <StatsCardContainer title={"cpu temperature"}> + <StatsCardError error={cpuStats.error} /> + </StatsCardContainer> + ) : null} + + <StatsCardContainer title={"memory"}> + {memoryStats.data ? ( + <StatsCardOk + percent={memoryStats.data.usedPercentage} + label="%" + text={humanFileSize(memoryStats.data.used) + " / " + humanFileSize(memoryStats.data.total)} + /> + ) : memoryStats.error ? ( + <StatsCardError error={memoryStats.error} /> + ) : ( + <StatsCardLoading /> + )} + </StatsCardContainer> + + <StatsCardContainer title={"disk"}> + {diskStats.data ? ( + <StatsCardOk + percent={diskStats.data.usedPercentage} + label="%" + text={humanFileSize(diskStats.data.used) + " / " + humanFileSize(diskStats.data.total)} + /> + ) : diskStats.error ? ( + <StatsCardError error={diskStats.error} /> + ) : ( + <StatsCardLoading /> + )} + </StatsCardContainer> + + <StatsCardContainer title={"uptime"}> + {hostUptime.data ? ( + hostUptime.data + ) : hostUptime.error ? ( + <StatsCardError error={hostUptime.error} /> + ) : ( + <StatsCardLoading /> + )} + </StatsCardContainer> + </div> + ); +} diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/NetworkCards.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/NetworkCards.tsx new file mode 100644 index 0000000000..7d9fde833e --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/NetworkCards.tsx @@ -0,0 +1,415 @@ +import React from "react"; +import { useNavigate } from "react-router"; +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"; +import Card from "components/Card"; +import Loading from "components/Loading"; +import Button from "components/Button"; +import { OverlayTrigger, ProgressBar, Tooltip } from "react-bootstrap"; +import { HealthIcon } from "./icons/HealthIcon"; +import { BoltIcon } from "./icons/BoltIcon"; +import { RewardsIcon } from "./icons/RewardsIcon"; +import { MdInfoOutline, MdWarningAmber } from "react-icons/md"; + +const NetworkCard = ({ + title, + icon, + children +}: { + title: string; + icon: React.ReactNode; + children: React.ReactNode; +}) => ( + <Card className="network-stats-card"> + <div className="network-card-header"> + <div className="network-card-icon">{icon}</div> + <div className="network-card-title">{title}</div> + </div> + {children} + </Card> +); + +export const StatusCard = ({ + network, + data, + clientsLoading, + clientsDnps +}: { + network: string; + data: NodeStatus | undefined; + clientsLoading: boolean; + clientsDnps?: { + ecDnp: string | null; + ccDnp: string | null; + }; +}) => { + const navigate = useNavigate(); + + 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 ( + <NetworkCard title="NODE STATUS" icon={<HealthIcon />}> + {clientsLoading ? ( + <Loading small /> + ) : data ? ( + <div className="status-card-container"> + <ClientSection + label="EXECUTION" + network={network} + dnpName={clientsDnps?.ecDnp ?? null} + clientData={execution} + clientError={ecError} + isInstalled={ecResult !== null} + showWaiting={!consensusSynced && !!execution} + showProgress={consensusSynced && !!execution && !execution.isSynced} + progress={execution?.progress} + /> + + <hr /> + + <ClientSection + label="CONSENSUS" + network={network} + dnpName={clientsDnps?.ccDnp ?? null} + clientData={consensus} + clientError={ccError} + isInstalled={ccResult !== null} + showProgress={!!consensus && !consensus.isSynced} + progress={consensus?.progress} + /> + </div> + ) : ( + <div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>Data could not be fetched</div> + )} + + <Button + onClick={() => navigate("/" + stakersBasePath + `/${network === Network.Mainnet ? "ethereum" : network}`)} + fullwidth + variant="outline-dappnode" + > + <span>View Setup</span> + </Button> + </NetworkCard> + ); +}; + +/** + * 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 ? ( + <Link to={`/${packagesRelativePath}/${dnpName}/info`}> + {clientData ? capitalize(clientData.name ?? "-") : parseClientName(dnpName)} + </Link> + ) : clientData ? ( + capitalize(clientData.name ?? "-") + ) : ( + "-" + ); + + if (!isInstalled) { + return ( + <div> + <div className="status-client-row"> + <div className="network-stat-col"> + <div>{label}</div> + <span>-</span> + </div> + <div className="status-client-details"> + <div /> + <div className="network-stat-col"> + <div className="badge-status waiting">Not installed</div> + </div> + </div> + </div> + </div> + ); + } + + if (clientError) { + return ( + <div> + <div className="status-client-row"> + <div className="network-stat-col"> + <div>{label}</div> + <span>{clientLink}</span> + </div> + <div className="status-client-details"> + <div /> + <div className="network-stat-col"> + <OverlayTrigger + overlay={<Tooltip id={`${label.toLowerCase()}-error-tooltip-${network}`}>{clientError.error}</Tooltip>} + placement="top" + > + <div> + <div> + <MdInfoOutline className="tooltip-icon" /> + </div> + <div className="badge-status offline">Error</div> + </div> + </OverlayTrigger> + </div> + </div> + </div> + </div> + ); + } + + if (clientData) { + return ( + <div> + <div className="status-client-row"> + <div className="network-stat-col"> + <div>{label}</div> + <span>{clientLink}</span> + </div> + <div className="status-client-details"> + <div className="network-stat-col"> + <div>PEERS</div> + <span>{clientData.peers}</span> + </div> + <div className="network-stat-col"> + {showWaiting ? ( + <OverlayTrigger + overlay={ + <Tooltip id={`${label.toLowerCase()}-waiting-tooltip`}> + {label.charAt(0) + label.slice(1).toLowerCase()} client status will be available once the + consensus client finishes syncing. + </Tooltip> + } + placement="top" + > + <div> + <div> + <MdInfoOutline className="tooltip-icon" /> + </div> + <div className="badge-status waiting">Waiting</div> + </div> + </OverlayTrigger> + ) : ( + <> + <div>#{clientData.currentBlock}</div> + <div className={`badge-status ${clientData.isSynced ? "synced" : "syncing"}`}> + {clientData.isSynced ? "synced" : "syncing"} + </div> + </> + )} + </div> + </div> + </div> + {showProgress && progress !== undefined && <ProgressBar animated now={progress} />} + </div> + ); + } + + return null; +}; + +export const ValidatorsCard = ({ + network, + validatorsLoading, + data +}: { + network: string; + validatorsLoading: boolean; + data: NetworkStatus["validators"]; +}) => { + const signerInstalled = data?.signerStatus.isInstalled; + const brainRunning = data?.signerStatus.brainRunning; + const navigate = useNavigate(); + + return ( + <NetworkCard title="YOUR VALIDATORS" icon={<BoltIcon />}> + {validatorsLoading ? ( + <Loading small /> + ) : !signerInstalled || !brainRunning ? ( + <div className="signer-error-card"> + <div /> + <div> + <div className="error-message"> + <MdWarningAmber className="warning-icon" />{" "} + <div> + {!signerInstalled + ? "Web3Signer is not installed on this network." + : "Web3Signer is not running properly on this network."} + </div> + </div> + <div className="action-text">Select Web3Signer in the stakers tab and apply changes.</div> + </div> + <Button + onClick={() => navigate("/" + stakersBasePath + `/${network === Network.Mainnet ? "ethereum" : network}`)} + fullwidth + variant="outline-dappnode" + > + <span>Set Web3Signer</span> + </Button> + </div> + ) : ( + <> + <div className="validators-card-container"> + <div className="validators-row"> + <div className="network-stat-col"> + <div> + TOTAL{" "} + {data?.beaconError && ( + <OverlayTrigger + overlay={ + <Tooltip id="beacon-api-error"> + Error fetching {capitalize(network)} validators status. All keystores imported in your{" "} + {capitalize(network)} Web3Signer are being considered as active validators. + </Tooltip> + } + placement="top" + > + <span> + <MdWarningAmber className="tooltip-beacon-api-error" />{" "} + </span> + </OverlayTrigger> + )} + </div> + <span>{data?.total ?? "0"}</span> + </div> + <div className="network-stat-col"> + <div>STATUS</div> + {renderAttestingStatus(data?.attesting ?? 0, data?.total ?? 0)} + </div> + </div> + <hr /> + <div className="validators-row"> + <div className="network-stat-col"> + <div>BALANCE</div> + <span> + {typeof data?.balance === "number" || typeof data?.balance === "string" + ? gweiToToken(data.balance, network as Network) + : "-"} + </span> + </div> + </div> + </div> + <Button + href={ + network === Network.Mainnet + ? "http://brain.web3signer.dappnode" + : `http://brain.web3signer-${network}.dappnode` + } + fullwidth + {...newTabProps} + variant="outline-dappnode" + > + <span>{data?.total < 1 ? "Import Validators" : "Manage Validators"}</span> + </Button> + </> + )} + </NetworkCard> + ); +}; + +export const RewardsCard = ({ + network, + beaconExplorer, + pubKeys +}: { + network: string; + beaconExplorer: { [key: string]: string }; + pubKeys?: string[]; +}) => { + // Construct dynamic Beaconcha.in dashboard URL for networks that support it (Mainnet and Hoodi) + const getDashboardUrl = () => { + const baseUrl = beaconExplorer.url; + + if (pubKeys && pubKeys.length > 0 && (network === Network.Mainnet || network === Network.Hoodi)) { + return `${baseUrl}dashboard?validators=${pubKeys.join(",")}`; + } + + return baseUrl; + }; + + const dashboardUrl = getDashboardUrl(); + + return ( + <NetworkCard title="REWARDS" icon={<RewardsIcon />}> + <div + style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontSize: "16px" }} + > + View your detailed validator rewards in the explorer. + </div> + <Button href={dashboardUrl} fullwidth {...newTabProps} variant="outline-dappnode"> + <span>{`Visit ${beaconExplorer.name}`}</span> + </Button> + </NetworkCard> + ); +}; + +export const NoNodesCard = () => { + return ( + <Card> + <div className="no-nodes-card"> + <h5>No nodes configured yet!</h5> + <div>You haven't set up a node on any network.</div> + <div> + Set up your nodes from the <Link to={`/${stakersPath}`}>Stakers tab</Link>. + </div> + </div> + </Card> + ); +}; + +// Helper function to render attesting status +const renderAttestingStatus = (attesting: number, total: number) => { + if (total === 0) { + return <span>-</span>; + } + if (attesting === total) { + return <div className="badge-status synced">Online</div>; + } + if (attesting === 0) { + return <div className="badge-status offline">Offline</div>; + } + // Partial: some attesting, some not + return ( + <span className="badge-status syncing"> + {attesting}/{total} + </span> + ); +}; diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/NetworkStats.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/NetworkStats.tsx new file mode 100644 index 0000000000..d0d8721f54 --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/NetworkStats.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import SubTitle from "components/SubTitle"; +import { useNetworkStats } from "hooks/useNetworkStats"; +import { NoNodesCard, RewardsCard, StatusCard, ValidatorsCard } from "./NetworkCards"; +import { DashboardSupportedNetwork, Network } from "@dappnode/types"; +import Loading from "components/Loading"; +import "./networkStats.scss"; + +export default function NetworkStats() { + const { networkStats, clientsLoading, getNetworkLogo, validatorsLoading } = useNetworkStats(); + return ( + <div className="network-stats"> + {clientsLoading ? ( + <Loading /> + ) : Object.entries(networkStats).length === 0 ? ( + <NoNodesCard /> + ) : ( + <> + {Object.entries(networkStats).map(([network, data]) => { + if (!data) return null; + const NetworkLogo = getNetworkLogo(network as DashboardSupportedNetwork); + return ( + <div key={network}> + <div className="network-header"> + <NetworkLogo width={24} height={24} /> + <SubTitle>{(network === Network.Mainnet ? "ethereum" : network).toUpperCase()}</SubTitle> + </div> + + <div className="network-cards-container"> + <StatusCard + network={network} + data={data.nodeStatus} + clientsLoading={clientsLoading} + clientsDnps={data.clientsDnps} + /> + + {data.hasValidators && ( + <ValidatorsCard network={network} validatorsLoading={validatorsLoading} data={data.validators} /> + )} + + {data.beaconExplorer && data.validators && data.validators.total > 0 && ( + <RewardsCard + network={network} + beaconExplorer={data.beaconExplorer} + pubKeys={data.validators.pubKeys} + /> + )} + </div> + </div> + ); + })} + </> + )} + </div> + ); +} diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/PackageUpdates.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/PackageUpdates.tsx new file mode 100644 index 0000000000..861e89f5ef --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/PackageUpdates.tsx @@ -0,0 +1,107 @@ +import React, { useState } from "react"; +import { useApi } from "api"; +import { getInstallerPath } from "pages/installer"; +import { UpdateAvailable } from "@dappnode/types"; +import { useNavigate } from "react-router-dom"; +import Button from "components/Button"; +import ErrorView from "components/ErrorView"; +import Ok from "components/Ok"; +import CardList from "components/CardList"; +import { prettyDnpName } from "utils/format"; +import { Accordion, useAccordionButton } from "react-bootstrap"; +import { IoIosArrowDown, IoIosArrowUp } from "react-icons/io"; +interface UpdatesInterface extends UpdateAvailable { + dnpName: string; +} + +export function PackageUpdates() { + const dnps = useApi.packagesGet(); + + if (dnps.error) return <ErrorView error={dnps.error} hideIcon red />; + if (dnps.isValidating) return <Ok loading msg="Loading packages" />; + if (!dnps.data) return <ErrorView error={"No data"} hideIcon red />; + + const updatesAvailable: UpdatesInterface[] = []; + for (const dnp of dnps.data) { + if (dnp.updateAvailable) { + const upstreamVersions = dnp.updateAvailable.upstreamVersion?.toString().split(","); + updatesAvailable.push({ + dnpName: dnp.dnpName, + newVersion: dnp.updateAvailable.newVersion, + upstreamVersion: upstreamVersions + }); + } + } + + return ( + <div className="dashboard-cards"> + <div className="package-updates"> + {updatesAvailable.length === 0 ? ( + <div className="card card-body">All packages are up to date</div> + ) : ( + <> + {updatesAvailable.map((update) => ( + <> + <CardList className="package-updates"> + <UpdateCard key={update.dnpName} update={update} /> + </CardList> + </> + ))} + </> + )} + </div> + </div> + ); +} + +function UpdateCard({ update }: { update: UpdatesInterface }) { + const [isOpen, setIsOpen] = useState(false); + const navigate = useNavigate(); + + const toggle = useAccordionButton("0", () => setIsOpen((v) => !v)); + + return ( + <div className="package-update-item"> + <Accordion activeKey={isOpen ? "0" : undefined} className="package-update-accordion"> + <Accordion.Item eventKey="0"> + {/* Clickable header (replaces Accordion.Toggle) */} + <div + role="button" + tabIndex={0} + onClick={toggle} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggle(e); + } + }} + > + <div> + <strong>{prettyDnpName(update.dnpName)}</strong> v{update.newVersion}{" "} + {isOpen ? <IoIosArrowUp /> : <IoIosArrowDown />} + </div> + </div> + + {/* Collapsible content (replaces Accordion.Collapse) */} + <Accordion.Body> + <div> + {Array.isArray(update.upstreamVersion) && update.upstreamVersion.length > 0 && ( + <ul className="package-update-details"> + {update.upstreamVersion.map((upstreamVersion, i) => ( + <li key={`${upstreamVersion}-${i}`}>{upstreamVersion}</li> + ))} + </ul> + )} + </div> + </Accordion.Body> + </Accordion.Item> + </Accordion> + + <div className="package-update-actions"> + <Button onClick={() => navigate(`${getInstallerPath(update.dnpName)}/${update.dnpName}`)} variant="dappnode"> + Update + </Button> + </div> + </div> + ); +} diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/SystemHealth.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/SystemHealth.tsx new file mode 100644 index 0000000000..f9436bb2df --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/SystemHealth.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import SubTitle from "components/SubTitle"; +import { useSystemHealth } from "hooks/useSystemHealth"; +import Card from "components/Card"; +import { CpuIcon } from "./icons/CpuIcon"; +import { MemoryIcon } from "./icons/MemoryIcon"; +import { DiskIcon } from "./icons/DiskIcon"; +import { CpuTempIcon } from "./icons/CpuTempIcon"; +import { UptimeIcon } from "./icons/UptimeIcon"; +import Loading from "components/Loading"; +import { ProgressBar } from "react-bootstrap"; +import "./systemHealth.scss"; + +export default function SystemHealth() { + const { + cpuUsage, + cpuTemp, + memoryUsed, + memoryTotal, + memoryPercentage, + diskUsed, + diskTotal, + diskPercentage, + uptime, + isLoading + } = useSystemHealth(); + + return ( + <div className="system-health"> + <SubTitle>SYSTEM HEALTH</SubTitle> + <Card className="system-health-card"> + {isLoading ? ( + <Loading small /> + ) : ( + <> + <div className="uptime"> + <div className="uptime-header"> + <UptimeIcon /> + <div>Your Dappnode has been running for:</div> + </div> + <div className="uptime-label">{uptime}</div> + </div> + <hr /> + <div className="stats-grid"> + {" "} + <StatBlock title="CPU Usage" icon={<CpuIcon />} data={`${cpuUsage}%`}> + <CustomProgressBar value={cpuUsage} /> + </StatBlock> + <StatBlock title="CPU Temp" icon={<CpuTempIcon />} data={`${cpuTemp}°C`}> + <CustomProgressBar + value={cpuTemp} + max={115} // cpu temperature above 100/110 triggers automatic shutdowns in intel NUCs + danger={95} + warning={85} + /> + </StatBlock> + <StatBlock title="Memory" icon={<MemoryIcon />} data={`${memoryUsed} / ${memoryTotal}`}> + <CustomProgressBar value={memoryPercentage} /> + </StatBlock> + <StatBlock title="Disk" icon={<DiskIcon />} data={`${diskUsed} / ${diskTotal}`}> + <CustomProgressBar value={diskPercentage} /> + </StatBlock> + </div> + </> + )} + </Card> + </div> + ); +} + +function StatBlock({ + title, + icon, + data, + children +}: { + title: string; + icon?: React.ReactNode; + data?: string; + children: React.ReactNode; +}) { + return ( + <div className="stats-block"> + <div className="stats-block-header"> + <div className="title"> + {icon} + {title} + </div> + {data ? <span>{data}</span> : null} + </div> + <div className="stats-block-child">{children}</div> + </div> + ); +} + +function parseVariant({ value, danger = 90, warning = 75 }: { value: number; danger?: number; warning?: number }) { + if (value > danger) return "danger"; + if (value > warning) return "warning"; + return "info"; +} + +function CustomProgressBar({ + value, + max, + danger, + warning, + label +}: { + value: number; + max?: number; + danger?: number; + warning?: number; + label?: string; +}) { + return <ProgressBar variant={parseVariant({ value, danger, warning })} max={max || 100} now={value} label={label} />; +} diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/dashboard.scss b/packages/admin-ui/src/pages/dashboard_v2/components/dashboard.scss new file mode 100644 index 0000000000..1504018550 --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/dashboard.scss @@ -0,0 +1,10 @@ +.dashboard-container { + .dashboard-header { + .section-title { + padding: 0; + } + hr { + margin: 0; + } + } +} diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/icons/BoltIcon.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/icons/BoltIcon.tsx new file mode 100644 index 0000000000..59541002c1 --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/icons/BoltIcon.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import "./iconsStyles.scss"; + +export const BoltIcon = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="bolt-icon" + > + <path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path> + </svg> +); diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/icons/CpuIcon.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/icons/CpuIcon.tsx new file mode 100644 index 0000000000..74a5ec78c0 --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/icons/CpuIcon.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import "./iconsStyles.scss"; + +export const CpuIcon = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="system-health-icon" + > + <rect width="16" height="16" x="4" y="4" rx="2"></rect> + <rect width="6" height="6" x="9" y="9" rx="1"></rect> + <path d="M15 2v2"></path> + <path d="M15 20v2"></path> + <path d="M2 15h2"></path> + <path d="M2 9h2"></path> + <path d="M20 15h2"></path> + <path d="M20 9h2"></path> + <path d="M9 2v2"></path> + <path d="M9 20v2"></path> + </svg> +); diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/icons/CpuTempIcon.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/icons/CpuTempIcon.tsx new file mode 100644 index 0000000000..9614602c4d --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/icons/CpuTempIcon.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import "./iconsStyles.scss"; + +export const CpuTempIcon = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="system-health-icon" + > + <path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"></path> + </svg> +); diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/icons/DiskIcon.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/icons/DiskIcon.tsx new file mode 100644 index 0000000000..58be8153bb --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/icons/DiskIcon.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import "./iconsStyles.scss"; + +export const DiskIcon = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="system-health-icon" + > + <line x1="22" x2="2" y1="12" y2="12"></line> + <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path> + <line x1="6" x2="6.01" y1="16" y2="16"></line> + <line x1="10" x2="10.01" y1="16" y2="16"></line> + </svg> +); diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/icons/HealthIcon.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/icons/HealthIcon.tsx new file mode 100644 index 0000000000..673e67a53b --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/icons/HealthIcon.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import "./iconsStyles.scss"; + +export const HealthIcon = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="health-icon" + > + <path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"></path> + </svg> +); diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/icons/MemoryIcon.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/icons/MemoryIcon.tsx new file mode 100644 index 0000000000..620c73252e --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/icons/MemoryIcon.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import "./iconsStyles.scss"; + +export const MemoryIcon = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="system-health-icon" + > + <path d="M6 19v-3"></path> + <path d="M10 19v-3"></path> + <path d="M14 19v-3"></path> + <path d="M18 19v-3"></path> + <path d="M8 11V9"></path> + <path d="M16 11V9"></path> + <path d="M12 11V9"></path> + <path d="M2 15h20"></path> + <path d="M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.1a2 2 0 0 0 0 3.837V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5.1a2 2 0 0 0 0-3.837Z"></path> + </svg> +); diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/icons/RewardsIcon.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/icons/RewardsIcon.tsx new file mode 100644 index 0000000000..8284e17fcb --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/icons/RewardsIcon.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import "./iconsStyles.scss"; + +export const RewardsIcon = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="rewards-icon" + > + <polyline points="22 7 13.5 15.5 8.5 10.5 2 17"></polyline> + <polyline points="16 7 22 7 22 13"></polyline> + </svg> +); diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/icons/UptimeIcon.tsx b/packages/admin-ui/src/pages/dashboard_v2/components/icons/UptimeIcon.tsx new file mode 100644 index 0000000000..5601914511 --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/icons/UptimeIcon.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import "./iconsStyles.scss"; + +export const UptimeIcon = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="system-health-icon" + > + <circle cx="12" cy="12" r="10"></circle> + <polyline points="12 6 12 12 16 14"></polyline> + </svg> +); diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/icons/iconsStyles.scss b/packages/admin-ui/src/pages/dashboard_v2/components/icons/iconsStyles.scss new file mode 100644 index 0000000000..08ee31794c --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/icons/iconsStyles.scss @@ -0,0 +1,4 @@ +.system-health-icon { + width: 1.2rem; + height: 1.2rem; +} diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/networkStats.scss b/packages/admin-ui/src/pages/dashboard_v2/components/networkStats.scss new file mode 100644 index 0000000000..6477f152bb --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/networkStats.scss @@ -0,0 +1,253 @@ +.network-stats { + width: 100%; + display: flex; + flex-direction: column; + gap: 15px; + margin-top: 15px; + + .network-header { + display: flex; + flex-direction: row; + gap: 5px; + align-items: center; + + svg { + &.eth-logo { + color: gray; + } + } + } + + .network-cards-container { + display: flex; + flex-direction: row; + gap: 10px; + + .card { + flex: 1; + max-width: 33%; + margin-top: 0px; + } + .network-stats-card { + display: flex; + flex-direction: column; + justify-content: space-between; + + .network-stat-col { + display: flex; + flex-direction: column; + font-size: 12px; + color: var(--light-text-color); + + > span { + font-size: 20px; + color: black; + margin-top: -5px; + } + .badge-status { + text-transform: capitalize; + font-size: 14px; + display: flex; + justify-content: center; + min-width: 60px; + + padding: 2px; + border-radius: 5px; + &.synced { + background-color: rgb(203, 241, 203); + color: green; + } + &.syncing { + background-color: rgb(255, 229, 204); + color: orange; + } + &.offline { + background-color: rgb(255, 204, 204); + color: rgb(180, 0, 0); + } + &.waiting { + padding-right: 4px; + padding-left: 4px; + background-color: rgb(230, 230, 230); + color: rgb(100, 100, 100); + cursor: help; + } + } + } + + .network-card-header { + display: flex; + flex-direction: row; + gap: 5px; + + .network-card-title { + color: var(--light-text-color); + margin-top: 1px; + } + + .network-card-icon { + svg { + padding: 3px; + border-radius: 5px; + + &.health-icon { + color: #0084ff; + background-color: #b6e5fa; + } + &.bolt-icon { + color: #6c3299; + background-color: #e5c4ff; + } + &.rewards-icon { + color: #017701; + background-color: #bce9bc; + } + } + } + } + + .status-card-container, + .validators-card-container { + display: flex; + flex-direction: column; + padding: 0.5rem 0; + + .status-client-row, + .validators-row { + display: flex; + flex-direction: row; + justify-content: space-between; + } + .status-client-row { + > div { + flex: 1; + } + } + + .status-client-details { + display: flex; + flex-direction: row; + justify-content: space-between; + } + + hr { + margin: 0.5rem 0; + } + + .progress { + margin-top: 5px; + height: 10px; + .progress-bar { + background-color: orange; + } + } + + .tooltip-beacon-api-error { + margin-bottom: 3px; + color: darkorange; + } + } + + .signer-error-card { + flex: 1; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 25px; + + .error-message { + display: flex; + gap: 5px; + padding: 10px; + background-color: var(--color-light-background-main); + border-radius: 10px; + font-style: italic; + align-items: center; + + .warning-icon { + color: darkorange; + } + } + + .action-text { + margin-top: 5px; + color: var(--light-text-color); + } + } + } + + @media (max-width: 65rem) { + flex-direction: column; + .card { + max-width: 100%; + } + } + } + + .no-nodes-card { + padding: 1rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + text-align: center; + + > div { + font-size: 16px; + } + } +} + +#dark { + #main { + .network-stats { + .network-cards-container { + .badge-status { + &.synced { + background-color: green; + color: rgb(203, 241, 203); + } + &.syncing { + background-color: orange; + color: rgb(255, 229, 204); + } + &.offline { + background-color: rgb(180, 0, 0); + color: rgb(255, 204, 204); + } + &.waiting { + background-color: #696969; + } + } + + .network-card-icon { + svg { + &.health-icon { + color: #9fddfa; + background-color: #0084ff; + } + &.bolt-icon { + color: #d4a1fd; + background-color: #6c3299; + } + &.rewards-icon { + color: #81b181; + background-color: #017701; + } + } + } + + .progress { + background-color: #696969; + } + + .signer-error-card { + .error-message { + background-color: var(--color-dark-background-main); + } + } + } + } + } +} diff --git a/packages/admin-ui/src/pages/dashboard_v2/components/systemHealth.scss b/packages/admin-ui/src/pages/dashboard_v2/components/systemHealth.scss new file mode 100644 index 0000000000..f5bb3a6307 --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/components/systemHealth.scss @@ -0,0 +1,101 @@ +.system-health { + .system-health-card { + display: flex; + flex-direction: column; + padding-top: 1rem; + padding-bottom: 1rem; + + .uptime { + display: flex; + align-items: flex-start; + gap: 5px; + + @media (max-width: 65rem) { + flex-direction: column; + } + + .uptime-header { + display: flex; + align-items: center; + gap: 5px; + svg { + opacity: 0.5; + @media (max-width: 30rem) { + display: none; + } + } + } + + .uptime-label { + font-style: italic; + color: var(--dappnode-strong-main-color); + } + } + + hr { + margin: 0.75rem 0; + } + + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + align-items: stretch; + justify-items: stretch; + gap: 2rem; + + .stats-block { + width: 100%; + .stats-block-header { + display: flex; + flex-direction: row; + justify-content: space-between; + + .title { + display: flex; + align-items: center; + gap: 5px; + } + + svg { + opacity: 0.5; + } + } + + .stats-block-child { + margin-top: 2px; + + // Overwrite bootstrap progress bar styles + .progress { + height: 10px; + background-color: var(--color-light-border); // Non-filled part bg color + .progress-bar { + &.bg-info { + background-color: var(--dappnode-strong-main-color); + } + } + } + } + } + } + } +} + +#dark { + #main { + .system-health { + .system-health-card { + .stats-block { + .progress { + background-color: dimgray; // Non-filled part bg color + } + } + + .uptime { + .uptime-label { + color: var(--dappnode-strong-main-color); + } + } + } + } + } +} diff --git a/packages/admin-ui/src/pages/dashboard_v2/data.ts b/packages/admin-ui/src/pages/dashboard_v2/data.ts new file mode 100644 index 0000000000..689bac2bfc --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/data.ts @@ -0,0 +1,3 @@ +// This will be used later in our root reducer and selectors +export const rootPath = "dashboardv2"; +export const relativePath = "dashboardv2"; diff --git a/packages/admin-ui/src/pages/dashboard_v2/index.ts b/packages/admin-ui/src/pages/dashboard_v2/index.ts new file mode 100644 index 0000000000..94ecdd4278 --- /dev/null +++ b/packages/admin-ui/src/pages/dashboard_v2/index.ts @@ -0,0 +1,3 @@ +import Dashboard from "./components/Dashboard"; +export { rootPath, relativePath } from "./data"; +export const RootComponent = Dashboard; diff --git a/packages/admin-ui/src/pages/index.ts b/packages/admin-ui/src/pages/index.ts index 7924d7897b..e6b4dd7aa4 100644 --- a/packages/admin-ui/src/pages/index.ts +++ b/packages/admin-ui/src/pages/index.ts @@ -1,4 +1,5 @@ import * as dashboard from "./dashboard"; +import * as dashboardv2 from "./dashboard_v2"; // Temporary alias while we transition to the new dashboard import * as VPN from "./vpn"; import * as installer from "./installer"; import * as packages from "./packages"; @@ -13,6 +14,7 @@ import * as premium from "./premium"; export const pages = { dashboard, + dashboardv2, // Temporary alias while we transition to the new dashboard wifi, VPN, installer, diff --git a/packages/admin-ui/src/pages/packages/components/StateBadge/stateBadge.scss b/packages/admin-ui/src/pages/packages/components/StateBadge/stateBadge.scss index d6d12aa4a2..31f2ee82e6 100644 --- a/packages/admin-ui/src/pages/packages/components/StateBadge/stateBadge.scss +++ b/packages/admin-ui/src/pages/packages/components/StateBadge/stateBadge.scss @@ -27,6 +27,10 @@ border-radius: 0 !important; font-size: 100% !important; color: transparent; + height: 30px; + display: flex; + align-items: center; + justify-content: center; } } diff --git a/packages/admin-ui/src/pages/stakers/index.ts b/packages/admin-ui/src/pages/stakers/index.ts index 97a1f7ec19..3e09fdeda9 100644 --- a/packages/admin-ui/src/pages/stakers/index.ts +++ b/packages/admin-ui/src/pages/stakers/index.ts @@ -1,4 +1,4 @@ import StakersRoot from "./components/StakersRoot"; -export { rootPath, relativePath } from "./data"; +export { rootPath, relativePath, basePath } from "./data"; export const RootComponent = StakersRoot; diff --git a/packages/admin-ui/src/utils/gweiToToken.ts b/packages/admin-ui/src/utils/gweiToToken.ts new file mode 100644 index 0000000000..b36f279f7f --- /dev/null +++ b/packages/admin-ui/src/utils/gweiToToken.ts @@ -0,0 +1,33 @@ +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. + * @param gwei - The value in gwei (string or number) + * @param network - The network (Network.Mainnet, Network.Lukso, Network.Gnosis) + * @param decimals - Number of decimals to display (default: 4) + * @returns The value in the correct token unit as a string + */ +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: + symbol = "LYX"; + break; + 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 + valueInEth = valueInEth / 32; + symbol = "GNO"; + break; + // Add more networks and their symbols as needed + default: + symbol = "ETH"; + } + + // Format with specified decimals + return `${valueInEth.toFixed(decimals)} ${symbol}`; +} diff --git a/packages/admin-ui/src/utils/humanFileSize.ts b/packages/admin-ui/src/utils/humanFileSize.ts index ed91b58f06..2ae7ce1a60 100644 --- a/packages/admin-ui/src/utils/humanFileSize.ts +++ b/packages/admin-ui/src/utils/humanFileSize.ts @@ -1,5 +1,16 @@ -export default function humanFileSize(size = 0) { +/** + * Convert a file size in bytes to a human-readable string. + * @param size The file size in bytes. + * @param units Whether to include the units in the output string. Default is true. + * @param decimalDigits The number of decimal digits to include in the output string. Default is 2. + * @returns A human-readable string representing the file size, or 0 if the size is 0 or not provided. + */ + +export default function humanFileSize(size = 0, units = true, decimalDigits = 2): string | number { if (!size) return 0; const i = Math.floor(Math.log(size) / Math.log(1024)); - return parseFloat((size / Math.pow(1024, i)).toFixed(2)) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i]; + return ( + parseFloat((size / Math.pow(1024, i)).toFixed(decimalDigits)) * 1 + + (units ? " " + ["B", "kB", "MB", "GB", "TB"][i] : "") + ); } diff --git a/packages/admin-ui/src/vite-env.d.ts b/packages/admin-ui/src/vite-env.d.ts new file mode 100644 index 0000000000..eadd11827a --- /dev/null +++ b/packages/admin-ui/src/vite-env.d.ts @@ -0,0 +1,16 @@ +/// <reference types="vite/client" /> + +// This module declaration enables importing SVG files as React components using the `?react` suffix. +// It works in conjunction with the SVGR plugin integration in Vite, allowing you to write: +// import Logo from './logo.svg?react' +// and use <Logo /> as a React component in your code. +declare module "*.svg?react" { + import * as React from "react"; + const Component: React.FC<React.SVGProps<SVGSVGElement>>; + export default Component; +} + +declare module "*.svg" { + const src: string; + export default src; +} diff --git a/packages/admin-ui/vite.config.ts b/packages/admin-ui/vite.config.ts index 4c6bd9049c..aa426eaa90 100644 --- a/packages/admin-ui/vite.config.ts +++ b/packages/admin-ui/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import viteTsconfigPaths from "vite-tsconfig-paths"; +import svgr from "vite-plugin-svgr"; export default defineConfig({ usePolling: true, @@ -9,7 +10,7 @@ export default defineConfig({ outDir: "build" }, base: "/", - plugins: [react(), viteTsconfigPaths()], + plugins: [react(), viteTsconfigPaths(), svgr()], server: { // this ensures that the browser opens upon server start //open: false, diff --git a/packages/dappmanager/src/calls/executionClientGet.ts b/packages/dappmanager/src/calls/executionClientGet.ts new file mode 100644 index 0000000000..55fe686f25 --- /dev/null +++ b/packages/dappmanager/src/calls/executionClientGet.ts @@ -0,0 +1,38 @@ +import { + executionClientGnosis, + executionClientHolesky, + executionClientHoodi, + executionClientLukso, + executionClientMainnet, + executionClientPrater, + executionClientSepolia +} from "@dappnode/db"; +import { Network } from "@dappnode/types"; + +// Mapping of each network to its corresponding execution client getter +const executionClientMap: { [key in Network]: () => string | null | undefined } = { + mainnet: () => executionClientMainnet.get(), + gnosis: () => executionClientGnosis.get(), + hoodi: () => executionClientHoodi.get(), + prater: () => executionClientPrater.get(), + holesky: () => executionClientHolesky.get(), + lukso: () => executionClientLukso.get(), + sepolia: () => executionClientSepolia.get(), + starknet: () => null, + "starknet-sepolia": () => null +}; + +export async function executionClientsGetByNetworks({ + networks +}: { + networks: Network[]; +}): Promise<Partial<Record<Network, string | null | undefined>>> { + const result: Partial<Record<Network, string | null | undefined>> = {}; + + for (const network of networks) { + const getter = executionClientMap[network]; + result[network] = getter ? getter() : undefined; + } + + return result; +} diff --git a/packages/dappmanager/src/calls/index.ts b/packages/dappmanager/src/calls/index.ts index 085209eeb9..7833d38934 100644 --- a/packages/dappmanager/src/calls/index.ts +++ b/packages/dappmanager/src/calls/index.ts @@ -13,6 +13,7 @@ export { diagnose } from "./diagnose.js"; export { dockerUpgradeCheck, dockerUpgrade } from "./dockerUpgrade.js"; export { dappnodeWebNameSet } from "./dappnodeWebNameSet.js"; export { disableEthicalMetrics, enableEthicalMetrics, getEthicalMetricsConfig } from "./ethicalMetrics.js"; +export { executionClientsGetByNetworks } from "./executionClientGet.js"; export { fetchCoreUpdateData } from "./fetchCoreUpdateData.js"; export { fetchDirectory } from "./fetchDirectory.js"; export { fetchDnpRequest } from "./fetchDnpRequest.js"; @@ -22,7 +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 } from "./validatorsFilterActive.js"; +export { validatorsFilterActiveByNetwork, validatorsDataByNetwork } from "./validatorsFilterActive.js"; export { notificationsSendCustom, notificationsGetAllEndpoints, @@ -41,6 +42,7 @@ export { notificationsDeleteSubscription, notificationsSendSubTest } from "./notifications.js"; +export { nodeStatusGetByNetwork } from "./nodeStatusGet.js"; export * from "./httpsPortal.js"; export { ipfsTest } from "./ipfsTest.js"; export { ipfsClientTargetSet } from "./ipfsClientTargetSet.js"; @@ -85,6 +87,7 @@ export { export { rebootHost } from "./rebootHost.js"; export * from "./releaseTrustedKey.js"; export { setStaticIp } from "./setStaticIp.js"; +export { signerByNetworkGet } from "./signerByNetworkGet.js"; export { getShouldShowSmooth, setShouldShownSmooth } from "./smooth.js"; export { statsCpuGet } from "./statsCpuGet.js"; export { sshPortGet, sshPortSet, sshStatusGet, sshStatusSet } from "./sshManager.js"; diff --git a/packages/dappmanager/src/calls/nodeStatusGet.ts b/packages/dappmanager/src/calls/nodeStatusGet.ts new file mode 100644 index 0000000000..fc111c291e --- /dev/null +++ b/packages/dappmanager/src/calls/nodeStatusGet.ts @@ -0,0 +1,253 @@ +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) => { + 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) => { + 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_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); + + 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 }; +}; + +const getEcPeers = async (network: DashboardSupportedNetwork) => { + 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) => { + 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 versionData = await versionResponse.json(); + + const clientName = versionData.data.version.split("/")[0]; + + return { name: clientName }; +}; + +// get also peers for the consensus clients + +const getCcPeers = async (network: DashboardSupportedNetwork) => { + 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) => { + // 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 { + progress = 0; + } + } + + return { isSynced, currentBlock: headSlot, progress }; +}; +const getEcData = async (network: DashboardSupportedNetwork): Promise<ClientResult> => { + const ecName = await getEcName(network); + const ecSync = await getEcSyncStatus(network); + const ecPeers = await getEcPeers(network); + + return { + name: ecName.name, + isSynced: ecSync.isSynced, + currentBlock: ecSync.currentBlock, + progress: ecSync.progress, + peers: ecPeers + }; +}; + +const getCcData = async (network: DashboardSupportedNetwork): Promise<ClientResult> => { + const ccVersion = await getCcName(network); + const ccSync = await getCcSyncStatus(network); + const ccPeers = await getCcPeers(network); + + return { + name: ccVersion.name, + isSynced: ccSync.isSynced, + currentBlock: ccSync.currentBlock, + progress: ccSync.progress, + peers: ccPeers + }; +}; + +/** + * 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<ClientResult>, + network: DashboardSupportedNetwork +): Promise<ClientResult> { + let timeoutId: ReturnType<typeof setTimeout> | undefined; + try { + const result = await Promise.race([ + fetchFn(), + new Promise<never>((_, reject) => { + timeoutId = 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}` }; + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } +} + +export async function nodeStatusGetByNetwork({ networks }: { networks: DashboardSupportedNetwork[] }) { + const resultsByNetwork: NodeStatusByNetwork = {}; + + 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/signerByNetworkGet.ts b/packages/dappmanager/src/calls/signerByNetworkGet.ts new file mode 100644 index 0000000000..2a63c1def6 --- /dev/null +++ b/packages/dappmanager/src/calls/signerByNetworkGet.ts @@ -0,0 +1,30 @@ +import { listPackageNoThrow } from "@dappnode/dockerapi"; +import { Network, SignerStatus } from "@dappnode/types"; + +async function signerStatusGet({ network }: { network: Network }): Promise<SignerStatus> { + const signerDnp = await listPackageNoThrow({ + dnpName: `web3signer${network !== Network.Mainnet ? `-${network}` : ""}.dnp.dappnode.eth` + }); + + const isInstalled = Boolean(signerDnp); + + const brainRunning = Boolean( + signerDnp && signerDnp.containers.find((c) => c.serviceName.includes("brain"))?.state === "running" + ); + + return { isInstalled, brainRunning }; +} + +export async function signerByNetworkGet({ + networks +}: { + networks: Network[]; +}): Promise<Partial<Record<Network, SignerStatus>>> { + const results: Partial<Record<Network, SignerStatus>> = {}; + + for (const network of networks) { + results[network] = await signerStatusGet({ network }); + } + + return results; +} diff --git a/packages/dappmanager/src/calls/validatorsFilterActive.ts b/packages/dappmanager/src/calls/validatorsFilterActive.ts index febf8631a7..22d6fafb79 100644 --- a/packages/dappmanager/src/calls/validatorsFilterActive.ts +++ b/packages/dappmanager/src/calls/validatorsFilterActive.ts @@ -1,34 +1,120 @@ -import { Network } from "@dappnode/types"; +import { Network, ValidatorsNetworkData } from "@dappnode/types"; import { keystoresGetByNetwork } from "./keystoresGet.js"; type KeystoresByProtocol = Record<string, string[]>; const ACTIVE_STATUSES = new Set(["active_ongoing", "active_exiting", "active_slashed"]); +type ValidatorEntry = { + index: string; + validator: { pubkey: string }; + balance: string; + status: string; +}; + /** - * Query the beacon API for a batch of pubkeys and return the active ones. + * calls /eth/v1/beacon/states/head/validators ONCE for a batch + * and returns all data needed by active, balances, and attesting */ -async function fetchActivePubkeysForBatch(network: Network, pubkeys: string[]): Promise<string[]> { +async function fetchValidatorsBatch( + network: Network, + pubkeys: string[] +): Promise<{ + activePubkeys: string[]; + balances: Record<string, string>; + pubkeyToIndex: Map<string, string>; + indexToPubkey: Map<string, string>; +}> { 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 pubkeys) params.append("id", pk); + for (const pk of normPubkeys) params.append("id", pk); url.search = params.toString(); - const res = await fetch(url.toString()); + const res = await fetch(url.toString(), { headers: { Accept: "application/json" } }); if (!res.ok) { - throw new Error(`Beacon API ${network} responded ${res.status} ${res.statusText}`); + throw new Error(`Beacon API ${network} validators responded ${res.status} ${res.statusText}`); } - type ValidatorEntry = { - validator: { pubkey: string }; - status: string; - index?: string; - }; + const json: { data?: ValidatorEntry[] } = await res.json(); + const entries = json.data ?? []; - const json: { data: ValidatorEntry[] } = await res.json(); + const activePubkeys: string[] = []; + const balances: Record<string, string> = {}; + const pubkeyToIndex = new Map<string, string>(); + const indexToPubkey = new Map<string, string>(); - return (json.data ?? []).filter((v) => ACTIVE_STATUSES.has(v.status)).map((v) => v.validator.pubkey.toLowerCase()); + 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); + } + if (pk) { + balances[pk] = row.balance; + if (ACTIVE_STATUSES.has(row.status)) { + activePubkeys.push(pk); + } + } + } + + return { activePubkeys, balances, pubkeyToIndex, indexToPubkey }; +} + +/** + * Query the liveness endpoint using precomputed index mappings. + * Returns attesting pubkeys. + */ +async function fetchAttestingFromLiveness( + network: Network, + pubkeys: string[], + pubkeyToIndex: Map<string, string>, + indexToPubkey: Map<string, string> +): Promise<string[]> { + const base = `http://beacon-chain.${network}.dncore.dappnode:3500`; + const normPubkeys = pubkeys.map((p) => p.toLowerCase()); + + // Get current epoch from head + const headHeaderUrl = new URL(`/eth/v1/beacon/headers/head`, base); + 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`); + const currentEpoch = Math.floor(Number(slotStr) / 32); + const livenessEpoch = currentEpoch - 1; + + const indices = normPubkeys.map((pk) => pubkeyToIndex.get(pk)).filter((x): x is string => !!x); + if (indices.length === 0) return []; + + 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" }, + body: JSON.stringify(indices) + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `Beacon API ${network} liveness responded ${res.status} ${res.statusText}${text ? `: ${text}` : ""}` + ); + } + + type LivenessEntry = { index: string; is_live: boolean }; + const json: { data?: LivenessEntry[] } = await res.json(); + + const live = new Set((json.data ?? []).filter((x) => x.is_live).map((x) => x.index)); + + return indices + .filter((idx) => live.has(idx)) + .map((idx) => indexToPubkey.get(idx)) + .filter((pk): pk is string => !!pk); } /** @@ -67,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<Partial<Record<Network, ValidatorsNetworkData>>> { + const result: Partial<Record<Network, ValidatorsNetworkData>> = {}; + 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<string, string> = {}; + 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/dappmanager/src/initializeDb.ts b/packages/dappmanager/src/initializeDb.ts index dd747e44ee..a52fcb5a81 100644 --- a/packages/dappmanager/src/initializeDb.ts +++ b/packages/dappmanager/src/initializeDb.ts @@ -55,8 +55,7 @@ export async function initializeDb(): Promise<void> { if (db.ipfsGateway.get() === "http://ipfs.dappnode.io:8081") db.ipfsGateway.set(params.IPFS_REMOTE); /** - * - * + * Initialize telegram notifications settings */ if (db.notifications.get() === null) { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 88ced5399f..d5b717bfcd 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -11,6 +11,7 @@ export * from "./routes.js"; export * from "./subscriptions.js"; export * from "./notifications.js"; export * from "./beaconBackup.js"; +export * from "./stakingDashboard.js"; // utils export * from "./utils/index.js"; diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index 6867a05f3c..d63a97d5b5 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -56,6 +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 { + DashboardSupportedNetwork, + NodeStatusByNetwork, + SignerStatus, + ValidatorsNetworkData +} from "./stakingDashboard.js"; export interface Routes { /** @@ -141,6 +147,14 @@ export interface Routes { networks: Network[]; }) => Promise<Partial<Record<Network, string | null | undefined>>>; + /** + * Returns the execution client for a given network + * @param network Network to get the execution client for + */ + executionClientsGetByNetworks: (kwargs: { + networks: Network[]; + }) => Promise<Partial<Record<Network, string | null | undefined>>>; + /** Set the dappnodeWebNameSet */ dappnodeWebNameSet: (kwargs: { dappnodeWebName: string }) => Promise<void>; @@ -262,6 +276,11 @@ export interface Routes { subscriptionEndpoint?: string; }): Promise<void>; + /** + * Returns the node status for the given networks + */ + nodeStatusGetByNetwork(kwargs: { networks: DashboardSupportedNetwork[] }): Promise<NodeStatusByNetwork>; + /** * Get all the notifications */ @@ -758,6 +777,11 @@ export interface Routes { */ updateUpgrade: () => Promise<string>; + /** + * Returns the signer status of the provided networks + */ + signerByNetworkGet: (kwargs: { networks: Network[] }) => Promise<Partial<Record<Network, SignerStatus>>>; + /** * Return the current SSH port from sshd */ @@ -796,6 +820,15 @@ export interface Routes { networks: Network[]; }): Promise<Partial<Record<Network, { validators: string[]; beaconError?: Error } | null>>>; + /** + * 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 + */ + validatorsDataByNetwork: (kwargs: { + networks: Network[]; + }) => Promise<Partial<Record<Network, ValidatorsNetworkData>>>; + /** * Removes a docker volume by name * @param name Full volume name: "bitcoindnpdappnodeeth_bitcoin_data" @@ -852,6 +885,7 @@ export const routesData: { [P in keyof Routes]: RouteData } = { stakerConfigGet: {}, stakerConfigSet: { log: true }, consensusClientsGetByNetworks: {}, + executionClientsGetByNetworks: {}, dappnodeWebNameSet: { log: true }, deviceAdd: { log: true }, deviceAdminToggle: { log: true }, @@ -872,6 +906,7 @@ export const routesData: { [P in keyof Routes]: RouteData } = { fetchDirectory: {}, fetchRegistry: {}, fetchDnpRequest: {}, + nodeStatusGetByNetwork: {}, notificationsSendCustom: {}, notificationsGetAll: {}, notificationsGetBanner: {}, @@ -948,6 +983,7 @@ export const routesData: { [P in keyof Routes]: RouteData } = { releaseTrustedKeyRemove: { log: true }, setShouldShownSmooth: {}, getShouldShowSmooth: {}, + signerByNetworkGet: {}, setStaticIp: { log: true }, statsCpuGet: {}, statsDiskGet: {}, @@ -964,7 +1000,8 @@ export const routesData: { [P in keyof Routes]: RouteData } = { updateUpgrade: { log: true }, natRenewalEnable: {}, natRenewalIsEnabled: {}, - validatorsFilterActiveByNetwork: { log: true }, + validatorsFilterActiveByNetwork: {}, + validatorsDataByNetwork: {}, volumeRemove: { log: true }, volumesGet: {}, ipPublicGet: {}, diff --git a/packages/types/src/stakingDashboard.ts b/packages/types/src/stakingDashboard.ts new file mode 100644 index 0000000000..f26dd36624 --- /dev/null +++ b/packages/types/src/stakingDashboard.ts @@ -0,0 +1,59 @@ +import { Network } from "./stakers.js"; + +export type DashboardSupportedNetwork = + | Network.Mainnet + | Network.Gnosis + | Network.Lukso + | Network.Hoodi + | Network.Sepolia; + +export type ClientData = { + name: string; + isSynced: boolean; + currentBlock: number; + progress: number; + peers: number; +} | null; + +export type ClientError = { + error: string; +}; + +export type ClientResult = ClientData | ClientError; + +export type NodeStatus = { ec: ClientResult; cc: ClientResult }; + +export type NetworkStatus = { + nodeStatus: NodeStatus | undefined; + clientsDnps?: { + ecDnp: string | null; + ccDnp: string | null; + }; + validators?: { + total: number; + balance: number; + attesting: number; + beaconError?: Error; + signerStatus: SignerStatus; + pubKeys?: string[]; // Validator public keys used for beaconcha.in dynamic dashboard URLs + }; + hasValidators: boolean; // Whether this network has validators + beaconExplorer?: { url: string; name: string }; // Whether this network has a beacon explorer to redirect to for validator rewards +}; + +export type SignerStatus = { + isInstalled: boolean; + brainRunning: boolean; +}; + +export type NetworkStats = Partial<Record<DashboardSupportedNetwork, NetworkStatus>>; + +export type NodeStatusByNetwork = Partial<Record<DashboardSupportedNetwork, NodeStatus>>; + +export type ValidatorsNetworkData = { + active: { validators: string[]; beaconError?: Error } | null; + attesting: { validators: string[]; beaconError?: Error } | null; + balances: { balances: Record<string, string>; beaconError?: Error } | null; +}; + +export type ValidatorsDataByNetwork = Partial<Record<DashboardSupportedNetwork, ValidatorsNetworkData>>; diff --git a/yarn.lock b/yarn.lock index d06440ed33..f26e09e5a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -212,6 +212,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/code-frame@npm:7.28.6" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.28.5" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/ed5d57f99455e3b1c23e75ebb8430c6b9800b4ecd0121b4348b97cecb65406a47778d6db61f0d538a4958bb01b4b277e90348a68d39bd3beff1d7c940ed6dd66 + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.24.7": version: 7.24.7 resolution: "@babel/compat-data@npm:7.24.7" @@ -219,6 +230,36 @@ __metadata: languageName: node linkType: hard +"@babel/compat-data@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/compat-data@npm:7.28.6" + checksum: 10c0/2d047431041281eaf33e9943d1a269d3374dbc9b498cafe6a18f5ee9aee7bb96f7f6cac0304eab4d13c41fc4db00fe4ca16c7aa44469ca6a211b8b6343b78fc4 + languageName: node + linkType: hard + +"@babel/core@npm:^7.21.3": + version: 7.28.6 + resolution: "@babel/core@npm:7.28.6" + dependencies: + "@babel/code-frame": "npm:^7.28.6" + "@babel/generator": "npm:^7.28.6" + "@babel/helper-compilation-targets": "npm:^7.28.6" + "@babel/helper-module-transforms": "npm:^7.28.6" + "@babel/helpers": "npm:^7.28.6" + "@babel/parser": "npm:^7.28.6" + "@babel/template": "npm:^7.28.6" + "@babel/traverse": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + "@jridgewell/remapping": "npm:^2.3.5" + convert-source-map: "npm:^2.0.0" + debug: "npm:^4.1.0" + gensync: "npm:^1.0.0-beta.2" + json5: "npm:^2.2.3" + semver: "npm:^6.3.1" + checksum: 10c0/716b88b1ab057aa53ffa40f2b2fb7e4ab7a35cd6a065fa60e55ca13d2a666672592329f7ea9269aec17e90cc7ce29f42eda566d07859bfd998329a9f283faadb + languageName: node + linkType: hard + "@babel/core@npm:^7.24.5": version: 7.24.7 resolution: "@babel/core@npm:7.24.7" @@ -278,6 +319,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/generator@npm:7.28.6" + dependencies: + "@babel/parser": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/162fa358484a9a18e8da1235d998f10ea77c63bab408c8d3e327d5833f120631a77ff022c5ed1d838ee00523f8bb75df1f08196d3657d0bca9f2cfeb8503cc12 + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.18.6, @babel/helper-annotate-as-pure@npm:^7.22.5": version: 7.22.5 resolution: "@babel/helper-annotate-as-pure@npm:7.22.5" @@ -300,6 +354,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-compilation-targets@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-compilation-targets@npm:7.28.6" + dependencies: + "@babel/compat-data": "npm:^7.28.6" + "@babel/helper-validator-option": "npm:^7.27.1" + browserslist: "npm:^4.24.0" + lru-cache: "npm:^5.1.1" + semver: "npm:^6.3.1" + checksum: 10c0/3fcdf3b1b857a1578e99d20508859dbd3f22f3c87b8a0f3dc540627b4be539bae7f6e61e49d931542fe5b557545347272bbdacd7f58a5c77025a18b745593a50 + languageName: node + linkType: hard + "@babel/helper-create-class-features-plugin@npm:^7.21.0": version: 7.22.9 resolution: "@babel/helper-create-class-features-plugin@npm:7.22.9" @@ -372,6 +439,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-globals@npm:^7.28.0": + version: 7.28.0 + resolution: "@babel/helper-globals@npm:7.28.0" + checksum: 10c0/5a0cd0c0e8c764b5f27f2095e4243e8af6fa145daea2b41b53c0c1414fe6ff139e3640f4e2207ae2b3d2153a1abd346f901c26c290ee7cb3881dd922d4ee9232 + languageName: node + linkType: hard + "@babel/helper-hoist-variables@npm:^7.22.5": version: 7.22.5 resolution: "@babel/helper-hoist-variables@npm:7.22.5" @@ -418,6 +492,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-imports@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-imports@npm:7.28.6" + dependencies: + "@babel/traverse": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10c0/b49d8d8f204d9dbfd5ac70c54e533e5269afb3cea966a9d976722b13e9922cc773a653405f53c89acb247d5aebdae4681d631a3ae3df77ec046b58da76eda2ac + languageName: node + linkType: hard + "@babel/helper-module-transforms@npm:^7.24.7": version: 7.24.7 resolution: "@babel/helper-module-transforms@npm:7.24.7" @@ -433,6 +517,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-transforms@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-transforms@npm:7.28.6" + dependencies: + "@babel/helper-module-imports": "npm:^7.28.6" + "@babel/helper-validator-identifier": "npm:^7.28.5" + "@babel/traverse": "npm:^7.28.6" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/6f03e14fc30b287ce0b839474b5f271e72837d0cafe6b172d759184d998fbee3903a035e81e07c2c596449e504f453463d58baa65b6f40a37ded5bec74620b2b + languageName: node + linkType: hard + "@babel/helper-optimise-call-expression@npm:^7.22.5": version: 7.22.5 resolution: "@babel/helper-optimise-call-expression@npm:7.22.5" @@ -534,6 +631,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-string-parser@npm:7.27.1" + checksum: 10c0/8bda3448e07b5583727c103560bcf9c4c24b3c1051a4c516d4050ef69df37bb9a4734a585fe12725b8c2763de0a265aa1e909b485a4e3270b7cfd3e4dbe4b602 + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.22.20": version: 7.22.20 resolution: "@babel/helper-validator-identifier@npm:7.22.20" @@ -555,6 +659,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.24.7": version: 7.24.7 resolution: "@babel/helper-validator-option@npm:7.24.7" @@ -562,6 +673,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-option@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-validator-option@npm:7.27.1" + checksum: 10c0/6fec5f006eba40001a20f26b1ef5dbbda377b7b68c8ad518c05baa9af3f396e780bdfded24c4eef95d14bb7b8fd56192a6ed38d5d439b97d10efc5f1a191d148 + languageName: node + linkType: hard + "@babel/helpers@npm:^7.24.7": version: 7.24.7 resolution: "@babel/helpers@npm:7.24.7" @@ -572,6 +690,16 @@ __metadata: languageName: node linkType: hard +"@babel/helpers@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helpers@npm:7.28.6" + dependencies: + "@babel/template": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10c0/c4a779c66396bb0cf619402d92f1610601ff3832db2d3b86b9c9dd10983bf79502270e97ac6d5280cea1b1a37de2f06ecbac561bd2271545270407fbe64027cb + languageName: node + linkType: hard + "@babel/highlight@npm:^7.22.5": version: 7.22.5 resolution: "@babel/highlight@npm:7.22.5" @@ -633,6 +761,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/parser@npm:7.28.6" + dependencies: + "@babel/types": "npm:^7.28.6" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/d6bfe8aa8e067ef58909e9905496157312372ca65d8d2a4f2b40afbea48d59250163755bba8ae626a615da53d192b084bcfc8c9dad8b01e315b96967600de581 + languageName: node + linkType: hard + "@babel/plugin-proposal-private-property-in-object@npm:^7.21.11": version: 7.21.11 resolution: "@babel/plugin-proposal-private-property-in-object@npm:7.21.11" @@ -749,6 +888,17 @@ __metadata: languageName: node linkType: hard +"@babel/template@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/template@npm:7.28.6" + dependencies: + "@babel/code-frame": "npm:^7.28.6" + "@babel/parser": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10c0/66d87225ed0bc77f888181ae2d97845021838c619944877f7c4398c6748bcf611f216dfd6be74d39016af502bca876e6ce6873db3c49e4ac354c56d34d57e9f5 + languageName: node + linkType: hard + "@babel/traverse@npm:^7.0.0": version: 7.22.8 resolution: "@babel/traverse@npm:7.22.8" @@ -803,6 +953,21 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/traverse@npm:7.28.6" + dependencies: + "@babel/code-frame": "npm:^7.28.6" + "@babel/generator": "npm:^7.28.6" + "@babel/helper-globals": "npm:^7.28.0" + "@babel/parser": "npm:^7.28.6" + "@babel/template": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + debug: "npm:^4.3.1" + checksum: 10c0/ed5deb9c3f03e2d1ad2d44b9c92c84cce24593245c3f7871ce27ee1b36d98034e6cd895fa98a94eb44ebabe1d22f51b10b09432939d1c51a0fcaab98f17a97bc + languageName: node + linkType: hard + "@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.22.5": version: 7.22.5 resolution: "@babel/types@npm:7.22.5" @@ -814,6 +979,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.21.3, @babel/types@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/types@npm:7.28.6" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10c0/54a6a9813e48ef6f35aa73c03b3c1572cad7fa32b61b35dd07e4230bc77b559194519c8a4d8106a041a27cc7a94052579e238a30a32d5509aa4da4d6fd83d990 + languageName: node + linkType: hard + "@babel/types@npm:^7.22.15, @babel/types@npm:^7.23.0, @babel/types@npm:^7.23.5": version: 7.23.5 resolution: "@babel/types@npm:7.23.5" @@ -971,6 +1146,7 @@ __metadata: swr: "npm:^0.2.0" ts-node: "npm:^10.9.2" vite: "npm:^5.4.19" + vite-plugin-svgr: "npm:^4.5.0" vite-tsconfig-paths: "npm:^4.3.2" dependenciesMeta: "@rollup/rollup-linux-x64-gnu": @@ -2678,6 +2854,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/gen-mapping@npm:^0.3.12": + version: 0.3.13 + resolution: "@jridgewell/gen-mapping@npm:0.3.13" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/9a7d65fb13bd9aec1fbab74cda08496839b7e2ceb31f5ab922b323e94d7c481ce0fc4fd7e12e2610915ed8af51178bdc61e168e92a8c8b8303b030b03489b13b + languageName: node + linkType: hard + "@jridgewell/gen-mapping@npm:^0.3.5": version: 0.3.5 resolution: "@jridgewell/gen-mapping@npm:0.3.5" @@ -2689,6 +2875,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/remapping@npm:^2.3.5": + version: 2.3.5 + resolution: "@jridgewell/remapping@npm:2.3.5" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194 + languageName: node + linkType: hard + "@jridgewell/resolve-uri@npm:3.1.0": version: 3.1.0 resolution: "@jridgewell/resolve-uri@npm:3.1.0" @@ -2738,6 +2934,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/sourcemap-codec@npm:^1.5.0": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + "@jridgewell/trace-mapping@npm:0.3.9": version: 0.3.9 resolution: "@jridgewell/trace-mapping@npm:0.3.9" @@ -2768,6 +2971,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:^0.3.28": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.5 resolution: "@leichtgewicht/ip-codec@npm:2.0.5" @@ -3429,6 +3642,22 @@ __metadata: languageName: node linkType: hard +"@rollup/pluginutils@npm:^5.2.0": + version: 5.3.0 + resolution: "@rollup/pluginutils@npm:5.3.0" + dependencies: + "@types/estree": "npm:^1.0.0" + estree-walker: "npm:^2.0.2" + picomatch: "npm:^4.0.2" + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10c0/001834bf62d7cf5bac424d2617c113f7f7d3b2bf3c1778cbcccb72cdc957b68989f8e7747c782c2b911f1dde8257f56f8ac1e779e29e74e638e3f1e2cac2bcd0 + languageName: node + linkType: hard + "@rollup/rollup-android-arm-eabi@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-android-arm-eabi@npm:4.50.1" @@ -3671,6 +3900,133 @@ __metadata: languageName: node linkType: hard +"@svgr/babel-plugin-add-jsx-attribute@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-add-jsx-attribute@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/a50bd0baa34faf16bcba712091f94c7f0e230431fe99a9dfc3401fa92823ad3f68495b86ab9bf9044b53839e8c416cfbb37eb3f246ff33f261e0fa9ee1779c5b + languageName: node + linkType: hard + +"@svgr/babel-plugin-remove-jsx-attribute@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-remove-jsx-attribute@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/8a98e59bd9971e066815b4129409932f7a4db4866834fe75677ea6d517972fb40b380a69a4413189f20e7947411f9ab1b0f029dd5e8068686a5a0188d3ccd4c7 + languageName: node + linkType: hard + +"@svgr/babel-plugin-remove-jsx-empty-expression@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-remove-jsx-empty-expression@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/517dcca75223bd05d3f056a8514dbba3031278bea4eadf0842c576d84f4651e7a4e0e7082d3ee4ef42456de0f9c4531d8a1917c04876ca64b014b859ca8f1bde + languageName: node + linkType: hard + +"@svgr/babel-plugin-replace-jsx-attribute-value@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-replace-jsx-attribute-value@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/004bd1892053b7e9c1b0bb14acc44e77634ec393722b87b1e4fae53e2c35122a2dd0d5c15e9070dbeec274e22e7693a2b8b48506733a8009ee92b12946fcb10a + languageName: node + linkType: hard + +"@svgr/babel-plugin-svg-dynamic-title@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-svg-dynamic-title@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/80e0a7fcf902f984c705051ca5c82ea6050ccbb70b651a8fea6d0eb5809e4dac274b49ea6be2d87f1eb9dfc0e2d6cdfffe1669ec2117f44b67a60a07d4c0b8b8 + languageName: node + linkType: hard + +"@svgr/babel-plugin-svg-em-dimensions@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-svg-em-dimensions@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/73e92c8277a89279745c0c500f59f083279a8dc30cd552b22981fade2a77628fb2bd2819ee505725fcd2e93f923e3790b52efcff409a159e657b46604a0b9a21 + languageName: node + linkType: hard + +"@svgr/babel-plugin-transform-react-native-svg@npm:8.1.0": + version: 8.1.0 + resolution: "@svgr/babel-plugin-transform-react-native-svg@npm:8.1.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/655ed6bc7a208ceaa4ecff0a54ccc36008c3cb31efa90d11e171cab325ebbb21aa78f09c7b65f9b3ddeda3a85f348c0c862902c48be13c14b4de165c847974e3 + languageName: node + linkType: hard + +"@svgr/babel-plugin-transform-svg-component@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-transform-svg-component@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/4ac00bb99a3db4ef05e4362f116a3c608ee365a2d26cf7318d8d41a4a5b30a02c80455cce0e62c65b60ed815b5d632bedabac2ccd4b56f998fadef5286e3ded4 + languageName: node + linkType: hard + +"@svgr/babel-preset@npm:8.1.0": + version: 8.1.0 + resolution: "@svgr/babel-preset@npm:8.1.0" + dependencies: + "@svgr/babel-plugin-add-jsx-attribute": "npm:8.0.0" + "@svgr/babel-plugin-remove-jsx-attribute": "npm:8.0.0" + "@svgr/babel-plugin-remove-jsx-empty-expression": "npm:8.0.0" + "@svgr/babel-plugin-replace-jsx-attribute-value": "npm:8.0.0" + "@svgr/babel-plugin-svg-dynamic-title": "npm:8.0.0" + "@svgr/babel-plugin-svg-em-dimensions": "npm:8.0.0" + "@svgr/babel-plugin-transform-react-native-svg": "npm:8.1.0" + "@svgr/babel-plugin-transform-svg-component": "npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/49367d3ad0831f79b1056871b91766246f449d4d1168623af5e283fbaefce4a01d77ab00de6b045b55e956f9aae27895823198493cd232d88d3435ea4517ffc5 + languageName: node + linkType: hard + +"@svgr/core@npm:^8.1.0": + version: 8.1.0 + resolution: "@svgr/core@npm:8.1.0" + dependencies: + "@babel/core": "npm:^7.21.3" + "@svgr/babel-preset": "npm:8.1.0" + camelcase: "npm:^6.2.0" + cosmiconfig: "npm:^8.1.3" + snake-case: "npm:^3.0.4" + checksum: 10c0/6a2f6b1bc79bce39f66f088d468985d518005fc5147ebf4f108570a933818b5951c2cb7da230ddff4b7c8028b5a672b2d33aa2acce012b8b9770073aa5a2d041 + languageName: node + linkType: hard + +"@svgr/hast-util-to-babel-ast@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/hast-util-to-babel-ast@npm:8.0.0" + dependencies: + "@babel/types": "npm:^7.21.3" + entities: "npm:^4.4.0" + checksum: 10c0/f4165b583ba9eaf6719e598977a7b3ed182f177983e55f9eb55a6a73982d81277510e9eb7ab41f255151fb9ed4edd11ac4bef95dd872f04ed64966d8c85e0f79 + languageName: node + linkType: hard + +"@svgr/plugin-jsx@npm:^8.1.0": + version: 8.1.0 + resolution: "@svgr/plugin-jsx@npm:8.1.0" + dependencies: + "@babel/core": "npm:^7.21.3" + "@svgr/babel-preset": "npm:8.1.0" + "@svgr/hast-util-to-babel-ast": "npm:8.0.0" + svg-parser: "npm:^2.0.4" + peerDependencies: + "@svgr/core": "*" + checksum: 10c0/07b4d9e00de795540bf70556fa2cc258774d01e97a12a26234c6fdf42b309beb7c10f31ee24d1a71137239347b1547b8bb5587d3a6de10669f95dcfe99cddc56 + languageName: node + linkType: hard + "@swc/helpers@npm:^0.5.0": version: 0.5.17 resolution: "@swc/helpers@npm:0.5.17" @@ -4211,7 +4567,7 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:1.0.8": +"@types/estree@npm:1.0.8, @types/estree@npm:^1.0.0": version: 1.0.8 resolution: "@types/estree@npm:1.0.8" checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 @@ -5941,6 +6297,15 @@ __metadata: languageName: node linkType: hard +"baseline-browser-mapping@npm:^2.9.0": + version: 2.9.15 + resolution: "baseline-browser-mapping@npm:2.9.15" + bin: + baseline-browser-mapping: dist/cli.js + checksum: 10c0/e5c8cb8600fcbed8132f122b737b00b5b3fcf25a119ea5e42476e6d6b2263274ddc5df16d4cffebbcd46974b691008558973b06100508903ea8a382a5edd34ab + languageName: node + linkType: hard + "bcrypt-pbkdf@npm:^1.0.0, bcrypt-pbkdf@npm:^1.0.2": version: 1.0.2 resolution: "bcrypt-pbkdf@npm:1.0.2" @@ -6281,6 +6646,21 @@ __metadata: languageName: node linkType: hard +"browserslist@npm:^4.24.0": + version: 4.28.1 + resolution: "browserslist@npm:4.28.1" + dependencies: + baseline-browser-mapping: "npm:^2.9.0" + caniuse-lite: "npm:^1.0.30001759" + electron-to-chromium: "npm:^1.5.263" + node-releases: "npm:^2.0.27" + update-browserslist-db: "npm:^1.2.0" + bin: + browserslist: cli.js + checksum: 10c0/545a5fa9d7234e3777a7177ec1e9134bb2ba60a69e6b95683f6982b1473aad347c77c1264ccf2ac5dea609a9731fbfbda6b85782bdca70f80f86e28a402504bd + languageName: node + linkType: hard + "bs58@npm:^4.0.0, bs58@npm:^4.0.1": version: 4.0.1 resolution: "bs58@npm:4.0.1" @@ -6553,7 +6933,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^6.0.0, camelcase@npm:^6.3.0": +"camelcase@npm:^6.0.0, camelcase@npm:^6.2.0, camelcase@npm:^6.3.0": version: 6.3.0 resolution: "camelcase@npm:6.3.0" checksum: 10c0/0d701658219bd3116d12da3eab31acddb3f9440790c0792e0d398f0a520a6a4058018e546862b6fba89d7ae990efaeb97da71e1913e9ebf5a8b5621a3d55c710 @@ -6574,6 +6954,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001759": + version: 1.0.30001765 + resolution: "caniuse-lite@npm:1.0.30001765" + checksum: 10c0/2bab28b322ec040dde2b6f56019ffd1e0bbd719111e45f58cb0fb06a783812d8ba8df65755320fd253aa1926dffc7bf0864adc11f6b231ac2b3a5b8221199c29 + languageName: node + linkType: hard + "caseless@npm:~0.12.0": version: 0.12.0 resolution: "caseless@npm:0.12.0" @@ -7135,6 +7522,23 @@ __metadata: languageName: node linkType: hard +"cosmiconfig@npm:^8.1.3": + version: 8.3.6 + resolution: "cosmiconfig@npm:8.3.6" + dependencies: + import-fresh: "npm:^3.3.0" + js-yaml: "npm:^4.1.0" + parse-json: "npm:^5.2.0" + path-type: "npm:^4.0.0" + peerDependencies: + typescript: ">=4.9.5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/0382a9ed13208f8bfc22ca2f62b364855207dffdb73dc26e150ade78c3093f1cf56172df2dd460c8caf2afa91c0ed4ec8a88c62f8f9cd1cf423d26506aa8797a + languageName: node + linkType: hard + "cpu-features@npm:~0.0.8": version: 0.0.8 resolution: "cpu-features@npm:0.0.8" @@ -7803,6 +8207,16 @@ __metadata: languageName: node linkType: hard +"dot-case@npm:^3.0.4": + version: 3.0.4 + resolution: "dot-case@npm:3.0.4" + dependencies: + no-case: "npm:^3.0.4" + tslib: "npm:^2.0.3" + checksum: 10c0/5b859ea65097a7ea870e2c91b5768b72ddf7fa947223fd29e167bcdff58fe731d941c48e47a38ec8aa8e43044c8fbd15cd8fa21689a526bc34b6548197cd5b05 + languageName: node + linkType: hard + "dot-prop@npm:^6.0.1": version: 6.0.1 resolution: "dot-prop@npm:6.0.1" @@ -7877,6 +8291,13 @@ __metadata: languageName: node linkType: hard +"electron-to-chromium@npm:^1.5.263": + version: 1.5.267 + resolution: "electron-to-chromium@npm:1.5.267" + checksum: 10c0/0732bdb891b657f2e43266a3db8cf86fff6cecdcc8d693a92beff214e136cb5c2ee7dc5945ed75fa1db16e16bad0c38695527a020d15f39e79084e0b2e447621 + languageName: node + linkType: hard + "elliptic@npm:6.5.4, elliptic@npm:^6.4.0, elliptic@npm:^6.5.3, elliptic@npm:^6.5.4": version: 6.5.4 resolution: "elliptic@npm:6.5.4" @@ -8014,6 +8435,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^4.4.0": + version: 4.5.0 + resolution: "entities@npm:4.5.0" + checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0, env-paths@npm:^2.2.1": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -8393,6 +8821,13 @@ __metadata: languageName: node linkType: hard +"escalade@npm:^3.2.0": + version: 3.2.0 + resolution: "escalade@npm:3.2.0" + checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 + languageName: node + linkType: hard + "escape-html@npm:~1.0.3": version: 1.0.3 resolution: "escape-html@npm:1.0.3" @@ -10321,6 +10756,16 @@ __metadata: languageName: node linkType: hard +"import-fresh@npm:^3.3.0": + version: 3.3.1 + resolution: "import-fresh@npm:3.3.1" + dependencies: + parent-module: "npm:^1.0.0" + resolve-from: "npm:^4.0.0" + checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec + languageName: node + linkType: hard + "imurmurhash@npm:^0.1.4": version: 0.1.4 resolution: "imurmurhash@npm:0.1.4" @@ -11399,6 +11844,15 @@ __metadata: languageName: node linkType: hard +"jsesc@npm:^3.0.2": + version: 3.1.0 + resolution: "jsesc@npm:3.1.0" + bin: + jsesc: bin/jsesc + checksum: 10c0/531779df5ec94f47e462da26b4cbf05eb88a83d9f08aac2ba04206508fc598527a153d08bd462bae82fc78b3eaa1a908e1a4a79f886e9238641c4cdefaf118b1 + languageName: node + linkType: hard + "json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" @@ -12079,6 +12533,15 @@ __metadata: languageName: node linkType: hard +"lower-case@npm:^2.0.2": + version: 2.0.2 + resolution: "lower-case@npm:2.0.2" + dependencies: + tslib: "npm:^2.0.3" + checksum: 10c0/3d925e090315cf7dc1caa358e0477e186ffa23947740e4314a7429b6e62d72742e0bbe7536a5ae56d19d7618ce998aba05caca53c2902bd5742fdca5fc57fd7b + languageName: node + linkType: hard + "lowercase-keys@npm:^2.0.0": version: 2.0.0 resolution: "lowercase-keys@npm:2.0.0" @@ -13359,6 +13822,16 @@ __metadata: languageName: node linkType: hard +"no-case@npm:^3.0.4": + version: 3.0.4 + resolution: "no-case@npm:3.0.4" + dependencies: + lower-case: "npm:^2.0.2" + tslib: "npm:^2.0.3" + checksum: 10c0/8ef545f0b3f8677c848f86ecbd42ca0ff3cd9dd71c158527b344c69ba14710d816d8489c746b6ca225e7b615108938a0bda0a54706f8c255933703ac1cf8e703 + languageName: node + linkType: hard + "node-abort-controller@npm:^3.0.1": version: 3.1.1 resolution: "node-abort-controller@npm:3.1.1" @@ -13539,6 +14012,13 @@ __metadata: languageName: node linkType: hard +"node-releases@npm:^2.0.27": + version: 2.0.27 + resolution: "node-releases@npm:2.0.27" + checksum: 10c0/f1e6583b7833ea81880627748d28a3a7ff5703d5409328c216ae57befbced10ce2c991bea86434e8ec39003bd017f70481e2e5f8c1f7e0a7663241f81d6e00e2 + languageName: node + linkType: hard + "node-telegram-bot-api@npm:^0.65.1": version: 0.65.1 resolution: "node-telegram-bot-api@npm:0.65.1" @@ -14002,7 +14482,7 @@ __metadata: languageName: node linkType: hard -"parse-json@npm:^5.0.0": +"parse-json@npm:^5.0.0, parse-json@npm:^5.2.0": version: 5.2.0 resolution: "parse-json@npm:5.2.0" dependencies: @@ -14215,6 +14695,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.2": + version: 4.0.3 + resolution: "picomatch@npm:4.0.3" + checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 + languageName: node + linkType: hard + "pify@npm:^2.0.0": version: 2.3.0 resolution: "pify@npm:2.3.0" @@ -16267,6 +16754,16 @@ __metadata: languageName: node linkType: hard +"snake-case@npm:^3.0.4": + version: 3.0.4 + resolution: "snake-case@npm:3.0.4" + dependencies: + dot-case: "npm:^3.0.4" + tslib: "npm:^2.0.3" + checksum: 10c0/ab19a913969f58f4474fe9f6e8a026c8a2142a01f40b52b79368068343177f818cdfef0b0c6b9558f298782441d5ca8ed5932eb57822439fad791d866e62cecd + languageName: node + linkType: hard + "socket.io-adapter@npm:~2.5.2": version: 2.5.2 resolution: "socket.io-adapter@npm:2.5.2" @@ -16842,6 +17339,13 @@ __metadata: languageName: node linkType: hard +"svg-parser@npm:^2.0.4": + version: 2.0.4 + resolution: "svg-parser@npm:2.0.4" + checksum: 10c0/02f6cb155dd7b63ebc2f44f36365bc294543bebb81b614b7628f1af3c54ab64f7e1cec20f06e252bf95bdde78441ae295a412c68ad1678f16a6907d924512b7a + languageName: node + linkType: hard + "swap-case@npm:^1.1.0": version: 1.1.2 resolution: "swap-case@npm:1.1.2" @@ -17334,6 +17838,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.0.3, tslib@npm:^2.8.0": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 + languageName: node + linkType: hard + "tslib@npm:^2.1.0, tslib@npm:^2.4.0": version: 2.6.1 resolution: "tslib@npm:2.6.1" @@ -17341,13 +17852,6 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.8.0": - version: 2.8.1 - resolution: "tslib@npm:2.8.1" - checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 - languageName: node - linkType: hard - "tslib@npm:~2.4.0": version: 2.4.1 resolution: "tslib@npm:2.4.1" @@ -17888,6 +18392,20 @@ __metadata: languageName: node linkType: hard +"update-browserslist-db@npm:^1.2.0": + version: 1.2.3 + resolution: "update-browserslist-db@npm:1.2.3" + dependencies: + escalade: "npm:^3.2.0" + picocolors: "npm:^1.1.1" + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 10c0/13a00355ea822388f68af57410ce3255941d5fb9b7c49342c4709a07c9f230bbef7f7499ae0ca7e0de532e79a82cc0c4edbd125f1a323a1845bf914efddf8bec + languageName: node + linkType: hard + "upper-case-first@npm:^1.1.0, upper-case-first@npm:^1.1.2": version: 1.1.2 resolution: "upper-case-first@npm:1.1.2" @@ -18155,6 +18673,19 @@ __metadata: languageName: node linkType: hard +"vite-plugin-svgr@npm:^4.5.0": + version: 4.5.0 + resolution: "vite-plugin-svgr@npm:4.5.0" + dependencies: + "@rollup/pluginutils": "npm:^5.2.0" + "@svgr/core": "npm:^8.1.0" + "@svgr/plugin-jsx": "npm:^8.1.0" + peerDependencies: + vite: ">=2.6.0" + checksum: 10c0/3e1959fec626bb4f5a8ec13ff15bc40ffbc1c0ff38149bebe3f37dc2d67ed1f276f129ff7983e06946cf712e19996affd9d6868aa7d20d8921d1fe4449109b55 + languageName: node + linkType: hard + "vite-tsconfig-paths@npm:^4.3.2": version: 4.3.2 resolution: "vite-tsconfig-paths@npm:4.3.2"