diff --git a/packages/admin-ui/src/__mock-backend__/index.ts b/packages/admin-ui/src/__mock-backend__/index.ts index b547e360dc..3191ae8a8e 100644 --- a/packages/admin-ui/src/__mock-backend__/index.ts +++ b/packages/admin-ui/src/__mock-backend__/index.ts @@ -386,7 +386,12 @@ export const otherCalls: Omit = { dockerLatestVersion: "20.10.8" }), getIsConnectedToInternet: async () => false, - getCoreVersion: async () => "0.2.92" + getCoreVersion: async () => "0.2.92", + gatusGetEndpoints: async () => { + return { "geth.dnp.dappnode.eth": [] }; + }, + gatusUpdateEndpoints: async () => {}, + gatuGetAllNotifications: async () => [] }; export const calls: Routes = { diff --git a/packages/admin-ui/src/components/Slider.scss b/packages/admin-ui/src/components/Slider.scss new file mode 100644 index 0000000000..fc74ee9c3e --- /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 { + color: #c0c0c0; + font-weight: bold; + min-width: fit-content; +} diff --git a/packages/admin-ui/src/components/Slider.tsx b/packages/admin-ui/src/components/Slider.tsx new file mode 100644 index 0000000000..86ec012bf0 --- /dev/null +++ b/packages/admin-ui/src/components/Slider.tsx @@ -0,0 +1,57 @@ +import React, { useState } 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); + + 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/sidebar/navbarItems.ts b/packages/admin-ui/src/components/sidebar/navbarItems.ts index 64c2ed1315..5f235ff485 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"; import { GiRolledCloth } from "react-icons/gi"; @@ -31,6 +32,7 @@ import { relativePath as communityRelativePath } from "pages/community"; import { relativePath as stakersRelativePath } from "pages/stakers"; import { relativePath as rollupsRelativePath } from "pages/rollups"; import { relativePath as repositoryRelativePath } from "pages/repository"; +import { relativePath as notificationsRelativePath } from "pages/notifications"; export const fundedBy: { logo: string; text: string; link: string }[] = [ { @@ -116,6 +118,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/pages/index.ts b/packages/admin-ui/src/pages/index.ts index 5534631d35..94e432de06 100644 --- a/packages/admin-ui/src/pages/index.ts +++ b/packages/admin-ui/src/pages/index.ts @@ -10,6 +10,7 @@ import * as community from "./community"; import * as stakers from "./stakers"; import * as rollups from "./rollups"; import * as repository from "./repository"; +import * as notifications from "./notifications"; export const pages = { dashboard, @@ -23,7 +24,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 251ccdb55b..141bb82261 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"; @@ -28,6 +28,7 @@ import { 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"; interface InstallDnpViewProps { dnp: RequestedDnp; @@ -54,6 +55,8 @@ const InstallDnpView: React.FC = ({ dnp, progressLogs }) => const dispatch = useDispatch(); const { dnpName, reqVersion, semVersion, settings, manifest, setupWizard, isInstalled, installedVersion } = dnp; + console.log("manifest", manifest); + const updateType = installedVersion && diff(installedVersion, semVersion); const areUpdateWarnings = manifest.warnings?.onPatchUpdate || manifest.warnings?.onMinorUpdate || manifest.warnings?.onMajorUpdate; @@ -172,6 +175,12 @@ 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 === "notifications.dnp.dappnode.eth"); + + const dnpNotificationEndpoints = manifest.notifications?.endpoints || []; + const disableInstallation = !isEmpty(progressLogs) || requiresCoreUpdate || requiresDockerUpdate || packagesToBeUninstalled.length > 0; @@ -179,6 +188,7 @@ const InstallDnpView: React.FC = ({ dnp, progressLogs }) => const permissionsSubPath = "permissions"; const warningsSubPath = "warnings"; const disclaimerSubPath = "disclaimer"; + const notificationsSubPath = "notifications"; const installSubPath = "install"; const availableRoutes: { @@ -228,6 +238,12 @@ const InstallDnpView: React.FC = ({ dnp, progressLogs }) => render: () => , available: disclaimers.length > 0 }, + { + name: "Notifications", + subPath: notificationsSubPath, + render: () => , + available: isNotificationsPkgInstalled && dnpNotificationEndpoints && dnpNotificationEndpoints.length > 0 + }, // 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..22369c4aee --- /dev/null +++ b/packages/admin-ui/src/pages/installer/components/Steps/Notifications.tsx @@ -0,0 +1,49 @@ +import React, { useMemo } from "react"; +import Button from "components/Button"; +import { ManagePackageSection } from "pages/notifications/tabs/Settings/components/ManagePackageSection"; +import { Endpoint } from "@dappnode/types"; +import { useApi } from "api"; + +interface NotificationsProps { + endpoints: Endpoint[]; + dnpName: string; + goNext: () => void; + goBack: () => void; +} + +export const Notifications: React.FC = ({ endpoints, goBack, goNext, dnpName }) => { + const endpointsCall = useApi.gatusGetEndpoints(); + const pkgEndpointsData = endpointsCall.data?.[dnpName]; + + // Merge endpoints in order to preserve existing endpoints config when updating a package + const mergedEndpoints = useMemo(() => { + if (!pkgEndpointsData) return endpoints; + + const endpointMap = new Map(); + + endpoints.forEach((endpoint) => { + endpointMap.set(endpoint.name, endpoint); + }); + + pkgEndpointsData.forEach((retrievedEndpoint: Endpoint) => { + endpointMap.set(retrievedEndpoint.name, { + ...endpointMap.get(retrievedEndpoint.name), // Preserve existing config if it exists + ...retrievedEndpoint, // Overwrite with new data + }); + }); + + return Array.from(endpointMap.values()); + }, [endpoints, pkgEndpointsData]); + + 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..17eacf8fb0 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { Routes, Route, NavLink } from "react-router-dom"; +import { useApi } from "api"; +// Own module +import { title, subPaths } from "./data"; +import { Inbox } from "./tabs/Inbox/Inbox"; +import { NotificationsSettings } from "./tabs/Settings/Settings"; +import { InstallNotificationsPkg } from "./tabs/InstallNotifications/InstallNotifications"; +// Components +import Title from "components/Title"; +import { renderResponse } from "components/SwrRender"; + +export const NotificationsRoot: React.FC = () => { + const availableRoutes: { + name: string; + subPath: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component: React.ComponentType; + }[] = [ + { + name: "Inbox", + subPath: subPaths.inbox, + component: Inbox + }, + { + name: "Settings", + subPath: subPaths.settings, + component: NotificationsSettings + } + ]; + + const dnpsRequest = useApi.packagesGet(); + + return renderResponse(dnpsRequest, ["Loading notifications"], (dnps) => { + const notificationsDnpName = "notifications.dnp.dappnode.eth"; + const isNotificationsPkgInstalled = dnps?.some((dnp) => dnp.dnpName === notificationsDnpName); + + return ( + <> + + {!isNotificationsPkgInstalled ? ( + <InstallNotificationsPkg pkgName={notificationsDnpName} /> + ) : ( + <> + <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..0e09541383 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/data.ts @@ -0,0 +1,11 @@ +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" +}; 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..37b01edfcc --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/index.ts @@ -0,0 +1,4 @@ +import { NotificationsRoot } from "./NotificationsRoot"; + +export { rootPath, relativePath } 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..a681d32ab6 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx @@ -0,0 +1,64 @@ +import SubTitle from "components/SubTitle"; +import React from "react"; +import Card from "components/Card"; +import "./inbox.scss"; +import { NotificationCard } from "./components/NotificationsCard"; +import { useApi } from "api"; +import Loading from "components/Loading"; +import defaultAvatar from "img/defaultAvatar.png"; +import dappnodeIcon from "img/dappnode-logo-only.png"; + +export function Inbox() { + const dnpsRequest = useApi.packagesGet(); + const notifications = useApi.gatuGetAllNotifications(); + const newNotifications = notifications.data + ?.filter((notification) => !notification.seen) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const seenNotifications = notifications.data + ?.filter((notification) => notification.seen) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const loading = dnpsRequest.isValidating; + const installedDnps = dnpsRequest.data; + const findPkgAvatar = (dnpName: string) => { + const dnp = installedDnps?.find((dnp) => dnp.dnpName === dnpName); + + if (!dnp) { + return defaultAvatar; + } else if (dnp.isCore) { + return dappnodeIcon; + } + return dnp.avatarUrl; + }; + + return loading ? ( + <Loading steps={["Loading data"]} /> + ) : ( + <> + {newNotifications && newNotifications.length > 0 && ( + <> + <SubTitle>New Notifications</SubTitle> + {newNotifications.map((notification) => ( + <NotificationCard + key={notification.timestamp} + notification={notification} + avatarUrl={findPkgAvatar(notification.dnpName)} + /> + ))} + </> + )} + + <SubTitle>History</SubTitle> + {!seenNotifications || seenNotifications.length === 0 ? ( + <Card>No notifications</Card> + ) : ( + seenNotifications.map((notification) => ( + <NotificationCard + key={notification.timestamp} + notification={notification} + avatarUrl={findPkgAvatar(notification.dnpName)} + /> + )) + )} + </> + ); +} diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/components/NotificationsCard.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/components/NotificationsCard.tsx new file mode 100644 index 0000000000..e145155d17 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/components/NotificationsCard.tsx @@ -0,0 +1,43 @@ +import React, { 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"; + +interface NotificationCardProps { + notification: Notification; + avatarUrl: string; +} + +export function NotificationCard({ notification, avatarUrl }: NotificationCardProps) { + const [isOpen, setIsOpen] = useState(false); + + 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={avatarUrl} 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="group-label">{notification.category}</div> + {notification.body.includes("Resolved: ") && <div className="sucess-label">resolved</div>} + {notification.body.includes("Triggered: ") && <div className="trigger-label">triggered</div>} + </div> + + <i>{new Date(notification.timestamp).toLocaleString()}</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">{notification.body}</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..c90c129828 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss @@ -0,0 +1,113 @@ +.notification-card { + border-radius: 10px; + background-color: #e9ecef; + padding: 10px; + cursor: pointer; + + .notification-header { + display: flex; + flex-direction: row; + gap: 10px; + align-items: center; + width: 100%; + + .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; + } + } + .secondary-text { + color: var(--light-text-color); + font-size: 1rem; + @media (max-width: 60rem) { + font-size: 0.8rem; + flex-direction: column-reverse; + } + } + + .group-label { + display: flex; + align-items: center; + background-color: var(--color-light-border); + padding: 0px 5px; + border-radius: 8px; + font-size: 0.7rem; + } + .sucess-label { + @extend .group-label; + background-color: rgba(0, 109, 29, 0.7) !important; + color: white; + } + .trigger-label { + @extend .group-label; + background-color: rgba(255, 30, 29, 0.5) !important; + color: white; + } + } + } + + .notification-body { + padding-top: 10px; + font-size: 1rem; + } +} +.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); + } + } + } + + .notification-card:hover { + background-color: var(--color-dark-card-hover); + } +} 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..3cd16c89ac --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/InstallNotifications/InstallNotifications.tsx @@ -0,0 +1,26 @@ +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"; + +interface InstallNotificationsPkgProps { + pkgName: string; +} + +export const InstallNotificationsPkg: React.FC<InstallNotificationsPkgProps> = ({ pkgName }) => { + const installerPath = getInstallerPath(pkgName); + + 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 + "/" + pkgName}> + <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/notifications/tabs/Settings/Settings.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx new file mode 100644 index 0000000000..f67c31e680 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx @@ -0,0 +1,42 @@ +import SubTitle from "components/SubTitle"; +import React, { useState } from "react"; +import Switch from "components/Switch"; +import { ManagePackageSection } from "./components/ManagePackageSection"; +import { useApi } from "api"; +import "./settings.scss"; + +export function NotificationsSettings() { + const [notificationsEnabled, setNotificationsEnabled] = useState(true); + const endpointsCall = useApi.gatusGetEndpoints(); + + return ( + <div className="notifications-settings"> + <div> + <div className="title-switch-row"> + <SubTitle className="notifications-section-title">Enable notifications</SubTitle> + <Switch + checked={notificationsEnabled} + onToggle={() => { + setNotificationsEnabled(!notificationsEnabled); + }} + /> + </div> + <div>Enable notifications to retrieve a registry of notifications on your Dappnode.</div> + </div> + <br /> + {notificationsEnabled && ( + <div> + <SubTitle className="notifications-section-title">Manage notifications</SubTitle> + <div>Enable, disable and customize notifications individually.</div> + <br /> + <div className="manage-notifications-wrapper"> + {endpointsCall.data && + Object.entries(endpointsCall.data).map(([dnpName, endpoints]) => ( + <ManagePackageSection key={dnpName} dnpName={dnpName} endpoints={endpoints} /> + ))} + </div> + </div> + )} + </div> + ); +} diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/components/EndpointItem.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/components/EndpointItem.tsx new file mode 100644 index 0000000000..1230b30c75 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/components/EndpointItem.tsx @@ -0,0 +1,84 @@ +import React, { useState } from "react"; +import { Endpoint } from "@dappnode/types"; +import Switch from "components/Switch"; +import Slider from "components/Slider"; + +interface EndpointItemProps { + endpoint: Endpoint; + index: number; + numEndpoints: number; + setPkgEndpoints: React.Dispatch<React.SetStateAction<Endpoint[]>>; +} + +export function EndpointItem({ endpoint, index, numEndpoints, setPkgEndpoints }: EndpointItemProps) { + 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)); + + const handleEndpointToggle = () => { + setPkgEndpoints((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]; + + setPkgEndpoints((prevEndpoints) => + prevEndpoints.map((ep, i) => + i === index + ? { + ...ep, + conditions: [ + updatedCondition, // Update ONLY the first condition + ...ep.conditions.slice(1) + ] + } + : ep + ) + ); + }; + + return ( + <> + <div key={index} className="endpoint-row"> + <div> + <strong>{endpoint.definition.title}</strong> + <div>{endpoint.definition.description}</div> + </div> + <Switch checked={endpointEnabled} onToggle={handleEndpointToggle} /> + </div> + {endpointEnabled && endpoint.metric && ( + <div className="slider-wrapper"> + <Slider + value={sliderValue} + onChange={handleSliderUpdate} + onChangeComplete={handleSliderUpdateComplete} + min={endpoint.metric.min} + max={endpoint.metric.max} + unit={endpoint.metric.unit} + /> + </div> + )} + {index + 1 < numEndpoints && <hr />} + </> + ); +} diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/components/ManagePackageSection.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/components/ManagePackageSection.tsx new file mode 100644 index 0000000000..466a039a2b --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/components/ManagePackageSection.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from "react"; +import SubTitle from "components/SubTitle"; +import Switch from "components/Switch"; +import { EndpointItem } from "./EndpointItem"; +import { Endpoint } from "@dappnode/types"; +import { prettyDnpName } from "utils/format"; +import { api } from "api"; + +interface ManagePackageSectionProps { + dnpName: string; + endpoints: Endpoint[]; +} + +export function ManagePackageSection({ dnpName, endpoints }: ManagePackageSectionProps) { + const [pkgEndpoints, setPkgEndpoints] = useState(endpoints); + const [pkgNotificationsEnabled, setPkgNotificationsEnabled] = useState(endpoints.some((ep) => ep.enabled)); + + // Sync state when `endpoints` prop changes, but keep user modifications + useEffect(() => { + setPkgEndpoints((prevPkgEndpoints) => { + const updatedEndpoints = endpoints.map((newEp) => { + const existingEp = prevPkgEndpoints.find((ep) => ep.name === newEp.name); + return existingEp ? { ...existingEp, ...newEp } : newEp; + }); + return updatedEndpoints; + }); + }, [endpoints]); + + // Handle switch toggle to enable/disable all endpoints + const handlePkgToggle = () => { + const newEnabledState = !pkgNotificationsEnabled; + setPkgEndpoints((prevPkgEndpoints) => prevPkgEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState }))); + setPkgNotificationsEnabled(newEnabledState); + }; + + useEffect(() => { + // TODO: Implement timeOut that waits for more config updates before sending the new Endpoints config + api.gatusUpdateEndpoints({ dnpName, updatedEndpoints: pkgEndpoints }); + }, [pkgEndpoints]); + 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} /> + </div> + {pkgNotificationsEnabled && ( + <div className="endpoint-list-card"> + {pkgEndpoints.map((endpoint, i) => ( + <EndpointItem + key={endpoint.name} + endpoint={endpoint} + index={i} + numEndpoints={pkgEndpoints.length} + setPkgEndpoints={setPkgEndpoints} + /> + ))} + </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..e826517ea0 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/settings.scss @@ -0,0 +1,58 @@ +.notifications-settings { + .title-switch-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + } + .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; + padding: 20px 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); + } + } +} diff --git a/packages/dappmanager/src/api/routes/packageManifest.ts b/packages/dappmanager/src/api/routes/packageManifest.ts index d8b38b9d59..bbc63afb0c 100644 --- a/packages/dappmanager/src/api/routes/packageManifest.ts +++ b/packages/dappmanager/src/api/routes/packageManifest.ts @@ -54,7 +54,8 @@ export const packageManifest = wrapHandler<Params>(async (req, res) => { "links", "repository", "bugs", - "license" + "license", + "notifications" ]); res.status(200).send(filteredManifest); diff --git a/packages/dappmanager/src/calls/gatusConfig.ts b/packages/dappmanager/src/calls/gatusConfig.ts new file mode 100644 index 0000000000..64726e0a16 --- /dev/null +++ b/packages/dappmanager/src/calls/gatusConfig.ts @@ -0,0 +1,66 @@ +import { listPackages } from "@dappnode/dockerapi"; +import { Endpoint, Manifest, Notification } from "@dappnode/types"; +import { getManifestPath } from "@dappnode/utils"; +import fs from "fs"; + +/** + * Get all the notifications + * @returns all the notifications + */ +export async function gatuGetAllNotifications(): Promise<Notification[]> { + const response = await fetch(`http://notifier.notifications.dappnode:8080/api/v1/notifications`); + return response.json(); +} + +/** + * Get gatus endpoints indexed by dnpName + */ +export async function gatusGetEndpoints(): Promise<{ [dnpName: string]: Endpoint[] }> { + const packages = await listPackages(); + + // Read all manifests files and retrieve the gatus config + const endpoints: { [dnpName: string]: Endpoint[] } = {}; + for (const pkg of packages) { + const manifestPath = getManifestPath(pkg.dnpName, pkg.isCore); + const manifest: Manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (manifest.notifications) { + endpoints[pkg.dnpName] = manifest.notifications.endpoints; + } + } + + return endpoints; +} + +/** + * Update endpoint properties + * @param dnpName + * @param updatedEndpoints + */ +export async function gatusUpdateEndpoints({ + dnpName, + updatedEndpoints +}: { + dnpName: string; + updatedEndpoints: Endpoint[]; +}): Promise<void> { + // Get current endpoint status + const manifest: Manifest = JSON.parse(fs.readFileSync(getManifestPath(dnpName, false), "utf8")); + if (!manifest.notifications) throw new Error("No notifications found in manifest"); + + const endpoints = manifest.notifications.endpoints; + if (!endpoints) throw new Error(`No endpoints found in manifest`); + + // Update endpoint + Object.assign(endpoints, updatedEndpoints); + + // Save manifest + fs.writeFileSync(getManifestPath(dnpName, false), JSON.stringify(manifest, null, 2)); + + // Trigger reload. Gatus will execute reload at a minimum interval of x seconds + await fetch(`http://notifier.notifications.dappnode:8082/api/v1/gatus/endpoints/reload`, { + method: "POST", + headers: { + "Content-Type": "application/json" + } + }); +} diff --git a/packages/dappmanager/src/calls/index.ts b/packages/dappmanager/src/calls/index.ts index a61851ced7..8c5254db63 100644 --- a/packages/dappmanager/src/calls/index.ts +++ b/packages/dappmanager/src/calls/index.ts @@ -22,6 +22,7 @@ export { getCoreVersion } from "./getCoreVersion.js"; export { getUserActionLogs } from "./getUserActionLogs.js"; export { getHostUptime } from "./getHostUptime.js"; export { getIsConnectedToInternet } from "./getIsConnectedToInternet.js"; +export { gatusGetEndpoints, gatusUpdateEndpoints, gatuGetAllNotifications } from "./gatusConfig.js"; export * from "./httpsPortal.js"; export { ipfsTest } from "./ipfsTest.js"; export { ipfsClientTargetSet } from "./ipfsClientTargetSet.js"; diff --git a/packages/installer/src/dappnodeInstaller.ts b/packages/installer/src/dappnodeInstaller.ts index 40aa987a35..42786a7a4b 100644 --- a/packages/installer/src/dappnodeInstaller.ts +++ b/packages/installer/src/dappnodeInstaller.ts @@ -11,7 +11,8 @@ import { PackageRequest, SetupWizard, GrafanaDashboard, - PrometheusTarget + PrometheusTarget, + GatusConfig } from "@dappnode/types"; import { DappGetState, DappgetOptions, dappGet } from "./dappGet/index.js"; import { validateDappnodeCompose, validateManifestSchema } from "@dappnode/schemas"; @@ -72,7 +73,8 @@ export class DappnodeInstaller extends DappnodeRepository { disclaimer: pkgRelease.disclaimer, gettingStarted: pkgRelease.gettingStarted, grafanaDashboards: pkgRelease.grafanaDashboards, - prometheusTargets: pkgRelease.prometheusTargets + prometheusTargets: pkgRelease.prometheusTargets, + notifications: pkgRelease.notifications }); // set compose to custom dappnode compose in release @@ -107,7 +109,8 @@ export class DappnodeInstaller extends DappnodeRepository { disclaimer: pkgRelease.disclaimer, gettingStarted: pkgRelease.gettingStarted, grafanaDashboards: pkgRelease.grafanaDashboards, - prometheusTargets: pkgRelease.prometheusTargets + prometheusTargets: pkgRelease.prometheusTargets, + notifications: pkgRelease.notifications }); }); @@ -151,7 +154,8 @@ export class DappnodeInstaller extends DappnodeRepository { disclaimer, gettingStarted, prometheusTargets, - grafanaDashboards + grafanaDashboards, + notifications }: { manifest: Manifest; SetupWizard?: SetupWizard; @@ -159,12 +163,14 @@ export class DappnodeInstaller extends DappnodeRepository { gettingStarted?: string; prometheusTargets?: PrometheusTarget[]; grafanaDashboards?: GrafanaDashboard[]; + notifications?: GatusConfig; }): 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; return manifest; } 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/toolkit/src/repository/repository.ts b/packages/toolkit/src/repository/repository.ts index a57c3cf632..7d204faf78 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/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..74e9042299 100644 --- a/packages/types/src/manifest.ts +++ b/packages/types/src/manifest.ts @@ -1,3 +1,4 @@ +import { GatusConfig } from "./notifications.js"; import { SetupSchema, SetupTarget, SetupUiJson, SetupWizard } from "./setupWizard.js"; export interface Manifest { @@ -98,6 +99,9 @@ export interface Manifest { // setupWizard for compacted manifests in core packages setupWizard?: SetupWizard; + + // notifications + notifications?: GatusConfig; } export interface UpstreamItem { diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts new file mode 100644 index 0000000000..3c181bfe7c --- /dev/null +++ b/packages/types/src/notifications.ts @@ -0,0 +1,47 @@ +export interface Notification { + title: string; + body: string; + dnpName: string; + timestamp: string; + category: string; + seen: boolean; + callToAction?: { + title: string; + url: string; + }; +} + +export interface GatusConfig { + endpoints: Endpoint[]; +} + +export interface Endpoint { + name: string; + enabled: boolean; + url: string; + method: string; + conditions: string[]; + interval: string; // e.g., "1m" + group: string; + alerts: Alert[]; + definition: { + // dappnode specific + title: string; + description: string; + }; + metric?: { + // dappnode specific + min: number; + max: number; + unit: string; // e.g ÂșC + }; +} + +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..16d9d6a973 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 { GatusConfig } from "./notifications.js"; import { SetupWizard } from "./setupWizard.js"; /** @@ -97,6 +98,7 @@ export type DirectoryFiles = { gettingStarted?: string; prometheusTargets?: PrometheusTarget[]; grafanaDashboards?: GrafanaDashboard[]; + notifications?: GatusConfig; }; 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 5173890dc1..514d9b82f6 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -45,9 +45,11 @@ import { } from "./calls.js"; import { PackageEnvs } from "./compose.js"; import { PackageBackup } from "./manifest.js"; +import { Endpoint } from "./notifications.js"; import { TrustedReleaseKey } from "./pkg.js"; import { OptimismConfigSet, OptimismConfigGet } from "./rollups.js"; import { Network, StakerConfigGet, StakerConfigSet } from "./stakers.js"; +import { Notification } from "./notifications.js"; export interface Routes { /** @@ -261,6 +263,21 @@ export interface Routes { */ fetchDnpRequest: (kwargs: { id: string; version?: string }) => Promise<RequestedDnp>; + /** + * Gatus get all notifications + */ + gatuGetAllNotifications(): Promise<Notification[]>; + + /** + * Gatus get endpoints + */ + gatusGetEndpoints(): Promise<{ [dnpName: string]: Endpoint[] }>; + + /** + * Gatus update endpoint + */ + gatusUpdateEndpoints: (kwargs: { dnpName: string; updatedEndpoints: Endpoint[] }) => Promise<void>; + /** * Returns the user action logs. This logs are stored in a different * file and format, and are meant to ease user support @@ -690,6 +707,9 @@ export const routesData: { [P in keyof Routes]: RouteData } = { fetchDirectory: {}, fetchRegistry: {}, fetchDnpRequest: {}, + gatuGetAllNotifications: { log: true }, + gatusGetEndpoints: { log: true }, + gatusUpdateEndpoints: { log: true }, getUserActionLogs: {}, getHostUptime: {}, httpsPortalMappingAdd: { log: true }, 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/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)); -}