diff --git a/avatar-dappmanager.png b/avatar-dappmanager.png index 16d2283db7..f136d28567 100644 Binary files a/avatar-dappmanager.png and b/avatar-dappmanager.png differ diff --git a/notifications.yaml b/notifications.yaml new file mode 100644 index 0000000000..1062718899 --- /dev/null +++ b/notifications.yaml @@ -0,0 +1,6 @@ +customEndpoints: + - name: "Package updates notifications" + isBanner: false + correlationId: "dappmanager-update-pkg" + description: "This endpoint notifies users about available package updates." + enabled: true diff --git a/packages/admin-ui/server-mock/index.ts b/packages/admin-ui/server-mock/index.ts index 1717ab35df..e39685c2dc 100644 --- a/packages/admin-ui/server-mock/index.ts +++ b/packages/admin-ui/server-mock/index.ts @@ -49,7 +49,6 @@ startHttpApi({ env: () => {}, fileDownload: () => {}, globalEnvs: () => {}, - notificationSend: () => {}, packageManifest: () => {}, metrics: () => {}, publicPackagesData: () => {}, diff --git a/packages/admin-ui/src/__mock-backend__/index.ts b/packages/admin-ui/src/__mock-backend__/index.ts index b547e360dc..a17d282511 100644 --- a/packages/admin-ui/src/__mock-backend__/index.ts +++ b/packages/admin-ui/src/__mock-backend__/index.ts @@ -183,10 +183,6 @@ export const otherCalls: Omit = { newFeatureStatusSet: async () => {}, poweroffHost: async () => {}, rebootHost: async () => {}, - rebootHostIsRequiredGet: async () => ({ - rebootRequired: true, - pkgs: "docker" - }), setStaticIp: async () => {}, systemInfoGet: async () => ({ @@ -385,8 +381,19 @@ export const otherCalls: Omit = { dockerHostVersion: "20.10.7", dockerLatestVersion: "20.10.8" }), - getIsConnectedToInternet: async () => false, - getCoreVersion: async () => "0.2.92" + getCoreVersion: async () => "0.2.92", + notificationsGetAllEndpoints: async () => { + return { "geth.dnp.dappnode.eth": { endpoints: [], customEndpoints: [], isCore: false } }; + }, + notificationsGetUnseenCount: async () => 2, + notificationsSetAllSeen: async () => {}, + notificationSetSeenByCorrelationID: async () => {}, + notificationsUpdateEndpoints: async () => {}, + notificationsGetAll: async () => [], + notificationsGetBanner: async () => [], + notificationsApplyPreviousEndpoints: async () => { + return { endpoints: [], customEndpoints: [] }; + } }; export const calls: Routes = { diff --git a/packages/admin-ui/src/__mock-backend__/wifi.ts b/packages/admin-ui/src/__mock-backend__/wifi.ts index 440549ec74..e36a873792 100644 --- a/packages/admin-ui/src/__mock-backend__/wifi.ts +++ b/packages/admin-ui/src/__mock-backend__/wifi.ts @@ -34,7 +34,9 @@ export const wifi: Pick = { report: { lastLog: "[Error] any wifi error".replace(/\[.*?\]/g, ""), exitCode: 57 - } + }, + isDefaultPassphrase: false, + isRunning: false }; } }; diff --git a/packages/admin-ui/src/api/initialCalls.ts b/packages/admin-ui/src/api/initialCalls.ts index 97c68f1794..9f22f7875a 100644 --- a/packages/admin-ui/src/api/initialCalls.ts +++ b/packages/admin-ui/src/api/initialCalls.ts @@ -2,24 +2,12 @@ import { store } from "../store"; import { fetchDnpInstalled } from "services/dnpInstalled/actions"; import { fetchCoreUpdateData } from "services/coreUpdate/actions"; -import { - fetchSystemInfo, - fetchVolumes, - fetchPasswordIsSecure, - fetchWifiCredentials, - fetchRebootIsRequired, - fetchShouldShowSmooth, - fetchIsConnectedToInternet -} from "services/dappnodeStatus/actions"; +import { fetchSystemInfo, fetchVolumes, fetchShouldShowSmooth } from "services/dappnodeStatus/actions"; export function initialCallsOnOpen() { store.dispatch(fetchDnpInstalled()); store.dispatch(fetchCoreUpdateData()); store.dispatch(fetchSystemInfo()); store.dispatch(fetchVolumes()); - store.dispatch(fetchPasswordIsSecure()); - store.dispatch(fetchWifiCredentials()); - store.dispatch(fetchRebootIsRequired()); store.dispatch(fetchShouldShowSmooth()); - store.dispatch(fetchIsConnectedToInternet()); } diff --git a/packages/admin-ui/src/api/subscriptions.ts b/packages/admin-ui/src/api/subscriptions.ts index c0315fb168..70a51c6f15 100644 --- a/packages/admin-ui/src/api/subscriptions.ts +++ b/packages/admin-ui/src/api/subscriptions.ts @@ -1,6 +1,5 @@ import { store } from "../store"; // Actions to push received content -import { pushNotification } from "services/notifications/actions"; import { clearIsInstallingLog, updateIsInstallingLog } from "services/isInstallingLogs/actions"; import { updateVolumes, setSystemInfo } from "services/dappnodeStatus/actions"; import { setDnpInstalled } from "services/dnpInstalled/actions"; @@ -27,10 +26,6 @@ export function mapSubscriptionsToRedux(subscriptions: Subscriptions): void { else store.dispatch(updateIsInstallingLog({ id, dnpName, log })); }); - subscriptions.pushNotification.on((notification) => { - store.dispatch(pushNotification(notification)); - }); - subscriptions.systemInfo.on((systemInfo) => { store.dispatch(setSystemInfo(systemInfo)); }); diff --git a/packages/admin-ui/src/components/NotificationsMain.tsx b/packages/admin-ui/src/components/NotificationsMain.tsx index 4dde6297cf..831e56dae0 100644 --- a/packages/admin-ui/src/components/NotificationsMain.tsx +++ b/packages/admin-ui/src/components/NotificationsMain.tsx @@ -1,116 +1,138 @@ -import React from "react"; -import { useSelector } from "react-redux"; +import React, { useEffect, useMemo, useState } from "react"; import { NavLink } from "react-router-dom"; -import { useApi } from "api"; import RenderMarkdown from "components/RenderMarkdown"; -// Selectors -import { getCoreUpdateAvailable, getIsCoreUpdateTypePatch, getUpdatingCore } from "services/coreUpdate/selectors"; -import { - getWifiStatus, - getPasswordIsSecure, - getRebootIsRequired, - getIsConnectedToInternet -} from "services/dappnodeStatus/selectors"; -import { pathName as systemPathName, subPaths as systemSubPaths } from "pages/system/data"; -import Button from "components/Button"; -// Style +import Button, { ButtonVariant } from "components/Button"; +import { api, useApi } from "api"; +import { Notification, Priority } from "@dappnode/types"; import "./notificationsMain.scss"; -import { autoUpdateIds } from "params"; -import { AlertDismissible } from "./AlertDismissible"; +import { MdClose } from "react-icons/md"; +import { Accordion } from "react-bootstrap"; +import { dappmanagerAliases, externalUrlProps } from "params"; /** - * Aggregate notification and display logic + * Displays banner notifications among all tabs */ export default function NotificationsView() { - const coreUpdateAvailable = useSelector(getCoreUpdateAvailable); - const updatingCore = useSelector(getUpdatingCore); - const isCoreUpdateTypePatch = useSelector(getIsCoreUpdateTypePatch); - const wifiStatus = useSelector(getWifiStatus); - const passwordIsSecure = useSelector(getPasswordIsSecure); - const rebootHostIsRequired = useSelector(getRebootIsRequired); - const isConnectedToInternet = useSelector(getIsConnectedToInternet); - - // Check is auto updates are enabled for the core - const autoUpdateSettingsReq = useApi.autoUpdateDataGet(); - const isCoreAutoUpdateActive = ((autoUpdateSettingsReq.data?.settings || {})[autoUpdateIds.SYSTEM_PACKAGES] || {}) - .enabled; - - const notifications = [ - /** - * [HOST-CONNECTED-TO-INTERNET] - * Tell the user if is connected to internet - */ - { - id: "connectedToInternet", - linkText: "Navigate", - linkPath: "support/auto-diagnose", - body: `**Dappnode host is not connected to internet.** Click **Navigate** to autodiagnose and check the dappnode health.`, - active: isConnectedToInternet === false - }, - /** - * [HOST-REBOOT] - * Tell the user to reboot the host - */ - { - id: "hostReboot", - linkText: "Reboot", - linkPath: systemPathName + "/" + systemSubPaths.power, - body: `**Dappnode host reboot required.** Click **Reboot** to reboot the host and apply the changes. The following packages will be updated: ${rebootHostIsRequired?.pkgs}`, - active: rebootHostIsRequired?.rebootRequired - }, - /** - * [SYSTEM-UPDATE] - * Tell the user to update the core DNPs - */ - { - id: "systemUpdate", - linkText: "Update", - linkPath: systemPathName + "/" + systemSubPaths.update, - body: "**Dappnode system update available.** Click **Update** to review and approve it", - active: - coreUpdateAvailable && - !updatingCore && - // Show if NOT patch, or if patch is must not be active - (!isCoreUpdateTypePatch || !isCoreAutoUpdateActive) - }, - /** - * [WIFI-PASSWORD] - * Tell the user to change the wifi credentials - */ - { - id: "wifiCredentials", - linkText: "Change", - linkPath: systemPathName + "/" + systemSubPaths.security, - body: "**Change the Dappnode WiFi credentials**, they are insecure default values.", - active: wifiStatus?.isDefaultPassphrase && wifiStatus?.isRunning - }, - /** - * [HOST-USER-PASSWORD] - * Tell the user to change the host's "dappnode" user password - */ - { - id: "hostPasswordInsecure", - linkText: "Change", - linkPath: systemPathName + "/" + systemSubPaths.security, - body: "**Change the host 'dappnode' user password**, it's an insecure default.", - active: passwordIsSecure === false + const [notifications, setNotifications] = useState([]); + + const numOfBannersShown = 3; // Number of banners that will be shown in the UI + + // gets the timestamp of one month ago in UNIX format + const oneMonthAgoTimestamp = useMemo(() => { + const now = new Date(); + now.setMonth(now.getMonth() - 1); + return Math.floor(now.getTime() / 1000); // Convert to seconds + }, []); + + const notificationsCall = useApi.notificationsGetBanner(oneMonthAgoTimestamp); + + useEffect(() => { + if (notificationsCall.data) { + setNotifications(filterNotifications(notificationsCall.data)); } - ]; + }, [notificationsCall.data]); + + /** + * filters notifications: + * 1. Filters out notifications that have errors + * 2. Filters out duplicate notifications by correlationId, keeping the most recent one + * 3. Filters out resolved notifications + * 4. Filters out seen notifications + * 5. Sorts notifications by priority + */ + + function filterNotifications(notifications: Notification[]): Notification[] { + const priorityOrder = [Priority.critical, Priority.high, Priority.medium, Priority.low]; + + const map = new Map(); + + notifications + .filter((n) => !n.errors) // Filter out notifications with errors + .forEach((notification) => { + const existing = map.get(notification.correlationId); + + if (!existing || new Date(notification.timestamp) > new Date(existing.timestamp)) { + map.set(notification.correlationId, notification); + } + }); + + return Array.from(map.values()) + .filter((n) => n.status === "triggered") // Filter out resolved notifications + .filter((n) => n.seen === false) // Filter out seen notifications + .sort((a, b) => priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority)); + } return ( -
- {notifications - .filter(({ active }) => active) - .map(({ id, linkText, linkPath, body }) => ( - - - {linkText && linkPath ? ( - - - - ) : null} - + notifications && + notifications.length > 0 && ( +
+ {notifications.slice(0, numOfBannersShown).map((notification) => ( + setNotifications((prev) => prev.filter((n) => n.id !== notification.id))} + /> ))} -
+
+ ) + ); +} + +const priorityBtnVariants: Record = { + [Priority.low]: "dappnode", + [Priority.medium]: "dappnode", + [Priority.high]: "warning", + [Priority.critical]: "danger" +}; + +export function CollapsableBannerNotification({ + notification, + onClose +}: { + notification: Notification; + onClose: () => void; +}) { + const [isOpen, setIsOpen] = useState(notification.priority === Priority.critical); + const [hasClosed, setHasClosed] = useState(false); + + const handleClose = () => { + api.notificationSetSeenByCorrelationID(notification.correlationId); + setHasClosed(true); + onClose(); + }; + + const isExternalUrl = + notification.callToAction && !dappmanagerAliases.some((alias) => notification.callToAction!.url.includes(alias)); + + return ( + !hasClosed && ( + + setIsOpen(!isOpen)} + className={`banner-card ${notification.priority}-priority`} + > +
+
{notification.title}
+ +
+ +
+ + {notification.callToAction && ( + + + + )} +
+
+
+
+ ) ); } diff --git a/packages/admin-ui/src/components/Searchbar.tsx b/packages/admin-ui/src/components/Searchbar.tsx new file mode 100644 index 0000000000..c1477dfa2f --- /dev/null +++ b/packages/admin-ui/src/components/Searchbar.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { BiSearch } from "react-icons/bi"; + +import "./searchbar.scss"; + +interface SearchbarProps { + value: string; + onChange: (e: React.ChangeEvent) => void; + classname?: string; + placeholder?: string; +} + +export function Searchbar({ value, onChange, classname, placeholder = "Search..." }: SearchbarProps) { + return ( +
+ + + +
+ ); +} diff --git a/packages/admin-ui/src/components/Slider.tsx b/packages/admin-ui/src/components/Slider.tsx new file mode 100644 index 0000000000..80110ac46b --- /dev/null +++ b/packages/admin-ui/src/components/Slider.tsx @@ -0,0 +1,63 @@ +import React, { useState, useEffect } from "react"; +import "./slider.scss"; + +interface SliderProps { + min?: number; + max?: number; + step?: number; + value?: number; + unit?: string; + onChange?: (value: number) => void; + onChangeComplete?: (value: number) => void; +} + +const Slider: React.FC = ({ + min = 0, + max = 100, + step = 1, + value = 50, + unit = "%", + onChange, + onChangeComplete // In order to trigger an action when the user releases the slider +}) => { + const [sliderValue, setSliderValue] = useState(value); + + useEffect(() => { + setSliderValue(value); + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = Number(e.target.value); + setSliderValue(newValue); + if (onChange) onChange(newValue); + }; + + const handleMouseUp = () => { + if (onChangeComplete) onChangeComplete(sliderValue); + }; + + const handleTouchEnd = () => { + if (onChangeComplete) onChangeComplete(sliderValue); + }; + + return ( +
+ + + {sliderValue} {unit && unit} + +
+ ); +}; + +export default Slider; diff --git a/packages/admin-ui/src/components/notificationsMain.scss b/packages/admin-ui/src/components/notificationsMain.scss index c9a604f048..29682c71b8 100644 --- a/packages/admin-ui/src/components/notificationsMain.scss +++ b/packages/admin-ui/src/components/notificationsMain.scss @@ -1,26 +1,111 @@ -.main-notification { +.banner-notifications-col { display: flex; - justify-content: space-between; + flex-direction: column; + gap: 5px; - p { - margin-bottom: 0; - } - a { - margin-left: 0.5rem; - } + .banner-card { + padding: 10px 15px; + border-radius: 5px; + background-color: #e9ecef; + display: flex; + flex-direction: column; + gap: 5px; - > a, - > button { - flex: none; - } + &:hover { + opacity: 0.8; + cursor: pointer; + } + + &.medium-priority { + background-color: #d1ecf1; + color: #0c5460 !important; + } + + &.high-priority { + background-color: #ffeda6; + color: #433c1d !important; + } + + &.critical-priority { + background-color: #f8d7da; + color: #721c24 !important; + } + + h5 { + margin: 0px; + } + + .banner-header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; - @media screen and (max-width: 40rem) { - display: block; - p { - margin-bottom: 0.5rem; + .close-btn { + padding: 0px; + border: none; + background: none; + + cursor: pointer; + color: #000; + font-size: 1.2rem; + font-weight: 700; + + &:hover { + opacity: 0.5; + } + } } - a { - margin-left: 0; + + .banner-body { + font-size: 1rem; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 15px; + + @media (max-width: 60rem) { + flex-direction: column; + } + + button { + > div { + white-space: nowrap; // Prevent CTA text from wrapping + } + } + } + } +} + +#dark { + #main { + .banner-notifications-col { + .banner-card { + .banner-header { + color: black; + } + + &.medium-priority { + color: #0c5460 !important; + } + + &.high-priority { + color: #433c1d !important; + } + + &.critical-priority { + color: #721c24 !important; + } + + .banner-body { + p, + strong, + li { + color: black !important; + } + } + } } } } diff --git a/packages/admin-ui/src/components/searchbar.scss b/packages/admin-ui/src/components/searchbar.scss new file mode 100644 index 0000000000..17eb8e2686 --- /dev/null +++ b/packages/admin-ui/src/components/searchbar.scss @@ -0,0 +1,40 @@ +.searchbar-wrapper { + display: flex; + align-items: center; + position: relative; + cursor: text; + width: 100%; + + .search-icon { + position: absolute; + left: 12px; + font-size: 18px; + color: #888; + } + + .searchbar { + padding: 10px 10px 10px 36px; + flex: 1; + border: none; + font-size: 1rem; + background-color: #e9ecef; + border-radius: 10px; + } + + input:focus { + outline: none; + box-shadow: none; + border-color: inherit; + } +} + +#dark { + .searchbar { + background-color: var(--color-dark-card) !important; + color: var(--color-dark-maintext); + } + + .search-icon { + color: var(--color-dark-maintext); + } +} diff --git a/packages/admin-ui/src/components/sidebar/navbarItems.ts b/packages/admin-ui/src/components/sidebar/navbarItems.ts index b760f2c7c9..529b106963 100644 --- a/packages/admin-ui/src/components/sidebar/navbarItems.ts +++ b/packages/admin-ui/src/components/sidebar/navbarItems.ts @@ -15,6 +15,7 @@ import { MdWifi, MdPeople } from "react-icons/md"; +import { FaRegBell } from "react-icons/fa"; import { SiEthereum } from "react-icons/si"; import { BiGitRepoForked } from "react-icons/bi"; // URLs @@ -29,6 +30,7 @@ import { relativePath as wifiRelativePath } from "pages/wifi"; import { relativePath as communityRelativePath } from "pages/community"; import { relativePath as stakersRelativePath } from "pages/stakers"; import { relativePath as repositoryRelativePath } from "pages/repository"; +import { relativePath as notificationsRelativePath } from "pages/notifications"; export const fundedBy: { logo: string; text: string; link: string }[] = [ { @@ -108,6 +110,12 @@ export const sidenavItems: { icon: MdSettings, show: true }, + { + name: "Notifications", + href: notificationsRelativePath, + icon: FaRegBell, + show: true + }, { name: "Community", href: communityRelativePath, diff --git a/packages/admin-ui/src/components/slider.scss b/packages/admin-ui/src/components/slider.scss new file mode 100644 index 0000000000..a3d1121cfa --- /dev/null +++ b/packages/admin-ui/src/components/slider.scss @@ -0,0 +1,39 @@ +.slider-container { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .slider-component { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + background: #c0c0c0; + border-radius: 2px; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: var(--dappnode-color); + border-radius: 50%; + cursor: pointer; + } + + &::-moz-range-thumb { + width: 16px; + height: 16px; + background: var(--dappnode-color); + border-radius: 50%; + cursor: pointer; + } + } + + .slider-value { + font-weight: bold; + min-width: fit-content; + } + \ No newline at end of file diff --git a/packages/admin-ui/src/components/topbar/dropdownMenus/Notifications.tsx b/packages/admin-ui/src/components/topbar/dropdownMenus/Notifications.tsx index 4ee2a42fce..9fcd49b270 100644 --- a/packages/admin-ui/src/components/topbar/dropdownMenus/Notifications.tsx +++ b/packages/admin-ui/src/components/topbar/dropdownMenus/Notifications.tsx @@ -1,27 +1,35 @@ -import React, { useEffect } from "react"; -import { useSelector, useDispatch } from "react-redux"; -import BaseDropdown from "./BaseDropdown"; -import { getNotifications } from "services/notifications/selectors"; -import { viewedNotifications, fetchNotifications } from "services/notifications/actions"; +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + // Icons import { FaRegBell } from "react-icons/fa"; +import { useApi } from "api"; export default function Notifications() { - const notifications = useSelector(getNotifications); - const dispatch = useDispatch(); + const unseenNotificationsReq = useApi.notificationsGetUnseenCount(); + const [newNotifications, setNewNotifications] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + const interval = setInterval(() => { + unseenNotificationsReq.revalidate(); + }, 60 * 1000); // Updates the new norifications "blue dot" every minute + + return () => { + clearInterval(interval); + }; + }, [unseenNotificationsReq]); + useEffect(() => { - dispatch(fetchNotifications()); - }, [dispatch]); + if (unseenNotificationsReq.data !== undefined && unseenNotificationsReq.data !== null) { + setNewNotifications(unseenNotificationsReq.data > 0); + } + }, [unseenNotificationsReq.data]); return ( - dispatch(viewedNotifications())} - moreVisible={true} - className={"notifications"} - placeholder="No notifications yet" - /> +
navigate("/notifications/inbox")} className="tn-dropdown tn-dropdown-toggle"> + + {newNotifications &&
} +
); } diff --git a/packages/admin-ui/src/components/topbar/dropdownMenus/dropdown.scss b/packages/admin-ui/src/components/topbar/dropdownMenus/dropdown.scss index 377a46630d..e6dcc2d885 100644 --- a/packages/admin-ui/src/components/topbar/dropdownMenus/dropdown.scss +++ b/packages/admin-ui/src/components/topbar/dropdownMenus/dropdown.scss @@ -92,8 +92,6 @@ } } - /* Right position of the dropdown to prevent clipping on small screens */ - &.installer { > .menu { right: -3rem; diff --git a/packages/admin-ui/src/components/welcome/BottomButtons.tsx b/packages/admin-ui/src/components/welcome/BottomButtons.tsx index 544b1fbcfa..25787dc880 100644 --- a/packages/admin-ui/src/components/welcome/BottomButtons.tsx +++ b/packages/admin-ui/src/components/welcome/BottomButtons.tsx @@ -7,7 +7,8 @@ export default function BottomButtons({ backTag = "Back", nextTag = "Next", backVariant = "outline-secondary", - nextVariant = "dappnode" + nextVariant = "dappnode", + nextDisabled = false }: { onBack?: () => void; onNext?: () => void; @@ -15,6 +16,7 @@ export default function BottomButtons({ nextTag?: string; backVariant?: ButtonVariant; nextVariant?: ButtonVariant; + nextDisabled?: boolean; }) { return (
@@ -24,7 +26,7 @@ export default function BottomButtons({ )} {onNext && ( - )} diff --git a/packages/admin-ui/src/components/welcome/Welcome.tsx b/packages/admin-ui/src/components/welcome/Welcome.tsx index b7bfad3704..65da3f21ac 100644 --- a/packages/admin-ui/src/components/welcome/Welcome.tsx +++ b/packages/admin-ui/src/components/welcome/Welcome.tsx @@ -16,6 +16,7 @@ import { isEqual } from "lodash-es"; import { NewFeatureId } from "@dappnode/types"; // styles import "./welcome.scss"; +import EnableNotifications from "./features/EnableNotifications"; /** * This internal Welcome status allows to freeze featureIds @@ -45,6 +46,8 @@ function getRouteIdComponent(routeId: NewFeatureId): React.FC | unde return (props: RouteProps) => ; case "enable-ethical-metrics": return (props: RouteProps) => ; + case "enable-notifications": + return (props: RouteProps) => ; default: return undefined; } diff --git a/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx b/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx new file mode 100644 index 0000000000..da5d228ed5 --- /dev/null +++ b/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useState } from "react"; +import BottomButtons from "../BottomButtons"; +import { docsUrl, externalUrlProps } from "params"; + +import SubTitle from "components/SubTitle"; +import Switch from "components/Switch"; +import { api, useApi } from "api"; +import { notificationsDnpName } from "params.js"; +import { withToast } from "components/toast/Toast"; +import { continueIfCalleDisconnected } from "api/utils"; + +import Loading from "components/Loading"; +import { prettyDnpName } from "utils/format"; + +export default function EnableNotifications({ onBack, onNext }: { onBack?: () => void; onNext: () => void }) { + const [notificationsDisabled, setNotificationsDisabled] = useState(false); + const [notificationsNotInstalled, setNotificationsNotInstalled] = useState(false); + const [isNotificationsInstalling, setIsNotificationsInstalling] = useState(false); + + const dnps = useApi.packagesGet(); + useEffect(() => { + if (dnps.data) { + setNotificationsNotInstalled(dnps.data.find((dnp) => dnp.dnpName === notificationsDnpName) === undefined); + } + }, [dnps.data]); + + useEffect(() => { + async function installNotificationsPkg() { + try { + setIsNotificationsInstalling(true); + await withToast( + continueIfCalleDisconnected( + () => + api.packageInstall({ + name: notificationsDnpName + }), + notificationsDnpName + ), + { + message: `Installing ${prettyDnpName(notificationsDnpName)}...`, + onSuccess: `Installed ${prettyDnpName(notificationsDnpName)}` + } + ); + } catch (error) { + console.error(`Error while installing notifications package: ${error}`); + setIsNotificationsInstalling(false); + return; + } finally { + setIsNotificationsInstalling(false); + notificationsDnp.revalidate(); + } + } + + if (notificationsNotInstalled) { + installNotificationsPkg(); + } + }, [notificationsNotInstalled]); + + const notificationsDnp = useApi.packageGet({ dnpName: notificationsDnpName }); + useEffect(() => { + if (notificationsDnp.data) { + const isStopped = notificationsDnp.data.containers.some((c) => c.state !== "running"); + setNotificationsDisabled(isStopped); + } + }, [notificationsDnp.data]); + + async function startStopNotifications(): Promise { + try { + if (notificationsDnp.data) { + await withToast( + continueIfCalleDisconnected( + () => api.packageStartStop({ dnpName: notificationsDnpName }), + notificationsDnpName + ), + { + message: notificationsDisabled ? "Enabling notifications" : "Disabling notifications", + onSuccess: notificationsDisabled ? "Notifications Enabled" : "Notifications disabled" + } + ); + + notificationsDnp.revalidate(); + } + } catch (e) { + console.error(`Error on start/stop notifications package: ${e}`); + } + } + + return ( +
+
+
Enable Dappnode's Notifications
+
+

📣 Heads up! Changes are coming to Notifications

+
+ The current notification system will be deprecated in upcoming Dappnode core releases. +
+ We're transitioning to a new and improved in-app Notifications experience, designed to be more reliable, + configurable and scalable. +
+ {notificationsNotInstalled ? ( + isNotificationsInstalling ? ( + + ) : ( + notificationsDnp.error && Error while installing notifications package + ) + ) : ( + <> + Enable new notifications + { + startStopNotifications(); + }} + /> +
+
+

+ This notifications may alert you to critical issues if they arise. Disabling them could result in missing + critical notifications +

+

+ Learn more about notifications package and how to configure it in the{" "} + + Dappnode's documentation + +

+ + )} +
+ + onNext()} nextDisabled={isNotificationsInstalling} /> +
+
+
+ ); +} diff --git a/packages/admin-ui/src/components/welcome/welcome.scss b/packages/admin-ui/src/components/welcome/welcome.scss index aedf026f64..d6b2e81ded 100644 --- a/packages/admin-ui/src/components/welcome/welcome.scss +++ b/packages/admin-ui/src/components/welcome/welcome.scss @@ -43,7 +43,11 @@ minmax(min-content, max-content) 1fr auto; - grid-gap: 2rem; + + // Add spacing between rows manually + > *:not(:last-child) { + margin-bottom: 2rem; + } // align-items: center; // min-height: 100vh; @@ -134,10 +138,24 @@ } } +#dark { + .welcome-container { + background: rgba(0, 0, 0, 0.9); + } + + .welcome { + background: var(--color-dark-card); + color: var(--color-dark-maintext); + + .description { + color: var(--color-dark-secondarytext); + } + } +} + // Ethical Metrics modal container .ethical-container { display: grid; gap: 1rem; } - diff --git a/packages/admin-ui/src/pages/index.ts b/packages/admin-ui/src/pages/index.ts index 66011b0ac6..856f1c61f5 100644 --- a/packages/admin-ui/src/pages/index.ts +++ b/packages/admin-ui/src/pages/index.ts @@ -9,6 +9,7 @@ import * as wifi from "./wifi"; import * as community from "./community"; import * as stakers from "./stakers"; import * as repository from "./repository"; +import * as notifications from "./notifications"; export const pages = { dashboard, @@ -21,7 +22,8 @@ export const pages = { support, community, system, - repository + repository, + notifications }; export const defaultPage = dashboard; diff --git a/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx b/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx index 8b40a07b43..b9f1fc06e2 100644 --- a/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx +++ b/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from "react"; -import { api } from "api"; +import { api, useApi } from "api"; import { useDispatch } from "react-redux"; import { Routes, Route, useNavigate, useLocation, useParams, NavLink } from "react-router-dom"; import { isEmpty, throttle } from "lodash-es"; @@ -24,10 +24,12 @@ import { clearIsInstallingLog } from "services/isInstallingLogs/actions"; import { continueIfCalleDisconnected } from "api/utils"; import { enableAutoUpdatesForPackageWithConfirm } from "pages/system/components/AutoUpdates"; import Warnings from "./Steps/Warnings"; -import { RequestedDnp, UserSettingsAllDnps } from "@dappnode/types"; +import { CustomEndpoint, GatusEndpoint, RequestedDnp, UserSettingsAllDnps } from "@dappnode/types"; import { diff } from "semver"; import Button from "components/Button"; import { pathName as systemPathName, subPaths as systemSubPaths } from "pages/system/data"; +import { Notifications } from "./Steps/Notifications"; +import { notificationsDnpName } from "params"; interface InstallDnpViewProps { dnp: RequestedDnp; @@ -53,7 +55,17 @@ const InstallDnpView: React.FC = ({ dnp, progressLogs }) => const [isInstalling, setIsInstalling] = useState(false); const dispatch = useDispatch(); - const { dnpName, reqVersion, semVersion, settings, manifest, setupWizard, isInstalled, installedVersion } = dnp; + const { + dnpName, + reqVersion, + semVersion, + settings, + manifest, + setupWizard, + isInstalled, + installedVersion, + notificationsSettings + } = dnp; const updateType = installedVersion && diff(installedVersion, semVersion); const areUpdateWarnings = manifest.warnings?.onPatchUpdate || manifest.warnings?.onMinorUpdate || manifest.warnings?.onMajorUpdate; @@ -66,6 +78,19 @@ const InstallDnpView: React.FC = ({ dnp, progressLogs }) => const isWizardEmpty = isSetupWizardEmpty(setupWizard); const oldEditorAvailable = Boolean(userSettings); + const [endpoints, setEndpoints] = React.useState(manifest.notifications?.endpoints || []); + + const [customEndpoints, setCustomEndpoints] = React.useState( + manifest.notifications?.customEndpoints || [] + ); + + useEffect(() => { + if (notificationsSettings && notificationsSettings[dnpName]) { + setEndpoints(notificationsSettings[dnpName].endpoints || []); + setCustomEndpoints(notificationsSettings[dnpName].customEndpoints || []); + } + }, [notificationsSettings]); + useEffect(() => { setUserSettings(settings || {}); }, [settings, setUserSettings]); @@ -99,6 +124,12 @@ const InstallDnpView: React.FC = ({ dnp, progressLogs }) => options: { BYPASS_CORE_RESTRICTION: bypassCoreOpt, BYPASS_SIGNED_RESTRICTION: bypassSignedOpt + }, + notificationsSettings: { + [dnpName]: { + endpoints: endpoints.length > 0 ? endpoints : undefined, + customEndpoints: customEndpoints.length > 0 ? customEndpoints : undefined + } } }), dnpName @@ -112,6 +143,9 @@ const InstallDnpView: React.FC = ({ dnp, progressLogs }) => // Re-direct user to package page if installation is successful if (componentIsMounted.current) { setShowSuccess(true); + + await setNotifications(); + setTimeout(() => { if (componentIsMounted.current) { setShowSuccess(false); @@ -133,6 +167,21 @@ const InstallDnpView: React.FC = ({ dnp, progressLogs }) => // Prevent a burst of install calls const onInstallThrottle = throttle(onInstall, 1000); + const setNotifications = async () => { + if (!manifest.notifications) return; + + if (endpoints.length > 0 || customEndpoints.length > 0) { + await api.notificationsUpdateEndpoints({ + dnpName, + notificationsConfig: { + endpoints: endpoints.length > 0 ? endpoints : undefined, + customEndpoints: customEndpoints.length > 0 ? customEndpoints : undefined + }, + isCore + }); + } + }; + const disclaimers: { name: string; message: string }[] = []; // Default disclaimer for public DNPs if (!isDnpVerified(dnpName) || dnp.origin) @@ -172,6 +221,10 @@ const InstallDnpView: React.FC = ({ dnp, progressLogs }) => } ].filter((option) => option.available); + const dnpsRequest = useApi.packagesGet(); + const installedDnps = dnpsRequest.data; + const isNotificationsPkgInstalled = installedDnps?.some((dnp) => dnp.dnpName === notificationsDnpName); + const disableInstallation = !isEmpty(progressLogs) || requiresCoreUpdate || requiresDockerUpdate || packagesToBeUninstalled.length > 0; @@ -179,8 +232,12 @@ const InstallDnpView: React.FC = ({ dnp, progressLogs }) => const permissionsSubPath = "permissions"; const warningsSubPath = "warnings"; const disclaimerSubPath = "disclaimer"; + const notificationsSubPath = "notifications"; const installSubPath = "install"; + // Only display notifications step if the notifications package is installed && there are endpoints in manifest + const showNotificationsStep = isNotificationsPkgInstalled && manifest.notifications; + const availableRoutes: { name: string; subPath: string; @@ -228,6 +285,21 @@ const InstallDnpView: React.FC = ({ dnp, progressLogs }) => render: () => , available: disclaimers.length > 0 }, + { + name: "Notifications", + subPath: notificationsSubPath, + render: () => ( + + ), + available: showNotificationsStep + }, // Placeholder for the final step in the horizontal stepper { name: "Install", diff --git a/packages/admin-ui/src/pages/installer/components/Steps/Notifications.tsx b/packages/admin-ui/src/pages/installer/components/Steps/Notifications.tsx new file mode 100644 index 0000000000..f27439d983 --- /dev/null +++ b/packages/admin-ui/src/pages/installer/components/Steps/Notifications.tsx @@ -0,0 +1,42 @@ +import React from "react"; +// Components +import Card from "components/Card"; +import Button from "components/Button"; +import { CustomEndpoint, GatusEndpoint } from "@dappnode/types"; +import { InstallerEndpointsList } from "pages/notifications/tabs/Settings/InstallerEndpointsList"; + +interface NotificationsProps { + endpointsGatus: GatusEndpoint[]; + setEndpointsGatus: React.Dispatch>; + endpointsCustom: CustomEndpoint[]; + setEndpointsCustom: React.Dispatch>; + goNext: () => void; + goBack: () => void; +} + +export const Notifications: React.FC = ({ + endpointsGatus, + setEndpointsGatus, + endpointsCustom, + setEndpointsCustom, + goNext, + goBack +}) => { + return ( + + + +
+ + +
+
+ ); +}; diff --git a/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx new file mode 100644 index 0000000000..dea4952bc4 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { Routes, Route, NavLink } from "react-router-dom"; +import { useApi } from "api"; +// Own module +import { subPaths, title } from "./data"; +import { InstallNotificationsPkg } from "./tabs/InstallNotifications/InstallNotifications"; +// Components +import Title from "components/Title"; +import { renderResponse } from "components/SwrRender"; +import { Inbox } from "./tabs/Inbox/Inbox"; +import { NotificationsSettings } from "./tabs/Settings/Settings"; +import { notificationsDnpName } from "params"; +import { LegacyNotifications } from "./tabs/Legacy"; + +export const NotificationsRoot: React.FC = () => { + const dnpsRequest = useApi.packagesGet(); + return renderResponse(dnpsRequest, ["Loading notifications"], (dnps) => { + const isNotificationsPkgInstalled = dnps?.some((dnp) => dnp.dnpName === notificationsDnpName); + + const availableRoutes: { + name: string; + subPath: string; + component: React.ComponentType; + }[] = [ + { + name: "Inbox", + subPath: subPaths.inbox, + component: isNotificationsPkgInstalled ? Inbox : () => + }, + { + name: "Settings", + subPath: subPaths.settings, + component: isNotificationsPkgInstalled ? NotificationsSettings : () => + }, + { + name: "Legacy", + subPath: subPaths.legacy, + component: LegacyNotifications + } + ]; + + return ( + <> + + + <div className="horizontal-navbar"> + {availableRoutes.map((route) => ( + <button key={route.subPath} className="item-container"> + <NavLink to={route.subPath} className="item no-a-style" style={{ whiteSpace: "nowrap" }}> + {route.name} + </NavLink> + </button> + ))} + </div> + + <div className="section-spacing"> + <Routes> + {availableRoutes.map((route) => ( + <Route key={route.subPath} path={route.subPath} element={<route.component />} /> + ))} + </Routes> + </div> + </> + ); + }); +}; diff --git a/packages/admin-ui/src/pages/notifications/data.ts b/packages/admin-ui/src/pages/notifications/data.ts new file mode 100644 index 0000000000..072073369e --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/data.ts @@ -0,0 +1,12 @@ +export const relativePath = "notifications/inbox"; // default redirect to inbox +export const rootPath = "notifications/*"; +export const title = "Notifications"; + +// Additional data + +// SubPaths +export const subPaths = { + inbox: "inbox", + settings: "settings", + legacy: "legacy", +}; diff --git a/packages/admin-ui/src/pages/notifications/index.ts b/packages/admin-ui/src/pages/notifications/index.ts new file mode 100644 index 0000000000..a6b52d030f --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/index.ts @@ -0,0 +1,4 @@ +import { NotificationsRoot } from "./NotificationsRoot"; + +export { rootPath, relativePath, subPaths } from "./data"; +export const RootComponent = NotificationsRoot; diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx new file mode 100644 index 0000000000..a14ce36445 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx @@ -0,0 +1,164 @@ +import SubTitle from "components/SubTitle"; +import React, { useEffect, useMemo, useState } from "react"; +import Card from "components/Card"; +import { NotificationCard } from "./NotificationsCard"; +import { useApi, api } from "api"; +import { Searchbar } from "components/Searchbar"; +import Loading from "components/Loading"; +import "./inbox.scss"; + +export function Inbox() { + const notifications = useApi.notificationsGetAll(); + + const [search, setSearch] = useState(""); + const [categories, setCategories] = useState<string[]>([]); + const [selectedCategory, setSelectedCategory] = useState<string | null>(null); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 15; + + useEffect(() => { + if (!notifications.data) { + setCategories([]); + return; + } + + const uniqueCategories = Array.from(new Set(notifications.data.map((n) => n.category).filter(Boolean))); + setCategories(uniqueCategories); + api.notificationsSetAllSeen(); + }, [notifications.data]); + + const filteredNotifications = useMemo(() => { + if (!notifications.data) return []; + + // Filter notifications that encountered an error while making the request + const healthyNotifications = notifications.data.filter((notification) => !notification.errors); + + // Filter by search and category + return healthyNotifications.filter( + (notification) => + (notification.title.toLowerCase().includes(search.toLowerCase()) || + notification.dnpName.toLowerCase().includes(search.toLowerCase())) && + (!selectedCategory || notification.category === selectedCategory) + ); + }, [search, notifications.data, selectedCategory]); + + const newNotifications = filteredNotifications + .filter((notification) => !notification.seen) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + const seenNotifications = filteredNotifications + .filter((notification) => notification.seen) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + const totalPages = Math.ceil(seenNotifications.length / itemsPerPage); + + const paginatedSeenNotifications = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return seenNotifications.slice(startIndex, endIndex); + }, [seenNotifications, currentPage]); + + // Reset to first page when filters change + useEffect(() => { + setCurrentPage(1); + }, [search, selectedCategory]); + + const handleNextPage = () => { + if (currentPage < totalPages) setCurrentPage((prev) => prev + 1); + }; + + const handlePreviousPage = () => { + if (currentPage > 1) setCurrentPage((prev) => prev - 1); + }; + + const handleFirstPage = () => { + setCurrentPage(1); + }; + + const handleLastPage = () => { + setCurrentPage(totalPages); + }; + + const loading = notifications.isValidating; + + return loading ? ( + <Loading steps={["Loading data"]} /> + ) : ( + <> + <div> + <Searchbar + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search by package name or notification title..." + /> + + {categories.length > 0 && ( + <div className="categories"> + {categories.map((category) => ( + <div + key={category} + className={`category ${selectedCategory === category ? "selected" : ""}`} + onClick={() => setSelectedCategory(category === selectedCategory ? null : category)} + > + {category} + </div> + ))} + </div> + )} + </div> + + {newNotifications.length > 0 && ( + <> + <SubTitle>New Notifications</SubTitle> + {newNotifications.map((notification) => ( + <NotificationCard key={notification.id} notification={notification} openByDefault /> + ))} + </> + )} + + <SubTitle>History</SubTitle> + {!seenNotifications || seenNotifications.length === 0 ? ( + <Card>No notifications</Card> + ) : ( + <> + {paginatedSeenNotifications.map((notification) => ( + <NotificationCard key={notification.timestamp} notification={notification} /> + ))} + {totalPages > 1 && ( + <div className="pagination"> + {currentPage !== 1 && ( + <> + <button onClick={handleFirstPage} className="page-item"> + 1 + </button> + {currentPage > 3 && <span className="dots">. . .</span>} + </> + )} + + {currentPage > 2 && ( + <button onClick={handlePreviousPage} className="page-item"> + {currentPage - 1} + </button> + )} + <span className="active">{currentPage}</span> + {totalPages - 1 > currentPage && ( + <button onClick={handleNextPage} className="page-item"> + {currentPage + 1} + </button> + )} + + {currentPage !== totalPages && ( + <> + {totalPages - 2 > currentPage && <span className="dots">. . .</span>} + <button onClick={handleLastPage} className="page-item"> + {totalPages} + </button> + </> + )} + </div> + )} + </> + )} + </> + ); +} diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx new file mode 100644 index 0000000000..ff46f180b2 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx @@ -0,0 +1,95 @@ +import React, { useEffect, useState } from "react"; +import { Accordion } from "react-bootstrap"; +import { Notification } from "@dappnode/types"; +import { IoIosArrowDown, IoIosArrowUp } from "react-icons/io"; +import { prettyDnpName } from "utils/format"; +import dappnodeLogo from "img/dappnode-logo-only.png"; +import { Priority } from "@dappnode/types"; +import RenderMarkdown from "components/RenderMarkdown"; +import Button from "components/Button"; +import { NavLink } from "react-router-dom"; +import { api } from "api"; +import { dappmanagerAliases, externalUrlProps } from "params"; + +interface NotificationCardProps { + notification: Notification; + openByDefault?: boolean; +} + +const priorityLabels: Record<Priority, string> = { + [Priority.low]: "Informational", + [Priority.medium]: "Relevant", + [Priority.high]: "Important", + [Priority.critical]: "Critical" +}; + +const prettifiedBody = (body: string) => { + if (body.includes("resolved: ")) return body.replace("resolved:", "Resolved:"); + else if (body.includes("triggered: ")) return body.replace("triggered:", "Attention:"); + else return body; +}; + +export function NotificationCard({ notification, openByDefault = false }: NotificationCardProps) { + const notificationAvatar = () => { + if (notification.icon) return notification.icon; + else return dappnodeLogo; + }; + const [isOpen, setIsOpen] = useState(openByDefault); + + useEffect(() => { + if (!notification.seen && notification.isBanner && notification.status === "resolved") { + api.notificationSetSeenByCorrelationID(notification.correlationId); + } + }, []); + + const isExternalUrl = + notification.callToAction && !dappmanagerAliases.some((alias) => notification.callToAction!.url.includes(alias)); + + return ( + <Accordion defaultActiveKey={isOpen ? "0" : "1"}> + <Accordion.Toggle as={"div"} eventKey="0" onClick={() => setIsOpen(!isOpen)} className="notification-card"> + <div className="notification-header"> + <img className="avatar" src={notificationAvatar()} alt={notification.dnpName} /> + <div className="notification-header-data"> + <div className="notification-header-row secondary-text"> + <div className="notification-name-row"> + <div>{prettyDnpName(notification.dnpName)}</div> + <div className="labels-wrapper"> + <div className="category-label"> + {notification.category.charAt(0).toUpperCase() + notification.category.slice(1)} + </div> + <div className={`${notification.priority}-label`}>{priorityLabels[notification.priority]}</div> + {notification.status === "resolved" && <div className="resolved-label">Resolved</div>} + </div> + </div> + + <i> + {new Date(notification.timestamp * 1000).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + })} + </i> + </div> + <div className="notification-header-row "> + <div className="notification-title">{notification.title}</div> + {isOpen ? <IoIosArrowUp /> : <IoIosArrowDown />} + </div> + </div> + </div> + <Accordion.Collapse eventKey="0"> + <div className="notification-body"> + <RenderMarkdown source={prettifiedBody(notification.body)} /> + {notification.callToAction && ( + <NavLink to={notification.callToAction.url} {...isExternalUrl ? externalUrlProps : {}}> + <Button variant="dappnode"><div>{notification.callToAction.title}</div></Button> + </NavLink> + )} + </div> + </Accordion.Collapse> + </Accordion.Toggle> + </Accordion> + ); +} diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss b/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss new file mode 100644 index 0000000000..596b0a360e --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss @@ -0,0 +1,278 @@ +.categories { + display: flex; + flex-direction: row; + gap: 10px; + margin-top: 10px; + overflow-x: auto; + white-space: nowrap; + -webkit-overflow-scrolling: touch; + padding-bottom: 5px; + + .category { + padding: 3px 5px; + border-radius: 10px; + cursor: pointer; + font-size: 1rem; + color: var(--light-text-color); + border: #ced4da 1px solid; + flex-shrink: 0; + } + + .selected { + background-color: #ced4da; + } + + .category:hover { + @extend .selected; + } +} + +#dark { + .category { + border: var(--color-dark-card) 1px solid; + color: var(--color-dark-maintext); + } + .selected { + background-color: var(--color-dark-card-hover); + } + .category:hover { + background-color: var(--color-dark-card-hover); + } +} + +.notification-card { + border-radius: 10px; + background-color: #e9ecef; + padding: 10px; + cursor: pointer; + + .notification-header { + display: flex; + flex-direction: row; + padding-left: 10px; + gap: 20px; + align-items: center; + width: 100%; + + @media (max-width: 60rem) { + gap: 10px; + } + + .avatar { + width: 40px; + height: 40px; + + @media (max-width: 60rem) { + width: 35px; + height: 35px; + } + } + + .notification-header-data { + display: flex; + flex-direction: column; + justify-content: space-between; + text-align: left; + flex-grow: 1; + + .notification-title { + font-size: 1.2rem; + @media (max-width: 40rem) { + font-size: 1rem; + } + } + + .notification-header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + width: 100%; + + .notification-name-row { + display: flex; + gap: 5px; + + @media (max-width: 40rem) { + flex-direction: column-reverse; + gap: 0px; + } + + .labels-wrapper { + display: flex; + gap: 5px; + } + } + } + .secondary-text { + color: var(--light-text-color); + font-size: 1rem; + @media (max-width: 60rem) { + font-size: 0.8rem; + flex-direction: column-reverse; + } + } + + .base-label { + display: flex; + align-items: center; + background-color: transparent !important; + padding: 0px 5px; + border-radius: 10px; + font-size: 0.7rem; + + @media (max-width: 60rem) { + font-size: 0.65rem; + } + } + .resolved-label { + @extend .base-label; + background-color: var(--success-green-color) !important; + color: #bbffad !important; + } + + .critical-label { + @extend .base-label; + border: 1px solid var(--danger-color); + color: var(--danger-color) !important; + } + .high-label { + @extend .base-label; + border: 1px solid rgb(228, 156, 0); + color: rgb(228, 156, 0) !important; + } + .medium-label { + @extend .base-label; + border: 1px solid var(--success-color); + color: var(--success-color) !important; + } + .low-label { + @extend .base-label; + border: 1px solid gray; + color: gray !important; + } + + .category-label { + @extend .base-label; + border: 1px solid #82898f; + color: #82898f !important; + } + } + } + + .notification-body { + padding-top: 10px; + font-size: 1rem; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 15px; + + @media (max-width: 60rem) { + flex-direction: column; + } + + button { + > div { + white-space: nowrap; // Prevent CTA text from wrapping + } + } + } +} +.notification-card:hover { + background-color: #f1f3f5; + transition: all 0.1s ease-in-out; +} + +#dark { + .notification-card { + background-color: var(--color-dark-card); + color: var(--color-dark-maintext); + + .notification-header { + .notification-img { + background-color: var(--color-dark-card-hover); + color: white; + } + + .notification-header-details { + color: var(--light-text-color); + } + + .group-label { + background-color: var(--color-dark-border); + } + + .critical-label { + @extend .group-label; + border: 1px solid red; + color: red !important; + } + .high-label { + @extend .group-label; + border: 1px solid rgb(255, 196, 0); + color: rgb(255, 196, 0) !important; + } + .normal-label { + @extend .group-label; + border: 1px solid rgb(114, 213, 243); + color: rgb(114, 213, 243) !important; + } + .low-label { + @extend .group-label; + border: 1px solid gray; + color: gray !important; + } + } + } + + .notification-card:hover { + background-color: var(--color-dark-card-hover); + } +} + +.pagination { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 10px; + margin: 30px 0px; + + .page-item { + cursor: pointer; + padding: 5px 10px; + border-radius: 5px; + background-color: #e9ecef; + border: none; + + &:hover { + background-color: #ced4da; + } + } + .dots { + padding-bottom: 8px; + } + + .active { + background-color: #ced4da !important; + padding: 5px 10px; + border-radius: 5px; + } +} + +#dark { + .pagination { + .page-item { + background-color: var(--color-dark-card); + color: var(--color-dark-maintext); + + &:hover { + background-color: var(--color-dark-card-hover); + } + } + .active { + background-color: var(--color-dark-card-hover) !important; + } + } +} diff --git a/packages/admin-ui/src/pages/notifications/tabs/InstallNotifications/InstallNotifications.tsx b/packages/admin-ui/src/pages/notifications/tabs/InstallNotifications/InstallNotifications.tsx new file mode 100644 index 0000000000..392a5a975e --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/InstallNotifications/InstallNotifications.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { NavLink } from "react-router-dom"; +import Button from "components/Button"; +import { getInstallerPath } from "pages/installer/data"; +import SubTitle from "components/SubTitle"; +import Card from "components/Card"; + +import "./installNotifications.scss"; +import { notificationsDnpName } from "params"; + + +export const InstallNotificationsPkg: React.FC = () => { + const installerPath = getInstallerPath(notificationsDnpName); + + return ( + <Card className="install-notifications-card"> + <SubTitle>Install notifications package</SubTitle> + <p>To receive notifications on your Dappnode, you must install the Notifications Dappnode Package.</p> + <NavLink to={installerPath + "/" + notificationsDnpName}> + <Button variant="dappnode">Install</Button> + </NavLink> + </Card> + ); +}; diff --git a/packages/admin-ui/src/pages/notifications/tabs/InstallNotifications/installNotifications.scss b/packages/admin-ui/src/pages/notifications/tabs/InstallNotifications/installNotifications.scss new file mode 100644 index 0000000000..d84515a4c6 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/InstallNotifications/installNotifications.scss @@ -0,0 +1,7 @@ +.install-notifications-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + \ No newline at end of file diff --git a/packages/admin-ui/src/pages/system/components/Notifications/EthicalMetrics.tsx b/packages/admin-ui/src/pages/notifications/tabs/Legacy/EthicalMetrics.tsx similarity index 100% rename from packages/admin-ui/src/pages/system/components/Notifications/EthicalMetrics.tsx rename to packages/admin-ui/src/pages/notifications/tabs/Legacy/EthicalMetrics.tsx diff --git a/packages/admin-ui/src/pages/system/components/Notifications/Telegram.tsx b/packages/admin-ui/src/pages/notifications/tabs/Legacy/Telegram.tsx similarity index 100% rename from packages/admin-ui/src/pages/system/components/Notifications/Telegram.tsx rename to packages/admin-ui/src/pages/notifications/tabs/Legacy/Telegram.tsx diff --git a/packages/admin-ui/src/pages/notifications/tabs/Legacy/index.tsx b/packages/admin-ui/src/pages/notifications/tabs/Legacy/index.tsx new file mode 100644 index 0000000000..b3cec5ebc3 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Legacy/index.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import SubTitle from "components/SubTitle"; +import { AlertDismissible } from "components/AlertDismissible"; +import { Link } from "react-router-dom"; +import { docsUrl } from "params"; +import { subPaths } from "pages/notifications/index"; + +import { TelegramNotifications } from "./Telegram"; +import EthicalMetrics from "./EthicalMetrics"; +import "./notifications.scss"; + +export function LegacyNotifications() { + return ( + <> + <AlertDismissible variant="warning"> + <h4>📣 Legacy Notifications will be deprecated!</h4> + <p> + The current notification system using email and Telegram will be deprecated in upcoming Dappnode core + releases. We're transitioning to a new and improved in-app Notifications experience, designed to be more + reliable, configurable and scalable. + </p> + <p> + 🔁 To enable them, make sure you check out the new{" "} + <Link to={`/notifications/${subPaths.settings}`}> + Settings Notifications tab + </Link>{" "} + <br /> + 📘 For full details about the new system, see our{" "} + <Link to={docsUrl.notificationsOverview} target="_blank" rel="noopener noreferrer"> + Notifications Documentation + </Link> + . + </p> + </AlertDismissible> + + <SubTitle>Ethical metrics</SubTitle> + <EthicalMetrics /> + + <SubTitle>Telegram</SubTitle> + <TelegramNotifications /> + </> + ); +} diff --git a/packages/admin-ui/src/pages/system/components/Notifications/notifications.scss b/packages/admin-ui/src/pages/notifications/tabs/Legacy/notifications.scss similarity index 100% rename from packages/admin-ui/src/pages/system/components/Notifications/notifications.scss rename to packages/admin-ui/src/pages/notifications/tabs/Legacy/notifications.scss diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/CustomEndpointItem.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/CustomEndpointItem.tsx new file mode 100644 index 0000000000..4d93ae5646 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/CustomEndpointItem.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; +import { CustomEndpoint } from "@dappnode/types"; +import { EndpointItem } from "./EndpointItem"; + +interface CustomEndpointItemProps { + endpoint: CustomEndpoint; + index: number; + numEndpoints: number; + setCustomEndpoints: React.Dispatch<React.SetStateAction<CustomEndpoint[]>>; +} + +export function CustomEndpointItem({ endpoint, index, numEndpoints, setCustomEndpoints }: CustomEndpointItemProps) { + const endpointEnabled = endpoint.enabled; + + const [sliderValue, setSliderValue] = useState<number>(endpoint.metric?.treshold || 0); + + const handleEndpointToggle = () => { + setCustomEndpoints((prevEndpoints) => + prevEndpoints.map((ep, i) => (i === index ? { ...ep, enabled: !ep.enabled } : ep)) + ); + }; + + const handleSliderUpdate = (value: number) => { + setSliderValue(value); + }; + + const handleSliderUpdateComplete = (value: number) => { + setCustomEndpoints((prevEndpoints) => + prevEndpoints.map((ep, i) => + i === index && ep.metric + ? { + ...ep, + metric: { + ...ep.metric, + treshold: value + } + } + : ep + ) + ); + }; + + return ( + <> + <EndpointItem + index={index} + title={endpoint.name} + description={endpoint.description} + endpointEnabled={endpointEnabled} + handleEndpointToggle={handleEndpointToggle} + metric={ + endpoint.metric + ? { + min: endpoint.metric.min, + max: endpoint.metric.max, + unit: endpoint.metric.unit, + sliderValue: sliderValue + } + : undefined + } + handleSliderUpdate={handleSliderUpdate} + handleSliderUpdateComplete={handleSliderUpdateComplete} + numEndpoints={numEndpoints} + /> + </> + ); +} diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/EndpointItem.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/EndpointItem.tsx new file mode 100644 index 0000000000..5ae9381436 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/EndpointItem.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import Switch from "components/Switch"; +import Slider from "components/Slider"; +import RenderMarkdown from "components/RenderMarkdown"; + +interface EndpointItemProps { + title: string; + description: string; + endpointEnabled: boolean; + metric?: { min: number; max: number; unit: string; sliderValue: number }; + index: number; + numEndpoints: number; + handleEndpointToggle: () => void; + handleSliderUpdate: (value: number) => void; + handleSliderUpdateComplete: (value: number) => void; +} + +export function EndpointItem({ + index, + title, + description, + endpointEnabled, + metric, + numEndpoints, + handleEndpointToggle, + handleSliderUpdate, + handleSliderUpdateComplete +}: EndpointItemProps) { + return ( + <> + <div key={index} className="endpoint-row"> + <div> + <strong>{title}</strong> + <RenderMarkdown source={description} /> + </div> + <Switch checked={endpointEnabled} onToggle={handleEndpointToggle} /> + </div> + {endpointEnabled && metric && ( + <div className="slider-wrapper"> + <Slider + value={metric.sliderValue} + onChange={handleSliderUpdate} + onChangeComplete={handleSliderUpdateComplete} + min={metric.min} + max={metric.max} + unit={metric.unit} + /> + </div> + )} + {index + 1 < numEndpoints && <hr />} + </> + ); +} diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx new file mode 100644 index 0000000000..156a8a67d7 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useState } from "react"; +import { GatusEndpoint } from "@dappnode/types"; +import { EndpointItem } from "./EndpointItem"; + +interface GatusEndpointItemProps { + endpoint: GatusEndpoint; + index: number; + numEndpoints: number; + setGatusEndpoints: React.Dispatch<React.SetStateAction<GatusEndpoint[]>>; +} + +export function GatusEndpointItem({ endpoint, index, numEndpoints, setGatusEndpoints }: GatusEndpointItemProps) { + const endpointEnabled = endpoint.enabled; + + const operators = [">=", "<=", "<", ">", "==", "!="]; + + // Extract the operator and number from the condition string from the 1ST CONDITION. Rn, is only supporting 1 slider (from 1st condition) per endpoint + const conditionString = endpoint.conditions[0]; + const operator = operators.find((op) => conditionString.includes(op)); + const conditionValue = operator + ? conditionString + .split(operator) + .pop() + ?.trim() || "" + : "0"; + + const [sliderValue, setSliderValue] = useState<number>(parseFloat(conditionValue)); + + useEffect(() => { + setSliderValue(parseFloat(conditionValue)); + }, [conditionValue, endpoint]); + + const handleEndpointToggle = () => { + setGatusEndpoints((prevEndpoints) => + prevEndpoints.map((ep, i) => (i === index ? { ...ep, enabled: !ep.enabled } : ep)) + ); + }; + + const handleSliderUpdate = (value: number) => { + setSliderValue(value); + }; + + const handleSliderUpdateComplete = (value: number) => { + const updatedCondition = operator + ? `${endpoint.conditions[0].split(operator)[0].trim()} ${operator} ${value}` + : endpoint.conditions[0]; + + setGatusEndpoints((prevEndpoints) => + prevEndpoints.map((ep, i) => + i === index + ? { + ...ep, + conditions: [ + updatedCondition, // Update ONLY the first condition + ...ep.conditions.slice(1) + ] + } + : ep + ) + ); + }; + + return ( + <> + <EndpointItem + index={index} + title={endpoint.definition.title} + description={endpoint.definition.description} + endpointEnabled={endpointEnabled} + handleEndpointToggle={handleEndpointToggle} + metric={ + endpoint.metric + ? { + min: endpoint.metric.min, + max: endpoint.metric.max, + unit: endpoint.metric.unit, + sliderValue: sliderValue + } + : undefined + } + handleSliderUpdate={handleSliderUpdate} + handleSliderUpdateComplete={handleSliderUpdateComplete} + numEndpoints={numEndpoints} + /> + </> + ); +} diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/InstallerEndpointsList.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/InstallerEndpointsList.tsx new file mode 100644 index 0000000000..db119f888a --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/InstallerEndpointsList.tsx @@ -0,0 +1,49 @@ +import SubTitle from "components/SubTitle"; +import React from "react"; +import { CustomEndpointItem } from "./CustomEndpointItem"; +import { GatusEndpointItem } from "./GatusEndpointItem"; +import { CustomEndpoint, GatusEndpoint } from "@dappnode/types"; +import "./settings.scss"; + +interface InstallerEndpointsListProps { + endpointsGatus: GatusEndpoint[]; + setEndpointsGatus: React.Dispatch<React.SetStateAction<GatusEndpoint[]>>; + endpointsCustom: CustomEndpoint[]; + setEndpointsCustom: React.Dispatch<React.SetStateAction<CustomEndpoint[]>>; +} + +export const InstallerEndpointsList: React.FC<InstallerEndpointsListProps> = ({ + endpointsGatus, + setEndpointsGatus, + endpointsCustom, + setEndpointsCustom +}) => { + return ( + <div className="notifications-settings"> + <SubTitle className="notifications-section-title">Manage notifications</SubTitle> + <div>Enable, disable and customize notifications individually.</div> + <div className="endpoint-list-card"> + {endpointsGatus && + endpointsGatus.map((endpoint, i) => ( + <GatusEndpointItem + key={endpoint.name} + endpoint={endpoint} + index={i} + numEndpoints={endpointsGatus.length} + setGatusEndpoints={setEndpointsGatus} + /> + ))} + {endpointsCustom && + endpointsCustom.map((endpoint, i) => ( + <CustomEndpointItem + key={endpoint.name} + endpoint={endpoint} + index={i} + numEndpoints={endpointsCustom.length} + setCustomEndpoints={setEndpointsCustom} + /> + ))} + </div> + </div> + ); +}; diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx new file mode 100644 index 0000000000..74e0fa2164 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState, useRef } from "react"; +import SubTitle from "components/SubTitle"; +import Switch from "components/Switch"; +import { GatusEndpointItem } from "./GatusEndpointItem.js"; +import { CustomEndpointItem } from "./CustomEndpointItem.js"; +import { CustomEndpoint, GatusEndpoint } from "@dappnode/types"; +import { prettyDnpName } from "utils/format"; +import { api, useApi } from "api"; +import Tooltip from "react-bootstrap/Tooltip"; +import OverlayTrigger from "react-bootstrap/OverlayTrigger"; +import { withToast } from "components/toast/Toast"; + +interface ManagePackageNotificationsProps { + dnpName: string; + gatusEndpoints: GatusEndpoint[]; + customEndpoints: CustomEndpoint[]; + isCore: boolean; +} + +export function ManagePackageNotifications({ + dnpName, + gatusEndpoints, + customEndpoints, + isCore +}: ManagePackageNotificationsProps) { + const [endpointsGatus, setEndpointsGatus] = useState([...gatusEndpoints]); + const [endpointsCustom, setEndpointsCustom] = useState([...customEndpoints]); + const [pkgNotificationsEnabled, setPkgNotificationsEnabled] = useState( + gatusEndpoints.some((ep) => ep.enabled) || customEndpoints.some((ep) => ep.enabled) + ); + const isStateUpdatedByUser = useRef(false); + + const dnpCall = useApi.packageGet({ dnpName: dnpName }); + const [allServicesNotRunning, setAllServicesNotRunning] = useState(false); + + useEffect(() => { + if (dnpCall.data) { + setAllServicesNotRunning(dnpCall.data.containers.every((c) => c.state !== "running")); + } + }, [dnpCall.data]); + + // Synchronize state with props when they change + useEffect(() => { + setEndpointsGatus([...gatusEndpoints]); + setEndpointsCustom([...customEndpoints]); + setPkgNotificationsEnabled(gatusEndpoints.some((ep) => ep.enabled) || customEndpoints.some((ep) => ep.enabled)); + }, [gatusEndpoints, customEndpoints]); + + // Handle switch toggle to enable/disable all endpoints + const handlePkgToggle = () => { + const newEnabledState = !pkgNotificationsEnabled; + isStateUpdatedByUser.current = true; + setEndpointsGatus((prevGatusEndpoints) => prevGatusEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState }))); + setEndpointsCustom((prevCustomEndpoints) => prevCustomEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState }))); + setPkgNotificationsEnabled(newEnabledState); + }; + + useEffect(() => { + const updateEndpoints = async () => { + if (isStateUpdatedByUser.current) { + isStateUpdatedByUser.current = false; + await withToast( + () => + api.notificationsUpdateEndpoints({ + dnpName, + notificationsConfig: { + endpoints: endpointsGatus.length > 0 ? endpointsGatus : undefined, + customEndpoints: endpointsCustom.length > 0 ? endpointsCustom : undefined + }, + isCore: isCore + }), + { + message: `Updating settings for ${prettyDnpName(dnpName)}...`, + onSuccess: `${prettyDnpName(dnpName)} settings updated`, + onError: `Error updating settings for ${prettyDnpName(dnpName)}` + } + ); + } + }; + + updateEndpoints(); + }, [endpointsGatus, endpointsCustom]); + + return ( + <div key={String(dnpName)} className="notifications-settings"> + <div className="title-switch-row"> + <SubTitle className="notifications-pkg-name">{prettyDnpName(dnpName)}</SubTitle> + <Switch checked={pkgNotificationsEnabled} onToggle={handlePkgToggle} /> + {allServicesNotRunning && ( + <OverlayTrigger + overlay={<Tooltip id="not-running-tooltip">Package not running, notifications will not be sent</Tooltip>} + placement="top" + > + <div className="not-running-label">i</div> + </OverlayTrigger> + )} + </div> + {pkgNotificationsEnabled && ( + <div className="endpoint-list-card"> + {endpointsGatus.map((endpoint, i) => ( + <GatusEndpointItem + key={endpoint.name} + endpoint={endpoint} + index={i} + numEndpoints={endpointsGatus.length} + setGatusEndpoints={(updatedEndpoints) => { + isStateUpdatedByUser.current = true; + setEndpointsGatus(updatedEndpoints); + }} + /> + ))} + {endpointsCustom.map((endpoint, i) => ( + <CustomEndpointItem + key={endpoint.name} + endpoint={endpoint} + index={i} + numEndpoints={endpointsCustom.length} + setCustomEndpoints={(updatedEndpoints) => { + isStateUpdatedByUser.current = true; + setEndpointsCustom(updatedEndpoints); + }} + /> + ))} + </div> + )} + </div> + ); +} diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx new file mode 100644 index 0000000000..59f94883ca --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx @@ -0,0 +1,119 @@ +import SubTitle from "components/SubTitle"; +import React, { useEffect, useState } from "react"; +import Switch from "components/Switch"; +import { ManagePackageNotifications } from "./ManagePackageNotifications.js"; +import { api, useApi } from "api"; +import { CustomEndpoint, GatusEndpoint } from "@dappnode/types"; +import "./settings.scss"; +import { notificationsDnpName } from "params.js"; +import { confirm } from "components/ConfirmDialog"; +import { withToast } from "components/toast/Toast"; +import { continueIfCalleDisconnected } from "api/utils"; + +interface EndpointsData { + [dnpName: string]: { + endpoints: GatusEndpoint[]; + customEndpoints: CustomEndpoint[]; + isCore: boolean; + }; +} + +export function NotificationsSettings() { + const [notRunningServices, setNotRunningServices] = useState<string[]>([]); + const [endpointsData, setEndpointsData] = useState<EndpointsData | undefined>(); + const endpointsCall = useApi.notificationsGetAllEndpoints(); + const notificationsDnp = useApi.packageGet({ dnpName: notificationsDnpName }); + + useEffect(() => { + // Fetch the latest endpoints data when the component is mounted + if (endpointsCall.data) { + setEndpointsData(endpointsCall.data as EndpointsData); + } + }, [endpointsCall.data]); + + useEffect(() => { + if (notificationsDnp.data) { + const notRunningServices = notificationsDnp.data.containers + .filter((c) => c.state !== "running") + .map((c) => c.serviceName); + setNotRunningServices(notRunningServices); + } + }, [notificationsDnp.data]); + + const notificationsDisabled = notRunningServices.length > 0; + + async function startStopNotifications(): Promise<void> { + try { + if (notificationsDnp.data) { + if (!notificationsDisabled) + await new Promise<void>((resolve) => { + confirm({ + title: `Pause notifications package`, + text: `Attention, the notifications package may alert you to critical issues if they arise. Pausing this package could result in missing important notifications.`, + label: "Pause", + onClick: resolve + }); + }); + + await withToast( + continueIfCalleDisconnected( + () => + api.packageStartStop({ + dnpName: notificationsDnpName, + serviceNames: notificationsDisabled + ? notRunningServices + : notificationsDnp.data!.containers.map((c) => c.serviceName) + }), + notificationsDnpName + ), + { + message: notificationsDisabled ? "Enabling notifications" : "Disabling notifications", + onSuccess: notificationsDisabled ? "Notifications Enabled" : "Notifications disabled" + } + ); + + notificationsDnp.revalidate(); + } + } catch (e) { + console.error(`Error on start/stop notifications package: ${e}`); + } + } + + return ( + <div className="notifications-settings"> + <div> + <div className="title-switch-row"> + <SubTitle className="notifications-section-title">Enable notifications</SubTitle> + <Switch + checked={!notificationsDisabled} + disabled={notificationsDnp.isValidating} + onToggle={() => { + startStopNotifications(); + }} + /> + </div> + <div>Enable notifications to retrieve a registry of notifications on your Dappnode.</div> + </div> + <br /> + {!notificationsDisabled && !notificationsDnp.isValidating && ( + <div> + <SubTitle className="notifications-section-title">Manage notifications</SubTitle> + <div>Enable, disable and customize notifications individually.</div> + <br /> + <div className="manage-notifications-wrapper"> + {endpointsData && + Object.entries(endpointsData).map(([dnpName, endpoints]) => ( + <ManagePackageNotifications + key={dnpName} + dnpName={dnpName} + gatusEndpoints={endpoints.endpoints} + customEndpoints={endpoints.customEndpoints} + isCore={endpoints.isCore} + /> + ))} + </div> + </div> + )} + </div> + ); +} diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/settings.scss b/packages/admin-ui/src/pages/notifications/tabs/Settings/settings.scss new file mode 100644 index 0000000000..2028f3202c --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/settings.scss @@ -0,0 +1,79 @@ +.notifications-settings { + .title-switch-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + + .not-running-label { + color: rgb(228, 156, 0) !important; + display: flex; + align-items: center; + background-color: transparent; + padding: 0px 6px 0px 5px; + border-radius: 10px; + border: 1px solid rgb(228, 156, 0); + font-size: 0.75rem; + cursor: help; + height: 15px; + width: 15px; + font-weight: bold; + } + + .switch-container { + margin-left: 5px; + } + } + + .notifications-section-title { + margin: 0.5rem 0; + } + + .manage-notifications-wrapper { + display: flex; + flex-direction: column; + gap: 15px; + } + + .notifications-pkg-name { + font-size: 1.2rem; + margin: 0.5rem 0; + } + + .endpoint-list-card { + border-radius: 10px; + background-color: #e9ecef; + margin: 10px 0; + padding: 15px; + display: flex; + flex-direction: column; + + .endpoint-row { + display: flex; + flex-direction: row; + justify-content: space-between; + } + + hr { + width: 100%; + } + + .slider-wrapper { + padding-top: 15px; + width: 30%; + + @media (max-width: 60rem) { + width: 100%; + } + } + } +} + +#dark { + .notifications-settings { + .endpoint-list-card { + background-color: var(--color-dark-card); + color: var(--color-dark-maintext); + } + } +} \ No newline at end of file diff --git a/packages/admin-ui/src/pages/system/actions.ts b/packages/admin-ui/src/pages/system/actions.ts index 84582d2a0c..34d53525f4 100644 --- a/packages/admin-ui/src/pages/system/actions.ts +++ b/packages/admin-ui/src/pages/system/actions.ts @@ -1,8 +1,6 @@ import { confirm } from "components/ConfirmDialog"; import { api } from "api"; import { prettyDnpName, prettyVolumeName } from "utils/format"; -// External actions -import { fetchPasswordIsSecure } from "services/dappnodeStatus/actions"; // Selectors import { getEthClientTarget, getEthRemoteRpc } from "services/dappnodeStatus/selectors"; import { withToastNoThrow } from "components/toast/Toast"; @@ -12,190 +10,179 @@ import { isEqual } from "lodash-es"; // Redux Thunk actions -export const changeEthClientTarget = - (nextTarget: Eth2ClientTarget, newEthRemoteRpc: string): AppThunk => - async (_, getState) => { - const prevTarget = getEthClientTarget(getState()); - const prevEthRemoteRpc = getEthRemoteRpc(getState()); - - // Make sure the target has changed or the call will error - if (isEqual(nextTarget, prevTarget) && prevEthRemoteRpc === newEthRemoteRpc) return; +export const changeEthClientTarget = (nextTarget: Eth2ClientTarget, newEthRemoteRpc: string): AppThunk => async ( + _, + getState +) => { + const prevTarget = getEthClientTarget(getState()); + const prevEthRemoteRpc = getEthRemoteRpc(getState()); + + // Make sure the target has changed or the call will error + if (isEqual(nextTarget, prevTarget) && prevEthRemoteRpc === newEthRemoteRpc) return; + + // If the previous target is package, ask the user if deleteVolumes + let deletePrevExecClient = false; + let deletePrevExecClientVolumes = false; + let deletePrevConsClient = false; + let deletePrevConsClientVolumes = false; + if (prevTarget && prevTarget !== "remote") { + if (nextTarget === "remote" || nextTarget.execClient !== prevTarget.execClient) { + await new Promise<void>((resolve) => + confirm({ + title: `Remove ${prettyDnpName(prevTarget.execClient)}?`, + text: `Do you want to remove your current Execution Layer (EL) client? This action cannot be undone. You can keep the data volume to avoid needing to resync from scratch next time you install the same EL client. Keeping the data volume will NOT clear space in your hard drive.`, + buttons: [ + { + label: "Keep node running", + variant: "danger", + onClick: () => { + deletePrevExecClient = false; + deletePrevExecClientVolumes = false; + resolve(); + } + }, + { + label: "Remove and keep volumes", + variant: "warning", + onClick: () => { + deletePrevExecClient = true; + deletePrevExecClientVolumes = false; + resolve(); + } + }, + { + label: "Remove and delete volumes", + variant: "dappnode", + onClick: () => { + deletePrevExecClient = true; + deletePrevExecClientVolumes = true; + resolve(); + } + } + ] + }) + ); + } // If the previous target is package, ask the user if deleteVolumes - let deletePrevExecClient = false; - let deletePrevExecClientVolumes = false; - let deletePrevConsClient = false; - let deletePrevConsClientVolumes = false; - if (prevTarget && prevTarget !== "remote") { - if (nextTarget === "remote" || nextTarget.execClient !== prevTarget.execClient) { - await new Promise<void>((resolve) => - confirm({ - title: `Remove ${prettyDnpName(prevTarget.execClient)}?`, - text: `Do you want to remove your current Execution Layer (EL) client? This action cannot be undone. You can keep the data volume to avoid needing to resync from scratch next time you install the same EL client. Keeping the data volume will NOT clear space in your hard drive.`, - buttons: [ - { - label: "Keep node running", - variant: "danger", - onClick: () => { - deletePrevExecClient = false; - deletePrevExecClientVolumes = false; - resolve(); - } - }, - { - label: "Remove and keep volumes", - variant: "warning", - onClick: () => { - deletePrevExecClient = true; - deletePrevExecClientVolumes = false; - resolve(); - } - }, - { - label: "Remove and delete volumes", - variant: "dappnode", - onClick: () => { - deletePrevExecClient = true; - deletePrevExecClientVolumes = true; - resolve(); - } + if (nextTarget === "remote" || nextTarget.consClient !== prevTarget.consClient) { + await new Promise<void>((resolve) => + confirm({ + title: `Remove ${prettyDnpName(prevTarget.consClient)}?`, + text: `Do you want to remove your current Consensus Layer (CL) client? This action cannot be undone. You can keep the volume data to avoid resyncing from scratch next time you install the same CL client. Keeping the volume will NOT clear space in your hard drive.`, + buttons: [ + { + label: "Keep node running", + variant: "danger", + onClick: () => { + deletePrevConsClient = false; + deletePrevConsClientVolumes = false; + resolve(); } - ] - }) - ); - } - - // If the previous target is package, ask the user if deleteVolumes - if (nextTarget === "remote" || nextTarget.consClient !== prevTarget.consClient) { - await new Promise<void>((resolve) => - confirm({ - title: `Remove ${prettyDnpName(prevTarget.consClient)}?`, - text: `Do you want to remove your current Consensus Layer (CL) client? This action cannot be undone. You can keep the volume data to avoid resyncing from scratch next time you install the same CL client. Keeping the volume will NOT clear space in your hard drive.`, - buttons: [ - { - label: "Keep node running", - variant: "danger", - onClick: () => { - deletePrevConsClient = false; - deletePrevConsClientVolumes = false; - resolve(); - } - }, - { - label: "Remove and keep volumes", - variant: "warning", - onClick: () => { - deletePrevConsClient = true; - deletePrevConsClientVolumes = false; - resolve(); - } - }, - { - label: "Remove and delete volumes", - variant: "dappnode", - onClick: () => { - deletePrevConsClient = true; - deletePrevConsClientVolumes = true; - resolve(); - } + }, + { + label: "Remove and keep volumes", + variant: "warning", + onClick: () => { + deletePrevConsClient = true; + deletePrevConsClientVolumes = false; + resolve(); + } + }, + { + label: "Remove and delete volumes", + variant: "dappnode", + onClick: () => { + deletePrevConsClient = true; + deletePrevConsClientVolumes = true; + resolve(); } - ] - }) - ); - } + } + ] + }) + ); } - - await withToastNoThrow( - () => - api.ethClientTargetSet({ - target: nextTarget, - sync: false, - ethRemoteRpc: newEthRemoteRpc, - deletePrevExecClient, - deletePrevExecClientVolumes, - deletePrevConsClient, - deletePrevConsClientVolumes - }), - { - message: "Changing Ethereum client...", - onSuccess: `Changed Ethereum client` - } - ); - }; - -export const passwordChangeInBackground = - (newPassword: string): AppThunk => - async (dispatch) => { - await api.passwordChange({ newPassword }).catch(console.error); - - dispatch(fetchPasswordIsSecure()); - }; - -export const passwordChange = - (newPassword: string): AppThunk => - async (dispatch) => { - // Display a dialog to confirm the password change - await new Promise<void>((resolve) => - confirm({ - title: `Changing host user password`, - text: `Make sure to safely store this password and keep a back up. \n\nYou will never be able to see this password again. If you lose it, you will not be able to recover it in any way.`, - label: "Change", - variant: "dappnode", - onClick: resolve - }) - ); - - await withToastNoThrow(() => api.passwordChange({ newPassword }), { - message: `Changing host user password...`, - onSuccess: `Changed host user password` - }); - - dispatch(fetchPasswordIsSecure()); - }; - -export const volumeRemove = - (name: string): AppThunk => - async () => { - // Display a dialog to confirm the password change - await new Promise<void>((resolve) => - confirm({ - title: `Removing volume`, - text: `Are you sure you want to permanently remove volume ${name}?`, - label: "Remove", - variant: "danger", - onClick: resolve - }) - ); - - await withToastNoThrow(() => api.volumeRemove({ name }), { - message: `Removing volume...`, - onSuccess: `Removed volume` - }); - }; - -export const packageVolumeRemove = - (dnpName: string, volName: string): AppThunk => - async () => { - // Make sure there are no colliding volumes with this DNP - const prettyVolName = prettyVolumeName(volName, dnpName).name; - const prettyVolRef = `${prettyDnpName(dnpName)} ${prettyVolName} volume`; - - const warningsList: { title: string; body: string }[] = []; - - // If there are NOT conflicting volumes, - // Display a dialog to confirm volumes reset - await new Promise<void>((resolve) => - confirm({ - title: `Removing ${prettyVolRef}`, - text: `Are you sure you want to permanently remove this volume? This action cannot be undone. If this DAppNode Package is a blockchain node, it will lose all the chain data and start syncing from scratch.`, - list: warningsList, - label: "Remove", - onClick: resolve - }) - ); - - await withToastNoThrow(() => api.packageRestartVolumes({ dnpName, volumeId: volName }), { - message: `Removing ${prettyVolRef}...`, - onSuccess: `Removed ${prettyVolRef}` - }); - }; + } + + await withToastNoThrow( + () => + api.ethClientTargetSet({ + target: nextTarget, + sync: false, + ethRemoteRpc: newEthRemoteRpc, + deletePrevExecClient, + deletePrevExecClientVolumes, + deletePrevConsClient, + deletePrevConsClientVolumes + }), + { + message: "Changing Ethereum client...", + onSuccess: `Changed Ethereum client` + } + ); +}; + +export const passwordChangeInBackground = (newPassword: string): AppThunk => async () => { + await api.passwordChange({ newPassword }).catch(console.error); +}; + +export const passwordChange = (newPassword: string): AppThunk => async () => { + // Display a dialog to confirm the password change + await new Promise<void>((resolve) => + confirm({ + title: `Changing host user password`, + text: `Make sure to safely store this password and keep a back up. \n\nYou will never be able to see this password again. If you lose it, you will not be able to recover it in any way.`, + label: "Change", + variant: "dappnode", + onClick: resolve + }) + ); + + await withToastNoThrow(() => api.passwordChange({ newPassword }), { + message: `Changing host user password...`, + onSuccess: `Changed host user password` + }); +}; + +export const volumeRemove = (name: string): AppThunk => async () => { + // Display a dialog to confirm the password change + await new Promise<void>((resolve) => + confirm({ + title: `Removing volume`, + text: `Are you sure you want to permanently remove volume ${name}?`, + label: "Remove", + variant: "danger", + onClick: resolve + }) + ); + + await withToastNoThrow(() => api.volumeRemove({ name }), { + message: `Removing volume...`, + onSuccess: `Removed volume` + }); +}; + +export const packageVolumeRemove = (dnpName: string, volName: string): AppThunk => async () => { + // Make sure there are no colliding volumes with this DNP + const prettyVolName = prettyVolumeName(volName, dnpName).name; + const prettyVolRef = `${prettyDnpName(dnpName)} ${prettyVolName} volume`; + + const warningsList: { title: string; body: string }[] = []; + + // If there are NOT conflicting volumes, + // Display a dialog to confirm volumes reset + await new Promise<void>((resolve) => + confirm({ + title: `Removing ${prettyVolRef}`, + text: `Are you sure you want to permanently remove this volume? This action cannot be undone. If this DAppNode Package is a blockchain node, it will lose all the chain data and start syncing from scratch.`, + list: warningsList, + label: "Remove", + onClick: resolve + }) + ); + + await withToastNoThrow(() => api.packageRestartVolumes({ dnpName, volumeId: volName }), { + message: `Removing ${prettyVolRef}...`, + onSuccess: `Removed ${prettyVolRef}` + }); +}; diff --git a/packages/admin-ui/src/pages/system/components/Notifications/index.tsx b/packages/admin-ui/src/pages/system/components/Notifications/index.tsx deleted file mode 100644 index c2f04f7e65..0000000000 --- a/packages/admin-ui/src/pages/system/components/Notifications/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from "react"; -import SubTitle from "components/SubTitle"; -import { TelegramNotifications } from "./Telegram"; -import EthicalMetrics from "./EthicalMetrics"; -import "./notifications.scss"; - -export function Notifications() { - return ( - <> - <SubTitle>Ethical metrics</SubTitle> - <EthicalMetrics /> - - <SubTitle>Telegram</SubTitle> - <TelegramNotifications /> - </> - ); -} diff --git a/packages/admin-ui/src/pages/system/components/Security/securityIssues/index.tsx b/packages/admin-ui/src/pages/system/components/Security/securityIssues/index.tsx index 18237e93b5..297bbe4251 100644 --- a/packages/admin-ui/src/pages/system/components/Security/securityIssues/index.tsx +++ b/packages/admin-ui/src/pages/system/components/Security/securityIssues/index.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { useSelector } from "react-redux"; // Components import SubTitle from "components/SubTitle"; import Card from "components/Card"; @@ -8,10 +7,9 @@ import SeverityBadge, { SeverityLevel } from "./SeverityBadge"; import ChangeHostUserPassword from "./ChangeHostUserPassword"; import ChangeWifiPassword from "./ChangeWifiPassword"; import Ok from "components/Ok"; -// External -import { getPasswordIsSecure, getWifiStatus } from "services/dappnodeStatus/selectors"; // Style import "./securityIssues.scss"; +import { useApi } from "api"; interface SecurityIssue { name: string; @@ -22,23 +20,23 @@ interface SecurityIssue { } export default function SecurityIssues() { - const passwordIsSecure = useSelector(getPasswordIsSecure); - const wifiStatus = useSelector(getWifiStatus); + const passwordIsSecureReq = useApi.passwordIsSecure(); + const wifiReportReq = useApi.wifiReportGet(); const securityIssues: SecurityIssue[] = [ { name: "Change host user password", severity: "critical", component: ChangeHostUserPassword, - isActive: passwordIsSecure === false, + isActive: passwordIsSecureReq.data === false, okMessage: "Host user password changed" }, { name: "Change WIFI default password", severity: "critical", component: ChangeWifiPassword, - isActive: Boolean(wifiStatus?.isDefaultPassphrase && wifiStatus?.isRunning), - okMessage: wifiStatus?.isRunning ? "WIFI credentials changed" : "WIFI is disabled" + isActive: Boolean(wifiReportReq.data && wifiReportReq.data.isDefaultPassphrase && wifiReportReq.data.isRunning), + okMessage: wifiReportReq.data?.isRunning ? "WIFI credentials changed" : "WIFI is disabled" } ]; diff --git a/packages/admin-ui/src/pages/system/components/SystemRoot.tsx b/packages/admin-ui/src/pages/system/components/SystemRoot.tsx index 2d8034fee6..de298bcf5a 100644 --- a/packages/admin-ui/src/pages/system/components/SystemRoot.tsx +++ b/packages/admin-ui/src/pages/system/components/SystemRoot.tsx @@ -12,7 +12,6 @@ import SystemInfo from "./SystemInfo"; import Profile from "./Profile"; import { Network } from "./Network"; import { Advanced } from "./Advanced"; -import { Notifications } from "./Notifications"; import Hardware from "./Hardware"; const SystemRoot: React.FC = () => { @@ -48,12 +47,6 @@ const SystemRoot: React.FC = () => { subPath: subPaths.power, component: PowerManagment }, - { - name: "Notifications", - subLink: subPaths.notifications, - subPath: subPaths.notifications, - component: Notifications - }, { name: "Network", subLink: subPaths.network, diff --git a/packages/admin-ui/src/pages/system/data.ts b/packages/admin-ui/src/pages/system/data.ts index 293e833887..dab94feebb 100644 --- a/packages/admin-ui/src/pages/system/data.ts +++ b/packages/admin-ui/src/pages/system/data.ts @@ -20,7 +20,6 @@ export const subPaths = { peers: "add-ipfs-peer", power: "power", profile: "profile", - notifications: "notifications", advanced: "advanced", hardware: "hardware" }; diff --git a/packages/admin-ui/src/params.ts b/packages/admin-ui/src/params.ts index dcf623071e..d483e1b7d8 100755 --- a/packages/admin-ui/src/params.ts +++ b/packages/admin-ui/src/params.ts @@ -51,6 +51,7 @@ export const ipfsDnpName = "ipfs.dnp.dappnode.eth"; export const coreDnpName = "core.dnp.dappnode.eth"; export const bindDnpName = "bind.dnp.dappnode.eth"; export const vpnDnpName = "vpn.dnp.dappnode.eth"; +export const notificationsDnpName = "notifications.dnp.dappnode.eth"; export const dappmanagerDnpName = "dappmanager.dnp.dappnode.eth"; export const mandatoryCoreDnps = [ dappmanagerDnpName, @@ -95,7 +96,8 @@ export const docsUrl = { ipfsPeersExplanation: `${docsBaseUrl}`, // TODO: Add link to IPFS page in docs when it's ready stakers: `${docsBaseUrl}/docs/user/staking/overview`, rollups: `${docsBaseUrl}/docs/user/rollups/overview`, - ethicalMetricsOverview: `${docsBaseUrl}/docs/user/ethical-metrics/overview` + ethicalMetricsOverview: `${docsBaseUrl}/docs/user/ethical-metrics/overview`, + notificationsOverview: `${docsBaseUrl}/docs/user/notifications/overview` }; export const forumUrl = { @@ -104,6 +106,13 @@ export const forumUrl = { expandFileSystemHowTo: "https://forum.dappnode.io/t/how-to-expand-your-dappnode-filesystem-space/1296" }; +export const dappmanagerAliases = ["dappmanager.dappnode", "my.dappnode", "dappnode.local"]; + +export const externalUrlProps = { + target: "_blank", + rel: "noopener noreferrer" +}; + export const troubleShootMountpointsGuideUrl = "https://docs.dappnode.io/developers/package-dev/wizard#target"; export const dappnodeUserGuideUrl = "https://docs.dappnode.io/user/faq/general"; export const explorerGitcoinUrl = @@ -129,4 +138,4 @@ export const IPFS_GATEWAY_CHECKER = "https://ipfs.github.io/public-gateway-check // VPN export const MAIN_ADMIN_NAME = "dappnode_admin"; -// Support, where to send issues +// Support, where to send issues \ No newline at end of file diff --git a/packages/admin-ui/src/rootReducer.ts b/packages/admin-ui/src/rootReducer.ts index e7187c958e..e31815df89 100644 --- a/packages/admin-ui/src/rootReducer.ts +++ b/packages/admin-ui/src/rootReducer.ts @@ -7,7 +7,6 @@ import { reducer as dnpDirectory } from "services/dnpDirectory/reducer"; import { reducer as dnpRegistry } from "services/dnpRegistry/reducer"; import { reducer as dnpInstalled } from "services/dnpInstalled/reducer"; import { reducer as isInstallingLogs } from "services/isInstallingLogs/reducer"; -import { reducer as notifications } from "services/notifications/reducer"; export const rootReducer = combineReducers({ coreUpdate, @@ -16,7 +15,6 @@ export const rootReducer = combineReducers({ dnpRegistry, dnpInstalled, isInstallingLogs, - notifications }); export type RootState = ReturnType<typeof rootReducer>; diff --git a/packages/admin-ui/src/services/coreUpdate/selectors.ts b/packages/admin-ui/src/services/coreUpdate/selectors.ts index 5f49afdeca..c0061fb0e9 100644 --- a/packages/admin-ui/src/services/coreUpdate/selectors.ts +++ b/packages/admin-ui/src/services/coreUpdate/selectors.ts @@ -10,8 +10,3 @@ export const getCoreUpdateAvailable = (state: RootState): boolean => { const coreUpdateData = getCoreUpdateData(state); return coreUpdateData !== null && coreUpdateData.available; }; - -export const getIsCoreUpdateTypePatch = (state: RootState): boolean => { - const coreUpdateData = getCoreUpdateData(state); - return coreUpdateData !== null && coreUpdateData.available && coreUpdateData.type === "patch"; -}; diff --git a/packages/admin-ui/src/services/dappnodeStatus/actions.ts b/packages/admin-ui/src/services/dappnodeStatus/actions.ts index 468541a18c..d899bd9d26 100644 --- a/packages/admin-ui/src/services/dappnodeStatus/actions.ts +++ b/packages/admin-ui/src/services/dappnodeStatus/actions.ts @@ -1,18 +1,13 @@ import { api } from "api"; import { dappnodeStatus } from "./reducer"; import { AppThunk } from "store"; -import { wifiDnpName, wifiEnvWPA_PASSPHRASE, wifiEnvSSID, wifiDefaultWPA_PASSPHRASE } from "params"; // Service > dappnodeStatus // Update -export const setIsConnectedToInternet = dappnodeStatus.actions.isConnectedToInternet; export const setSystemInfo = dappnodeStatus.actions.systemInfo; export const updateVolumes = dappnodeStatus.actions.volumes; -export const setRebootHostIsRequired = dappnodeStatus.actions.rebootRequiredScript; -const updateWifiCredentials = dappnodeStatus.actions.wifiCredentials; -const updatePasswordIsSecure = dappnodeStatus.actions.passwordIsSecure; const updateShouldShowSmooth = dappnodeStatus.actions.shouldShowSmooth; // Fetch @@ -22,21 +17,6 @@ export const fetchShouldShowSmooth = (): AppThunk => async (dispatch) => dispatch(updateShouldShowSmooth(await api.getShouldShowSmooth())); }, "getShouldShowSmooth"); -export const fetchIsConnectedToInternet = (): AppThunk => async (dispatch) => - withTryCatch(async () => { - dispatch(setIsConnectedToInternet(await api.getIsConnectedToInternet())); - }, "getIsConnectedToInternet"); - -export const fetchRebootIsRequired = (): AppThunk => async (dispatch) => - withTryCatch(async () => { - dispatch(setRebootHostIsRequired(await api.rebootHostIsRequiredGet())); - }, "rebootHostIsRequiredGet"); - -export const fetchPasswordIsSecure = (): AppThunk => async (dispatch) => - withTryCatch(async () => { - dispatch(updatePasswordIsSecure(await api.passwordIsSecure())); - }, "passwordIsSecure"); - export const fetchVolumes = (): AppThunk => async (dispatch) => withTryCatch(async () => { dispatch(updateVolumes(await api.volumesGet())); @@ -47,21 +27,6 @@ export const fetchSystemInfo = (): AppThunk => async (dispatch) => dispatch(setSystemInfo(await api.systemInfoGet())); }, "systemInfoGet"); -/** - * Check if the wifi DNP has the same credentials as the default ones - * @returns credentials are the same as the default ones - */ -export const fetchWifiCredentials = (): AppThunk => async (dispatch) => - withTryCatch(async () => { - const wifiDnp = await api.packageGet({ dnpName: wifiDnpName }); - const environment = (wifiDnp.userSettings?.environment || {})[wifiDnpName] || {}; - const ssid: string = environment[wifiEnvSSID]; - const pass: string = environment[wifiEnvWPA_PASSPHRASE]; - const isDefaultPassphrase = pass === wifiDefaultWPA_PASSPHRASE; - - dispatch(updateWifiCredentials({ ssid, isDefaultPassphrase })); - }, "wifiStatus"); - /** * Util to guard against throws in thunk actions */ diff --git a/packages/admin-ui/src/services/dappnodeStatus/reducer.ts b/packages/admin-ui/src/services/dappnodeStatus/reducer.ts index 072d6404f1..e6e5105bf1 100644 --- a/packages/admin-ui/src/services/dappnodeStatus/reducer.ts +++ b/packages/admin-ui/src/services/dappnodeStatus/reducer.ts @@ -1,5 +1,5 @@ import { mapValues } from "lodash-es"; -import { RebootRequiredScript, SystemInfo, VolumeData } from "@dappnode/types"; +import { SystemInfo, VolumeData } from "@dappnode/types"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { WifiCredentials } from "types"; @@ -12,21 +12,15 @@ export interface DappnodeStatusState { * Will trigger alerts when it's a boolean and false, x === false * Must be null at start */ - passwordIsSecure: boolean | null; volumes: VolumeData[]; - rebootRequiredScript: RebootRequiredScript | null; shouldShowSmooth: boolean | null; - isConnectedToInternet: boolean | null; } const initialState: DappnodeStatusState = { systemInfo: null, wifiCredentials: null, - passwordIsSecure: null, volumes: [], - rebootRequiredScript: null, - shouldShowSmooth: null, - isConnectedToInternet: null + shouldShowSmooth: null }; export const dappnodeStatus = createSlice({ diff --git a/packages/admin-ui/src/services/dappnodeStatus/selectors.ts b/packages/admin-ui/src/services/dappnodeStatus/selectors.ts index 19dfa9fb33..9abdabb43b 100644 --- a/packages/admin-ui/src/services/dappnodeStatus/selectors.ts +++ b/packages/admin-ui/src/services/dappnodeStatus/selectors.ts @@ -10,11 +10,8 @@ import { wifiDnpName } from "params"; // Sub-local properties const getSystemInfo = (state: RootState) => state.dappnodeStatus.systemInfo; export const getDappnodeParams = (state: RootState) => getSystemInfo(state); -export const getPasswordIsSecure = (state: RootState) => state.dappnodeStatus.passwordIsSecure; -export const getRebootIsRequired = (state: RootState) => state.dappnodeStatus.rebootRequiredScript; export const getVolumes = (state: RootState) => state.dappnodeStatus.volumes; export const getShouldShowSmooth = (state: RootState) => state.dappnodeStatus.shouldShowSmooth; -export const getIsConnectedToInternet = (state: RootState) => state.dappnodeStatus.isConnectedToInternet; // Sub-sub local properties export const getEthRemoteRpc = (state: RootState) => (getSystemInfo(state) || {}).ethRemoteRpc; diff --git a/packages/admin-ui/src/services/notifications/actions.ts b/packages/admin-ui/src/services/notifications/actions.ts deleted file mode 100644 index 19dc159b95..0000000000 --- a/packages/admin-ui/src/services/notifications/actions.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { api } from "api"; -import { AppThunk } from "store"; -import { notificationsSlice } from "./reducer"; -import { getNotifications } from "./selectors"; - -// Service > notifications - -/** - * Using a `kwargs` form to make the `fromDappmanager` argument explicit - */ -export const pushNotification = notificationsSlice.actions.pushNotification; - -export const viewedNotifications = (): AppThunk => async (dispatch, getState) => { - // Mark notifications as viewed immediately - dispatch(notificationsSlice.actions.viewedNotifications()); - - // Load notifications - const notifications = getNotifications(getState()); - // Check the ones that came from the dappmanager - const ids = Object.values(notifications).map((notification) => notification.id); - if (ids.length) { - // Send the ids to the dappmanager - await api.notificationsRemove({ ids }); - } -}; - -export const fetchNotifications = (): AppThunk => async (dispatch) => { - try { - const notifications = await api.notificationsGet(); - for (const notification of notifications) dispatch(pushNotification(notification)); - } catch (e) { - console.error("Error on notificationsGet", e); - } -}; diff --git a/packages/admin-ui/src/services/notifications/reducer.ts b/packages/admin-ui/src/services/notifications/reducer.ts deleted file mode 100644 index 04083c9ed3..0000000000 --- a/packages/admin-ui/src/services/notifications/reducer.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { mapValues } from "lodash-es"; -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { PackageNotificationDb, PackageNotification } from "@dappnode/types"; - -export const notificationsSlice = createSlice({ - name: "notifications", - initialState: {} as { - [notificationId: string]: PackageNotificationDb; - }, - reducers: { - viewedNotifications: (state) => mapValues(state, (n) => ({ ...n, viewed: true })), - - pushNotification: (state, action: PayloadAction<PackageNotificationDb | PackageNotification>) => ({ - ...state, - [action.payload.id]: { - timestamp: Date.now(), - viewed: false, - ...action.payload - } - }) - } -}); - -export const reducer = notificationsSlice.reducer; diff --git a/packages/admin-ui/src/services/notifications/selectors.ts b/packages/admin-ui/src/services/notifications/selectors.ts deleted file mode 100644 index eb643d1758..0000000000 --- a/packages/admin-ui/src/services/notifications/selectors.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { RootState } from "rootReducer"; - -// Service > notifications - -export const getNotifications = (state: RootState) => Object.values(state.notifications); diff --git a/packages/daemons/package.json b/packages/daemons/package.json index 92c1014c93..d4d5870d6d 100644 --- a/packages/daemons/package.json +++ b/packages/daemons/package.json @@ -27,6 +27,7 @@ "@dappnode/hostscriptsservices": "workspace:^0.1.0", "@dappnode/installer": "workspace:^0.1.0", "@dappnode/logger": "workspace:^0.1.0", + "@dappnode/notifications": "workspace:^0.1.0", "@dappnode/params": "workspace:^0.1.0", "@dappnode/types": "workspace:^0.1.0", "@dappnode/upnpc": "workspace:^0.1.0", diff --git a/packages/daemons/src/autoUpdates/formatNotificationBody.ts b/packages/daemons/src/autoUpdates/formatNotificationBody.ts index cf2dc3031e..39bef68f98 100644 --- a/packages/daemons/src/autoUpdates/formatNotificationBody.ts +++ b/packages/daemons/src/autoUpdates/formatNotificationBody.ts @@ -1,52 +1,35 @@ import { CoreUpdateDataAvailable } from "@dappnode/types"; -import { urlJoin, prettyDnpName } from "@dappnode/utils"; - -const adminUiUpdateCoreUrl = "http://my.dappnode/system/update"; -const adminUiInstallPackageUrl = "http://my.dappnode/installer"; +import { prettyDnpName } from "@dappnode/utils"; export function formatPackageUpdateNotification({ dnpName, currentVersion, newVersion, - upstreamVersion, - autoUpdatesEnabled + upstreamVersion }: { dnpName: string; currentVersion: string; newVersion: string; upstreamVersion?: string | string[]; - autoUpdatesEnabled: boolean; }): string { const prettyName = prettyDnpName(dnpName); - const installUrl = urlJoin(adminUiInstallPackageUrl, dnpName); return [ `New version ready to install for ${prettyName} (current version ${currentVersion})`, upstreamVersion ? ` - package version: ${newVersion}\n` + ` - upstream version: ${upstreamVersion}` - : ` - version: ${newVersion}`, - - `Connect to your DAppNode to install this new version [install / ${prettyName}](${installUrl}).`, - autoUpdatesEnabled - ? `You may also wait for auto-updates to automatically install this version for you` - : `You can also enable auto-updates so packages are updated automatically by responding with the command: \n\n /enable_auto_updates` + : ` - version: ${newVersion}` ].join("\n\n"); } export function formatSystemUpdateNotification({ - packages, - autoUpdatesEnabled + packages }: { packages: CoreUpdateDataAvailable["packages"]; autoUpdatesEnabled: boolean; }): string { return [ "New system version ready to install", - packages.map((p) => ` - ${prettyDnpName(p.name)}: ${p.to} ${p.from ? `(current: ${p.from})` : ""}`), - - `Connect to your DAppNode to install this [system / update](${adminUiUpdateCoreUrl}).`, - autoUpdatesEnabled - ? `You may also wait for auto-updates to automatically install this version for you` - : `You can also enable auto-updates so packages are updated automatically by responding with the command: \n\n /enable_auto_updates` + packages.map((p) => ` - ${prettyDnpName(p.name)}: ${p.to} ${p.from ? `(current: ${p.from})` : ""}`) ].join("\n\n"); } diff --git a/packages/daemons/src/autoUpdates/index.ts b/packages/daemons/src/autoUpdates/index.ts index 7d0c05a217..81f8e0a13f 100644 --- a/packages/daemons/src/autoUpdates/index.ts +++ b/packages/daemons/src/autoUpdates/index.ts @@ -1,19 +1,12 @@ export { startAutoUpdatesDaemon } from "./startAutoUpdatesDaemon.js"; export { clearCompletedCoreUpdatesIfAny } from "./clearCompletedCoreUpdatesIfAny.js"; export { clearPendingUpdates } from "./clearPendingUpdates.js"; -export { clearRegistry } from "./clearRegistry.js"; export { editCoreSetting } from "./editCoreSetting.js"; export { editDnpSetting } from "./editDnpSetting.js"; export { flagCompletedUpdate } from "./flagCompletedUpdate.js"; export { flagErrorUpdate } from "./flagErrorUpdate.js"; -export { formatPackageUpdateNotification } from "./formatNotificationBody.js"; export { isCoreUpdateEnabled } from "./isCoreUpdateEnabled.js"; export { isDnpUpdateEnabled } from "./isDnpUpdateEnabled.js"; export { isUpdateDelayCompleted, updateDelay } from "./isUpdateDelayCompleted.js"; export * from "./params.js"; -export { sendUpdatePackageNotificationMaybe } from "./sendUpdateNotification.js"; -export { setPending } from "./setPending.js"; -export { setSettings } from "./setSettings.js"; -export { checkNewPackagesVersion } from "./updateMyPackages.js"; -export { checkSystemPackagesVersion, autoUpdateSystemPackages } from "./updateSystemPackages.js"; export { getCoreUpdateData } from "./getCoreUpdateData.js"; diff --git a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts index b43469a7dc..048853b654 100644 --- a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts +++ b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts @@ -1,13 +1,13 @@ import { valid, lte } from "semver"; import { params } from "@dappnode/params"; import * as db from "@dappnode/db"; -import { eventBus } from "@dappnode/eventbus"; import { DappnodeInstaller } from "@dappnode/installer"; -import { prettyDnpName } from "@dappnode/utils"; -import { CoreUpdateDataAvailable, upstreamVersionToString } from "@dappnode/types"; +import { prettyDnpName, urlJoin } from "@dappnode/utils"; +import { CoreUpdateDataAvailable, Category, Priority, upstreamVersionToString, Status } from "@dappnode/types"; import { formatPackageUpdateNotification, formatSystemUpdateNotification } from "./formatNotificationBody.js"; import { isCoreUpdateEnabled } from "./isCoreUpdateEnabled.js"; -import { isDnpUpdateEnabled } from "./isDnpUpdateEnabled.js"; +import { notifications } from "@dappnode/notifications"; +import { logs } from "@dappnode/logger"; export async function sendUpdatePackageNotificationMaybe({ dappnodeInstaller, @@ -20,6 +20,13 @@ export async function sendUpdatePackageNotificationMaybe({ currentVersion: string; newVersion: string; }): Promise<void> { + // Check if auto-update notifications are enabled + const dappmanagerCustomEndpoint = notifications + .getEndpointsIfExists(params.dappmanagerDnpName, true) + ?.customEndpoints?.find((customEndpoint) => customEndpoint.correlationId === "dappmanager-update-pkg"); + + if (!dappmanagerCustomEndpoint || !dappmanagerCustomEndpoint.enabled) return; + // If version has already been emitted, skip const lastEmittedVersion = db.notificationLastEmitVersion.get(dnpName); if (lastEmittedVersion && valid(lastEmittedVersion) && lte(newVersion, lastEmittedVersion)) return; // Already emitted update available for this version @@ -31,19 +38,31 @@ export async function sendUpdatePackageNotificationMaybe({ upstream: release.manifest.upstream }); - // Emit notification about new version available - eventBus.notification.emit({ - id: `update-available-${dnpName}-${newVersion}`, - type: "info", - title: `Update available for ${prettyDnpName(dnpName)}`, - body: formatPackageUpdateNotification({ - dnpName: dnpName, - newVersion, - upstreamVersion, - currentVersion, - autoUpdatesEnabled: isDnpUpdateEnabled(dnpName) + const adminUiInstallPackageUrl = "http://my.dappnode/installer/dnp"; + + // Send notification about new version available + await notifications + .sendNotification({ + title: `Update available for ${prettyDnpName(dnpName)}`, + dnpName, + body: formatPackageUpdateNotification({ + dnpName, + currentVersion, + newVersion, + upstreamVersion + }), + category: Category.system, + priority: Priority.low, + status: Status.triggered, + callToAction: { + title: "Update", + url: urlJoin(adminUiInstallPackageUrl, dnpName) + }, + isBanner: false, + isRemote: false, + correlationId: "dappmanager-update-pkg" }) - }); + .catch((e) => logs.error("Error sending package update notification", e)); // Register version to prevent sending notification again db.packageLatestKnownVersion.set(dnpName, { newVersion, upstreamVersion }); @@ -54,20 +73,33 @@ export async function sendUpdateSystemNotificationMaybe(data: CoreUpdateDataAvai const newVersion = data.coreVersion; const dnpName = params.coreDnpName; + const adminUiUpdateCoreUrl = "http://my.dappnode/system/update"; + // If version has already been emitted, skip const lastEmittedVersion = db.notificationLastEmitVersion.get(dnpName); if (lastEmittedVersion && valid(lastEmittedVersion) && lte(newVersion, lastEmittedVersion)) return; // Already emitted update available for this version - // Emit notification about new version available - eventBus.notification.emit({ - id: `update-available-${dnpName}-${newVersion}`, - type: "info", - title: "System update available", - body: formatSystemUpdateNotification({ - packages: data.packages, - autoUpdatesEnabled: isCoreUpdateEnabled() + // Send notification about new version available + await notifications + .sendNotification({ + title: `System update available`, + dnpName, + body: formatSystemUpdateNotification({ + packages: data.packages, + autoUpdatesEnabled: isCoreUpdateEnabled() + }), + category: Category.system, + priority: Priority.high, + status: Status.triggered, + callToAction: { + title: "Update", + url: adminUiUpdateCoreUrl + }, + isBanner: true, + isRemote: false, + correlationId: "dappmanager-update-systemPkg" }) - }); + .catch((e) => logs.error("Error sending system update notification", e)); data.packages; diff --git a/packages/daemons/src/diskUsage/index.ts b/packages/daemons/src/diskUsage/index.ts index f38964ca27..e518d94e87 100644 --- a/packages/daemons/src/diskUsage/index.ts +++ b/packages/daemons/src/diskUsage/index.ts @@ -3,6 +3,8 @@ import { shell, runAtMostEvery, prettyDnpName } from "@dappnode/utils"; import { params } from "@dappnode/params"; import { eventBus } from "@dappnode/eventbus"; import { logs } from "@dappnode/logger"; +import { notifications } from "@dappnode/notifications"; +import { Category, Priority, Status } from "@dappnode/types"; /** * Commands @@ -14,13 +16,13 @@ import { logs } from "@dappnode/logger"; const thresholds = [ { - id: "dangerous level of 5 GB", + id: "5 GB", kb: 5 * 1e6, // ~ 5 GB filterCommand: `--filter "name=DAppNodePackage"`, containersDescription: "all non-core DAppNode packages" }, { - id: "critical level of 1 GB", + id: "1 GB", kb: 1 * 1e6, // ~ 1 GB filterCommand: `--filter "name=DAppNodePackage" --filter "name=DAppNodeCore-ipfs.dnp.dappnode.eth"`, containersDescription: "all non-core DAppNode packages and the IPFS package" @@ -93,17 +95,23 @@ async function monitorDiskUsage(): Promise<void> { `WARNING: DAppNode has stopped ${threshold.containersDescription} (${stoppedDnpNameList}) after the disk space reached a ${threshold.id}` ); - eventBus.notification.emit({ - id: "diskSpaceRanOut-stoppedPackages", - type: "danger", - title: `Disk space is running out, ${threshold.id.split(" ")[0]}`, - body: [ - `Available disk space is less than a ${threshold.id}.`, - `To prevent your DAppNode from becoming unusable ${threshold.containersDescription} where stopped.`, - stoppedDnpNames.map((dnpName) => ` - ${prettyDnpName(dnpName)}`).join("\n"), - `Please, free up enough disk space and start them again.` - ].join("\n\n") - }); + await notifications + .sendNotification({ + title: `Available disk space is less than a ${threshold.id}`, + dnpName: params.dappmanagerDnpName, + body: [ + `To prevent your DAppNode from becoming unusable ${threshold.containersDescription} where stopped.`, + stoppedDnpNames.map((dnpName) => ` - ${prettyDnpName(dnpName)}`).join("\n"), + `Please, free up enough disk space and start them again.` + ].join("\n\n"), + category: Category.hardware, + priority: Priority.critical, + status: Status.triggered, + isBanner: true, + isRemote: false, + correlationId : 'core-disk-usage', + }) + .catch((e) => logs.error("Error sending disk usage notification", e)); // Emit packages update eventBus.requestPackages.emit(); diff --git a/packages/daemons/src/hostReboot/index.ts b/packages/daemons/src/hostReboot/index.ts new file mode 100644 index 0000000000..2c6cf5fe22 --- /dev/null +++ b/packages/daemons/src/hostReboot/index.ts @@ -0,0 +1,76 @@ +import { logs } from "@dappnode/logger"; +import { runAtMostEvery } from "@dappnode/utils"; +import { notifications } from "@dappnode/notifications"; +import { Category, Priority, Status } from "@dappnode/types"; +import { getRebootRequiredMemoized } from "@dappnode/hostscriptsservices"; +import { params } from "@dappnode/params"; + +const CHECK_INTERVAL = 7 * 24 * 60 * 60 * 1000; // 7 days + +let notificationSent = false; + +/** + * Monitors if the host requires a reboot. + * Sends a notification if a reboot is required. + * Sends a resolve notification once the reboot is no longer required. + */ +async function monitorHostReboot(): Promise<void> { + try { + const rebootRequired = await getRebootRequiredMemoized(); + const correlationId = "core-reboot-required"; + + if (rebootRequired?.rebootRequired) { + logs.warn("Host reboot is required"); + + if (!notificationSent) { + await notifications + .sendNotification({ + title: "DAppNode host reboot required", + dnpName: params.dappmanagerDnpName, + body: `A reboot is required to install updates from some linux packages`, + callToAction: { + title: "Reboot", + url: "http://my.dappnode/system/power" + }, + category: Category.system, + priority: Priority.low, + status: Status.triggered, + isBanner: true, + isRemote: false, + correlationId + }) + .catch((e) => logs.error("Error sending host reboot notification", e)); + notificationSent = true; + } + } else { + if (notificationSent) { + logs.info("Host reboot no longer required, sending resolve notification"); + + await notifications + .sendNotification({ + title: "Dappnode host reboot was successful", + dnpName: params.dappmanagerDnpName, + body: `All packages have been installed successfully.`, + category: Category.system, + priority: Priority.low, + status: Status.resolved, + isBanner: false, + isRemote: false, + correlationId + }) + .catch((e) => logs.error("Error sending host reboot resolve notification", e)); + notificationSent = false; + } + } + } catch (e) { + logs.error("Error monitoring host reboot requirement", e); + } +} + +/** + * Host reboot daemon. + * Periodically checks if the host requires a reboot. + */ +export function startHostRebootDaemon(signal: AbortSignal): void { + runAtMostEvery(monitorHostReboot, CHECK_INTERVAL, signal); +} diff --git a/packages/daemons/src/index.ts b/packages/daemons/src/index.ts index fa6883a6f3..d27e0df590 100644 --- a/packages/daemons/src/index.ts +++ b/packages/daemons/src/index.ts @@ -8,11 +8,17 @@ import { startNatRenewalDaemon } from "./natRenewal/index.js"; import { startStakerDaemon } from "./stakerConfig/index.js"; import { startTelegramBotDaemon } from "./telegramBot/index.js"; import { startBindDaemon } from "./bind/index.js"; -import { startTemperatureDaemon } from "./temperature/index.js"; +import { startInternetConnectionDaemon } from "./internetConnection/index.js"; +import { startHostRebootDaemon } from "./hostReboot/index.js"; +import { startRepositoryHealthDaemon } from "./repositoryHealth/index.js"; +import { setMaxListeners } from "events"; // Import setMaxListeners // DAEMONS EXPORT export function startDaemons(dappnodeInstaller: DappnodeInstaller, signal: AbortSignal): void { + // Increase the max listeners for AbortSignal. default is 10 + setMaxListeners(12, signal); + startAutoUpdatesDaemon(dappnodeInstaller, signal); startDiskUsageDaemon(signal); startDynDnsDaemon(signal); @@ -22,7 +28,9 @@ export function startDaemons(dappnodeInstaller: DappnodeInstaller, signal: Abort startStakerDaemon(dappnodeInstaller); startTelegramBotDaemon(); startBindDaemon(signal); - startTemperatureDaemon(signal); + startInternetConnectionDaemon(signal); + startHostRebootDaemon(signal); + startRepositoryHealthDaemon(signal); } export { startAvahiDaemon } from "./avahi/index.js"; diff --git a/packages/daemons/src/internetConnection/index.ts b/packages/daemons/src/internetConnection/index.ts new file mode 100644 index 0000000000..a6a5ba1458 --- /dev/null +++ b/packages/daemons/src/internetConnection/index.ts @@ -0,0 +1,108 @@ +import { logs } from "@dappnode/logger"; +import { runAtMostEvery } from "@dappnode/utils"; +import { notifications } from "@dappnode/notifications"; +import { Category, Priority, Status } from "@dappnode/types"; +import { params } from "@dappnode/params"; + +const CHECK_INTERVAL = 2 * 60 * 1000; // 2 minutes +let notificationSent = false; + +/** + * Checks whether the DAppNode is connected to the internet. + */ +async function getIsConnectedToInternet(): Promise<boolean> { + const urlsCheckList = [ + "https://1.1.1.1", // Cloudfare DNS + "https://8.8.8.8" // Google DNS + ]; + const timeoutMs = 3000; + + for (const url of urlsCheckList) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + const response = await fetch(url, { + method: "HEAD", + signal: controller.signal + }); + + clearTimeout(timeout); + + if (response.ok) { + return true; // Internet is reachable + } + } catch (error) { + logs.info(`Error while checking DAppNode internet connectivity: ${error}`); + continue; + } + } + return false; +} + +/** + * Monitors internet connectivity of the DAppNode. + * Sends a notification if the DAppNode is not connected to the internet. + * Sends a resolve notification once the connection is restored. + */ +async function monitorInternetConnection(): Promise<void> { + try { + const isConnected = await getIsConnectedToInternet(); + const correlationId = "core-internet-connection"; + + if (!isConnected) { + logs.warn("DAppNode is not connected to the internet"); + + if (!notificationSent) { + await notifications + .sendNotification({ + title: "Your Dappnode is not connected to internet", + dnpName: params.dappmanagerDnpName, + body: `Your DAppNode host machine is currently offline and cannot access the internet. This may disrupt the operation of your nodes and prevent updates or remote access. + Please check your network connection and ensure your router or modem is functioning properly.`, + category: Category.system, + priority: Priority.critical, + status: Status.triggered, + callToAction: { + title: "Diagnose", + url: "http://my.dappnode/support/auto-diagnose" + }, + isBanner: true, + isRemote: false, + correlationId + }) + .catch((e) => logs.error("Error sending internet connectivity notification", e)); + notificationSent = true; + } + } else { + if (notificationSent) { + logs.info("Internet connection restored, sending resolve notification"); + + await notifications + .sendNotification({ + title: "Your Dappnode is back online", + dnpName: params.dappmanagerDnpName, + body: `Your Dappnode connection is functioning properly`, + category: Category.system, + priority: Priority.critical, + status: Status.resolved, + isBanner: false, + isRemote: false, + correlationId + }) + .catch((e) => logs.error("Error sending internet connectivity resolve notification", e)); + notificationSent = false; + } + } + } catch (e) { + logs.error("Error monitoring internet connectivity", e); + } +} + +/** + * Internet connection daemon. + * Periodically checks if the DAppNode is connected to the internet. + */ +export function startInternetConnectionDaemon(signal: AbortSignal): void { + runAtMostEvery(monitorInternetConnection, CHECK_INTERVAL, signal); +} diff --git a/packages/daemons/src/repositoryHealth/index.ts b/packages/daemons/src/repositoryHealth/index.ts new file mode 100644 index 0000000000..ebf45a64b5 --- /dev/null +++ b/packages/daemons/src/repositoryHealth/index.ts @@ -0,0 +1,160 @@ +import { logs } from "@dappnode/logger"; +import { runAtMostEvery } from "@dappnode/utils"; +import { notifications } from "@dappnode/notifications"; +import { Category, Priority, Status } from "@dappnode/types"; +import * as db from "@dappnode/db"; +import { getEthUrl, getIpfsUrl } from "@dappnode/installer"; +import { params } from "@dappnode/params"; + +const CHECK_INTERVAL = 10 * 60 * 1000; // 10 minutes + +let ipfsFailureCount = 0; +let ipfsNotificationSent = false; +let ethFailureCount = 0; +let ethNotificationSent = false; + +async function checkIpfsHealth(): Promise<void> { + const ipfsClientTarget = db.ipfsClientTarget.get(); + const ipfsUrl = getIpfsUrl(); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + + const correlationId = "core-ipfs-check"; + + try { + const res = await fetch(`${ipfsUrl}/api/v0/version`, { + method: "GET", + signal: controller.signal + }); + + clearTimeout(timeout); + + if (!res.ok) throw new Error(`Status ${res.status}`); + + logs.info(`IPFS endpoint (${ipfsClientTarget}) at ${ipfsUrl} is healthy`); + + // reset failure count on success + ipfsFailureCount = 0; + if (ipfsNotificationSent) { + await notifications.sendNotification({ + title: "Your Dappnode IPFS endpoint is resolving content correctly", + dnpName: params.dappmanagerDnpName, + body: `Access to decentralized content has been restored and is functioning as expected.`, + category: Category.system, + priority: Priority.high, + status: Status.resolved, + isBanner: false, + isRemote: false, + correlationId + }); + ipfsNotificationSent = false; + } + } catch (error) { + clearTimeout(timeout); + logs.error(`IPFS endpoint (${ipfsClientTarget}) at ${ipfsUrl} is unhealthy: ${error}`); + + // increment failure count and send notification after threshold + ipfsFailureCount += 1; + if (ipfsFailureCount >= 3 && !ipfsNotificationSent) { + await notifications.sendNotification({ + title: "Your Dappnode IPFS endpoint is not resolving content correctly.", + dnpName: params.dappmanagerDnpName, + body: `Dappnode IPFS endpoint (${ipfsClientTarget}) at ${ipfsUrl} is currently unreachable or not resolving content correctly. This may affect access to decentralized content or applications relying on IPFS.`, + category: Category.system, + priority: Priority.high, + status: Status.triggered, + callToAction: { + title: `Switch to ${ipfsClientTarget && ipfsClientTarget === "local" ? "Remote" : "Local"} IPFS`, + url: "http://my.dappnode/repository/ipfs" + }, + isBanner: true, + isRemote: false, + correlationId + }); + ipfsNotificationSent = true; + } + } +} + +async function checkEthHealth(): Promise<void> { + const ethClientTarget = db.ethClientRemote.get(); + const ethUrl = await getEthUrl(); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + + const correlationId = "core-eth-check"; + + try { + const res = await fetch(ethUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "web3_clientVersion", + params: [], + id: 1 + }), + signal: controller.signal + }); + + clearTimeout(timeout); + + if (!res.ok) throw new Error(`Status ${res.status}`); + const data = await res.json(); + if (!data.result) throw new Error(`Invalid response: ${JSON.stringify(data)}`); + + logs.info(`Ethereum endpoint (${ethClientTarget}) at ${ethUrl} is healthy`); + + // reset failure count on success + ethFailureCount = 0; + if (ethNotificationSent) { + await notifications.sendNotification({ + title: "Ethereum Repository Accessible", + dnpName: params.dappmanagerDnpName, + body: `Your DAppNode has successfully reconnected to the Ethereum repository. +Syncing and access to Ethereum chain data should now resume normally.`, + category: Category.system, + priority: Priority.high, + status: Status.resolved, + isBanner: false, + isRemote: false, + correlationId + }); + ethNotificationSent = false; + } + } catch (error) { + clearTimeout(timeout); + logs.error(`Ethereum endpoint (${ethClientTarget}) at ${ethUrl} is unhealthy: ${error}`); + + // increment failure count and send notification after threshold + ethFailureCount += 1; + if (ethFailureCount >= 3 && !ethNotificationSent) { + await notifications.sendNotification({ + title: "Ethereum Repository Unreachable", + dnpName: params.dappmanagerDnpName, + body: `Your Dappnode is currently unable to connect to the Ethereum endpoint (${ethClientTarget}) at ${ethUrl}`, + category: Category.system, + priority: Priority.high, + status: Status.triggered, + callToAction: { + title: + ethClientTarget && ethClientTarget === "off" + ? "Change to Remote" + : "Make sure your Ethereum RPC is reachable", + url: "http://my.dappnode/repository/eth" + }, + isBanner: true, + isRemote: false, + correlationId + }); + ethNotificationSent = true; + } + } +} + +export function startRepositoryHealthDaemon(signal: AbortSignal): void { + runAtMostEvery(() => checkIpfsHealth(), CHECK_INTERVAL, signal); + runAtMostEvery(() => checkEthHealth(), CHECK_INTERVAL, signal); +} diff --git a/packages/daemons/src/temperature/index.ts b/packages/daemons/src/temperature/index.ts deleted file mode 100644 index 80da5c3332..0000000000 --- a/packages/daemons/src/temperature/index.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { eventBus } from "@dappnode/eventbus"; -import { params } from "@dappnode/params"; -import { runAtMostEvery } from "@dappnode/utils"; -import { PackageNotification } from "@dappnode/types"; -import si from "systeminformation"; - -interface TemperatureThreshold extends PackageNotification { - celsius: number; -} - -interface TemperatureRecord { - lastEmit: number; - count: number; -} - -const thresholds: TemperatureThreshold[] = [ - { - id: "cpuTemperature-warning", - type: "warning", - celsius: 95, - title: "CPU temperature is too high", - body: "The CPU temperature has consistently been at a warning level of 95ºC, you can ommit this waring if your dappnode is syncinga blockchain, its temperature should decrease once synced." - }, - { - id: "cpuTemperature-danger", - type: "danger", - celsius: 100, - title: "CPU temperature is too high", - body: "The CPU temperature is at a dangerous level of 100ºC. An unexpected shutdown might occur." - } -]; - -// Store temperature exceedances -const temperatureRecords: Record<string, TemperatureRecord> = { - "cpuTemperature-warning": { lastEmit: 0, count: 0 }, - "cpuTemperature-danger": { lastEmit: 0, count: 0 } -}; - -const HOUR_IN_MS = 3600000; // 60 minutes * 60 seconds * 1000 milliseconds - -/** - * Monitor CPU temperature and emit events based on specified conditions. - * - If the CPU temperature exceeds 105ºC, emit a danger notification immediately. - * - If the CPU temperature exceeds 95ºC, emit a warning notification if it has been at that level for 5 times within an hour. - */ -async function monitorCpuTemperature(): Promise<void> { - const cpuTemperature = await si.cpuTemperature(); - const now = Date.now(); - - for (const threshold of thresholds) { - const record = temperatureRecords[threshold.id]; - - // Check if the CPU temperature exceeds the threshold - if (cpuTemperature.main > threshold.celsius) { - if (threshold.type === "danger" && now - record.lastEmit > HOUR_IN_MS) { - // For danger notifications, emit at most once per hour - emitNotification(threshold); - record.lastEmit = now; - } else if (threshold.type === "warning") { - // Increment count if within an hour for warning - if (now - record.lastEmit <= HOUR_IN_MS) { - record.count += 1; - } else { - // Reset count and lastEmit if more than an hour has passed - record.count = 1; - record.lastEmit = now; - continue; - } - - // Emit warning notification at most once per hour when count reaches 5 - if (record.count >= 5 && now - record.lastEmit > HOUR_IN_MS) { - emitNotification(threshold); - // Reset count and update lastEmit to prevent multiple notifications within the same hour - record.count = 0; - record.lastEmit = now; - } - } - } - } -} - -function emitNotification(threshold: TemperatureThreshold): void { - eventBus.notification.emit({ - id: threshold.id, - type: threshold.type, - title: threshold.title, - body: threshold.body - }); -} - -/** - * Temperature daemon. - * Checks CPU temperature and emit events if it's too high. - */ -export function startTemperatureDaemon(signal: AbortSignal): void { - runAtMostEvery(async () => monitorCpuTemperature(), params.TEMPERATURE_DAEMON_INTERVAL, signal); -} diff --git a/packages/daemons/src/wifiPassword/index.ts b/packages/daemons/src/wifiPassword/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/dappmanager/package.json b/packages/dappmanager/package.json index 499cf5cb68..10f491a89b 100644 --- a/packages/dappmanager/package.json +++ b/packages/dappmanager/package.json @@ -32,6 +32,7 @@ "@dappnode/installer": "workspace:^0.1.0", "@dappnode/logger": "workspace:^0.1.0", "@dappnode/migrations": "workspace:^0.1.0", + "@dappnode/notifications": "workspace:^0.1.0", "@dappnode/optimism": "workspace:^0.1.0", "@dappnode/params": "workspace:^0.1.0", "@dappnode/stakers": "workspace:^0.1.0", diff --git a/packages/dappmanager/src/api/routes/index.ts b/packages/dappmanager/src/api/routes/index.ts index 9f9643a067..daf212f1a6 100644 --- a/packages/dappmanager/src/api/routes/index.ts +++ b/packages/dappmanager/src/api/routes/index.ts @@ -9,6 +9,5 @@ export * from "./publicPackagesData.js"; export * from "./sign.js"; export * from "./upload.js"; export * from "./downloadWireguardConfig.js"; -export * from "./notificationSend.js"; export * from "./metrics.js"; export * from "./env.js"; diff --git a/packages/dappmanager/src/api/routes/notificationSend.ts b/packages/dappmanager/src/api/routes/notificationSend.ts deleted file mode 100644 index ae38c3e5ed..0000000000 --- a/packages/dappmanager/src/api/routes/notificationSend.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { getDnpFromIp } from "./sign.js"; -import { eventBus } from "@dappnode/eventbus"; -import { HttpError, wrapHandler } from "../utils.js"; -import { NotificationType } from "@dappnode/types"; - -/** - * Receive arbitrary notifications from packages to be shown in the UI - */ -export const notificationSend = wrapHandler(async (req, res) => { - const type = req.query.type; - const title = req.query.title; // "Some notification" - const body = req.query.body; // "Some text about notification" - - try { - if (typeof type === "undefined") throw Error("missing"); - if (typeof type !== "string") throw Error("must be a string"); - if (!["danger", "warning", "success", "info"].includes(type)) - throw Error("must be danger, warning, success or info"); - } catch (e) { - throw new HttpError({ statusCode: 400, name: `Arg type ${e.message}` }); - } - - try { - if (typeof title === "undefined") throw Error("missing"); - if (typeof title !== "string") throw Error("must be a string"); - if (!title) throw Error("must not be empty"); - } catch (e) { - throw new HttpError({ statusCode: 400, name: `Arg title ${e.message}` }); - } - - try { - if (typeof body === "undefined") throw Error("missing"); - if (typeof body !== "string") throw Error("must be a string"); - if (!body) throw Error("must not be empty"); - } catch (e) { - throw new HttpError({ statusCode: 400, name: `Arg body ${e.message}` }); - } - - const { dnpName } = await getDnpFromIp(req.ip); - - eventBus.notification.emit({ - id: `notification-${dnpName}`, - type: type as NotificationType, // TODO: fix this type cast by using enum instead - title, - body - }); - - return res.status(200).send(); -}); diff --git a/packages/dappmanager/src/api/routes/packageManifest.ts b/packages/dappmanager/src/api/routes/packageManifest.ts index d8b38b9d59..2aba01b541 100644 --- a/packages/dappmanager/src/api/routes/packageManifest.ts +++ b/packages/dappmanager/src/api/routes/packageManifest.ts @@ -1,7 +1,7 @@ import { pick } from "lodash-es"; -import { listPackage } from "@dappnode/dockerapi"; import { readManifestIfExists } from "@dappnode/utils"; import { wrapHandler } from "../utils.js"; +import { listPackage } from "@dappnode/dockerapi"; interface Params { dnpName: string; } @@ -13,11 +13,15 @@ export const packageManifest = wrapHandler<Params>(async (req, res) => { const { dnpName } = req.params; if (!dnpName) throw Error(`Must provide containerName`); - const dnp = await listPackage({ dnpName }); - const manifest = readManifestIfExists(dnp); - if (!manifest) { - return res.status(404).send("Manifest not found"); - } + const manifest = readManifestIfExists(dnpName); + if (!manifest) return res.status(404).send("Manifest not found"); + + // This is a temporary fix to get the avatarUrl from the package list + // Intaller now sets avatarUrl in the manifest. See `dappnodeInstaller` > `joinFilesInManifest` + // TODO: This setter should be removed once users have updated their packages + if(!manifest.avatarUrl) + manifest.avatarUrl = (await listPackage({dnpName})).avatarUrl + // Filter manifest manually to not send new private properties const filteredManifest = pick(manifest, [ @@ -54,7 +58,9 @@ export const packageManifest = wrapHandler<Params>(async (req, res) => { "links", "repository", "bugs", - "license" + "license", + "notifications", + "avatarUrl" ]); res.status(200).send(filteredManifest); diff --git a/packages/dappmanager/src/api/startHttpApi.ts b/packages/dappmanager/src/api/startHttpApi.ts index 130ee0c9b8..3c3c78db8d 100644 --- a/packages/dappmanager/src/api/startHttpApi.ts +++ b/packages/dappmanager/src/api/startHttpApi.ts @@ -34,7 +34,6 @@ export interface HttpRoutes { env: RequestHandler<{ dnpName: string; envName: string }>; fileDownload: RequestHandler<{ containerName: string }>; globalEnvs: RequestHandler<{ name: string }>; - notificationSend: RequestHandler; packageManifest: RequestHandler<{ dnpName: string }>; metrics: RequestHandler; publicPackagesData: RequestHandler<{ containerName: string }>; @@ -165,7 +164,6 @@ export function startHttpApi({ app.get("/metrics", routes.metrics); app.post("/sign", routes.sign); app.post("/data-send", routes.dataSend); - app.post("/notification-send", routes.notificationSend); // Rest of RPC methods // prettier-ignore diff --git a/packages/dappmanager/src/calls/fetchDnpRequest.ts b/packages/dappmanager/src/calls/fetchDnpRequest.ts index d0f5a37365..486aca923e 100644 --- a/packages/dappmanager/src/calls/fetchDnpRequest.ts +++ b/packages/dappmanager/src/calls/fetchDnpRequest.ts @@ -14,10 +14,12 @@ import { PackageRelease, CompatibleDnps, InstalledPackageData, - ReleaseSignatureStatusCode + ReleaseSignatureStatusCode, + NotificationsSettingsAllDnps } from "@dappnode/types"; import { Manifest, SetupWizardField } from "@dappnode/types"; import { logs } from "@dappnode/logger"; +import { notifications } from "@dappnode/notifications"; export async function fetchDnpRequest({ id, version }: { id: string; version?: string }): Promise<RequestedDnp> { const mainRelease = await dappnodeInstaller.getRelease(id, version); @@ -25,6 +27,7 @@ export async function fetchDnpRequest({ id, version }: { id: string; version?: s const settings: UserSettingsAllDnps = {}; const specialPermissions: SpecialPermissionAllDnps = {}; const setupWizard: SetupWizardAllDnps = {}; + const notificationsSettings: NotificationsSettingsAllDnps = {}; const signedSafe: RequestedDnp["signedSafe"] = {}; const dnpList = await listPackages(); @@ -38,6 +41,9 @@ export async function fetchDnpRequest({ id, version }: { id: string; version?: s const prevUserSet = ComposeFileEditor.getUserSettingsIfExist(dnpName, isCore); settings[dnpName] = deepmerge(defaultUserSet, prevUserSet); + if (release.notifications) + notificationsSettings[dnpName] = notifications.applyPreviousEndpoints(dnpName, isCore, release.notifications); + specialPermissions[dnpName] = parseSpecialPermissions(compose, isCore); if (release.setupWizard) { @@ -101,6 +107,7 @@ export async function fetchDnpRequest({ id, version }: { id: string; version?: s manifest: omit(mainRelease.manifest, ["setupWizard"]), specialPermissions, // Decoupled metadata // Settings must include the previous user settings + notificationsSettings, settings, compatible: { // Compute version metadata diff --git a/packages/dappmanager/src/calls/getIsConnectedToInternet.ts b/packages/dappmanager/src/calls/getIsConnectedToInternet.ts deleted file mode 100644 index caee21b212..0000000000 --- a/packages/dappmanager/src/calls/getIsConnectedToInternet.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { logs } from "@dappnode/logger"; -import { getPublicIpFromUrls } from "@dappnode/utils"; - -/** - * Check whether or not if the dappnode si connected to the internet by fethcing server to get its public ip, - * will retry 6 times with 3 secons delay - * TODO: Check what happens if the notification of dappnode connected to the internet has eben send and the user eventually recovered internet connection - * @returns Whether or not if the dappnode is connected to internet - */ -export async function getIsConnectedToInternet(): Promise<boolean> { - try { - await getPublicIpFromUrls({ - timeout: 3 * 1000, - retries: 6 - }); - return true; - } catch (error) { - logs.error(`Error while cheching dappnode Internt connectivity: ${error}`); - return false; - } -} diff --git a/packages/dappmanager/src/calls/index.ts b/packages/dappmanager/src/calls/index.ts index a61851ced7..fa227ad763 100644 --- a/packages/dappmanager/src/calls/index.ts +++ b/packages/dappmanager/src/calls/index.ts @@ -21,7 +21,16 @@ export { fetchRegistry } from "./fetchRegistry.js"; export { getCoreVersion } from "./getCoreVersion.js"; export { getUserActionLogs } from "./getUserActionLogs.js"; export { getHostUptime } from "./getHostUptime.js"; -export { getIsConnectedToInternet } from "./getIsConnectedToInternet.js"; +export { + notificationsGetAllEndpoints, + notificationsGetBanner, + notificationsUpdateEndpoints, + notificationsGetAll, + notificationsApplyPreviousEndpoints, + notificationsGetUnseenCount, + notificationsSetAllSeen, + notificationSetSeenByCorrelationID +} from "./notifications.js"; export * from "./httpsPortal.js"; export { ipfsTest } from "./ipfsTest.js"; export { ipfsClientTargetSet } from "./ipfsClientTargetSet.js"; @@ -53,7 +62,6 @@ export { portsApiStatusGet } from "./portsStatusGet.js"; export { portsUpnpStatusGet } from "./portsStatusGet.js"; export { portsToOpenGet } from "./portsToOpenGet.js"; export { rebootHost } from "./rebootHost.js"; -export { rebootHostIsRequiredGet } from "./rebootHostIsRequiredGet.js"; export * from "./releaseTrustedKey.js"; export { setStaticIp } from "./setStaticIp.js"; export { getShouldShowSmooth, setShouldShownSmooth } from "./smooth.js"; diff --git a/packages/dappmanager/src/calls/notifications.ts b/packages/dappmanager/src/calls/notifications.ts new file mode 100644 index 0000000000..b3de6e45cd --- /dev/null +++ b/packages/dappmanager/src/calls/notifications.ts @@ -0,0 +1,78 @@ +import { notifications } from "@dappnode/notifications"; +import { CustomEndpoint, GatusEndpoint, Notification, NotificationsConfig } from "@dappnode/types"; + +/** + * Get all the notifications + * @returns all the notifications + */ +export async function notificationsGetAll(): Promise<Notification[]> { + return await notifications.getAllNotifications(); +} + +/** + * Get all the notifications + * @returns all the notifications + */ +export async function notificationsGetBanner(timestamp: number): Promise<Notification[]> { + return await notifications.getBannerNotifications(timestamp); +} + +/** + * Get unseen notifications count + */ +export async function notificationsGetUnseenCount(): Promise<number> { + return await notifications.getUnseenNotificationsCount(); +} + +/** + * Set all non-banner notifications as seen + */ +export async function notificationsSetAllSeen(): Promise<void> { + return await notifications.setAllNotificationsSeen(); +} + +/** + * Set a notification as seen by providing its correlationId + */ +export async function notificationSetSeenByCorrelationID(correlationId: string): Promise<void> { + return await notifications.setNotificationSeenByCorrelationID(correlationId); +} + +/** + * Get gatus and custom endpoints indexed by dnpName + */ +export async function notificationsGetAllEndpoints(): Promise<{ + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; +}> { + return await notifications.getAllEndpoints(); +} + +/** + * Update endpoint properties + */ +export async function notificationsUpdateEndpoints({ + dnpName, + isCore, + notificationsConfig +}: { + dnpName: string; + isCore: boolean; + notificationsConfig: NotificationsConfig; +}): Promise<void> { + await notifications.updateEndpoints(dnpName, isCore, notificationsConfig); +} + +/** + * Joins new endpoints with previous ones + */ +export async function notificationsApplyPreviousEndpoints({ + dnpName, + isCore, + newNotificationsConfig +}: { + dnpName: string; + isCore: boolean; + newNotificationsConfig: NotificationsConfig; +}): Promise<NotificationsConfig> { + return await notifications.applyPreviousEndpoints(dnpName, isCore, newNotificationsConfig); +} diff --git a/packages/dappmanager/src/calls/packageInstall.ts b/packages/dappmanager/src/calls/packageInstall.ts index a9189f4a5c..aabce256e5 100644 --- a/packages/dappmanager/src/calls/packageInstall.ts +++ b/packages/dappmanager/src/calls/packageInstall.ts @@ -20,12 +20,14 @@ export async function packageInstall({ name: reqName, version: reqVersion, userSettings = {}, + notificationsSettings = {}, options = {} }: Parameters<Routes["packageInstall"]>[0]): Promise<void> { await pkgInstall(dappnodeInstaller, { name: reqName, version: reqVersion, userSettings, + notificationsSettings, options }); diff --git a/packages/dappmanager/src/calls/packageStartStop.ts b/packages/dappmanager/src/calls/packageStartStop.ts index f06c6639ee..af005cf99e 100644 --- a/packages/dappmanager/src/calls/packageStartStop.ts +++ b/packages/dappmanager/src/calls/packageStartStop.ts @@ -6,7 +6,7 @@ import { getServicesSharingPid } from "@dappnode/utils"; import { ComposeFileEditor } from "@dappnode/dockercompose"; import { PackageContainer } from "@dappnode/types"; -const dnpsAllowedToStop = [params.ipfsDnpName, params.wifiDnpName, params.HTTPS_PORTAL_DNPNAME]; +const dnpsAllowedToStop = [params.ipfsDnpName, params.wifiDnpName, params.HTTPS_PORTAL_DNPNAME, params.notificationsDnpName]; /** * Stops or starts a package containers diff --git a/packages/dappmanager/src/calls/passwordManager.ts b/packages/dappmanager/src/calls/passwordManager.ts index 0192234ba8..49ea3b9f91 100644 --- a/packages/dappmanager/src/calls/passwordManager.ts +++ b/packages/dappmanager/src/calls/passwordManager.ts @@ -1,11 +1,17 @@ import * as db from "@dappnode/db"; import { shell } from "@dappnode/utils"; import { getDappmanagerImage } from "@dappnode/dockerapi"; +import { notifications } from "@dappnode/notifications"; +import { logs } from "@dappnode/logger"; +import { Category, Priority, Status } from "@dappnode/types"; +import { params } from "@dappnode/params"; const insecureSalt = "insecur3"; const baseCommand = `docker run --rm -v /etc:/etc --privileged --entrypoint=""`; +let passwordInsecureNotificationSent = false; + /** * Checks if the user `dappnode`'s password in the host machine * is NOT the insecure default set at installation time. @@ -76,6 +82,30 @@ export async function passwordIsSecure(): Promise<boolean> { } else { const isSecure = await isPasswordSecure(); if (isSecure) db.passwordIsSecure.set(isSecure); + // Send notification if the password is insecure. It will be sent only once on app lifetime + else if (!passwordInsecureNotificationSent) { + try { + await notifications.sendNotification({ + title: "Insecure host password", + dnpName: params.dappmanagerDnpName, + body: "Change the host `dappnode` user password.", + category: Category.system, + priority: Priority.high, + status: Status.triggered, + callToAction: { + title: "Change", + url: "http://my.dappnode/system/security" + }, + isBanner: true, + isRemote: false, + correlationId: "core-password-insecure" + }); + passwordInsecureNotificationSent = true; + } catch (e) { + logs.error("Error sending host reboot notification", e); + } + } + return isSecure; } } diff --git a/packages/dappmanager/src/calls/rebootHostIsRequiredGet.ts b/packages/dappmanager/src/calls/rebootHostIsRequiredGet.ts deleted file mode 100644 index 7466f25399..0000000000 --- a/packages/dappmanager/src/calls/rebootHostIsRequiredGet.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { RebootRequiredScript } from "@dappnode/types"; -import { getRebootRequiredMemoized } from "@dappnode/hostscriptsservices"; - -/** - * Checks weather or not the host machine needs to be rebooted - * - */ -export async function rebootHostIsRequiredGet(): Promise<RebootRequiredScript> { - return await getRebootRequiredMemoized(); -} diff --git a/packages/dappmanager/src/calls/setStaticIp.ts b/packages/dappmanager/src/calls/setStaticIp.ts index 4e5806be82..499c2b9f60 100644 --- a/packages/dappmanager/src/calls/setStaticIp.ts +++ b/packages/dappmanager/src/calls/setStaticIp.ts @@ -2,6 +2,9 @@ import * as db from "@dappnode/db"; import { updateDyndnsIp } from "@dappnode/dyndns"; import { eventBus } from "@dappnode/eventbus"; import { logs } from "@dappnode/logger"; +import { notifications } from "@dappnode/notifications"; +import { params } from "@dappnode/params"; +import { Category, Priority, Status } from "@dappnode/types"; /** * Sets the static IP @@ -25,12 +28,19 @@ export async function setStaticIp({ staticIp }: { staticIp: string }): Promise<v logs.info(`Updated static IP: ${staticIp}`); } - eventBus.notification.emit({ - id: "staticIpUpdated", - type: "warning", - title: "Update connection profiles", - body: "Your static IP was changed, please download and install your VPN connection profile again. Instruct your users to do so also." - }); + await notifications + .sendNotification({ + title: "Static IP has changed", + body: `Your static IP has changed to ${staticIp}.`, + dnpName: params.dappmanagerDnpName, + category: Category.system, + priority: Priority.low, + status: Status.triggered, + isBanner: true, + isRemote: false, + correlationId: "core-static-ip-update" + }) + .catch((e) => logs.error("Error sending static IP updated notification", e)); // Dynamic update with the new staticIp eventBus.requestSystemInfo.emit(); diff --git a/packages/dappmanager/src/calls/systemInfoGet.ts b/packages/dappmanager/src/calls/systemInfoGet.ts index d1f6544351..b0ac504c5b 100644 --- a/packages/dappmanager/src/calls/systemInfoGet.ts +++ b/packages/dappmanager/src/calls/systemInfoGet.ts @@ -62,6 +62,9 @@ function getNewFeatureIds(): NewFeatureId[] { // enable-ethical-metrics: Show only if not seen if (db.newFeatureStatus.get("enable-ethical-metrics") !== "seen") newFeatureIds.push("enable-ethical-metrics"); + + // enable-notifications: Show only if not seen + if (db.newFeatureStatus.get("enable-notifications") !== "seen") newFeatureIds.push("enable-notifications"); // change-host-password: Show only if insecure if (!db.passwordIsSecure.get()) newFeatureIds.push("change-host-password"); diff --git a/packages/dappmanager/src/calls/wifi.ts b/packages/dappmanager/src/calls/wifi.ts index a6bf9241ad..a0d88250aa 100644 --- a/packages/dappmanager/src/calls/wifi.ts +++ b/packages/dappmanager/src/calls/wifi.ts @@ -1,7 +1,10 @@ import { ComposeFileEditor } from "@dappnode/dockercompose"; import { listContainer, logContainer } from "@dappnode/dockerapi"; -import { CurrentWifiCredentials, WifiReport } from "@dappnode/types"; +import { CurrentWifiCredentials, WifiReport, Category, Priority, Status } from "@dappnode/types"; import { params } from "@dappnode/params"; +import { notifications } from "@dappnode/notifications"; + +let wifiDefaultPasswordNotificationSent = false; /** * Return wifi report @@ -47,9 +50,40 @@ export async function wifiReportGet(): Promise<WifiReport> { break; } + const { password } = await wifiCredentialsGet(); + const isDefaultPassphrase = password === params.WIFI_DEFAULT_PASSWORD; + + // Send notification if the password is insecure. Only once in the app lifetime + if (isDefaultPassphrase && !wifiDefaultPasswordNotificationSent) { + try { + await notifications + .sendNotification({ + title: "Default WiFi Password", + dnpName: params.dappmanagerDnpName, + body: `Your Dappnode WiFi is using the default password. For security reasons, it's strongly recommended to change it to a custom, secure password.`, + category: Category.system, + priority: Priority.high, + status: Status.triggered, + callToAction: { + title: "Change", + url: "http://my.dappnode/wireless-network/wifi" + }, + isBanner: true, + isRemote: false, + correlationId: "core-wifi-default-password" + }) + .catch((e) => console.error("Error sending wifi password notification", e)); + wifiDefaultPasswordNotificationSent = true; + } catch (e) { + console.error("Error sending wifi password notification", e); + } + } + return { info, - report + report, + isDefaultPassphrase, + isRunning: wifiContainer.state === "running" }; } diff --git a/packages/dappmanager/src/index.ts b/packages/dappmanager/src/index.ts index 2bbf2189ec..61699b62a6 100644 --- a/packages/dappmanager/src/index.ts +++ b/packages/dappmanager/src/index.ts @@ -51,7 +51,7 @@ export const dappnodeInstaller = new DappnodeInstaller(ipfsUrl, await getEthersP export const publicRegistry = new DappNodeRegistry("public"); -// TODO: find a way to move the velow constants to the api itself +// TODO: find a way to move the below constants to the api itself const vpnApiClient = getVpnApiClient(params); const adminPasswordDb = new AdminPasswordDb(params); const deviceCalls = new DeviceCalls({ diff --git a/packages/dappmanager/src/initializeDb.ts b/packages/dappmanager/src/initializeDb.ts index e6e5e9fa25..dd747e44ee 100644 --- a/packages/dappmanager/src/initializeDb.ts +++ b/packages/dappmanager/src/initializeDb.ts @@ -69,6 +69,7 @@ export async function initializeDb(): Promise<void> { }); db.newFeatureStatus.set("enable-ethical-metrics", "pending"); + db.newFeatureStatus.set("enable-notifications", "pending"); } /** diff --git a/packages/httpsPortal/src/exposable/index.ts b/packages/httpsPortal/src/exposable/index.ts index 763a57d323..4fd7df24d2 100644 --- a/packages/httpsPortal/src/exposable/index.ts +++ b/packages/httpsPortal/src/exposable/index.ts @@ -11,7 +11,7 @@ import { parseExposableServiceManifest } from "./parseExposable.js"; const getExposableServicesByDnpMemo = memoizee( function getExposableServicesByDnp(dnp: InstalledPackageData): ExposableServiceInfo[] | null { // Read disk - const manifest = readManifestIfExists(dnp); + const manifest = readManifestIfExists(dnp.dnpName); return manifest?.exposable ? parseExposableServiceManifest(dnp, manifest.exposable) : null; }, { diff --git a/packages/installer/src/calls/packageGet.ts b/packages/installer/src/calls/packageGet.ts index 76540d7b34..30e95fffbc 100644 --- a/packages/installer/src/calls/packageGet.ts +++ b/packages/installer/src/calls/packageGet.ts @@ -45,7 +45,7 @@ export async function packageGet({ dnpName }: { dnpName: string }): Promise<Inst // Add non-blocking data try { - const manifest = readManifestIfExists(dnp); + const manifest = readManifestIfExists(dnp.dnpName); if (manifest) { // Append manifest for general info dnpData.manifest = omit(manifest, ["setupWizard", "gettingStarted", "backup"]); diff --git a/packages/installer/src/calls/packageInstall.ts b/packages/installer/src/calls/packageInstall.ts index 944fe4fcb5..2e17cc10c4 100644 --- a/packages/installer/src/calls/packageInstall.ts +++ b/packages/installer/src/calls/packageInstall.ts @@ -32,7 +32,13 @@ import { DappnodeInstaller } from "../dappnodeInstaller.js"; */ export async function packageInstall( dappnodeInstaller: DappnodeInstaller, - { name: reqName, version: reqVersion, userSettings = {}, options = {} }: Parameters<Routes["packageInstall"]>[0] + { + name: reqName, + version: reqVersion, + userSettings = {}, + notificationsSettings = {}, + options = {} + }: Parameters<Routes["packageInstall"]>[0] ): Promise<void> { // 1. Parse the id into a request const req: PackageRequest = { @@ -61,6 +67,7 @@ export async function packageInstall( const packagesData = await getInstallerPackagesData({ releases, userSettings, + notificationsSettings, currentVersions, reqName }); diff --git a/packages/installer/src/dappnodeInstaller.ts b/packages/installer/src/dappnodeInstaller.ts index 40aa987a35..1b72d3835d 100644 --- a/packages/installer/src/dappnodeInstaller.ts +++ b/packages/installer/src/dappnodeInstaller.ts @@ -11,13 +11,14 @@ import { PackageRequest, SetupWizard, GrafanaDashboard, - PrometheusTarget + PrometheusTarget, + NotificationsConfig } from "@dappnode/types"; import { DappGetState, DappgetOptions, dappGet } from "./dappGet/index.js"; -import { validateDappnodeCompose, validateManifestSchema } from "@dappnode/schemas"; +import { validateDappnodeCompose, validateManifestSchema, validateNotificationsSchema, validateSetupWizardSchema } from "@dappnode/schemas"; import { ComposeEditor, setDappnodeComposeDefaults, writeMetadataToLabels } from "@dappnode/dockercompose"; import { computeGlobalEnvsFromDb } from "@dappnode/db"; -import { getIsCore } from "@dappnode/utils"; +import { fileToGatewayUrl, getIsCore } from "@dappnode/utils"; import { sanitizeDependencies } from "./dappGet/utils/sanitizeDependencies.js"; import { parseTimeoutSeconds } from "./utils.js"; import { getEthersProvider } from "./ethClient/index.js"; @@ -63,8 +64,8 @@ export class DappnodeInstaller extends DappnodeRepository { version }); - // validate manifest and compose files - this.validateManifestAndComposeSchemas(pkgRelease); + // validate manifest and compose and notifications and setupwizard files + this.validateSchemas(pkgRelease); // join metadata files in manifest pkgRelease.manifest = this.joinFilesInManifest({ @@ -72,7 +73,9 @@ export class DappnodeInstaller extends DappnodeRepository { disclaimer: pkgRelease.disclaimer, gettingStarted: pkgRelease.gettingStarted, grafanaDashboards: pkgRelease.grafanaDashboards, - prometheusTargets: pkgRelease.prometheusTargets + prometheusTargets: pkgRelease.prometheusTargets, + notifications: pkgRelease.notifications, + avatarFile: pkgRelease.avatarFile }); // set compose to custom dappnode compose in release @@ -94,9 +97,9 @@ export class DappnodeInstaller extends DappnodeRepository { const pkgReleases = await this.getPkgsReleases(packages, db.releaseKeysTrusted.get(), process.arch); - // validate manifest and compose files + // validate manifest and compose and notifications and setupwizard files pkgReleases.forEach((pkgRelease) => { - this.validateManifestAndComposeSchemas(pkgRelease); + this.validateSchemas(pkgRelease); }); // join metadata files in manifest @@ -107,7 +110,9 @@ export class DappnodeInstaller extends DappnodeRepository { disclaimer: pkgRelease.disclaimer, gettingStarted: pkgRelease.gettingStarted, grafanaDashboards: pkgRelease.grafanaDashboards, - prometheusTargets: pkgRelease.prometheusTargets + prometheusTargets: pkgRelease.prometheusTargets, + notifications: pkgRelease.notifications, + avatarFile: pkgRelease.avatarFile }); }); @@ -151,7 +156,9 @@ export class DappnodeInstaller extends DappnodeRepository { disclaimer, gettingStarted, prometheusTargets, - grafanaDashboards + grafanaDashboards, + notifications, + avatarFile }: { manifest: Manifest; SetupWizard?: SetupWizard; @@ -159,22 +166,28 @@ export class DappnodeInstaller extends DappnodeRepository { gettingStarted?: string; prometheusTargets?: PrometheusTarget[]; grafanaDashboards?: GrafanaDashboard[]; + notifications?: NotificationsConfig; + avatarFile?: DistributedFile; }): Manifest { if (SetupWizard) manifest.setupWizard = SetupWizard; if (disclaimer) manifest.disclaimer = { message: disclaimer }; if (gettingStarted) manifest.gettingStarted = gettingStarted; if (prometheusTargets) manifest.prometheusTargets = prometheusTargets; if (grafanaDashboards && grafanaDashboards.length > 0) manifest.grafanaDashboards = grafanaDashboards; + if (notifications) manifest.notifications = notifications; + if (avatarFile) manifest.avatarUrl = fileToGatewayUrl(avatarFile); return manifest; } /** - * Validates manifest and compose schemas + * Validates manifest and compose and notifications and setupwizard schemas */ - private validateManifestAndComposeSchemas(pkgRelease: PackageRelease): void { + private validateSchemas(pkgRelease: PackageRelease): void { validateManifestSchema(pkgRelease.manifest); validateDappnodeCompose(pkgRelease.compose, pkgRelease.manifest); + if (pkgRelease.setupWizard) validateSetupWizardSchema(pkgRelease.setupWizard); + if (pkgRelease.notifications) validateNotificationsSchema(pkgRelease.notifications); } /** diff --git a/packages/installer/src/ethClient/syncedNotification.ts b/packages/installer/src/ethClient/syncedNotification.ts index e720b5d2d3..52c54ba326 100644 --- a/packages/installer/src/ethClient/syncedNotification.ts +++ b/packages/installer/src/ethClient/syncedNotification.ts @@ -1,5 +1,4 @@ import * as db from "@dappnode/db"; -import { eventBus } from "@dappnode/eventbus"; import { Eth2ClientTarget, EthClientStatus } from "@dappnode/types"; /** @@ -30,11 +29,5 @@ export function emitSyncedNotification(target: Eth2ClientTarget, status: EthClie execClientTarget: target.execClient, status: "Synced" }); - eventBus.notification.emit({ - id: `eth-client-synced-${target}`, - type: "success", - title: "Ethereum node synced", - body: `Your DAppNode's Ethereum node ${target} is synced.` - }); } } diff --git a/packages/installer/src/installer/getInstallerPackageData.ts b/packages/installer/src/installer/getInstallerPackageData.ts index 9c6b291210..48ca09cb0b 100644 --- a/packages/installer/src/installer/getInstallerPackageData.ts +++ b/packages/installer/src/installer/getInstallerPackageData.ts @@ -8,7 +8,9 @@ import { UserSettings, PackageRelease, InstallPackageData, - ContainersStatus + ContainersStatus, + NotificationsConfig, + NotificationsSettingsAllDnps } from "@dappnode/types"; import { getBackupPath, getDockerComposePath, getImagePath, getManifestPath } from "@dappnode/utils"; import { gt } from "semver"; @@ -17,6 +19,7 @@ import { logs } from "@dappnode/logger"; interface GetInstallerPackageDataArg { releases: PackageRelease[]; userSettings: UserSettingsAllDnps; + notificationsSettings: NotificationsSettingsAllDnps; currentVersions: { [dnpName: string]: string | undefined }; reqName: string; } @@ -24,6 +27,7 @@ interface GetInstallerPackageDataArg { export async function getInstallerPackagesData({ releases, userSettings, + notificationsSettings, currentVersions, reqName }: GetInstallerPackageDataArg): Promise<InstallPackageData[]> { @@ -37,6 +41,7 @@ export async function getInstallerPackagesData({ getInstallerPackageData( release, userSettings[release.dnpName], + notificationsSettings?.[release.dnpName], currentVersions[release.dnpName], await getContainersStatus({ dnpName: release.dnpName, @@ -58,10 +63,11 @@ export async function getInstallerPackagesData({ function getInstallerPackageData( release: PackageRelease, userSettings: UserSettings | undefined, + notificationsSettings: NotificationsConfig | undefined, currentVersion: string | undefined, containersStatus: ContainersStatus ): InstallPackageData { - const { dnpName, semVersion, isCore, imageFile } = release; + const { dnpName, semVersion, isCore, imageFile, manifest } = release; // Compute paths const composePath = getDockerComposePath(dnpName, isCore); @@ -96,6 +102,13 @@ function getInstallerPackageData( imagePath, // Data to write compose: compose.output(), + manifest: release.manifest.notifications + ? { + ...release.manifest, + // Apply notitications user settings if any + notifications: notificationsSettings + } + : manifest, // User settings to be applied by the installer fileUploads: userSettings?.fileUploads, dockerTimeout, diff --git a/packages/installer/src/installer/writeAndValidateFiles.ts b/packages/installer/src/installer/writeAndValidateFiles.ts index 8ad61238a9..a69c6688c0 100644 --- a/packages/installer/src/installer/writeAndValidateFiles.ts +++ b/packages/installer/src/installer/writeAndValidateFiles.ts @@ -1,10 +1,10 @@ import fs from "fs"; import { Log } from "@dappnode/logger"; import { validatePath } from "@dappnode/utils"; -import { InstallPackageData } from "@dappnode/types"; +import { InstallPackageData, Manifest } from "@dappnode/types"; import { dockerComposeConfig } from "@dappnode/dockerapi"; import { ComposeEditor } from "@dappnode/dockercompose"; -import { isNotFoundError, writeManifest } from "@dappnode/utils"; +import { isNotFoundError } from "@dappnode/utils"; /** * Write the new compose and test it with config @@ -47,3 +47,12 @@ function copyIfExists(src: string, dest: string): void { if (!isNotFoundError(e)) throw e; } } + +/** + * Util: Write manifest to file + * @param manfiestPath + * @param manifest + */ +function writeManifest(manfiestPath: string, manifest: Manifest): void { + fs.writeFileSync(manfiestPath, JSON.stringify(manifest, null, 2)); +} diff --git a/packages/installer/test/unit/release/findEntries.test.ts b/packages/installer/test/unit/release/findEntries.test.ts index c8a27f3b19..649e374acf 100644 --- a/packages/installer/test/unit/release/findEntries.test.ts +++ b/packages/installer/test/unit/release/findEntries.test.ts @@ -54,7 +54,8 @@ describe("validateTarImage", () => { "host-grafana-dashboard.json", "prometheus-targets.json", "setup-wizard.json", - "signature.json" + "signature.json", + "notifications.yaml" ].map((name) => ({ name, path: `Qm-root/${name}`, @@ -70,6 +71,7 @@ describe("validateTarImage", () => { disclaimer: "disclaimer.md", gettingStarted: "getting-started.md", prometheusTargets: "prometheus-targets.json", + notifications: "notifications.yaml", grafanaDashboards: ["docker-grafana-dashboard.json", "host-grafana-dashboard.json"] }; diff --git a/packages/notifications/.mocharc.yaml b/packages/notifications/.mocharc.yaml new file mode 100644 index 0000000000..41e7c635de --- /dev/null +++ b/packages/notifications/.mocharc.yaml @@ -0,0 +1,8 @@ +colors: true +exit: true +extension: [ts] +require: + - dotenv/config +node-option: + - experimental-specifier-resolution=node + - import=tsx/esm diff --git a/packages/notifications/README.md b/packages/notifications/README.md new file mode 100644 index 0000000000..409d993170 --- /dev/null +++ b/packages/notifications/README.md @@ -0,0 +1,9 @@ +# notifications package + +## Overview + +## Testing + +## Todo + +## Contact diff --git a/packages/notifications/package.json b/packages/notifications/package.json new file mode 100644 index 0000000000..fb6f6ce0ae --- /dev/null +++ b/packages/notifications/package.json @@ -0,0 +1,29 @@ +{ + "name": "@dappnode/notifications", + "type": "module", + "version": "0.1.0", + "license": "GPL-3.0", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "mocha --config ./.mocharc.yaml --recursive ./test/unit", + "dev": "tsc -w" + }, + "dependencies": { + "@dappnode/dockerapi": "workspace:^0.1.0", + "@dappnode/types": "workspace:^0.1.0", + "@dappnode/utils": "workspace:^0.1.0" + }, + "devDependencies": { + "@types/mocha": "^10", + "mocha": "^10.7.0" + } +} diff --git a/packages/notifications/src/api.ts b/packages/notifications/src/api.ts new file mode 100644 index 0000000000..560ec294ba --- /dev/null +++ b/packages/notifications/src/api.ts @@ -0,0 +1,87 @@ +import { Notification, NotificationPayload } from "@dappnode/types"; + +export class NotificationsApi { + private readonly rootUrl: string; + + constructor(rootUrl: string = "http://notifier.notifications.dappnode") { + this.rootUrl = rootUrl; + } + + /** + * Send a new notification + */ + async sendNotification(notificationPaylaod: NotificationPayload): Promise<void> { + await fetch(new URL("/api/v1/notifications", `${this.rootUrl}:8080`).toString(), { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(notificationPaylaod) + }); + } + + /** + * Get all the notifications from the endpoint + */ + async getAllNotifications(): Promise<Notification[]> { + return await (await fetch(new URL("/api/v1/notifications", `${this.rootUrl}:8080`).toString())).json(); + } + + /** + * Retrieve all "banner" notifications that should be displayed within the given timestamp range + */ + async getBannerNotifications(timestamp?: number): Promise<Notification[]> { + const url = new URL(`/api/v1/notifications?isBanner=true×tamp=${timestamp}`, `${this.rootUrl}:8080`); + + const response = await fetch(url); + return await response.json(); + } + + /** + * Get the count of unseen notifications + */ + async getUnseenNotificationsCount(): Promise<{ unseenCount: number }> { + return await (await fetch(new URL("/api/v1/notifications/unseen", `${this.rootUrl}:8080`).toString())).json(); + } + + /** + * Trigger reload of endpoint to make changes effective + */ + async reloadEndpoints(): Promise<void> { + await fetch(new URL("/api/v1/gatus/endpoints/reload", `${this.rootUrl}:8082`).toString(), { + method: "POST", + headers: { + "Content-Type": "application/json" + } + }); + } + + /** + * Set all non-banner notifications as seen + */ + async setAllNotificationsSeen(): Promise<void> { + const url = new URL("/api/v1/notifications/seen", `${this.rootUrl}:8080`); + url.searchParams.append("isBanner", "false"); + + await fetch(url.toString(), { + method: "PUT", + headers: { + "Content-Type": "application/json" + } + }); + } + + /** + * Set a notification as seen by providing its correlationId + */ + async setNotificationSeenByCorrelationID(correlationId: string): Promise<void> { + const url = new URL(`/api/v1/notifications/${correlationId}/seen`, `${this.rootUrl}:8080`); + + await fetch(url.toString(), { + method: "PUT", + headers: { + "Content-Type": "application/json" + } + }); + } +} diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts new file mode 100644 index 0000000000..01eeb66076 --- /dev/null +++ b/packages/notifications/src/index.ts @@ -0,0 +1,91 @@ +import { NotificationsApi } from "./api.js"; +import { NotificationsManifest } from "./manifest.js"; +import { CustomEndpoint, GatusEndpoint, Notification, NotificationPayload, NotificationsConfig } from "@dappnode/types"; + +class Notifications { + private readonly api: NotificationsApi; + private readonly manifest: NotificationsManifest; + + constructor(rootUrl: string = "http://notifier.notifications.dappnode") { + this.api = new NotificationsApi(rootUrl); + this.manifest = new NotificationsManifest(); + } + + /** + * Send a new notification + */ + async sendNotification(notificationPayload: NotificationPayload): Promise<void> { + await this.api.sendNotification(notificationPayload); + } + + /** + * Get all the notifications + */ + async getAllNotifications(): Promise<Notification[]> { + return await this.api.getAllNotifications(); + } + + /** + * Get banner notifications that should be displayed within the given timestamp range + */ + async getBannerNotifications(timestamp?: number): Promise<Notification[]> { + return await this.api.getBannerNotifications(timestamp); + } + + /** + * Get the count of unseen notifications + */ + async getUnseenNotificationsCount(): Promise<number> { + return (await this.api.getUnseenNotificationsCount()).unseenCount; + } + + /** + * Set all non-banner notifications as seen + */ + async setAllNotificationsSeen(): Promise<void> { + return await this.api.setAllNotificationsSeen(); + } + /** + * Set a notification as seen by providing its ID + */ + async setNotificationSeenByCorrelationID(correlationId:string): Promise<void> { + return await this.api.setNotificationSeenByCorrelationID(correlationId); + } + + /** + * Get gatus and custom endpoints indexed by dnpName + */ + async getAllEndpoints(): Promise<{ + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; + }> { + return await this.manifest.getAllEndpoints(); + } + + /** + * Get package endpoints (if exists) properties + */ + getEndpointsIfExists(dnpName: string, isCore: boolean): NotificationsConfig | null { + return this.manifest.getEndpointsIfExists(dnpName, isCore); + } + + /** + * Joins new endpoints with previous ones + */ + applyPreviousEndpoints( + dnpName: string, + isCore: boolean, + newNotificationsConfig: NotificationsConfig + ): NotificationsConfig { + return this.manifest.applyPreviousEndpoints(dnpName, isCore, newNotificationsConfig); + } + + /** + * Update endpoint properties + */ + async updateEndpoints(dnpName: string, isCore: boolean, notificationsConfig: NotificationsConfig): Promise<void> { + this.manifest.updateEndpoints(dnpName, isCore, notificationsConfig); + await this.api.reloadEndpoints(); + } +} + +export const notifications = new Notifications(); diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts new file mode 100644 index 0000000000..61386686c3 --- /dev/null +++ b/packages/notifications/src/manifest.ts @@ -0,0 +1,132 @@ +import { listPackages } from "@dappnode/dockerapi"; +import { CustomEndpoint, GatusEndpoint, Manifest, NotificationsConfig } from "@dappnode/types"; +import { getManifestPath } from "@dappnode/utils"; +import fs from "fs"; + +export class NotificationsManifest { + /** + * Get gatus and custom endpoints indexed by dnpName from filesystem + */ + async getAllEndpoints(): Promise<{ + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; + }> { + const packages = await listPackages(); + + const notificationsEndpoints: { + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; + } = {}; + for (const pkg of packages) { + const { dnpName, isCore } = pkg; + const manifestPath = getManifestPath(dnpName, isCore); + if (!fs.existsSync(manifestPath)) continue; + + const manifest: Manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (!manifest.notifications) continue; + + const { endpoints, customEndpoints } = manifest.notifications; + notificationsEndpoints[dnpName] = { endpoints: endpoints || [], customEndpoints: customEndpoints || [], isCore }; + } + + return notificationsEndpoints; + } + + /** + * Get package endpoints (if exists) properties from filesystem + */ + getEndpointsIfExists(dnpName: string, isCore: boolean): NotificationsConfig | null { + const manifestPath = getManifestPath(dnpName, isCore); + if (!fs.existsSync(manifestPath)) return { endpoints: [], customEndpoints: [] }; + + const manifest: Manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (!manifest.notifications) return { endpoints: [], customEndpoints: [] }; + + const { endpoints, customEndpoints } = manifest.notifications; + return { endpoints: endpoints || [], customEndpoints: customEndpoints || [] }; + } + + /** + * Merges new and previous notifications configurations by taking all fields from the new config + * except for the `enabled` flag (for both Gatus and Custom endpoints) and the + * `metric.treshold` (for Custom endpoints only), which are preserved from the old config. + * Endpoints are matched by `correlationId` + * + * @returns New NotificationsConfig with merged endpoints. + */ + applyPreviousEndpoints( + dnpName: string, + isCore: boolean, + newNotificationsConfig: NotificationsConfig, + oldNotificationsConfig?: NotificationsConfig | null + ): NotificationsConfig { + if (!oldNotificationsConfig) oldNotificationsConfig = this.getEndpointsIfExists(dnpName, isCore); + if (!oldNotificationsConfig) return newNotificationsConfig; + + const { endpoints: oldEndpoints, customEndpoints: oldCustomEndpoints } = oldNotificationsConfig; + const { endpoints: newEndpoints, customEndpoints: newCustomEndpoints } = newNotificationsConfig; + + const mergedEndpoints = newEndpoints?.map((newEndpoint) => { + const oldEndpoint = oldEndpoints?.find((e) => e.correlationId === newEndpoint.correlationId); + // If no previous version exists, return the new endpoint as-is + if (!oldEndpoint) return newEndpoint; + + // Copy all fields from the new endpoint, but preserve the old enabled flag + const mergedEndpoint: GatusEndpoint = { ...newEndpoint, enabled: oldEndpoint.enabled }; + // Persist old threshold value by preserving each condition's right-hand side + if (newEndpoint.conditions && Array.isArray(newEndpoint.conditions) && oldEndpoint.conditions) { + mergedEndpoint.conditions = newEndpoint.conditions.map((condition, index) => { + const parts = condition.split(/([=<>]+)/); + if (parts.length < 3) return condition; + const left = parts[0]; + const operator = parts[1]; + let right = parts.slice(2).join(""); + const oldCond = oldEndpoint.conditions[index]; + if (oldCond) { + const oldParts = oldCond.split(/([=<>]+)/); + if (oldParts.length >= 3) right = oldParts.slice(2).join(""); + } + return `${left}${operator}${right}`; + }); + } + return mergedEndpoint; + }); + + const mergedCustomEndpoints = newCustomEndpoints?.map((newCustomEndpoint) => { + const oldCustomEndpoint = oldCustomEndpoints?.find((e) => e.correlationId === newCustomEndpoint.correlationId); + // If no previous version exists, return the new custom endpoint as-is + if (!oldCustomEndpoint) return newCustomEndpoint; + + // Copy all fields from the new custom endpoint, but preserve old enabled and metric.treshold + const mergedCustomEndpoint: CustomEndpoint = { ...newCustomEndpoint, enabled: oldCustomEndpoint.enabled }; + if (mergedCustomEndpoint.metric && oldCustomEndpoint.metric && oldCustomEndpoint.metric.treshold !== undefined) { + mergedCustomEndpoint.metric.treshold = oldCustomEndpoint.metric.treshold; + } + return mergedCustomEndpoint; + }); + + return { endpoints: mergedEndpoints, customEndpoints: mergedCustomEndpoints }; + } + + /** + * Update endpoint properties in filesystem + */ + updateEndpoints(dnpName: string, isCore: boolean, notificationsConfig: NotificationsConfig): void { + const { endpoints: updatedEndpoints, customEndpoints: updatedCustomEndpoints } = notificationsConfig; + + const manifest: Manifest = JSON.parse(fs.readFileSync(getManifestPath(dnpName, isCore), "utf8")); + if (!manifest.notifications) throw new Error("No notifications found in manifest"); + + if (updatedEndpoints) { + const endpoints = manifest.notifications.endpoints; + if (!endpoints) throw new Error(`No endpoints found in manifest`); + Object.assign(endpoints, updatedEndpoints); + } + + if (updatedCustomEndpoints) { + const customEndpoints = manifest.notifications.customEndpoints; + if (!customEndpoints) throw new Error(`No custom endpoints found in manifest`); + Object.assign(customEndpoints, updatedCustomEndpoints); + } + + fs.writeFileSync(getManifestPath(dnpName, isCore), JSON.stringify(manifest, null, 2)); + } +} diff --git a/packages/notifications/test/unit/notifications.test.ts b/packages/notifications/test/unit/notifications.test.ts new file mode 100644 index 0000000000..888fb28528 --- /dev/null +++ b/packages/notifications/test/unit/notifications.test.ts @@ -0,0 +1,639 @@ +import "mocha"; +import { expect } from "chai"; + +import { NotificationsManifest } from "../../src/manifest.js"; +import { NotificationsConfig, Alert, Priority } from "@dappnode/types"; + +// Dummy objects to satisfy required properties. +const dummyAlert: Alert = { + type: "custom", + "failure-threshold": 2, + "success-threshold": 1, + "send-on-resolved": true, + description: "Dummy alert", + enabled: true +}; + +const dummyDefinition = { + title: "Dummy Title", + description: "Dummy Description" +}; + +const dummyMetric = { + min: 0, + max: 100, + unit: "%" +}; + +describe("applyPreviousEndpoints", () => { + let merger: NotificationsManifest; + + beforeEach(() => { + merger = new NotificationsManifest(); + }); + + it("should return new config when old config is null", () => { + const newConfig: NotificationsConfig = { + endpoints: [ + { + name: "Test Endpoint", + enabled: true, + isBanner: false, + correlationId: "Test Endpoint", + priority: Priority.medium, + url: "http://example.com", + method: "GET", + conditions: ["[BODY].value < 80"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [ + { + name: "Test Custom", + correlationId: "Test Custom", + enabled: true, + isBanner: false, + description: "Test custom endpoint", + metric: { treshold: 50, min: 0, max: 100, unit: "%" } + } + ] + }; + + const result = merger.applyPreviousEndpoints("dnp", true, newConfig, null); + expect(result).to.deep.equal(newConfig); + }); + + it("should return new config when old config is undefined", () => { + const newConfig: NotificationsConfig = { + endpoints: [ + { + name: "Test Endpoint", + enabled: true, + isBanner: false, + correlationId: "Test Endpoint", + priority: Priority.medium, + url: "http://example.com", + method: "GET", + conditions: ["[BODY].value < 80"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [ + { + name: "Test Custom", + correlationId: "Test Custom", + enabled: true, + isBanner: false, + description: "Test custom endpoint", + metric: { treshold: 50, min: 0, max: 100, unit: "%" } + } + ] + }; + + const result = merger.applyPreviousEndpoints("dnp", true, newConfig); + expect(result).to.deep.equal(newConfig); + }); + + it("should merge enabled flag and condition right-hand side for matching endpoints", () => { + const newConfig: NotificationsConfig = { + endpoints: [ + { + name: "High CPU Usage Check", + enabled: true, + isBanner: false, + correlationId: "High CPU Usage Check", + priority: Priority.medium, + url: "http://cpu.example.com", + method: "GET", + conditions: ["[BODY].data.result[0].value[1] < 80"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [] + }; + + const oldConfig: NotificationsConfig = { + endpoints: [ + { + name: "High CPU Usage Check", + enabled: false, + isBanner: false, + correlationId: "High CPU Usage Check", + priority: Priority.medium, + url: "http://cpu.example.com", + method: "GET", + conditions: ["[BODY].data.result[0].value[1] < 75"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [] + }; + + const result = merger.applyPreviousEndpoints("dnp", true, newConfig, oldConfig); + expect(result.endpoints).to.have.lengthOf(1); + const mergedEndpoint = result.endpoints![0]; + // The endpoint's enabled flag should be from the old config. + expect(mergedEndpoint.enabled).to.equal(false); + // The condition's right-hand side should be taken from the old config. + expect(mergedEndpoint.conditions[0]).to.equal("[BODY].data.result[0].value[1] < 75"); + }); + + it("should merge custom endpoint enabled flag and metric.treshold", () => { + const newConfig: NotificationsConfig = { + endpoints: [], + customEndpoints: [ + { + name: "Custom Check", + correlationId: "Custom Check", + enabled: true, + isBanner: false, + description: "Custom check description", + metric: { treshold: 50, min: 0, max: 100, unit: "%" } + } + ] + }; + + const oldConfig: NotificationsConfig = { + endpoints: [], + customEndpoints: [ + { + name: "Custom Check", + correlationId: "Custom Check", + enabled: false, + isBanner: false, + description: "Custom check description", + metric: { treshold: 25, min: 0, max: 100, unit: "%" } + } + ] + }; + + const result = merger.applyPreviousEndpoints("dnp", true, newConfig, oldConfig); + expect(result.customEndpoints).to.have.lengthOf(1); + const mergedCustom = result.customEndpoints![0]; + expect(mergedCustom.enabled).to.equal(false); + expect(mergedCustom.metric?.treshold).to.equal(25); + }); + + it("should ignore old endpoints that are not present in new config", () => { + const newConfig: NotificationsConfig = { + endpoints: [ + { + name: "New Endpoint", + enabled: true, + isBanner: false, + correlationId: "New Endpoint", + priority: Priority.medium, + url: "http://new.example.com", + method: "GET", + conditions: ["[BODY].value < 50"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [] + }; + + const oldConfig: NotificationsConfig = { + endpoints: [ + { + name: "Old Endpoint", + enabled: false, + isBanner: false, + correlationId: "Old Endpoint", + priority: Priority.medium, + url: "http://old.example.com", + method: "GET", + conditions: ["[BODY].value < 30"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [] + }; + + const result = merger.applyPreviousEndpoints("dnp", true, newConfig, oldConfig); + expect(result.endpoints).to.have.lengthOf(1); + expect(result.endpoints![0].name).to.equal("New Endpoint"); + }); + + it("should handle malformed condition gracefully", () => { + const newConfig: NotificationsConfig = { + endpoints: [ + { + name: "Malformed Condition Endpoint", + enabled: true, + isBanner: false, + correlationId: "Malformed Condition Endpoint", + priority: Priority.medium, + url: "http://malformed.example.com", + method: "GET", + conditions: ["malformed condition"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [] + }; + + const oldConfig: NotificationsConfig = { + endpoints: [ + { + name: "Malformed Condition Endpoint", + enabled: false, + isBanner: false, + correlationId: "Malformed Condition Endpoint", + priority: Priority.medium, + url: "http://malformed.example.com", + method: "GET", + conditions: ["ignored condition"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [] + }; + + const result = merger.applyPreviousEndpoints("dnp", true, newConfig, oldConfig); + // Since the condition does not split into three parts, it should remain unchanged. + expect(result.endpoints![0].conditions[0]).to.equal("malformed condition"); + }); + + it("should handle different lengths of conditions arrays", () => { + const newConfig: NotificationsConfig = { + endpoints: [ + { + name: "Multiple Conditions Endpoint", + enabled: true, + isBanner: false, + correlationId: "Multiple Conditions Endpoint", + priority: Priority.medium, + url: "http://multiple.example.com", + method: "GET", + conditions: ["[BODY].data[0] < 80", "[BODY].data[1] > 20"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [] + }; + + const oldConfig: NotificationsConfig = { + endpoints: [ + { + name: "Multiple Conditions Endpoint", + enabled: false, + isBanner: false, + correlationId: "Multiple Conditions Endpoint", + priority: Priority.medium, + url: "http://multiple.example.com", + method: "GET", + conditions: [ + "[BODY].data[0] < 75" // Only one condition in the old config. + ], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [] + }; + + const result = merger.applyPreviousEndpoints("dnp", true, newConfig, oldConfig); + expect(result.endpoints![0].conditions[0]).to.equal("[BODY].data[0] < 75"); + expect(result.endpoints![0].conditions[1]).to.equal("[BODY].data[1] > 20"); + }); + + it("should merge multiple endpoints and custom endpoints correctly", () => { + const newConfig: NotificationsConfig = { + endpoints: [ + { + name: "High CPU Usage Check", + enabled: true, + isBanner: false, + correlationId: "dms-host-cpu-check", + priority: Priority.medium, + url: "http://cpu.example.com", + method: "GET", + conditions: ["[BODY].cpu < 80"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + }, + { + name: "Host out of memory check", + enabled: true, + isBanner: false, + correlationId: "dms-host-out-of-memory-check", + priority: Priority.medium, + url: "http://memory.example.com", + method: "GET", + conditions: ["[BODY].memory > 10"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [ + { + name: "Custom Check A", + correlationId: "custom-check-a", + enabled: true, + isBanner: false, + description: "Custom Check A description", + metric: { treshold: 50, min: 0, max: 100, unit: "%" } + }, + { + name: "Custom Check B", + correlationId: "custom-check-b", + enabled: true, + isBanner: false, + description: "Custom Check B description", + metric: { treshold: 60, min: 0, max: 100, unit: "%" } + } + ] + }; + + const oldConfig: NotificationsConfig = { + endpoints: [ + { + name: "High CPU Usage Check", + enabled: false, + isBanner: false, + correlationId: "dms-host-cpu-check", + priority: Priority.medium, + url: "http://cpu.example.com", + method: "GET", + conditions: ["[BODY].cpu < 70"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + }, + { + name: "Host out of memory check", + enabled: false, + isBanner: false, + correlationId: "dms-host-out-of-memory-check", + priority: Priority.medium, + url: "http://memory.example.com", + method: "GET", + conditions: ["[BODY].memory > 20"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + }, + { + name: "Obsolete Endpoint", + enabled: false, + isBanner: false, + correlationId: "Obsolete Endpoint", + priority: Priority.medium, + url: "http://obsolete.example.com", + method: "GET", + conditions: ["[BODY].obsolete < 10"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [ + { + name: "Custom Check A", + enabled: false, + isBanner: false, + correlationId: "custom-check-a", + description: "Custom Check A description", + metric: { treshold: 40, min: 0, max: 100, unit: "%" } + } + // "Custom Check B" does not exist in the old config. + ] + }; + + const result = merger.applyPreviousEndpoints("dnp", true, newConfig, oldConfig); + + // Verify endpoints merging. + expect(result.endpoints).to.have.lengthOf(2); + const cpuEndpoint = result.endpoints!.find((e) => e.correlationId === "dms-host-cpu-check"); + const memEndpoint = result.endpoints!.find((e) => e.correlationId === "dms-host-out-of-memory-check"); + expect(cpuEndpoint?.enabled, "merged CPU endpoint enabled").to.equal(false); + expect(cpuEndpoint?.conditions[0], "merged CPU endpoint threshold").to.equal("[BODY].cpu < 70"); + expect(memEndpoint?.enabled, "merged memory endpoint enabled").to.equal(false); + expect(memEndpoint?.conditions[0], "merged memory endpoint threshold").to.equal("[BODY].memory > 20"); + + // Verify custom endpoints merging. + expect(result.customEndpoints).to.have.lengthOf(2); + const customA = result.customEndpoints!.find((e) => e.correlationId === "custom-check-a"); + const customB = result.customEndpoints!.find((e) => e.correlationId === "custom-check-b"); + expect(customA?.enabled, "merged Custom Check A enabled").to.equal(false); + expect(customA?.metric?.treshold, "merged Custom Check A threshold").to.equal(40); + // Custom Check B remains as defined in the new config. + expect(customB?.enabled, "merged Custom Check B enabled").to.equal(true); + expect(customB?.metric?.treshold, "merged Custom Check B threshold").to.equal(60); + }); + + it("should preserve new definition description when it differs from old config", () => { + const newConfig: NotificationsConfig = { + endpoints: [ + { + name: "Test Endpoint Description", + enabled: true, + isBanner: false, + correlationId: "Test Endpoint Description", + priority: Priority.medium, + url: "http://example.com", + method: "GET", + conditions: ["[BODY].value < 80"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: { title: "Test Title", description: "New Description" }, + metric: dummyMetric + } + ], + customEndpoints: [] + }; + const oldConfig: NotificationsConfig = { + endpoints: [ + { + name: "Test Endpoint Description", + enabled: false, + isBanner: false, + correlationId: "Test Endpoint Description", + priority: Priority.medium, + url: "http://example.com", + method: "GET", + conditions: ["[BODY].value < 80"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: { title: "Test Title", description: "Old Description" }, + metric: dummyMetric + } + ], + customEndpoints: [] + }; + const result = merger.applyPreviousEndpoints("dnp", true, newConfig, oldConfig); + expect(result.endpoints).to.have.lengthOf(1); + expect(result.endpoints![0].definition.description).to.equal("New Description"); + }); + + it("should persist updated custom endpoint description while keeping enabled and threshold", () => { + const newConfig: NotificationsConfig = { + endpoints: [], + customEndpoints: [ + { + name: "Custom Desc Change", + correlationId: "Custom Desc Change", + enabled: true, + isBanner: true, + description: "New Description", + metric: { treshold: 55, min: 0, max: 100, unit: "%" } + } + ] + }; + const oldConfig: NotificationsConfig = { + endpoints: [], + customEndpoints: [ + { + name: "Custom Desc Change", + correlationId: "Custom Desc Change", + enabled: false, + isBanner: false, + description: "Old Description", + metric: { treshold: 60, min: 0, max: 100, unit: "%" } + } + ] + }; + const result = merger.applyPreviousEndpoints("dnp", true, newConfig, oldConfig); + expect(result.customEndpoints).to.have.lengthOf(1); + const merged = result.customEndpoints![0]; + // enabled and threshold preserved from old + expect(merged.enabled).to.equal(false); + expect(merged.metric?.treshold).to.equal(60); + // description and isBanner updated from new + expect(merged.description).to.equal("New Description"); + expect(merged.isBanner).to.equal(true); + }); + + it("should apply multiple new field changes on Gatus and Custom endpoints and preserve only enabled and threshold", () => { + const newConfig: NotificationsConfig = { + endpoints: [ + { + name: "Multi Change", + correlationId: "multi-change", + enabled: true, + isBanner: true, + priority: Priority.high, + url: "http://new-url.example.com", + method: "POST", + conditions: ["[BODY].value < 90"], + interval: "45s", + group: "new-group", + alerts: [dummyAlert], + definition: { title: "New Title", description: "New Desc" }, + metric: dummyMetric + } + ], + customEndpoints: [ + { + name: "Multi Change Custom", + correlationId: "multi-change-custom", + enabled: true, + isBanner: true, + description: "New Custom Desc", + metric: { treshold: 75, min: 0, max: 100, unit: "%" } + } + ] + }; + const oldConfig: NotificationsConfig = { + endpoints: [ + { + name: "Multi Change", + correlationId: "multi-change", + enabled: false, + isBanner: false, + priority: Priority.low, + url: "http://old-url.example.com", + method: "GET", + conditions: ["[BODY].value < 50"], + interval: "30s", + group: "old-group", + alerts: [dummyAlert], + definition: { title: "Old Title", description: "Old Desc" }, + metric: dummyMetric + } + ], + customEndpoints: [ + { + name: "Multi Change Custom", + correlationId: "multi-change-custom", + enabled: false, + isBanner: false, + description: "Old Custom Desc", + metric: { treshold: 65, min: 0, max: 100, unit: "%" } + } + ] + }; + const result = merger.applyPreviousEndpoints("dnp", true, newConfig, oldConfig); + // Gatus endpoint: only enabled and threshold from old + const ge = result.endpoints![0]; + expect(ge.enabled).to.equal(false); + expect(ge.conditions[0]).to.equal("[BODY].value < 50"); + // All other fields from new + expect(ge.url).to.equal("http://new-url.example.com"); + expect(ge.method).to.equal("POST"); + expect(ge.priority).to.equal(Priority.high); + expect(ge.isBanner).to.equal(true); + expect(ge.definition.title).to.equal("New Title"); + + // Custom endpoint: only enabled and threshold from old + const ce = result.customEndpoints![0]; + expect(ce.enabled).to.equal(false); + expect(ce.metric?.treshold).to.equal(65); + // All other fields from new + expect(ce.description).to.equal("New Custom Desc"); + expect(ce.isBanner).to.equal(true); + }); +}); diff --git a/packages/notifications/tsconfig.json b/packages/notifications/tsconfig.json new file mode 100644 index 0000000000..860413fba5 --- /dev/null +++ b/packages/notifications/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + /* Modules */ + "types": ["node", "mocha"], + + /* Emit */ + "outDir": "dist", + + /* Language and Environment */ + "lib": ["ES2020", "ESNext", "ESNext.Promise", "DOM"] + }, + + "include": ["src/**/*", "src/**/*.json"], + "exclude": ["node_modules", "test/**/*", "dist"] +} diff --git a/packages/notifications/tsconfig.test.json b/packages/notifications/tsconfig.test.json new file mode 100644 index 0000000000..0cdb9a18a6 --- /dev/null +++ b/packages/notifications/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./test" // Root directory of input files + }, + "include": ["test"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/params/src/params.ts b/packages/params/src/params.ts index 09d07dfeba..d584f7b666 100644 --- a/packages/params/src/params.ts +++ b/packages/params/src/params.ts @@ -218,6 +218,7 @@ export const params = { wifiContainerName: "DAppNodeCore-wifi.dnp.dappnode.eth", ipfsDnpName: "ipfs.dnp.dappnode.eth", ipfsContainerName: "DAppNodeCore-ipfs.dnp.dappnode.eth", + notificationsDnpName: "notifications.dnp.dappnode.eth", vpnDataVolume: "dncore_vpndnpdappnodeeth_data", wireguardContainerName: "DAppNodeCore-wireguard.wireguard.dnp.dappnode.eth", restartContainerName: "DAppNodeTool-restart.dnp.dappnode.eth", @@ -253,6 +254,7 @@ export const params = { // Wi-Fi ENVs WIFI_KEY_SSID: "SSID", WIFI_KEY_PASSWORD: "WPA_PASSPHRASE", + WIFI_DEFAULT_PASSWORD: "dappnode", // Global ENVs dappnode prefix GLOBAL_ENVS_PREFIX: "_DAPPNODE_GLOBAL_", diff --git a/packages/schemas/package.json b/packages/schemas/package.json index eb4f241a59..f7a0cfc07e 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@dappnode/types": "^0.1.40", - "ajv": "^8.12.0", + "ajv": "^8.17.1", "semver": "^7.5.0" } } diff --git a/packages/schemas/src/ajv.ts b/packages/schemas/src/ajv.ts index 7db9b5fbb8..7b47f47039 100644 --- a/packages/schemas/src/ajv.ts +++ b/packages/schemas/src/ajv.ts @@ -1,9 +1,4 @@ -import _Ajv from "ajv"; - -const Ajv = _Ajv as unknown as typeof _Ajv.default; - -// TODO: fix once upstream issue is fixed -// https://github.com/ajv-validator/ajv/issues/2132 +import { Ajv } from "ajv"; export const ajv = new Ajv({ strict: false, diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index bcfe25b087..cfb74cec6f 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -2,3 +2,4 @@ export { validateComposeSchema } from "./validateComposeSchema.js"; export { validateManifestSchema } from "./validateManifestSchema.js"; export { validateSetupWizardSchema } from "./validateSetupWizardSchema.js"; export { validateDappnodeCompose } from "./validateDappnodeCompose.js"; +export { validateNotificationsSchema } from "./validateNotificationsSchema.js"; diff --git a/packages/schemas/src/schemas/notifications.schema.json b/packages/schemas/src/schemas/notifications.schema.json new file mode 100644 index 0000000000..45f3821876 --- /dev/null +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -0,0 +1,136 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/dappnode/DAppNode/raw/schema/notifications.schema.json", + "type": "object", + "title": "Notifications Configuration Schema", + "required": [], + "properties": { + "endpoints": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "correlationId", + "enabled", + "url", + "method", + "conditions", + "interval", + "group", + "alerts", + "definition", + "priority", + "isBanner" + ], + "properties": { + "name": { "type": "string" }, + "correlationId": { "type": "string", "pattern": "^[a-zA-Z]{3,}-[a-zA-Z0-9-]+$" }, + "enabled": { "type": "boolean" }, + "url": { "type": "string", "pattern": "^(https?|ftp):\\/\\/\\S+[\\s\\S]*$" }, + "method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"] }, + "conditions": { + "type": "array", + "items": { "type": "string" } + }, + "interval": { "type": "string", "pattern": "^[0-9]+[smhd]$" }, + "group": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" }, + "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, + "isBanner": { "type": "boolean" }, + "alerts": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "required": [ + "type", + "failure-threshold", + "success-threshold", + "send-on-resolved", + "description", + "enabled" + ], + "properties": { + "type": { "type": "string", "enum": ["custom"] }, + "failure-threshold": { "type": "integer", "minimum": 1 }, + "success-threshold": { "type": "integer", "minimum": 1 }, + "send-on-resolved": { "type": "boolean" }, + "description": { "type": "string" }, + "enabled": { "type": "boolean" } + } + } + }, + "definition": { + "type": "object", + "required": ["title", "description"], + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" } + } + }, + "metric": { + "type": "object", + "required": ["min", "max"], + "properties": { + "min": { "type": "number" }, + "max": { "type": "number" }, + "unit": { "type": "string" } + } + }, + "callToAction": { + "type": "object", + "required": ["title", "url"], + "properties": { + "title": { "type": "string" }, + "url": { "type": "string" } + } + }, + "requirements": { + "type": "object", + "properties": { + "pkgsInstalled": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+\\.(dnp|public)\\.dappnode\\.eth$": { + "type": "string", + "pattern": "^(\\^|~|>|>=|<|<=)?\\d+\\.\\d+\\.\\d+$" + } + }, + "additionalProperties": false + }, + "pkgsNotInstalled": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-zA-Z0-9._-]+\\.(dnp|public)\\.dappnode\\.eth$" } + } + } + } + } + } + }, + "customEndpoints": { + "type": "array", + "items": { + "type": "object", + "required": ["enabled", "name", "description", "isBanner", "correlationId"], + "properties": { + "correlationId": { "type": "string", "pattern": "^[a-zA-Z]{3,}-[a-zA-Z0-9-]+$" }, + "enabled": { "type": "boolean" }, + "name": { "type": "string" }, + "description": { "type": "string" }, + "isBanner": { "type": "boolean" }, + "metric": { + "type": "object", + "required": ["treshold", "min", "max"], + "properties": { + "treshold": { "type": "number" }, + "min": { "type": "number" }, + "max": { "type": "number" }, + "unit": { "type": "string" } + } + } + } + } + } + } +} diff --git a/packages/schemas/src/utils.ts b/packages/schemas/src/utils.ts index aee3dad9d2..5c69644ae2 100644 --- a/packages/schemas/src/utils.ts +++ b/packages/schemas/src/utils.ts @@ -53,7 +53,7 @@ import { ErrorObject } from "ajv"; */ export function processError( errorObject: ErrorObject, - releaseFileType: "compose" | "manifest" | "setupWizard" + releaseFileType: "compose" | "manifest" | "setupWizard" | "notifications" ): string { const { schemaPath, message } = errorObject; const path = `${releaseFileType}${schemaPath}`.replace(new RegExp("/", "g"), "."); diff --git a/packages/schemas/src/validateNotificationsSchema.ts b/packages/schemas/src/validateNotificationsSchema.ts new file mode 100644 index 0000000000..dc02950c10 --- /dev/null +++ b/packages/schemas/src/validateNotificationsSchema.ts @@ -0,0 +1,20 @@ +import { ajv } from "./ajv.js"; +import { CliError } from "./error.js"; +import { processError } from "./utils.js"; +import notificationsSchema from "./schemas/notifications.schema.json" with { type: "json" }; +import { NotificationsConfig } from "@dappnode/types"; + +/** + * Validates notifications.yaml file with schema + * @param config + */ +export function validateNotificationsSchema(config: NotificationsConfig): void { + const validateNotifications = ajv.compile(notificationsSchema); + const valid = validateNotifications(config); + if (!valid) { + const errors = validateNotifications.errors + ? validateNotifications.errors.map((e) => processError(e, "notifications")) + : []; + throw new CliError(`Invalid notifications configuration: \n${errors.map((msg) => ` - ${msg}`).join("\n")}`); + } +} diff --git a/packages/schemas/test/unit/validateSchema.test.ts b/packages/schemas/test/unit/validateSchema.test.ts index 1a84788608..1e4515c827 100644 --- a/packages/schemas/test/unit/validateSchema.test.ts +++ b/packages/schemas/test/unit/validateSchema.test.ts @@ -1,11 +1,17 @@ import { expect } from "chai"; -import { validateComposeSchema, validateManifestSchema, validateSetupWizardSchema } from "../../src/index.js"; +import { + validateComposeSchema, + validateManifestSchema, + validateSetupWizardSchema, + validateNotificationsSchema +} from "../../src/index.js"; import fs from "fs"; import path from "path"; import { cleanTestDir, testDir } from "../testUtils.js"; -import { Manifest, SetupWizard } from "@dappnode/types"; +import { Manifest, SetupWizard, NotificationsConfig, GatusEndpoint, CustomEndpoint } from "@dappnode/types"; +import { Priority } from "../../../types/src/notifications.js"; -describe("schemaValidation", function () { +describe.only("schemaValidation", function () { this.timeout(10000); describe("manifest", () => { before(() => { @@ -434,4 +440,360 @@ volumes: expect(() => validateManifestSchema(manifest)).to.not.throw(); }); }); + + describe("notifications", () => { + it("should validate a valid notifications configuration", () => { + const validNotifications: NotificationsConfig = { + endpoints: [ + { + name: "example-endpoint", + correlationId: "example-correlation-id", + enabled: true, + url: "http://example.com", + method: "POST", + conditions: ["response-time < 500ms", "status == 200"], + interval: "1m", + group: "example-group", + priority: Priority.low, + isBanner: false, + alerts: [ + { + type: "custom", + "failure-threshold": 3, + "success-threshold": 2, + "send-on-resolved": true, + description: "Custom alert description", + enabled: true + } + ], + definition: { + title: "Example Endpoint", + description: "An example endpoint for testing" + }, + metric: { + min: 0, + max: 1000, + unit: "ms" + } + } + ] + }; + + expect(() => validateNotificationsSchema(validNotifications)).to.not.throw(); + }); + + it("should throw an error for missing required fields", () => { + const invalidNotifications: Partial<NotificationsConfig> = { + endpoints: [ + { + name: "example-endpoint", + enabled: true, + url: "http://example.com", + method: "POST" + // Missing required fields like conditions, interval, group, alerts, and definition + } as GatusEndpoint + ] + }; + + expect(() => validateNotificationsSchema(invalidNotifications as NotificationsConfig)).to.throw( + "Invalid notifications configuration" + ); + }); + + it("should throw an error for invalid URL format", () => { + const invalidNotifications: NotificationsConfig = { + endpoints: [ + { + name: "example-endpoint", + correlationId: "example-correlation-id", + enabled: true, + url: "invalid-url", + method: "POST", + conditions: ["response-time < 500ms"], + interval: "1m", + group: "example-group", + priority: Priority.low, + isBanner: false, + alerts: [ + { + type: "response-time", + "failure-threshold": 3, + "success-threshold": 2, + "send-on-resolved": true, + description: "Response time exceeded", + enabled: true + } + ], + definition: { + title: "Example Endpoint", + description: "An example endpoint for testing" + } + } + ] + }; + + expect(() => validateNotificationsSchema(invalidNotifications)).to.throw("Invalid notifications configuration"); + }); + + it("should throw an error for invalid interval format", () => { + const invalidNotifications: NotificationsConfig = { + endpoints: [ + { + name: "example-endpoint", + correlationId: "example-correlation-id", + enabled: true, + url: "http://example.com", + method: "POST", + conditions: ["response-time < 500ms"], + interval: "invalid-interval", + group: "example-group", + priority: Priority.low, + isBanner: false, + alerts: [ + { + type: "response-time", + "failure-threshold": 3, + "success-threshold": 2, + "send-on-resolved": true, + description: "Response time exceeded", + enabled: true + } + ], + definition: { + title: "Example Endpoint", + description: "An example endpoint for testing" + } + } + ] + }; + + expect(() => validateNotificationsSchema(invalidNotifications)).to.throw("Invalid notifications configuration"); + }); + + it("should throw an error for missing alert fields", () => { + const invalidNotifications: NotificationsConfig = { + endpoints: [ + { + name: "example-endpoint", + enabled: true, + url: "http://example.com", + method: "POST", + conditions: ["response-time < 500ms"], + interval: "1m", + group: "example-group", + isBanner: false, + alerts: [ + { + type: "response-time", + "failure-threshold": 3, + // Missing success-threshold and other required fields + "send-on-resolved": true, + description: "Response time exceeded", + enabled: true + } + ], + definition: { + title: "Example Endpoint", + description: "An example endpoint for testing" + } + } as GatusEndpoint + ] + }; + + expect(() => validateNotificationsSchema(invalidNotifications)).to.throw("Invalid notifications configuration"); + }); + + it("should validate a valid notifications configuration with customEndpoints", () => { + const validNotifications: NotificationsConfig = { + customEndpoints: [ + { + enabled: true, + name: "custom-endpoint", + correlationId: "custom-correlation-id", + description: "A custom endpoint for testing", // Added required description + isBanner: false, + metric: { + treshold: 90, + min: 0, + max: 100, + unit: "%" + } + } + ] + }; + + expect(() => validateNotificationsSchema(validNotifications)).to.not.throw(); + }); + + it("should throw an error for missing required fields in customEndpoints", () => { + const invalidNotifications: NotificationsConfig = { + customEndpoints: [ + { + enabled: true, + name: "custom-endpoint", + // Missing required description field + group: "custom-group" + } as unknown as CustomEndpoint + ] + }; + + expect(() => validateNotificationsSchema(invalidNotifications)).to.throw("Invalid notifications configuration"); + }); + + it("should throw an error for invalid metric in customEndpoints", () => { + const invalidNotifications: NotificationsConfig = { + customEndpoints: [ + { + enabled: true, + name: "custom-endpoint", + description: "A custom endpoint for testing", + metric: { + treshold: "fd" as unknown as number, // Invalid treshold value + min: 0, + max: 100, + unit: "%" + } + } as CustomEndpoint + ] + }; + + expect(() => validateNotificationsSchema(invalidNotifications)).to.throw("Invalid notifications configuration"); + }); + + it("should validate a configuration with both endpoints and customEndpoints", () => { + const validNotifications: NotificationsConfig = { + endpoints: [ + { + name: "example-endpoint", + correlationId: "example-correlation-id", + enabled: true, + url: "http://example.com", + method: "POST", + conditions: ["response-time < 500ms", "status == 200"], + interval: "1m", + group: "example-group", + priority: Priority.low, + isBanner: false, + alerts: [ + { + type: "custom", + "failure-threshold": 3, + "success-threshold": 2, + "send-on-resolved": true, + description: "Response time exceeded", + enabled: true + } + ], + definition: { + title: "Example Endpoint", + description: "An example endpoint for testing" + }, + metric: { + min: 0, + max: 1000, + unit: "ms" + } + } + ], + customEndpoints: [ + { + enabled: true, + name: "custom-endpoint", + correlationId: "custom-correlation-id", + description: "A custom endpoint for testing", // Added required description + isBanner: false, + metric: { + treshold: 90, + min: 0, + max: 100, + unit: "%" + } + } + ] + }; + + expect(() => validateNotificationsSchema(validNotifications)).to.not.throw(); + }); + + it("should validate a notifications configuration with valid requirements and pkgsInstalled version ranges", () => { + const validNotifications: NotificationsConfig = { + endpoints: [ + { + name: "example-endpoint", + correlationId: "abc-example-endpoint", + enabled: true, + url: "http://example.com", + method: "POST", + conditions: ["response-time < 500ms", "status == 200"], + interval: "1m", + group: "example-group", + priority: Priority.low, + isBanner: false, + alerts: [ + { + type: "custom", + "failure-threshold": 3, + "success-threshold": 2, + "send-on-resolved": true, + description: "Custom alert description", + enabled: true + } + ], + definition: { + title: "Example Endpoint", + description: "An example endpoint for testing" + }, + requirements: { + pkgsInstalled: { + "geth.dnp.dappnode.eth": ">=0.4.3", + "other.dnp.dappnode.eth": "^1.2.3" + }, + pkgsNotInstalled: ["foo.dnp.dappnode.eth"] + } + } + ] + }; + expect(() => validateNotificationsSchema(validNotifications)).to.not.throw(); + }); + + it("should throw an error for requirements with invalid pkgsInstalled version range", () => { + const invalidNotifications: NotificationsConfig = { + endpoints: [ + { + name: "example-endpoint", + correlationId: "abc-example-endpoint", + enabled: true, + url: "http://example.com", + method: "POST", + conditions: ["response-time < 500ms", "status == 200"], + interval: "1m", + group: "example-group", + priority: Priority.low, + isBanner: false, + alerts: [ + { + type: "custom", + "failure-threshold": 3, + "success-threshold": 2, + "send-on-resolved": true, + description: "Custom alert description", + enabled: true + } + ], + definition: { + title: "Example Endpoint", + description: "An example endpoint for testing" + }, + requirements: { + pkgsInstalled: { + "geth.dnp.dappnode.eth": "invalid-version" + }, + pkgsNotInstalled: ["foo.dnp.dappnode.eth"] + } + } + ] + }; + expect(() => validateNotificationsSchema(invalidNotifications)).to.throw("Invalid notifications configuration"); + }); + }); }); diff --git a/packages/toolkit/src/repository/repository.ts b/packages/toolkit/src/repository/repository.ts index 924e9f4f2c..ca1aabe93f 100644 --- a/packages/toolkit/src/repository/repository.ts +++ b/packages/toolkit/src/repository/repository.ts @@ -205,7 +205,8 @@ export class DappnodeRepository extends ApmRepository { disclaimer: await this.getPkgAsset(releaseFilesToDownload.disclaimer, ipfsEntries), gettingStarted: await this.getPkgAsset(releaseFilesToDownload.gettingStarted, ipfsEntries), prometheusTargets: await this.getPkgAsset(releaseFilesToDownload.prometheusTargets, ipfsEntries), - grafanaDashboards: await this.getPkgAsset(releaseFilesToDownload.grafanaDashboards, ipfsEntries) + grafanaDashboards: await this.getPkgAsset(releaseFilesToDownload.grafanaDashboards, ipfsEntries), + notifications: await this.getPkgAsset(releaseFilesToDownload.notifications, ipfsEntries) }; } diff --git a/packages/types/src/calls.ts b/packages/types/src/calls.ts index 32254d41dc..e37c62f64c 100644 --- a/packages/types/src/calls.ts +++ b/packages/types/src/calls.ts @@ -2,6 +2,7 @@ import { ContainerState } from "./pkg.js"; import { ComposeNetworks, ComposeServiceNetworks, PackageEnvs } from "./compose.js"; import { Manifest, Dependencies, ChainDriver, PackageBackup, ManifestUpdateAlert } from "./manifest.js"; import { SetupWizard } from "./setupWizard.js"; +import { NotificationsSettingsAllDnps } from "./notifications.js"; /** * Take into account the following tags to document the new types inside this file @@ -75,6 +76,8 @@ export interface LoginStatusReturn { export interface WifiReport { info: string; + isDefaultPassphrase: boolean; + isRunning: boolean; report?: { lastLog: string; exitCode: number | null; @@ -276,6 +279,7 @@ export interface RequestedDnp { // Setup setupWizard?: SetupWizardAllDnps; settings: UserSettingsAllDnps; // MUST include the previous user settings + notificationsSettings?: NotificationsSettingsAllDnps; // Additional data imageSize: number; isUpdated: boolean; @@ -1081,7 +1085,8 @@ export type NewFeatureId = | "repository-fallback" | "system-auto-updates" | "enable-ethical-metrics" - | "change-host-password"; + | "change-host-password" + | "enable-notifications"; /** * ======= diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 61b5aa63dd..15c5bf572b 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -10,6 +10,7 @@ export * from "./releaseFiles.js"; export * from "./errors.js"; export * from "./routes.js"; export * from "./subscriptions.js"; +export * from "./notifications.js"; // utils export * from "./utils/index.js"; diff --git a/packages/types/src/manifest.ts b/packages/types/src/manifest.ts index f81ed8acdb..ef392d186c 100644 --- a/packages/types/src/manifest.ts +++ b/packages/types/src/manifest.ts @@ -1,3 +1,4 @@ +import { NotificationsConfig } from "./notifications.js"; import { SetupSchema, SetupTarget, SetupUiJson, SetupWizard } from "./setupWizard.js"; export interface Manifest { @@ -13,6 +14,7 @@ export interface Manifest { author?: string; license?: string; avatar?: string; + avatarUrl?: string; repository?: { type?: string; url?: string; @@ -98,6 +100,9 @@ export interface Manifest { // setupWizard for compacted manifests in core packages setupWizard?: SetupWizard; + + // notifications + notifications?: NotificationsConfig; } export interface UpstreamItem { diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts new file mode 100644 index 0000000000..9fb90ac8df --- /dev/null +++ b/packages/types/src/notifications.ts @@ -0,0 +1,112 @@ +export interface NotificationsConfig { + endpoints?: GatusEndpoint[]; + customEndpoints?: CustomEndpoint[]; +} + +export interface NotificationsSettingsAllDnps { + [dnpName: string]: NotificationsConfig; +} + +export interface Notification extends NotificationPayload { + id: number; + timestamp: number; + seen: boolean; +} + +export interface NotificationPayload { + title: string; + body: string; + dnpName: string; + category: Category; + priority: Priority; + status: Status; + isBanner: boolean; + isRemote: boolean; + icon?: string; + errors?: string; + callToAction?: CallToAction; + correlationId: string; +} + +export interface CallToAction { + title: string; + url: string; +} + +export enum Priority { + low = "low", + medium = "medium", + high = "high", + critical = "critical" +} + +export enum Status { + triggered = "triggered", + resolved = "resolved" +} + +export enum Category { + system = "system", + ethereum = "ethereum", + holesky = "holesky", + lukso = "lukso", + gnosis = "gnosis", + hoodi = "hoodi", + hardware = "hardware", + other = "other" +} + +export interface CustomEndpoint { + name: string; + enabled: boolean; + description: string; + isBanner: boolean; + correlationId: string; + metric?: { + treshold: number; + min: number; + max: number; + unit: string; + }; +} + +export interface GatusEndpoint { + name: string; + enabled: boolean; + url: string; + method: string; + conditions: string[]; + interval: string; // e.g., "1m" + group: string; + alerts: Alert[]; + + // Dappnode specific + correlationId: string; + priority: Priority; + isBanner: boolean; + callToAction?: CallToAction; + requirements?: Requirements; + definition: { + title: string; + description: string; + }; + metric?: { + min: number; + max: number; + unit: string; // e.g ºC + }; +} + +export interface Requirements { + pkgsInstalled: { [key: string]: string }; // i.e { "geth.dnp.dappnode.eth": "^0.1.2" } + pkgsNotInstalled: string[]; +} + +export interface Alert { + type: string; + "failure-threshold": number; + "success-threshold": number; + "send-on-resolved": boolean; + description: string; + enabled: boolean; +} diff --git a/packages/types/src/pkg.ts b/packages/types/src/pkg.ts index dd74b29166..263755d147 100644 --- a/packages/types/src/pkg.ts +++ b/packages/types/src/pkg.ts @@ -1,5 +1,6 @@ import { Compose } from "./compose.js"; import { Manifest, PrometheusTarget, GrafanaDashboard } from "./manifest.js"; +import { NotificationsConfig } from "./notifications.js"; import { SetupWizard } from "./setupWizard.js"; /** @@ -97,6 +98,7 @@ export type DirectoryFiles = { gettingStarted?: string; prometheusTargets?: PrometheusTarget[]; grafanaDashboards?: GrafanaDashboard[]; + notifications?: NotificationsConfig; }; export interface FileConfig { diff --git a/packages/types/src/releaseFiles.ts b/packages/types/src/releaseFiles.ts index 2c4d6df4b5..af7df593fd 100644 --- a/packages/types/src/releaseFiles.ts +++ b/packages/types/src/releaseFiles.ts @@ -84,6 +84,13 @@ export const releaseFiles = Object.freeze({ maxSize: 10e6, // ~ 10MB required: false as const, multiple: true as const + }), + notifications: Object.freeze({ + regex: /^.*notifications\.yaml$/, + format: FileFormat.YAML, + maxSize: 10e3, + required: false as const, + multiple: false as const }) } as const); @@ -95,5 +102,6 @@ export const releaseFilesToDownload = { disclaimer: releaseFiles.disclaimer, gettingStarted: releaseFiles.gettingStarted, prometheusTargets: releaseFiles.prometheusTargets, - grafanaDashboards: releaseFiles.grafanaDashboards + grafanaDashboards: releaseFiles.grafanaDashboards, + notifications: releaseFiles.notifications }; diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index c88d9303c7..11fbdc608a 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -30,7 +30,6 @@ import { PortToOpen, UpnpTablePortStatus, ApiTablePortStatus, - RebootRequiredScript, HostStatCpu, HostStatMemory, HostStatDisk, @@ -45,6 +44,13 @@ import { } from "./calls.js"; import { PackageEnvs } from "./compose.js"; import { PackageBackup } from "./manifest.js"; +import { + CustomEndpoint, + GatusEndpoint, + Notification, + NotificationsConfig, + NotificationsSettingsAllDnps +} from "./notifications.js"; import { TrustedReleaseKey } from "./pkg.js"; import { OptimismConfigSet, OptimismConfigGet } from "./rollups.js"; import { Network, StakerConfigGet, StakerConfigSet } from "./stakers.js"; @@ -236,11 +242,6 @@ export interface Routes { */ getEthicalMetricsConfig: () => Promise<EthicalMetricsConfig | null>; - /** - * Returns true if dappnode connected to internet - */ - getIsConnectedToInternet: () => Promise<boolean>; - /** * Return formated core update data */ @@ -261,6 +262,56 @@ export interface Routes { */ fetchDnpRequest: (kwargs: { id: string; version?: string }) => Promise<RequestedDnp>; + /** + * Get all the notifications + */ + notificationsGetAll(): Promise<Notification[]>; + + /** + * Get banner notifications that should be displayed within the given timestamp range + */ + notificationsGetBanner(timestamp: number): Promise<Notification[]>; + + /** + * Get unseen notifications count + */ + notificationsGetUnseenCount(): Promise<number>; + + /** + * Gatus get endpoints + */ + notificationsGetAllEndpoints(): Promise<{ + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; + }>; + + /** + * Set all non-banner notifications as seen + */ + notificationsSetAllSeen(): Promise<void>; + + /** + * Set a notification as seen by providing its correlationId + */ + notificationSetSeenByCorrelationID(correlationId: string): Promise<void>; + + /** + * Gatus update endpoint + */ + notificationsUpdateEndpoints: (kwargs: { + dnpName: string; + isCore: boolean; + notificationsConfig: NotificationsConfig; + }) => Promise<void>; + + /** + * Applies the previous endpoints configuration to the new ones if their names match + */ + notificationsApplyPreviousEndpoints: (kwargs: { + dnpName: string; + isCore: boolean; + newNotificationsConfig: NotificationsConfig; + }) => Promise<NotificationsConfig>; + /** * Returns the user action logs. This logs are stored in a different * file and format, and are meant to ease user support @@ -389,6 +440,7 @@ export interface Routes { name: string; version?: string; userSettings?: UserSettingsAllDnps; + notificationsSettings?: NotificationsSettingsAllDnps; options?: { /** * Forwarded option to dappGet @@ -519,11 +571,6 @@ export interface Routes { */ rebootHost: () => Promise<void>; - /** - * Returns true if a reboot is required - */ - rebootHostIsRequiredGet: () => Promise<RebootRequiredScript>; - /** Add a release key to trusted keys db */ releaseTrustedKeyAdd(newTrustedKey: TrustedReleaseKey): Promise<void>; /** List all keys from trusted keys db */ @@ -684,12 +731,19 @@ export const routesData: { [P in keyof Routes]: RouteData } = { enableEthicalMetrics: { log: true }, getCoreVersion: {}, getEthicalMetricsConfig: { log: true }, - getIsConnectedToInternet: {}, disableEthicalMetrics: { log: true }, fetchCoreUpdateData: {}, fetchDirectory: {}, fetchRegistry: {}, fetchDnpRequest: {}, + notificationsGetAll: {}, + notificationsGetBanner: {}, + notificationsGetUnseenCount: {}, + notificationsGetAllEndpoints: {}, + notificationsSetAllSeen: {}, + notificationSetSeenByCorrelationID: {}, + notificationsUpdateEndpoints: {}, + notificationsApplyPreviousEndpoints: {}, getUserActionLogs: {}, getHostUptime: {}, httpsPortalMappingAdd: { log: true }, @@ -732,7 +786,6 @@ export const routesData: { [P in keyof Routes]: RouteData } = { portsUpnpStatusGet: {}, portsApiStatusGet: {}, rebootHost: { log: true }, - rebootHostIsRequiredGet: {}, releaseTrustedKeyAdd: { log: true }, releaseTrustedKeyList: {}, releaseTrustedKeyRemove: { log: true }, diff --git a/packages/utils/src/getIsCore.ts b/packages/utils/src/getIsCore.ts index 8ebdd5eb0d..b6ab9ac2b0 100644 --- a/packages/utils/src/getIsCore.ts +++ b/packages/utils/src/getIsCore.ts @@ -1,5 +1,19 @@ import { Manifest } from "@dappnode/types"; +import { params } from "@dappnode/params"; -export function getIsCore(manifest: Manifest): boolean { - return manifest.type === "dncore"; +type Custom = Pick<Manifest, "type" | "name">; + +export function getIsCore(manifest: Custom): boolean { + if (manifest.type) return manifest.type === "dncore"; + return coreDnpNames.includes(manifest.name); } + +const coreDnpNames = [ + params.dappmanagerDnpName, + params.WIREGUARD_DNP_NAME, + params.vpnDnpName, + params.wifiDnpName, + params.bindDnpName, + params.ipfsDnpName, + params.HTTPS_PORTAL_DNPNAME +]; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index b2625f7596..62361276b9 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -37,6 +37,5 @@ export { shouldUpdate } from "./shouldUpdate.js"; export { getPublicIpFromUrls } from "./getPublicIpFromUrls.js"; export { computeSemverUpdateType } from "./computeSemverUpdateType.js"; export * from "./coreVersionId.js"; -export { writeManifest } from "./writeManifest.js"; export { readManifestIfExists } from "./readManifestIfExists.js"; export { removeCidrSuffix } from "./removeCidrSuffix.js"; diff --git a/packages/utils/src/readManifestIfExists.ts b/packages/utils/src/readManifestIfExists.ts index 48ce1f5f7d..5217242d83 100644 --- a/packages/utils/src/readManifestIfExists.ts +++ b/packages/utils/src/readManifestIfExists.ts @@ -4,6 +4,7 @@ import { getManifestPath } from "./getManifestPath.js"; import { isNotFoundError } from "./isNotFoundError.js"; import { validatePath } from "./validatePath.js"; import { yamlParse } from "./yaml.js"; +import { getIsCore } from "./getIsCore.js"; /** * Improve error reporting, know what type of parsing is failing. @@ -21,7 +22,8 @@ function readManifest(manfiestPath: string): Manifest { return parseManifest(fs.readFileSync(manfiestPath, "utf8")); } -export function readManifestIfExists({ dnpName, isCore }: { dnpName: string; isCore: boolean }): Manifest | null { +export function readManifestIfExists(dnpName: string): Manifest | null { + const isCore = getIsCore({ name: dnpName }); const manifestPath = validatePath(getManifestPath(dnpName, isCore)); try { return readManifest(manifestPath); diff --git a/packages/utils/src/writeManifest.ts b/packages/utils/src/writeManifest.ts deleted file mode 100644 index 00cc398a1c..0000000000 --- a/packages/utils/src/writeManifest.ts +++ /dev/null @@ -1,6 +0,0 @@ -import fs from "fs"; -import { Manifest } from "@dappnode/types"; - -export function writeManifest(manfiestPath: string, manifest: Manifest): void { - fs.writeFileSync(manfiestPath, JSON.stringify(manifest, null, 2)); -} diff --git a/yarn.lock b/yarn.lock index a6118021dc..3468c5a3c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1013,6 +1013,7 @@ __metadata: "@dappnode/hostscriptsservices": "workspace:^0.1.0" "@dappnode/installer": "workspace:^0.1.0" "@dappnode/logger": "workspace:^0.1.0" + "@dappnode/notifications": "workspace:^0.1.0" "@dappnode/params": "workspace:^0.1.0" "@dappnode/types": "workspace:^0.1.0" "@dappnode/upnpc": "workspace:^0.1.0" @@ -1051,6 +1052,7 @@ __metadata: "@dappnode/installer": "workspace:^0.1.0" "@dappnode/logger": "workspace:^0.1.0" "@dappnode/migrations": "workspace:^0.1.0" + "@dappnode/notifications": "workspace:^0.1.0" "@dappnode/optimism": "workspace:^0.1.0" "@dappnode/params": "workspace:^0.1.0" "@dappnode/stakers": "workspace:^0.1.0" @@ -1299,6 +1301,18 @@ __metadata: languageName: unknown linkType: soft +"@dappnode/notifications@workspace:^0.1.0, @dappnode/notifications@workspace:packages/notifications": + version: 0.0.0-use.local + resolution: "@dappnode/notifications@workspace:packages/notifications" + dependencies: + "@dappnode/dockerapi": "workspace:^0.1.0" + "@dappnode/types": "workspace:^0.1.0" + "@dappnode/utils": "workspace:^0.1.0" + "@types/mocha": "npm:^10" + mocha: "npm:^10.7.0" + languageName: unknown + linkType: soft + "@dappnode/optimism@workspace:^0.1.0, @dappnode/optimism@workspace:packages/optimism": version: 0.0.0-use.local resolution: "@dappnode/optimism@workspace:packages/optimism" @@ -1327,7 +1341,7 @@ __metadata: dependencies: "@dappnode/types": "npm:^0.1.40" "@types/mocha": "npm:^10" - ajv: "npm:^8.12.0" + ajv: "npm:^8.17.1" mocha: "npm:^10.7.0" semver: "npm:^7.5.0" languageName: unknown @@ -5269,6 +5283,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.17.1": + version: 8.17.1 + resolution: "ajv@npm:8.17.1" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10c0/ec3ba10a573c6b60f94639ffc53526275917a2df6810e4ab5a6b959d87459f9ef3f00d5e7865b82677cb7d21590355b34da14d1d0b9c32d75f95a187e76fff35 + languageName: node + linkType: hard + "ansi-colors@npm:4.1.1": version: 4.1.1 resolution: "ansi-colors@npm:4.1.1" @@ -8768,6 +8794,13 @@ __metadata: languageName: node linkType: hard +"fast-uri@npm:^3.0.1": + version: 3.0.6 + resolution: "fast-uri@npm:3.0.6" + checksum: 10c0/74a513c2af0584448aee71ce56005185f81239eab7a2343110e5bad50c39ad4fb19c5a6f99783ead1cac7ccaf3461a6034fda89fffa2b30b6d99b9f21c2f9d29 + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.15.0 resolution: "fastq@npm:1.15.0"