Skip to content

Commit 4070efc

Browse files
mateumirallespablomendezroyoPablo Mendez
authored
Premium tab (#2234)
* premium root * activate premium screen * activate UI fixes * notifications and support tabs * update activated screens * activate label bullet fix * premium package integration * deactivate && activationLoading * styles fix * premium root * activate premium screen * activate UI fixes * notifications and support tabs * update activated screens * activate label bullet fix * premium package integration * deactivate && activationLoading * styles fix * sidebar premium fix * Premium backup beacon (#2214) * WIP BeaconNodeBackup * WIP BeaconNodeBackup * beaconBackup calls && hook * beaconBacup basic styles * add hoodi network * validator service fix * stop backup confirmation modal * confirmation modal on deactivatePremium * mobile fixes * dynamic beacon backup countdowns * Implement getBackupIfActive (#2215) Co-authored-by: Pablo Mendez <pablo@dappnode.io> * rm docs btn * countdown revalidate fix * BackupNode renamed * activate premium timeout (#2216) * Rework backup node tab (#2225) * lil general fixes * new backup tab skeleton * networks card * action cards * active validators logic * validators card * rm old backup node tab * rename backupNode hook * validatorLimit from API * cards tooltips * copies updated * backup consensus warnings (#2232) * validatorsCard limit by network (#2233) * activate license error * remove activate logs * remove license set toast * avoid backup loading when premium not active * error card ion backup node subtab * remove unauthorized from activate license 403 * warning tooltip when beacon API req fails (#2235) * remove log * backup info copy updated * rm prefilled license regex * fix error on 1st backup render after license activated * fix auto-reload tabs bug * fix prefilledLicense * fix error toast link --------- Co-authored-by: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Co-authored-by: Pablo Mendez <pablo@dappnode.io>
1 parent 6066a70 commit 4070efc

31 files changed

+2526
-12
lines changed

packages/admin-ui/src/__mock-backend__/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,41 @@ export const otherCalls: Omit<Routes, keyof typeof namedSpacedCalls> = {
413413
pwaMappingUrl: "https://pwa.abcdef.dyndns.dappnode.io",
414414
httpsDnpInstalled: true,
415415
isHttpsRunning: true
416+
}),
417+
premiumPkgStatus: async () => ({
418+
premiumDnpInstalled: true,
419+
premiumDnpRunning: true
420+
}),
421+
premiumSetLicenseKey: async () => {},
422+
premiumGetLicenseKey: async () => ({
423+
key: "ABCDEF-123456-ABCDEF-123456-ABCDEF-V1",
424+
hash: "1234567890abcdef1234567890abcdef1234567890"
425+
}),
426+
premiumActivateLicense: async () => {},
427+
premiumDeactivateLicense: async () => {},
428+
premiumIsLicenseActive: async () => false,
429+
consensusClientsGetByNetworks: async () => {
430+
return {
431+
mainnet: "prysm.dnp.dappnode.eth",
432+
gnosis: "prysm-gnosis.dnp.dappnode.eth"
433+
};
434+
},
435+
premiumBeaconBackupActivate: async () => {},
436+
premiumBeaconBackupDeactivate: async () => {},
437+
premiumBeaconBackupStatus: async () => ({
438+
validatorLimit: 10,
439+
isActivable: false,
440+
isActive: true,
441+
secondsUntilDeactivation: 259200
442+
}),
443+
keystoresGetByNetwork: async () => ({
444+
mainnet: { solo: ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"] },
445+
hoodi: null
446+
}),
447+
validatorsFilterActiveByNetwork: async () => ({
448+
mainnet: { validators: ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"] },
449+
hoodi: { validators: [] },
450+
gnosis: null
416451
})
417452
};
418453

packages/admin-ui/src/components/sidebar/SideBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export default function SideBar({ screenWidth }: { screenWidth: number }) {
3636
{sidenavItems
3737
.filter((item) => item.show === true)
3838
.map((item) => (
39-
<NavLink className={`sidenav-item selectable`} to={item.href}>
39+
<NavLink className={`sidenav-item selectable ${item.name === "Premium" && "premium-item"}`} to={item.href}>
4040
<item.icon />
4141
{screenWidth > 640 && <span className="name svg-text">{item.name}</span>}
4242
</NavLink>

packages/admin-ui/src/components/sidebar/navbarItems.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
MdDevices,
1414
MdDashboard,
1515
MdWifi,
16-
MdPeople
16+
MdPeople,
17+
MdStar
1718
} from "react-icons/md";
1819
import { FaRegBell } from "react-icons/fa";
1920
import { SiEthereum } from "react-icons/si";
@@ -31,6 +32,7 @@ import { relativePath as communityRelativePath } from "pages/community";
3132
import { relativePath as stakersRelativePath } from "pages/stakers";
3233
import { relativePath as repositoryRelativePath } from "pages/repository";
3334
import { relativePath as notificationsRelativePath } from "pages/notifications";
35+
import { relativePath as premiumRelativePath } from "pages/premium";
3436

3537
export const fundedBy: { logo: string; text: string; link: string }[] = [
3638
{
@@ -133,5 +135,11 @@ export const sidenavItems: {
133135
href: supportRelativePath,
134136
icon: MdHelp,
135137
show: true
138+
},
139+
{
140+
name: "Premium",
141+
href: premiumRelativePath,
142+
icon: MdStar,
143+
show: true
136144
}
137145
];

packages/admin-ui/src/components/sidebar/sidebar.scss

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@
6666
&.selectable svg {
6767
font-size: 1.5rem;
6868
}
69+
70+
&.premium-item {
71+
color: #ff397f;
72+
background-color: #ffefe1;
73+
svg {
74+
opacity: 0.7;
75+
}
76+
&.active {
77+
color: #ff397f;
78+
}
79+
}
80+
&.premium-item:hover {
81+
color: #ff397f;
82+
}
83+
}
84+
85+
#dark {
86+
#sidebar {
87+
.nav .sidenav-item.premium-item {
88+
color: #ff397f;
89+
background-color: #443428;
90+
}
91+
}
6992
}
7093

7194
/*
@@ -97,8 +120,8 @@
97120
/* Controls the layout of the logos, all in one row with equal spacing */
98121
.funded-by-logos {
99122
--spacing: 0.7rem;
100-
@media (max-width: 40rem ){
101-
--spacing: 0.3rem
123+
@media (max-width: 40rem) {
124+
--spacing: 0.3rem;
102125
}
103126
display: grid;
104127
grid-auto-columns: auto;
@@ -118,8 +141,7 @@
118141

119142
.funded-by-logo-dark,
120143
.funded-by-text-dark {
121-
filter: invert(94%) sepia(1%) saturate(0%) hue-rotate(139deg) brightness(93%)
122-
contrast(97%);
144+
filter: invert(94%) sepia(1%) saturate(0%) hue-rotate(139deg) brightness(93%) contrast(97%);
123145
}
124146

125147
/* If the screen width is less or equal than 640 then display the funded by in flex columnd direction */
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { Network } from "@dappnode/types";
2+
import { api, useApi } from "api";
3+
import { withToast } from "components/toast/Toast";
4+
import { useEffect, useMemo, useState } from "react";
5+
import { prettyDnpName } from "utils/format";
6+
import { confirm } from "components/ConfirmDialog";
7+
8+
const availableNetworks: Network[] = [Network.Mainnet, Network.Hoodi];
9+
const backupEnvName = "BACKUP_BEACON_NODES";
10+
const beaconChainServiceName = "validator";
11+
12+
export const useBackupNode = ({
13+
hashedLicense,
14+
isPremiumActivated
15+
}: {
16+
hashedLicense: string;
17+
isPremiumActivated: boolean;
18+
}) => {
19+
const [currentConsensus, setCurrentConsensus] = useState<Partial<Record<Network, string | null | undefined>>>({});
20+
21+
const [anyPrysmOrTekuActive, setAnyPrysmOrTekuActive] = useState(false);
22+
const [allPrysmOrTekuActive, setAllPrysmOrTekuActive] = useState(false);
23+
24+
const [backupStatusError, setBackupStatusError] = useState<string | null>(null);
25+
const [backupActive, setBackupActive] = useState<boolean>(false);
26+
const [backupActivable, setBackupActivable] = useState<boolean>(false);
27+
const [secondsUntilActivable, setSecondsUntilActivable] = useState<number | undefined>(undefined);
28+
const [secondsUntilDeactivation, setSecondsUntilDeactivation] = useState<number | undefined>(undefined);
29+
const [validatorLimit, setValidatorLimit] = useState<number | undefined>(undefined);
30+
const [activeValidatorsCounts, setActiveValidatorsCounts] = useState<
31+
Partial<Record<Network, { count: number | null; limitExceeded: boolean; beaconApiError: boolean }>>
32+
>({});
33+
34+
const networksParam = useMemo(() => ({ networks: availableNetworks }), []);
35+
36+
const validatorsFilterActiveReq = useApi.validatorsFilterActiveByNetwork(networksParam);
37+
const currentConsensusReq = useApi.consensusClientsGetByNetworks(networksParam);
38+
const backupStatusReq = useApi.premiumBeaconBackupStatus(hashedLicense);
39+
40+
const consensusLoading = currentConsensusReq.isValidating;
41+
const backupStatusLoading = isPremiumActivated ? backupStatusReq.isValidating : false;
42+
43+
useEffect(() => {
44+
const data = validatorsFilterActiveReq.data;
45+
if (data === undefined) return;
46+
47+
const counts: Partial<Record<
48+
Network,
49+
{ count: number | null; limitExceeded: boolean; beaconApiError: boolean }
50+
>> = {};
51+
52+
for (const [network, res] of Object.entries(data) as [
53+
Network,
54+
{ validators: string[]; beaconError: Error } | null
55+
][]) {
56+
const count = Array.isArray(res?.validators) ? res?.validators.length : null;
57+
const beaconApiError = res?.beaconError !== undefined;
58+
59+
counts[network] = {
60+
count,
61+
beaconApiError,
62+
limitExceeded: count !== null && validatorLimit !== undefined ? count > validatorLimit : false
63+
};
64+
}
65+
66+
setActiveValidatorsCounts(counts);
67+
}, [validatorsFilterActiveReq.data, validatorLimit]);
68+
69+
useEffect(() => {
70+
if (currentConsensusReq.data) {
71+
setCurrentConsensus(currentConsensusReq.data);
72+
73+
const clients = Object.values(currentConsensusReq.data).filter(Boolean) as string[];
74+
75+
setAnyPrysmOrTekuActive(
76+
clients.some((client) => {
77+
const ccName = client.toLowerCase();
78+
return ccName.includes("prysm") || ccName.includes("teku");
79+
})
80+
);
81+
82+
setAllPrysmOrTekuActive(
83+
clients.length > 0 &&
84+
clients.every((client) => {
85+
const ccName = client.toLowerCase();
86+
return ccName.includes("prysm") || ccName.includes("teku");
87+
})
88+
);
89+
}
90+
}, [currentConsensusReq.data]);
91+
92+
useEffect(() => {
93+
if (backupStatusReq.data) {
94+
setValidatorLimit(backupStatusReq.data.validatorLimit);
95+
setBackupActive(backupStatusReq.data.isActive);
96+
setSecondsUntilActivable(backupStatusReq.data.secondsUntilActivable);
97+
setBackupActivable(backupStatusReq.data.isActivable);
98+
setSecondsUntilDeactivation(backupStatusReq.data.secondsUntilDeactivation);
99+
}
100+
}, [backupStatusReq.data]);
101+
102+
useEffect(() => {
103+
if (backupStatusReq.error) {
104+
console.log("Backup status error", backupStatusReq.error);
105+
const errorMessage =
106+
backupStatusReq.error instanceof Error ? backupStatusReq.error.message : "Unknown error occurred";
107+
setBackupStatusError(errorMessage);
108+
} else {
109+
setBackupStatusError(null);
110+
}
111+
}, [backupStatusReq.error]);
112+
113+
// countdown interval not depending on backupStatusReq
114+
useEffect(() => {
115+
const interval = setInterval(() => {
116+
setSecondsUntilActivable((prev) => {
117+
if (typeof prev === "number") {
118+
if (prev <= 0) {
119+
backupStatusReq.revalidate();
120+
return undefined;
121+
}
122+
return prev - 1;
123+
}
124+
return prev;
125+
});
126+
127+
setSecondsUntilDeactivation((prev) => {
128+
if (typeof prev === "number") {
129+
if (prev <= 0) {
130+
backupStatusReq.revalidate();
131+
return undefined;
132+
}
133+
return prev - 1;
134+
}
135+
return prev;
136+
});
137+
}, 1000);
138+
139+
return () => clearInterval(interval);
140+
}, []);
141+
const setBackupEnv = async (type: "activate" | "deactivate") => {
142+
if (!hashedLicense) {
143+
throw new Error("Hashed license is required to set backup environment");
144+
}
145+
146+
const entries = Object.entries(currentConsensus) as [Network, string | null | undefined][];
147+
148+
for (const [network, dnpName] of entries) {
149+
if (!dnpName) continue;
150+
const envValue = type === "activate" ? `https://${hashedLicense}:@${network}.beacon.dappnode.io` : "";
151+
const env = {
152+
[backupEnvName]: envValue
153+
};
154+
155+
await withToast(
156+
() =>
157+
api.packageSetEnvironment({
158+
dnpName,
159+
environmentByService: { [beaconChainServiceName]: env }
160+
}),
161+
{
162+
message: `Updating ${prettyDnpName(dnpName)} ENVs...`,
163+
onSuccess: `Updated ${prettyDnpName(dnpName)} ENVs`
164+
}
165+
);
166+
}
167+
};
168+
169+
const activate = async () => {
170+
if (!hashedLicense) throw new Error("Hashed license is required to activate beacon backup");
171+
await api.premiumBeaconBackupActivate(hashedLicense);
172+
await setBackupEnv("activate");
173+
backupStatusReq.revalidate();
174+
};
175+
176+
const deactivate = async () => {
177+
if (!hashedLicense) throw new Error("Hashed license is required to activate beacon backup");
178+
await api.premiumBeaconBackupDeactivate(hashedLicense);
179+
await setBackupEnv("deactivate");
180+
backupStatusReq.revalidate();
181+
};
182+
183+
const activateBackup = () => {
184+
withToast(() => activate(), {
185+
message: `Activating Backup node...`,
186+
onSuccess: `Backup node activated`,
187+
onError: `Error while activating Backup node`
188+
});
189+
};
190+
191+
const deactivateBackup = () => {
192+
confirm({
193+
title: `Deactivating Backup node`,
194+
text: `Deactivating the Backup node is not reversible until it is renewed. Once deactivated, it cannot be reactivated until ${formatCountdown(
195+
secondsUntilActivable
196+
)}.`,
197+
label: "Deactivate",
198+
variant: "danger",
199+
onClick: () =>
200+
withToast(() => deactivate(), {
201+
message: `Deactivating Backup node...`,
202+
onSuccess: `Backup node deactivated`,
203+
onError: `Error while deactivating Backup node`
204+
})
205+
});
206+
};
207+
208+
const formatCountdown = (totalSeconds?: number): string | undefined => {
209+
if (totalSeconds === undefined) return undefined;
210+
const d = Math.floor(totalSeconds / 86400);
211+
const h = Math.floor((totalSeconds % 86400) / 3600);
212+
const m = Math.floor((totalSeconds % 3600) / 60);
213+
const s = totalSeconds % 60;
214+
return `${d}d ${h}h ${m}m ${s}s`;
215+
};
216+
217+
return {
218+
consensusLoading,
219+
currentConsensus,
220+
anyPrysmOrTekuActive,
221+
allPrysmOrTekuActive,
222+
activateBackup,
223+
deactivateBackup,
224+
backupStatusLoading,
225+
backupStatusError,
226+
backupActive,
227+
backupActivable,
228+
secondsUntilActivable,
229+
secondsUntilDeactivation,
230+
formatCountdown,
231+
activeValidatorsCounts,
232+
validatorLimit
233+
};
234+
};

0 commit comments

Comments
 (0)