From 2be181fea3a34ad3927bd46b7fc0995a34cc5c19 Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Tue, 18 Mar 2025 11:52:26 +0100 Subject: [PATCH 01/90] Implement new release file notifications.yaml --- .../src/api/routes/packageManifest.ts | 3 +- packages/installer/src/dappnodeInstaller.ts | 14 ++++-- .../test/unit/release/findEntries.test.ts | 4 +- packages/toolkit/src/repository/repository.ts | 4 +- packages/types/src/index.ts | 1 + packages/types/src/manifest.ts | 4 ++ packages/types/src/notifications.ts | 47 +++++++++++++++++++ packages/types/src/pkg.ts | 2 + packages/types/src/releaseFiles.ts | 10 +++- 9 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 packages/types/src/notifications.ts 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(async (req, res) => { "links", "repository", "bugs", - "license" + "license", + "notifications" ]); res.status(200).send(filteredManifest); 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/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 c86992a794..53953ec005 100644 --- a/packages/toolkit/src/repository/repository.ts +++ b/packages/toolkit/src/repository/repository.ts @@ -70,7 +70,6 @@ export class DappnodeRepository extends ApmRepository { private async pinAddNoThrow(hash: any): Promise { try { await this.ipfs.pin.add(hash); - } catch (e) { // Do not spam the terminal // console.error(`Error pinning ${hash}`, e); @@ -205,7 +204,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 }; From 5f94bff01c334762f29ebf4515827a8ece9817b7 Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Tue, 18 Mar 2025 12:05:15 +0100 Subject: [PATCH 02/90] Implement backend API calls --- .../admin-ui/src/__mock-backend__/index.ts | 7 +- packages/dappmanager/src/calls/gatusConfig.ts | 68 +++++++++++++++++++ packages/dappmanager/src/calls/index.ts | 1 + .../src/installer/writeAndValidateFiles.ts | 13 +++- packages/types/src/routes.ts | 19 ++++++ packages/utils/src/index.ts | 1 - packages/utils/src/writeManifest.ts | 6 -- 7 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 packages/dappmanager/src/calls/gatusConfig.ts delete mode 100644 packages/utils/src/writeManifest.ts 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/dappmanager/src/calls/gatusConfig.ts b/packages/dappmanager/src/calls/gatusConfig.ts new file mode 100644 index 0000000000..0fc2476a8d --- /dev/null +++ b/packages/dappmanager/src/calls/gatusConfig.ts @@ -0,0 +1,68 @@ +import { listPackages } from "@dappnode/dockerapi"; +import { Endpoint, Manifest, Notification } from "@dappnode/types"; +import { getManifestPath } from "@dappnode/utils"; +import fs from "fs"; + +const BASE_URL = "http://notifier.notifications.dappnode"; + +/** + * Get all the notifications + * @returns all the notifications + */ +export async function gatuGetAllNotifications(): Promise { + const response = await fetch(new URL(":8080/api/v1/notifications", BASE_URL).toString()); + 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 { + // 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(new URL(":8082/api/v1/gatus/endpoints/reload", BASE_URL).toString(), { + 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/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/types/src/routes.ts b/packages/types/src/routes.ts index 5173890dc1..77ec1c4fee 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -45,6 +45,7 @@ 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"; @@ -261,6 +262,21 @@ export interface Routes { */ fetchDnpRequest: (kwargs: { id: string; version?: string }) => Promise; + /** + * Gatus get all notifications + */ + gatuGetAllNotifications(): Promise; + + /** + * Gatus get endpoints + */ + gatusGetEndpoints(): Promise<{ [dnpName: string]: Endpoint[] }>; + + /** + * Gatus update endpoint + */ + gatusUpdateEndpoints: (kwargs: { dnpName: string; updatedEndpoints: Endpoint[] }) => Promise; + /** * Returns the user action logs. This logs are stored in a different * file and format, and are meant to ease user support @@ -690,6 +706,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)); -} From c54bd624d26e4e03eafb6dcadafe02b57bae5f26 Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Tue, 18 Mar 2025 13:05:17 +0100 Subject: [PATCH 03/90] bump ajv and fix import issue --- packages/schemas/package.json | 2 +- packages/schemas/src/ajv.ts | 7 +------ yarn.lock | 21 ++++++++++++++++++++- 3 files changed, 22 insertions(+), 8 deletions(-) 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/yarn.lock b/yarn.lock index 0a6589d151..8b40641ca2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1327,7 +1327,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 @@ -5193,6 +5193,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" @@ -8670,6 +8682,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" From 1ba25fcaf6389c2dc93ca46486208f59e9ddf42c Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Tue, 18 Mar 2025 13:15:24 +0100 Subject: [PATCH 04/90] Implement notifications file and json schema --- packages/schemas/src/index.ts | 1 + .../src/schemas/notifications.schema.json | 66 +++++++ packages/schemas/src/utils.ts | 2 +- .../src/validateNotificationsSchema.ts | 20 +++ .../schemas/test/unit/validateSchema.test.ts | 162 +++++++++++++++++- 5 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 packages/schemas/src/schemas/notifications.schema.json create mode 100644 packages/schemas/src/validateNotificationsSchema.ts 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..e789cba236 --- /dev/null +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -0,0 +1,66 @@ +{ + "$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": ["endpoints"], + "properties": { + "endpoints": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "enabled", "url", "method", "conditions", "interval", "group", "alerts", "definition"], + "properties": { + "name": { "type": "string" }, + "enabled": { "type": "boolean" }, + "url": { "type": "string", "format": "uri" }, + "method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"] }, + "conditions": { + "type": "array", + "items": { "type": "string" } + }, + "interval": { "type": "string", "pattern": "^[0-9]+[smhd]$" }, + "group": { "type": "string" }, + "alerts": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "failure-threshold", + "success-threshold", + "send-on-resolved", + "description", + "enabled" + ], + "properties": { + "type": { "type": "string" }, + "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", + "properties": { + "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..573f69fda4 --- /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 { GatusConfig } from "@dappnode/types"; + +/** + * Validates notifications.yaml file with schema + * @param config + */ +export function validateNotificationsSchema(config: GatusConfig): 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..13b5abb78a 100644 --- a/packages/schemas/test/unit/validateSchema.test.ts +++ b/packages/schemas/test/unit/validateSchema.test.ts @@ -1,9 +1,14 @@ 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, GatusConfig, Endpoint } from "@dappnode/types"; describe("schemaValidation", function () { this.timeout(10000); @@ -434,4 +439,157 @@ volumes: expect(() => validateManifestSchema(manifest)).to.not.throw(); }); }); + + describe("notifications", () => { + it("should validate a valid notifications configuration", () => { + const validNotifications: GatusConfig = { + endpoints: [ + { + name: "example-endpoint", + enabled: true, + url: "http://example.com", + method: "POST", + conditions: ["response-time < 500ms", "status == 200"], + interval: "1m", + group: "example-group", + 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" + }, + 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 = { + endpoints: [ + { + name: "example-endpoint", + enabled: true, + url: "http://example.com", + method: "POST" + // Missing required fields like conditions, interval, group, alerts, and definition + } as Endpoint + ] + }; + + expect(() => validateNotificationsSchema(invalidNotifications as GatusConfig)).to.throw( + "Invalid notifications configuration" + ); + }); + + it("should throw an error for invalid URL format", () => { + const invalidNotifications: GatusConfig = { + endpoints: [ + { + name: "example-endpoint", + enabled: true, + url: "invalid-url", + method: "POST", + conditions: ["response-time < 500ms"], + interval: "1m", + group: "example-group", + 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: GatusConfig = { + endpoints: [ + { + name: "example-endpoint", + enabled: true, + url: "http://example.com", + method: "POST", + conditions: ["response-time < 500ms"], + interval: "invalid-interval", + group: "example-group", + 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: GatusConfig = { + endpoints: [ + { + name: "example-endpoint", + enabled: true, + url: "http://example.com", + method: "POST", + conditions: ["response-time < 500ms"], + interval: "1m", + group: "example-group", + 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 Endpoint + ] + }; + + expect(() => validateNotificationsSchema(invalidNotifications)).to.throw("Invalid notifications configuration"); + }); + }); }); From 22efe65af8e7fe1c18c23766be85ca308c5ba593 Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Tue, 18 Mar 2025 13:20:06 +0100 Subject: [PATCH 05/90] fix import --- packages/types/src/routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index 77ec1c4fee..b7abc9a55f 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -45,7 +45,7 @@ import { } from "./calls.js"; import { PackageEnvs } from "./compose.js"; import { PackageBackup } from "./manifest.js"; -import { Endpoint } from "./notifications.js"; +import { Endpoint, Notification } from "./notifications.js"; import { TrustedReleaseKey } from "./pkg.js"; import { OptimismConfigSet, OptimismConfigGet } from "./rollups.js"; import { Network, StakerConfigGet, StakerConfigSet } from "./stakers.js"; From cea42126caa52d284064cc4222c593792cf3e897 Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Tue, 18 Mar 2025 13:21:00 +0100 Subject: [PATCH 06/90] fix import --- packages/types/src/routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index 77ec1c4fee..b7abc9a55f 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -45,7 +45,7 @@ import { } from "./calls.js"; import { PackageEnvs } from "./compose.js"; import { PackageBackup } from "./manifest.js"; -import { Endpoint } from "./notifications.js"; +import { Endpoint, Notification } from "./notifications.js"; import { TrustedReleaseKey } from "./pkg.js"; import { OptimismConfigSet, OptimismConfigGet } from "./rollups.js"; import { Network, StakerConfigGet, StakerConfigSet } from "./stakers.js"; From 9765688593ba57a2168e95b37dda50d75f3f1d32 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Tue, 18 Mar 2025 15:19:27 +0100 Subject: [PATCH 07/90] Root notifications tab (#2105) --- .../src/components/sidebar/navbarItems.ts | 8 +++ packages/admin-ui/src/pages/index.ts | 4 +- .../pages/notifications/NotificationsRoot.tsx | 54 +++++++++++++++++++ .../admin-ui/src/pages/notifications/data.tsx | 10 ++++ .../admin-ui/src/pages/notifications/index.ts | 4 ++ .../InstallNotifications.tsx | 26 +++++++++ .../installNotifications.scss | 7 +++ 7 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx create mode 100644 packages/admin-ui/src/pages/notifications/data.tsx create mode 100644 packages/admin-ui/src/pages/notifications/index.ts create mode 100644 packages/admin-ui/src/pages/notifications/tabs/InstallNotifications/InstallNotifications.tsx create mode 100644 packages/admin-ui/src/pages/notifications/tabs/InstallNotifications/installNotifications.scss 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/notifications/NotificationsRoot.tsx b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx new file mode 100644 index 0000000000..1df42ffdb4 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { Routes, Route, NavLink } from "react-router-dom"; +import { useApi } from "api"; +// Own module +import { title } from "./data"; +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; + }[] = []; + + 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.tsx b/packages/admin-ui/src/pages/notifications/data.tsx new file mode 100644 index 0000000000..750941306e --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/data.tsx @@ -0,0 +1,10 @@ +export const relativePath = "notifications"; +export const rootPath = "notifications/*"; +export const title = "Notifications"; + +// Additional data + +// SubPaths +export const subPaths = { + +}; 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/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 From c5bef6c2c5cec2a00d750a396b7b9c8b127b161c Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Tue, 18 Mar 2025 16:43:40 +0100 Subject: [PATCH 08/90] fix `gatusConfig` urls (#2107) --- packages/dappmanager/src/calls/gatusConfig.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dappmanager/src/calls/gatusConfig.ts b/packages/dappmanager/src/calls/gatusConfig.ts index 0fc2476a8d..7eb1469cd6 100644 --- a/packages/dappmanager/src/calls/gatusConfig.ts +++ b/packages/dappmanager/src/calls/gatusConfig.ts @@ -10,7 +10,7 @@ const BASE_URL = "http://notifier.notifications.dappnode"; * @returns all the notifications */ export async function gatuGetAllNotifications(): Promise<Notification[]> { - const response = await fetch(new URL(":8080/api/v1/notifications", BASE_URL).toString()); + const response = await fetch(new URL("/api/v1/notifications", `${BASE_URL}:8080`).toString()); return response.json(); } @@ -59,7 +59,7 @@ export async function gatusUpdateEndpoints({ 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(new URL(":8082/api/v1/gatus/endpoints/reload", BASE_URL).toString(), { + await fetch(new URL("/api/v1/gatus/endpoints/reload", `${BASE_URL}:8082`).toString(), { method: "POST", headers: { "Content-Type": "application/json" From 0f5a4722ae18243ebf6403c9e7e8f14324dc3d06 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <mendez4a@gmail.com> Date: Wed, 19 Mar 2025 08:40:26 +0100 Subject: [PATCH 09/90] remove uri --- packages/schemas/src/schemas/notifications.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/schemas/src/schemas/notifications.schema.json b/packages/schemas/src/schemas/notifications.schema.json index e789cba236..9a8d0d244b 100644 --- a/packages/schemas/src/schemas/notifications.schema.json +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -13,7 +13,7 @@ "properties": { "name": { "type": "string" }, "enabled": { "type": "boolean" }, - "url": { "type": "string", "format": "uri" }, + "url": { "type": "string", "pattern": "^(https?|ftp)://[^s/$.?#].[^s]*$" }, "method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"] }, "conditions": { "type": "array", From 752f2479815638601ebca70366432a0ebb06179a Mon Sep 17 00:00:00 2001 From: pablomendezroyo <mendez4a@gmail.com> Date: Wed, 19 Mar 2025 09:27:18 +0100 Subject: [PATCH 10/90] remove temperature daemon --- packages/daemons/src/index.ts | 2 - packages/daemons/src/temperature/index.ts | 97 ----------------------- 2 files changed, 99 deletions(-) delete mode 100644 packages/daemons/src/temperature/index.ts diff --git a/packages/daemons/src/index.ts b/packages/daemons/src/index.ts index fa6883a6f3..b0ec32fc63 100644 --- a/packages/daemons/src/index.ts +++ b/packages/daemons/src/index.ts @@ -8,7 +8,6 @@ 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"; // DAEMONS EXPORT @@ -22,7 +21,6 @@ export function startDaemons(dappnodeInstaller: DappnodeInstaller, signal: Abort startStakerDaemon(dappnodeInstaller); startTelegramBotDaemon(); startBindDaemon(signal); - startTemperatureDaemon(signal); } export { startAvahiDaemon } from "./avahi/index.js"; 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); -} From 8894addb47f3d7e59d3b6124e4486c53b9b70413 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <mendez4a@gmail.com> Date: Wed, 19 Mar 2025 11:23:54 +0100 Subject: [PATCH 11/90] add type for custom endpoints --- .../admin-ui/src/__mock-backend__/index.ts | 8 +- packages/dappmanager/src/calls/gatusConfig.ts | 68 --------------- packages/dappmanager/src/calls/index.ts | 2 +- .../dappmanager/src/calls/notifications.ts | 82 +++++++++++++++++++ packages/installer/src/dappnodeInstaller.ts | 4 +- .../src/validateNotificationsSchema.ts | 4 +- packages/types/src/manifest.ts | 4 +- packages/types/src/notifications.ts | 23 +++++- packages/types/src/pkg.ts | 4 +- packages/types/src/routes.ts | 21 +++-- 10 files changed, 128 insertions(+), 92 deletions(-) delete mode 100644 packages/dappmanager/src/calls/gatusConfig.ts create mode 100644 packages/dappmanager/src/calls/notifications.ts diff --git a/packages/admin-ui/src/__mock-backend__/index.ts b/packages/admin-ui/src/__mock-backend__/index.ts index 3191ae8a8e..e1778eba45 100644 --- a/packages/admin-ui/src/__mock-backend__/index.ts +++ b/packages/admin-ui/src/__mock-backend__/index.ts @@ -387,11 +387,11 @@ export const otherCalls: Omit<Routes, keyof typeof namedSpacedCalls> = { }), getIsConnectedToInternet: async () => false, getCoreVersion: async () => "0.2.92", - gatusGetEndpoints: async () => { - return { "geth.dnp.dappnode.eth": [] }; + notificationsGetEndpoints: async () => { + return { "geth.dnp.dappnode.eth": { endpoints: [], customEndpoints: [] } }; }, - gatusUpdateEndpoints: async () => {}, - gatuGetAllNotifications: async () => [] + notificationsUpdateEndpoints: async () => {}, + notificationsGetAll: async () => [] }; export const calls: Routes = { diff --git a/packages/dappmanager/src/calls/gatusConfig.ts b/packages/dappmanager/src/calls/gatusConfig.ts deleted file mode 100644 index 7eb1469cd6..0000000000 --- a/packages/dappmanager/src/calls/gatusConfig.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { listPackages } from "@dappnode/dockerapi"; -import { Endpoint, Manifest, Notification } from "@dappnode/types"; -import { getManifestPath } from "@dappnode/utils"; -import fs from "fs"; - -const BASE_URL = "http://notifier.notifications.dappnode"; - -/** - * Get all the notifications - * @returns all the notifications - */ -export async function gatuGetAllNotifications(): Promise<Notification[]> { - const response = await fetch(new URL("/api/v1/notifications", `${BASE_URL}:8080`).toString()); - 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(new URL("/api/v1/gatus/endpoints/reload", `${BASE_URL}:8082`).toString(), { - method: "POST", - headers: { - "Content-Type": "application/json" - } - }); -} diff --git a/packages/dappmanager/src/calls/index.ts b/packages/dappmanager/src/calls/index.ts index 8c5254db63..004277a48f 100644 --- a/packages/dappmanager/src/calls/index.ts +++ b/packages/dappmanager/src/calls/index.ts @@ -22,7 +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 { notificationsGetEndpoints, notificationsUpdateEndpoints, notificationsGetAll } from "./notifications.js"; export * from "./httpsPortal.js"; export { ipfsTest } from "./ipfsTest.js"; export { ipfsClientTargetSet } from "./ipfsClientTargetSet.js"; diff --git a/packages/dappmanager/src/calls/notifications.ts b/packages/dappmanager/src/calls/notifications.ts new file mode 100644 index 0000000000..ed60801688 --- /dev/null +++ b/packages/dappmanager/src/calls/notifications.ts @@ -0,0 +1,82 @@ +import { listPackages } from "@dappnode/dockerapi"; +import { CustomEndpoint, GatusEndpoint, Manifest, Notification, NotificationsConfig } from "@dappnode/types"; +import { getManifestPath } from "@dappnode/utils"; +import fs from "fs"; + +const BASE_URL = "http://notifier.notifications.dappnode"; + +/** + * Get all the notifications + * @returns all the notifications + */ +export async function notificationsGetAll(): Promise<Notification[]> { + const response = await fetch(new URL("/api/v1/notifications", `${BASE_URL}:8080`).toString()); + return response.json(); +} + +/** + * Get gatus and custom endpoints indexed by dnpName + */ +export async function notificationsGetEndpoints(): Promise<{ + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[] }; +}> { + const packages = await listPackages(); + + // Read all manifests files and retrieve the gatus config + const endpoints: { [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[] } } = {}; + 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) endpoints[pkg.dnpName].endpoints = manifest.notifications.endpoints; + if (manifest.notifications?.customEndpoints) + endpoints[pkg.dnpName].customEndpoints = manifest.notifications.customEndpoints; + } + + return endpoints; +} + +/** + * Update endpoint properties + */ +export async function notificationsUpdateEndpoints({ + dnpName, + notificationsConfig +}: { + dnpName: string; + notificationsConfig: NotificationsConfig; +}): Promise<void> { + const { endpoints: updatedEndpoints, customEndpoints: updatedCustomEndpoints } = notificationsConfig; + + // 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"); + + // Update endpoints + if (updatedEndpoints) { + const endpoints = manifest.notifications.endpoints; + if (!endpoints) throw new Error(`No endpoints found in manifest`); + + // Update endpoint + Object.assign(endpoints, updatedEndpoints); + } + + // Update custom endpoints + if (updatedCustomEndpoints) { + const customEndpoints = manifest.notifications.customEndpoints; + if (!customEndpoints) throw new Error(`No custom endpoints found in manifest`); + + // Update custom endpoint + Object.assign(customEndpoints, updatedCustomEndpoints); + } + + // 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(new URL("/api/v1/gatus/endpoints/reload", `${BASE_URL}:8082`).toString(), { + method: "POST", + headers: { + "Content-Type": "application/json" + } + }); +} diff --git a/packages/installer/src/dappnodeInstaller.ts b/packages/installer/src/dappnodeInstaller.ts index 42786a7a4b..f3dbee7fe0 100644 --- a/packages/installer/src/dappnodeInstaller.ts +++ b/packages/installer/src/dappnodeInstaller.ts @@ -12,7 +12,7 @@ import { SetupWizard, GrafanaDashboard, PrometheusTarget, - GatusConfig + NotificationsConfig } from "@dappnode/types"; import { DappGetState, DappgetOptions, dappGet } from "./dappGet/index.js"; import { validateDappnodeCompose, validateManifestSchema } from "@dappnode/schemas"; @@ -163,7 +163,7 @@ export class DappnodeInstaller extends DappnodeRepository { gettingStarted?: string; prometheusTargets?: PrometheusTarget[]; grafanaDashboards?: GrafanaDashboard[]; - notifications?: GatusConfig; + notifications?: NotificationsConfig; }): Manifest { if (SetupWizard) manifest.setupWizard = SetupWizard; if (disclaimer) manifest.disclaimer = { message: disclaimer }; diff --git a/packages/schemas/src/validateNotificationsSchema.ts b/packages/schemas/src/validateNotificationsSchema.ts index 573f69fda4..dc02950c10 100644 --- a/packages/schemas/src/validateNotificationsSchema.ts +++ b/packages/schemas/src/validateNotificationsSchema.ts @@ -2,13 +2,13 @@ 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 { GatusConfig } from "@dappnode/types"; +import { NotificationsConfig } from "@dappnode/types"; /** * Validates notifications.yaml file with schema * @param config */ -export function validateNotificationsSchema(config: GatusConfig): void { +export function validateNotificationsSchema(config: NotificationsConfig): void { const validateNotifications = ajv.compile(notificationsSchema); const valid = validateNotifications(config); if (!valid) { diff --git a/packages/types/src/manifest.ts b/packages/types/src/manifest.ts index 74e9042299..1619eab2b0 100644 --- a/packages/types/src/manifest.ts +++ b/packages/types/src/manifest.ts @@ -1,4 +1,4 @@ -import { GatusConfig } from "./notifications.js"; +import { NotificationsConfig } from "./notifications.js"; import { SetupSchema, SetupTarget, SetupUiJson, SetupWizard } from "./setupWizard.js"; export interface Manifest { @@ -101,7 +101,7 @@ export interface Manifest { setupWizard?: SetupWizard; // notifications - notifications?: GatusConfig; + notifications?: NotificationsConfig; } export interface UpstreamItem { diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts index 3c181bfe7c..7d506fe182 100644 --- a/packages/types/src/notifications.ts +++ b/packages/types/src/notifications.ts @@ -11,11 +11,28 @@ export interface Notification { }; } -export interface GatusConfig { - endpoints: Endpoint[]; +export interface NotificationsConfig { + endpoints?: GatusEndpoint[]; + customEndpoints?: CustomEndpoint[]; } -export interface Endpoint { +export interface CustomEndpoint { + enabled: boolean; + name: string; + definition: { + title: string; + description: string; + }; + group: string; + metric?: { + treshold: number; + min: number; + max: number; + unit: string; + }; +} + +export interface GatusEndpoint { name: string; enabled: boolean; url: string; diff --git a/packages/types/src/pkg.ts b/packages/types/src/pkg.ts index 16d9d6a973..263755d147 100644 --- a/packages/types/src/pkg.ts +++ b/packages/types/src/pkg.ts @@ -1,6 +1,6 @@ import { Compose } from "./compose.js"; import { Manifest, PrometheusTarget, GrafanaDashboard } from "./manifest.js"; -import { GatusConfig } from "./notifications.js"; +import { NotificationsConfig } from "./notifications.js"; import { SetupWizard } from "./setupWizard.js"; /** @@ -98,7 +98,7 @@ export type DirectoryFiles = { gettingStarted?: string; prometheusTargets?: PrometheusTarget[]; grafanaDashboards?: GrafanaDashboard[]; - notifications?: GatusConfig; + notifications?: NotificationsConfig; }; export interface FileConfig { diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index b7abc9a55f..69b16a8053 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -45,7 +45,7 @@ import { } from "./calls.js"; import { PackageEnvs } from "./compose.js"; import { PackageBackup } from "./manifest.js"; -import { Endpoint, Notification } from "./notifications.js"; +import { CustomEndpoint, GatusEndpoint, Notification, NotificationsConfig } from "./notifications.js"; import { TrustedReleaseKey } from "./pkg.js"; import { OptimismConfigSet, OptimismConfigGet } from "./rollups.js"; import { Network, StakerConfigGet, StakerConfigSet } from "./stakers.js"; @@ -263,19 +263,24 @@ export interface Routes { fetchDnpRequest: (kwargs: { id: string; version?: string }) => Promise<RequestedDnp>; /** - * Gatus get all notifications + * Get all the notifications */ - gatuGetAllNotifications(): Promise<Notification[]>; + notificationsGetAll(): Promise<Notification[]>; /** * Gatus get endpoints */ - gatusGetEndpoints(): Promise<{ [dnpName: string]: Endpoint[] }>; + notificationsGetEndpoints(): Promise<{ + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[] }; + }>; /** * Gatus update endpoint */ - gatusUpdateEndpoints: (kwargs: { dnpName: string; updatedEndpoints: Endpoint[] }) => Promise<void>; + notificationsUpdateEndpoints: (kwargs: { + dnpName: string; + notificationsConfig: NotificationsConfig; + }) => Promise<void>; /** * Returns the user action logs. This logs are stored in a different @@ -706,9 +711,9 @@ export const routesData: { [P in keyof Routes]: RouteData } = { fetchDirectory: {}, fetchRegistry: {}, fetchDnpRequest: {}, - gatuGetAllNotifications: { log: true }, - gatusGetEndpoints: { log: true }, - gatusUpdateEndpoints: { log: true }, + notificationsGetAll: { log: true }, + notificationsGetEndpoints: { log: true }, + notificationsUpdateEndpoints: { log: true }, getUserActionLogs: {}, getHostUptime: {}, httpsPortalMappingAdd: { log: true }, From 6cd0e61c5ec995ad95101c87e6b29d627de74017 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <mendez4a@gmail.com> Date: Wed, 19 Mar 2025 11:35:41 +0100 Subject: [PATCH 12/90] add validation for custom endpoints --- .../src/schemas/notifications.schema.json | 31 +++- .../schemas/test/unit/validateSchema.test.ts | 138 ++++++++++++++++-- 2 files changed, 159 insertions(+), 10 deletions(-) diff --git a/packages/schemas/src/schemas/notifications.schema.json b/packages/schemas/src/schemas/notifications.schema.json index 9a8d0d244b..dffbc3b743 100644 --- a/packages/schemas/src/schemas/notifications.schema.json +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -3,7 +3,7 @@ "$id": "https://github.com/dappnode/DAppNode/raw/schema/notifications.schema.json", "type": "object", "title": "Notifications Configuration Schema", - "required": ["endpoints"], + "required": [], "properties": { "endpoints": { "type": "array", @@ -61,6 +61,35 @@ } } } + }, + "customEndpoints": { + "type": "array", + "items": { + "type": "object", + "required": ["enabled", "name", "definition", "group"], + "properties": { + "enabled": { "type": "boolean" }, + "name": { "type": "string" }, + "definition": { + "type": "object", + "required": ["title", "description"], + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" } + } + }, + "group": { "type": "string" }, + "metric": { + "type": "object", + "properties": { + "treshold": { "type": "number" }, + "min": { "type": "number" }, + "max": { "type": "number" }, + "unit": { "type": "string" } + } + } + } + } } } } diff --git a/packages/schemas/test/unit/validateSchema.test.ts b/packages/schemas/test/unit/validateSchema.test.ts index 13b5abb78a..a60a3f822b 100644 --- a/packages/schemas/test/unit/validateSchema.test.ts +++ b/packages/schemas/test/unit/validateSchema.test.ts @@ -8,7 +8,7 @@ import { import fs from "fs"; import path from "path"; import { cleanTestDir, testDir } from "../testUtils.js"; -import { Manifest, SetupWizard, GatusConfig, Endpoint } from "@dappnode/types"; +import { Manifest, SetupWizard, NotificationsConfig, GatusEndpoint, CustomEndpoint } from "@dappnode/types"; describe("schemaValidation", function () { this.timeout(10000); @@ -442,7 +442,7 @@ volumes: describe("notifications", () => { it("should validate a valid notifications configuration", () => { - const validNotifications: GatusConfig = { + const validNotifications: NotificationsConfig = { endpoints: [ { name: "example-endpoint", @@ -479,7 +479,7 @@ volumes: }); it("should throw an error for missing required fields", () => { - const invalidNotifications: Partial<GatusConfig> = { + const invalidNotifications: Partial<NotificationsConfig> = { endpoints: [ { name: "example-endpoint", @@ -487,17 +487,17 @@ volumes: url: "http://example.com", method: "POST" // Missing required fields like conditions, interval, group, alerts, and definition - } as Endpoint + } as GatusEndpoint ] }; - expect(() => validateNotificationsSchema(invalidNotifications as GatusConfig)).to.throw( + expect(() => validateNotificationsSchema(invalidNotifications as NotificationsConfig)).to.throw( "Invalid notifications configuration" ); }); it("should throw an error for invalid URL format", () => { - const invalidNotifications: GatusConfig = { + const invalidNotifications: NotificationsConfig = { endpoints: [ { name: "example-endpoint", @@ -529,7 +529,7 @@ volumes: }); it("should throw an error for invalid interval format", () => { - const invalidNotifications: GatusConfig = { + const invalidNotifications: NotificationsConfig = { endpoints: [ { name: "example-endpoint", @@ -561,7 +561,7 @@ volumes: }); it("should throw an error for missing alert fields", () => { - const invalidNotifications: GatusConfig = { + const invalidNotifications: NotificationsConfig = { endpoints: [ { name: "example-endpoint", @@ -585,11 +585,131 @@ volumes: title: "Example Endpoint", description: "An example endpoint for testing" } - } as Endpoint + } 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", + definition: { + title: "Custom Endpoint", + description: "A custom endpoint for testing" + }, + group: "custom-group", + metric: { + treshold: 90, + min: 0, + max: 100, + unit: "%" + } + } as CustomEndpoint + ] + }; + + 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", + definition: { + title: "Custom Endpoint" + // Missing description + }, + group: "custom-group" + } 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", + definition: { + title: "Custom Endpoint", + description: "A custom endpoint for testing" + }, + group: "custom-group", + 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", + enabled: true, + url: "http://example.com", + method: "POST", + conditions: ["response-time < 500ms", "status == 200"], + interval: "1m", + group: "example-group", + 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" + }, + metric: { + min: 0, + max: 1000, + unit: "ms" + } + } as GatusEndpoint + ], + customEndpoints: [ + { + enabled: true, + name: "custom-endpoint", + definition: { + title: "Custom Endpoint", + description: "A custom endpoint for testing" + }, + group: "custom-group", + metric: { + treshold: 90, + min: 0, + max: 100, + unit: "%" + } + } as CustomEndpoint + ] + }; + + expect(() => validateNotificationsSchema(validNotifications)).to.not.throw(); + }); }); }); From 8838c304ab7fb8dd4fd098716c23235ac7be3093 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Date: Wed, 19 Mar 2025 18:21:22 +0100 Subject: [PATCH 13/90] Implement notifications module (#2112) * implement notifications module * add notifications module * add missing dependencies --- notifications.yaml | 0 packages/admin-ui/server-mock/index.ts | 1 - packages/daemons/package.json | 1 + .../src/autoUpdates/sendUpdateNotification.ts | 51 +++++++++------- packages/daemons/src/diskUsage/index.ts | 26 ++++---- packages/dappmanager/package.json | 1 + packages/dappmanager/src/api/routes/index.ts | 1 - .../src/api/routes/notificationSend.ts | 49 --------------- packages/dappmanager/src/api/startHttpApi.ts | 2 - .../dappmanager/src/calls/notifications.ts | 60 ++----------------- packages/dappmanager/src/calls/setStaticIp.ts | 16 +++-- .../src/ethClient/syncedNotification.ts | 7 --- packages/notifications/.mocharc.yaml | 8 +++ packages/notifications/README.md | 9 +++ packages/notifications/package.json | 28 +++++++++ packages/notifications/src/api.ts | 41 +++++++++++++ packages/notifications/src/index.ts | 46 ++++++++++++++ packages/notifications/src/manifest.ts | 50 ++++++++++++++++ packages/notifications/tsconfig.json | 16 +++++ packages/notifications/tsconfig.test.json | 8 +++ packages/types/src/notifications.ts | 28 ++++++--- yarn.lock | 14 +++++ 22 files changed, 301 insertions(+), 162 deletions(-) create mode 100644 notifications.yaml delete mode 100644 packages/dappmanager/src/api/routes/notificationSend.ts create mode 100644 packages/notifications/.mocharc.yaml create mode 100644 packages/notifications/README.md create mode 100644 packages/notifications/package.json create mode 100644 packages/notifications/src/api.ts create mode 100644 packages/notifications/src/index.ts create mode 100644 packages/notifications/src/manifest.ts create mode 100644 packages/notifications/tsconfig.json create mode 100644 packages/notifications/tsconfig.test.json diff --git a/notifications.yaml b/notifications.yaml new file mode 100644 index 0000000000..e69de29bb2 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/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/sendUpdateNotification.ts b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts index b43469a7dc..46738ceb2c 100644 --- a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts +++ b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts @@ -1,13 +1,14 @@ 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 { CoreUpdateDataAvailable, NotificationCategory, upstreamVersionToString } 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, @@ -31,19 +32,21 @@ 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) + // Send notification about new version available + await notifications + .sendNotification({ + title: `Update available for ${prettyDnpName(dnpName)}`, + dnpName, + body: formatPackageUpdateNotification({ + dnpName, + currentVersion, + newVersion, + upstreamVersion, + autoUpdatesEnabled: isDnpUpdateEnabled(dnpName) + }), + category: NotificationCategory.CORE }) - }); + .catch((e) => logs.error("Error sending package update notification", e)); // Register version to prevent sending notification again db.packageLatestKnownVersion.set(dnpName, { newVersion, upstreamVersion }); @@ -58,16 +61,18 @@ export async function sendUpdateSystemNotificationMaybe(data: CoreUpdateDataAvai 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: NotificationCategory.CORE }) - }); + .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..1b9171a914 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 { NotificationCategory } from "@dappnode/types"; /** * Commands @@ -93,17 +95,19 @@ 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: `Disk space is running out, ${threshold.id.split(" ")[0]}`, + dnpName: "dappmanager.dnp.dappnode.eth", + 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"), + category: NotificationCategory.CORE + }) + .catch((e) => logs.error("Error sending disk usage notification", e)); // Emit packages update eventBus.requestPackages.emit(); 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/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/notifications.ts b/packages/dappmanager/src/calls/notifications.ts index ed60801688..562cbd07e9 100644 --- a/packages/dappmanager/src/calls/notifications.ts +++ b/packages/dappmanager/src/calls/notifications.ts @@ -1,17 +1,12 @@ -import { listPackages } from "@dappnode/dockerapi"; -import { CustomEndpoint, GatusEndpoint, Manifest, Notification, NotificationsConfig } from "@dappnode/types"; -import { getManifestPath } from "@dappnode/utils"; -import fs from "fs"; - -const BASE_URL = "http://notifier.notifications.dappnode"; +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[]> { - const response = await fetch(new URL("/api/v1/notifications", `${BASE_URL}:8080`).toString()); - return response.json(); + return await notifications.getAll(); } /** @@ -20,19 +15,7 @@ export async function notificationsGetAll(): Promise<Notification[]> { export async function notificationsGetEndpoints(): Promise<{ [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[] }; }> { - const packages = await listPackages(); - - // Read all manifests files and retrieve the gatus config - const endpoints: { [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[] } } = {}; - 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) endpoints[pkg.dnpName].endpoints = manifest.notifications.endpoints; - if (manifest.notifications?.customEndpoints) - endpoints[pkg.dnpName].customEndpoints = manifest.notifications.customEndpoints; - } - - return endpoints; + return await notifications.getEndpoints(); } /** @@ -45,38 +28,5 @@ export async function notificationsUpdateEndpoints({ dnpName: string; notificationsConfig: NotificationsConfig; }): Promise<void> { - const { endpoints: updatedEndpoints, customEndpoints: updatedCustomEndpoints } = notificationsConfig; - - // 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"); - - // Update endpoints - if (updatedEndpoints) { - const endpoints = manifest.notifications.endpoints; - if (!endpoints) throw new Error(`No endpoints found in manifest`); - - // Update endpoint - Object.assign(endpoints, updatedEndpoints); - } - - // Update custom endpoints - if (updatedCustomEndpoints) { - const customEndpoints = manifest.notifications.customEndpoints; - if (!customEndpoints) throw new Error(`No custom endpoints found in manifest`); - - // Update custom endpoint - Object.assign(customEndpoints, updatedCustomEndpoints); - } - - // 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(new URL("/api/v1/gatus/endpoints/reload", `${BASE_URL}:8082`).toString(), { - method: "POST", - headers: { - "Content-Type": "application/json" - } - }); + await notifications.updateEndpoints(dnpName, notificationsConfig); } diff --git a/packages/dappmanager/src/calls/setStaticIp.ts b/packages/dappmanager/src/calls/setStaticIp.ts index 4e5806be82..3b0edc4707 100644 --- a/packages/dappmanager/src/calls/setStaticIp.ts +++ b/packages/dappmanager/src/calls/setStaticIp.ts @@ -2,6 +2,8 @@ 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 { NotificationCategory } from "@dappnode/types"; /** * Sets the static IP @@ -25,12 +27,14 @@ 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 updated", + body: `Your static IP was changed to ${staticIp}.`, + dnpName: "dappmanager.dnp.dappnode.eth", + category: NotificationCategory.CORE + }) + .catch((e) => logs.error("Error sending static IP updated notification", e)); // Dynamic update with the new staticIp eventBus.requestSystemInfo.emit(); 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/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..b76b0bec21 --- /dev/null +++ b/packages/notifications/package.json @@ -0,0 +1,28 @@ +{ + "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", + "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..c9d8c64cb5 --- /dev/null +++ b/packages/notifications/src/api.ts @@ -0,0 +1,41 @@ +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(); + } + + /** + * 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" + } + }); + } +} diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts new file mode 100644 index 0000000000..0faca1e0ae --- /dev/null +++ b/packages/notifications/src/index.ts @@ -0,0 +1,46 @@ +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 getAll(): Promise<Notification[]> { + return await this.api.getAllNotifications(); + } + + /** + * Get gatus and custom endpoints indexed by dnpName + */ + async getEndpoints(): Promise<{ + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[] }; + }> { + return await this.manifest.getEndpoints(); + } + + /** + * Update endpoint properties + */ + async updateEndpoints(dnpName: string, notificationsConfig: NotificationsConfig): Promise<void> { + await this.manifest.updateEndpoints(dnpName, 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..f80735cdfc --- /dev/null +++ b/packages/notifications/src/manifest.ts @@ -0,0 +1,50 @@ +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 getEndpoints(): Promise<{ + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[] }; + }> { + const packages = await listPackages(); + + const endpoints: { [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[] } } = {}; + 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) endpoints[pkg.dnpName].endpoints = manifest.notifications.endpoints; + if (manifest.notifications?.customEndpoints) + endpoints[pkg.dnpName].customEndpoints = manifest.notifications.customEndpoints; + } + + return endpoints; + } + + /** + * Update endpoint properties in filesystem + */ + async updateEndpoints(dnpName: string, notificationsConfig: NotificationsConfig): Promise<void> { + const { endpoints: updatedEndpoints, customEndpoints: updatedCustomEndpoints } = notificationsConfig; + + const manifest: Manifest = JSON.parse(fs.readFileSync(getManifestPath(dnpName, false), "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, false), JSON.stringify(manifest, null, 2)); + } +} 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/types/src/notifications.ts b/packages/types/src/notifications.ts index 7d506fe182..abacd33850 100644 --- a/packages/types/src/notifications.ts +++ b/packages/types/src/notifications.ts @@ -1,19 +1,33 @@ -export interface Notification { +export interface NotificationsConfig { + endpoints?: GatusEndpoint[]; + customEndpoints?: CustomEndpoint[]; +} + +export interface Notification extends NotificationPayload { + timestamp: string; + seen: boolean; +} + +export interface NotificationPayload { title: string; body: string; dnpName: string; - timestamp: string; - category: string; - seen: boolean; + category: NotificationCategory; callToAction?: { title: string; url: string; }; } -export interface NotificationsConfig { - endpoints?: GatusEndpoint[]; - customEndpoints?: CustomEndpoint[]; +export enum NotificationCategory { + CORE = "CORE", + ETHEREUM = "ETHEREUM", + HOLESKY = "HOLESKY", + LUKSO = "LUKSO", + GNOSIS = "GNOSIS", + HOODI = "HOODI", + HOST = "HOST", + OTHER = "OTHER" } export interface CustomEndpoint { diff --git a/yarn.lock b/yarn.lock index 8b40641ca2..be273a6b7a 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" From 0cd9c7b2ea6eb0589bdb8d0159fab06f9966eba0 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Date: Thu, 20 Mar 2025 10:46:10 +0100 Subject: [PATCH 14/90] Add `notifications.yaml` to dappmanager (#2113) * Update custom endpoint types * add notifications yaml * remove unused exports * use dappmanager dnpname * Check is enabled before sending notification --- notifications.yaml | 4 ++++ packages/daemons/src/autoUpdates/index.ts | 7 ------- .../src/autoUpdates/sendUpdateNotification.ts | 7 +++++++ packages/daemons/src/diskUsage/index.ts | 2 +- packages/dappmanager/src/calls/notifications.ts | 2 +- packages/dappmanager/src/calls/setStaticIp.ts | 3 ++- packages/notifications/src/index.ts | 2 +- .../schemas/src/schemas/notifications.schema.json | 12 ++---------- packages/types/src/notifications.ts | 8 ++------ 9 files changed, 20 insertions(+), 27 deletions(-) diff --git a/notifications.yaml b/notifications.yaml index e69de29bb2..4657b0356b 100644 --- a/notifications.yaml +++ b/notifications.yaml @@ -0,0 +1,4 @@ +customEndpoints: + - name: "Package updates notifications" + description: string + enabled: true 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 46738ceb2c..7e079df7a7 100644 --- a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts +++ b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts @@ -21,6 +21,13 @@ export async function sendUpdatePackageNotificationMaybe({ currentVersion: string; newVersion: string; }): Promise<void> { + // Check if auto-update notifications are enabled + const dappmanagerCustomEndpoint = (await notifications.getEndpoints())[ + params.dappmanagerDnpName + ].customEndpoints.find((customEndpoint) => customEndpoint.name === "auto-updates"); + + 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 diff --git a/packages/daemons/src/diskUsage/index.ts b/packages/daemons/src/diskUsage/index.ts index 1b9171a914..95716a3b47 100644 --- a/packages/daemons/src/diskUsage/index.ts +++ b/packages/daemons/src/diskUsage/index.ts @@ -98,7 +98,7 @@ async function monitorDiskUsage(): Promise<void> { await notifications .sendNotification({ title: `Disk space is running out, ${threshold.id.split(" ")[0]}`, - dnpName: "dappmanager.dnp.dappnode.eth", + dnpName: params.dappmanagerDnpName, body: [ `Available disk space is less than a ${threshold.id}.`, `To prevent your DAppNode from becoming unusable ${threshold.containersDescription} where stopped.`, diff --git a/packages/dappmanager/src/calls/notifications.ts b/packages/dappmanager/src/calls/notifications.ts index 562cbd07e9..7434814f04 100644 --- a/packages/dappmanager/src/calls/notifications.ts +++ b/packages/dappmanager/src/calls/notifications.ts @@ -6,7 +6,7 @@ import { CustomEndpoint, GatusEndpoint, Notification, NotificationsConfig } from * @returns all the notifications */ export async function notificationsGetAll(): Promise<Notification[]> { - return await notifications.getAll(); + return await notifications.getAllNotifications(); } /** diff --git a/packages/dappmanager/src/calls/setStaticIp.ts b/packages/dappmanager/src/calls/setStaticIp.ts index 3b0edc4707..9a9bf2eb01 100644 --- a/packages/dappmanager/src/calls/setStaticIp.ts +++ b/packages/dappmanager/src/calls/setStaticIp.ts @@ -3,6 +3,7 @@ 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 { NotificationCategory } from "@dappnode/types"; /** @@ -31,7 +32,7 @@ export async function setStaticIp({ staticIp }: { staticIp: string }): Promise<v .sendNotification({ title: "Static IP updated", body: `Your static IP was changed to ${staticIp}.`, - dnpName: "dappmanager.dnp.dappnode.eth", + dnpName: params.dappmanagerDnpName, category: NotificationCategory.CORE }) .catch((e) => logs.error("Error sending static IP updated notification", e)); diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index 0faca1e0ae..861855d614 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -21,7 +21,7 @@ class Notifications { /** * Get all the notifications */ - async getAll(): Promise<Notification[]> { + async getAllNotifications(): Promise<Notification[]> { return await this.api.getAllNotifications(); } diff --git a/packages/schemas/src/schemas/notifications.schema.json b/packages/schemas/src/schemas/notifications.schema.json index dffbc3b743..d00af4ee9e 100644 --- a/packages/schemas/src/schemas/notifications.schema.json +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -66,19 +66,11 @@ "type": "array", "items": { "type": "object", - "required": ["enabled", "name", "definition", "group"], + "required": ["enabled", "name", "description"], "properties": { "enabled": { "type": "boolean" }, "name": { "type": "string" }, - "definition": { - "type": "object", - "required": ["title", "description"], - "properties": { - "title": { "type": "string" }, - "description": { "type": "string" } - } - }, - "group": { "type": "string" }, + "description": { "type": "string" }, "metric": { "type": "object", "properties": { diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts index abacd33850..855092910c 100644 --- a/packages/types/src/notifications.ts +++ b/packages/types/src/notifications.ts @@ -31,13 +31,9 @@ export enum NotificationCategory { } export interface CustomEndpoint { - enabled: boolean; name: string; - definition: { - title: string; - description: string; - }; - group: string; + enabled: boolean; + description: string; metric?: { treshold: number; min: number; From b487cbd82617309edecd5b46a0da140d3ac77b7b Mon Sep 17 00:00:00 2001 From: pablomendezroyo <mendez4a@gmail.com> Date: Thu, 20 Mar 2025 11:02:03 +0100 Subject: [PATCH 15/90] fix tests --- .../schemas/test/unit/validateSchema.test.ts | 31 +++++-------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/packages/schemas/test/unit/validateSchema.test.ts b/packages/schemas/test/unit/validateSchema.test.ts index a60a3f822b..9939d47570 100644 --- a/packages/schemas/test/unit/validateSchema.test.ts +++ b/packages/schemas/test/unit/validateSchema.test.ts @@ -598,18 +598,14 @@ volumes: { enabled: true, name: "custom-endpoint", - definition: { - title: "Custom Endpoint", - description: "A custom endpoint for testing" - }, - group: "custom-group", + description: "A custom endpoint for testing", // Added required description metric: { treshold: 90, min: 0, max: 100, unit: "%" } - } as CustomEndpoint + } ] }; @@ -622,12 +618,9 @@ volumes: { enabled: true, name: "custom-endpoint", - definition: { - title: "Custom Endpoint" - // Missing description - }, + // Missing required description field group: "custom-group" - } as CustomEndpoint + } as unknown as CustomEndpoint ] }; @@ -640,11 +633,7 @@ volumes: { enabled: true, name: "custom-endpoint", - definition: { - title: "Custom Endpoint", - description: "A custom endpoint for testing" - }, - group: "custom-group", + description: "A custom endpoint for testing", metric: { treshold: "fd" as unknown as number, // Invalid treshold value min: 0, @@ -688,24 +677,20 @@ volumes: max: 1000, unit: "ms" } - } as GatusEndpoint + } ], customEndpoints: [ { enabled: true, name: "custom-endpoint", - definition: { - title: "Custom Endpoint", - description: "A custom endpoint for testing" - }, - group: "custom-group", + description: "A custom endpoint for testing", // Added required description metric: { treshold: 90, min: 0, max: 100, unit: "%" } - } as CustomEndpoint + } ] }; From 2d2293bdc49345df32e831b28b80e95534e86ea9 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Thu, 20 Mar 2025 11:12:10 +0100 Subject: [PATCH 16/90] Implement notifications `inbox` subpath (#2106) * Implement `Inbox` subpath * inbox fix * Searchbar component in Inbox * inbox notifications filtering --- .../admin-ui/src/components/Searchbar.tsx | 27 ++++ .../admin-ui/src/components/searchbar.scss | 40 +++++ .../pages/notifications/NotificationsRoot.tsx | 11 +- .../admin-ui/src/pages/notifications/data.tsx | 4 +- .../pages/notifications/tabs/Inbox/Inbox.tsx | 115 +++++++++++++ .../tabs/Inbox/NotificationsCard.tsx | 43 +++++ .../pages/notifications/tabs/Inbox/inbox.scss | 153 ++++++++++++++++++ 7 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 packages/admin-ui/src/components/Searchbar.tsx create mode 100644 packages/admin-ui/src/components/searchbar.scss create mode 100644 packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx create mode 100644 packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx create mode 100644 packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss 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<HTMLInputElement>) => void; + classname?: string; + placeholder?: string; +} + +export function Searchbar({ value, onChange, classname, placeholder = "Search..." }: SearchbarProps) { + return ( + <div className="searchbar-wrapper"> + <BiSearch className="search-icon" /> + + <input + type="text" + placeholder={placeholder} + value={value} + onChange={onChange} + className={`searchbar ${classname}`} + /> + </div> + ); +} 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/pages/notifications/NotificationsRoot.tsx b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx index 1df42ffdb4..de198c0d20 100644 --- a/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx +++ b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx @@ -2,11 +2,12 @@ import React from "react"; import { Routes, Route, NavLink } from "react-router-dom"; import { useApi } from "api"; // Own module -import { title } from "./data"; +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"; export const NotificationsRoot: React.FC = () => { const availableRoutes: { @@ -14,7 +15,13 @@ export const NotificationsRoot: React.FC = () => { subPath: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any component: React.ComponentType; - }[] = []; + }[] = [ + { + name: "Inbox", + subPath: subPaths.inbox, + component: Inbox + } + ]; const dnpsRequest = useApi.packagesGet(); diff --git a/packages/admin-ui/src/pages/notifications/data.tsx b/packages/admin-ui/src/pages/notifications/data.tsx index 750941306e..ba2b80dfea 100644 --- a/packages/admin-ui/src/pages/notifications/data.tsx +++ b/packages/admin-ui/src/pages/notifications/data.tsx @@ -1,4 +1,4 @@ -export const relativePath = "notifications"; +export const relativePath = "notifications/inbox"; // default redirect to inbox export const rootPath = "notifications/*"; export const title = "Notifications"; @@ -6,5 +6,5 @@ export const title = "Notifications"; // SubPaths export const subPaths = { - + inbox: "inbox" }; 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..6ccd59dee2 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx @@ -0,0 +1,115 @@ +import SubTitle from "components/SubTitle"; +import React, { useEffect, useMemo, useState } from "react"; +import Card from "components/Card"; +import "./inbox.scss"; +import { NotificationCard } from "./NotificationsCard"; +import { useApi } from "api"; +import { Searchbar } from "components/Searchbar"; +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.notificationsGetAll(); + + const [search, setSearch] = useState(""); + const [categories, setCategories] = useState<string[]>([]); + const [selectedCategory, setSelectedCategory] = useState<string | null>(null); + + useEffect(() => { + if (!notifications.data) { + setCategories([]); + return; + } + + const uniqueCategories = Array.from(new Set(notifications.data.map((n) => n.category).filter(Boolean))); + setCategories(uniqueCategories); + }, [notifications.data]); + + const filteredNotifications = useMemo(() => { + if (!notifications.data) return []; + + return notifications.data.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 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"]} /> + ) : ( + <> + <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.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/NotificationsCard.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx new file mode 100644 index 0000000000..e145155d17 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/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..42f6f4b327 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss @@ -0,0 +1,153 @@ +.categories { + display: flex; + flex-direction: row; + gap: 10px; + margin-top: 10px; + + .category { + padding: 3px 5px; + border-radius: 10px; + cursor: pointer; + font-size: 1rem; + color: var(--light-text-color); + border: #ced4da 1px solid; + } + + .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%; + + .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: #dcdcdc; + padding: 0px 5px; + border-radius: 10px; + font-size: 0.7rem; + } + .sucess-label { + @extend .group-label; + background-color: transparent !important; + border: 1px solid var(--success-green-color); + color: var(--success-green-color) !important; + } + .trigger-label { + @extend .group-label; + background-color: transparent !important; + border: 1px solid var(--dappnode-complimentary-color); + color: var(--dappnode-complimentary-color) !important; + } + } + } + + .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); + } +} From dc62b8970274bab49caa058f91064c1618c95b2f Mon Sep 17 00:00:00 2001 From: Pablo Mendez <pablo@dappnode.io> Date: Thu, 20 Mar 2025 12:07:35 +0100 Subject: [PATCH 17/90] remove unnecesary asyn-await --- packages/notifications/src/index.ts | 2 +- packages/notifications/src/manifest.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index 861855d614..a625f23a86 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -38,7 +38,7 @@ class Notifications { * Update endpoint properties */ async updateEndpoints(dnpName: string, notificationsConfig: NotificationsConfig): Promise<void> { - await this.manifest.updateEndpoints(dnpName, notificationsConfig); + this.manifest.updateEndpoints(dnpName, notificationsConfig); await this.api.reloadEndpoints(); } } diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts index f80735cdfc..980b225be6 100644 --- a/packages/notifications/src/manifest.ts +++ b/packages/notifications/src/manifest.ts @@ -27,7 +27,7 @@ export class NotificationsManifest { /** * Update endpoint properties in filesystem */ - async updateEndpoints(dnpName: string, notificationsConfig: NotificationsConfig): Promise<void> { + updateEndpoints(dnpName: string, notificationsConfig: NotificationsConfig): void { const { endpoints: updatedEndpoints, customEndpoints: updatedCustomEndpoints } = notificationsConfig; const manifest: Manifest = JSON.parse(fs.readFileSync(getManifestPath(dnpName, false), "utf8")); From ded9ccf259d5912f03272038e9208d6ad3892ab7 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Date: Thu, 20 Mar 2025 13:06:02 +0100 Subject: [PATCH 18/90] Check is core package before updating notifications settings (#2114) * Check is core * use is core in write too --------- Co-authored-by: Pablo Mendez <pablo@dappnode.io> --- packages/dappmanager/src/calls/notifications.ts | 4 +++- packages/notifications/src/index.ts | 4 ++-- packages/notifications/src/manifest.ts | 6 +++--- packages/types/src/routes.ts | 1 + 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/dappmanager/src/calls/notifications.ts b/packages/dappmanager/src/calls/notifications.ts index 7434814f04..072d386cb5 100644 --- a/packages/dappmanager/src/calls/notifications.ts +++ b/packages/dappmanager/src/calls/notifications.ts @@ -23,10 +23,12 @@ export async function notificationsGetEndpoints(): Promise<{ */ export async function notificationsUpdateEndpoints({ dnpName, + isCore, notificationsConfig }: { dnpName: string; + isCore: boolean; notificationsConfig: NotificationsConfig; }): Promise<void> { - await notifications.updateEndpoints(dnpName, notificationsConfig); + await notifications.updateEndpoints(dnpName, isCore, notificationsConfig); } diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index a625f23a86..c24b28de73 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -37,8 +37,8 @@ class Notifications { /** * Update endpoint properties */ - async updateEndpoints(dnpName: string, notificationsConfig: NotificationsConfig): Promise<void> { - this.manifest.updateEndpoints(dnpName, notificationsConfig); + async updateEndpoints(dnpName: string, isCore: boolean, notificationsConfig: NotificationsConfig): Promise<void> { + this.manifest.updateEndpoints(dnpName, isCore, notificationsConfig); await this.api.reloadEndpoints(); } } diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts index 980b225be6..cb4329c655 100644 --- a/packages/notifications/src/manifest.ts +++ b/packages/notifications/src/manifest.ts @@ -27,10 +27,10 @@ export class NotificationsManifest { /** * Update endpoint properties in filesystem */ - updateEndpoints(dnpName: string, notificationsConfig: NotificationsConfig): void { + updateEndpoints(dnpName: string, isCore: boolean, notificationsConfig: NotificationsConfig): void { const { endpoints: updatedEndpoints, customEndpoints: updatedCustomEndpoints } = notificationsConfig; - const manifest: Manifest = JSON.parse(fs.readFileSync(getManifestPath(dnpName, false), "utf8")); + const manifest: Manifest = JSON.parse(fs.readFileSync(getManifestPath(dnpName, isCore), "utf8")); if (!manifest.notifications) throw new Error("No notifications found in manifest"); if (updatedEndpoints) { @@ -45,6 +45,6 @@ export class NotificationsManifest { Object.assign(customEndpoints, updatedCustomEndpoints); } - fs.writeFileSync(getManifestPath(dnpName, false), JSON.stringify(manifest, null, 2)); + fs.writeFileSync(getManifestPath(dnpName, isCore), JSON.stringify(manifest, null, 2)); } } diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index 69b16a8053..5db26f571b 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -279,6 +279,7 @@ export interface Routes { */ notificationsUpdateEndpoints: (kwargs: { dnpName: string; + isCore: boolean; notificationsConfig: NotificationsConfig; }) => Promise<void>; From 8dc351bb0b4e7b4a0cabb32533f1edc21dbf6774 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Date: Thu, 20 Mar 2025 13:20:15 +0100 Subject: [PATCH 19/90] Return is core to in getEndpoints from notifications (#2115) Co-authored-by: Pablo Mendez <pablo@dappnode.io> --- .../admin-ui/src/__mock-backend__/index.ts | 2 +- .../dappmanager/src/calls/notifications.ts | 2 +- packages/notifications/src/index.ts | 2 +- packages/notifications/src/manifest.ts | 18 ++++++++++++------ packages/types/src/routes.ts | 2 +- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/admin-ui/src/__mock-backend__/index.ts b/packages/admin-ui/src/__mock-backend__/index.ts index e1778eba45..e76f938add 100644 --- a/packages/admin-ui/src/__mock-backend__/index.ts +++ b/packages/admin-ui/src/__mock-backend__/index.ts @@ -388,7 +388,7 @@ export const otherCalls: Omit<Routes, keyof typeof namedSpacedCalls> = { getIsConnectedToInternet: async () => false, getCoreVersion: async () => "0.2.92", notificationsGetEndpoints: async () => { - return { "geth.dnp.dappnode.eth": { endpoints: [], customEndpoints: [] } }; + return { "geth.dnp.dappnode.eth": { endpoints: [], customEndpoints: [], isCore: false } }; }, notificationsUpdateEndpoints: async () => {}, notificationsGetAll: async () => [] diff --git a/packages/dappmanager/src/calls/notifications.ts b/packages/dappmanager/src/calls/notifications.ts index 072d386cb5..b754c803cf 100644 --- a/packages/dappmanager/src/calls/notifications.ts +++ b/packages/dappmanager/src/calls/notifications.ts @@ -13,7 +13,7 @@ export async function notificationsGetAll(): Promise<Notification[]> { * Get gatus and custom endpoints indexed by dnpName */ export async function notificationsGetEndpoints(): Promise<{ - [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[] }; + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; }> { return await notifications.getEndpoints(); } diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index c24b28de73..409210e2cd 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -29,7 +29,7 @@ class Notifications { * Get gatus and custom endpoints indexed by dnpName */ async getEndpoints(): Promise<{ - [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[] }; + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; }> { return await this.manifest.getEndpoints(); } diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts index cb4329c655..f10c3c67e9 100644 --- a/packages/notifications/src/manifest.ts +++ b/packages/notifications/src/manifest.ts @@ -8,20 +8,26 @@ export class NotificationsManifest { * Get gatus and custom endpoints indexed by dnpName from filesystem */ async getEndpoints(): Promise<{ - [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[] }; + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; }> { const packages = await listPackages(); - const endpoints: { [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[] } } = {}; + const notificationsEndpoints: { + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; + } = {}; 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) endpoints[pkg.dnpName].endpoints = manifest.notifications.endpoints; - if (manifest.notifications?.customEndpoints) - endpoints[pkg.dnpName].customEndpoints = manifest.notifications.customEndpoints; + + if (!manifest.notifications) continue; + + const { endpoints, customEndpoints } = manifest.notifications; + if (endpoints) notificationsEndpoints[pkg.dnpName].endpoints = endpoints; + if (customEndpoints) notificationsEndpoints[pkg.dnpName].customEndpoints = customEndpoints; + if (notificationsEndpoints[pkg.dnpName]) notificationsEndpoints[pkg.dnpName].isCore = pkg.isCore; } - return endpoints; + return notificationsEndpoints; } /** diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index 5db26f571b..f7b2615828 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -271,7 +271,7 @@ export interface Routes { * Gatus get endpoints */ notificationsGetEndpoints(): Promise<{ - [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[] }; + [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; }>; /** From ffe0143258d7239cca5122e869fd2de185861df1 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Thu, 20 Mar 2025 16:59:00 +0100 Subject: [PATCH 20/90] fix getEndpoints method (#2116) --- packages/notifications/src/manifest.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts index f10c3c67e9..8592ed21c1 100644 --- a/packages/notifications/src/manifest.ts +++ b/packages/notifications/src/manifest.ts @@ -22,9 +22,13 @@ export class NotificationsManifest { if (!manifest.notifications) continue; const { endpoints, customEndpoints } = manifest.notifications; + + if (!notificationsEndpoints[pkg.dnpName]) { + notificationsEndpoints[pkg.dnpName] = { endpoints: [], customEndpoints: [], isCore: pkg.isCore }; + } + if (endpoints) notificationsEndpoints[pkg.dnpName].endpoints = endpoints; if (customEndpoints) notificationsEndpoints[pkg.dnpName].customEndpoints = customEndpoints; - if (notificationsEndpoints[pkg.dnpName]) notificationsEndpoints[pkg.dnpName].isCore = pkg.isCore; } return notificationsEndpoints; From 0064a1fee1808d854e7d0442eeb0504a9b5ce0d4 Mon Sep 17 00:00:00 2001 From: Pablo Mendez <pablo@dappnode.io> Date: Fri, 21 Mar 2025 08:54:37 +0100 Subject: [PATCH 21/90] improve code redability --- packages/notifications/src/manifest.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts index 8592ed21c1..265102717a 100644 --- a/packages/notifications/src/manifest.ts +++ b/packages/notifications/src/manifest.ts @@ -16,19 +16,16 @@ export class NotificationsManifest { [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; } = {}; for (const pkg of packages) { - const manifestPath = getManifestPath(pkg.dnpName, pkg.isCore); + const { dnpName, isCore } = pkg; + const manifestPath = getManifestPath(dnpName, isCore); const manifest: Manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); if (!manifest.notifications) continue; const { endpoints, customEndpoints } = manifest.notifications; - - if (!notificationsEndpoints[pkg.dnpName]) { - notificationsEndpoints[pkg.dnpName] = { endpoints: [], customEndpoints: [], isCore: pkg.isCore }; - } - - if (endpoints) notificationsEndpoints[pkg.dnpName].endpoints = endpoints; - if (customEndpoints) notificationsEndpoints[pkg.dnpName].customEndpoints = customEndpoints; + if (endpoints) notificationsEndpoints[dnpName].endpoints = endpoints; + if (customEndpoints) notificationsEndpoints[dnpName].customEndpoints = customEndpoints; + notificationsEndpoints[dnpName].isCore = isCore; } return notificationsEndpoints; From 33c4716f6898a0074d42453dbef352c49c366c38 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Fri, 21 Mar 2025 09:55:06 +0100 Subject: [PATCH 22/90] Add `notifications/settings` subpath (#2117) * Implament `Settings` subpath * setting subtab fixes * refactor EndpointItem component to be reusable * Reuse `EndpointItem` component --- packages/admin-ui/src/components/Slider.tsx | 57 +++++++++++++ packages/admin-ui/src/components/slider.scss | 40 +++++++++ .../pages/notifications/NotificationsRoot.tsx | 6 ++ .../pages/notifications/{data.tsx => data.ts} | 3 +- .../tabs/Settings/CustomEndpointItem.tsx | 67 +++++++++++++++ .../tabs/Settings/EndpointItem.tsx | 52 ++++++++++++ .../tabs/Settings/GatusEndpointItem.tsx | 83 +++++++++++++++++++ .../Settings/ManagePackageNotifications.tsx | 77 +++++++++++++++++ .../notifications/tabs/Settings/Settings.tsx | 48 +++++++++++ .../notifications/tabs/Settings/settings.scss | 59 +++++++++++++ 10 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 packages/admin-ui/src/components/Slider.tsx create mode 100644 packages/admin-ui/src/components/slider.scss rename packages/admin-ui/src/pages/notifications/{data.tsx => data.ts} (84%) create mode 100644 packages/admin-ui/src/pages/notifications/tabs/Settings/CustomEndpointItem.tsx create mode 100644 packages/admin-ui/src/pages/notifications/tabs/Settings/EndpointItem.tsx create mode 100644 packages/admin-ui/src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx create mode 100644 packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx create mode 100644 packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx create mode 100644 packages/admin-ui/src/pages/notifications/tabs/Settings/settings.scss diff --git a/packages/admin-ui/src/components/Slider.tsx b/packages/admin-ui/src/components/Slider.tsx new file mode 100644 index 0000000000..08fa70646f --- /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<SliderProps> = ({ + 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<HTMLInputElement>) => { + 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 ( + <div className="slider-container"> + <input + type="range" + min={min} + max={max} + step={step} + value={sliderValue} + onChange={handleChange} + onMouseUp={handleMouseUp} // For mouse support + onTouchEnd={handleTouchEnd} // For mobile support + className="slider-component" + /> + <span className="slider-value">{sliderValue} {unit && unit}</span> + </div> + ); +}; + +export default Slider; diff --git a/packages/admin-ui/src/components/slider.scss b/packages/admin-ui/src/components/slider.scss new file mode 100644 index 0000000000..ff698e4186 --- /dev/null +++ b/packages/admin-ui/src/components/slider.scss @@ -0,0 +1,40 @@ +.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; + } + \ No newline at end of file diff --git a/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx index de198c0d20..f6e2698251 100644 --- a/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx +++ b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx @@ -8,6 +8,7 @@ import { InstallNotificationsPkg } from "./tabs/InstallNotifications/InstallNoti import Title from "components/Title"; import { renderResponse } from "components/SwrRender"; import { Inbox } from "./tabs/Inbox/Inbox"; +import { NotificationsSettings } from "./tabs/Settings/Settings"; export const NotificationsRoot: React.FC = () => { const availableRoutes: { @@ -20,6 +21,11 @@ export const NotificationsRoot: React.FC = () => { name: "Inbox", subPath: subPaths.inbox, component: Inbox + }, + { + name: "Settings", + subPath: subPaths.settings, + component: NotificationsSettings } ]; diff --git a/packages/admin-ui/src/pages/notifications/data.tsx b/packages/admin-ui/src/pages/notifications/data.ts similarity index 84% rename from packages/admin-ui/src/pages/notifications/data.tsx rename to packages/admin-ui/src/pages/notifications/data.ts index ba2b80dfea..20623cbf42 100644 --- a/packages/admin-ui/src/pages/notifications/data.tsx +++ b/packages/admin-ui/src/pages/notifications/data.ts @@ -6,5 +6,6 @@ export const title = "Notifications"; // SubPaths export const subPaths = { - inbox: "inbox" + inbox: "inbox", + settings: "settings" }; 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..00a3e3da64 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/EndpointItem.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import Switch from "components/Switch"; +import Slider from "components/Slider"; + +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> + <div>{description}</div> + </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..d983860b06 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx @@ -0,0 +1,83 @@ +import React, { 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)); + + 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/ManagePackageNotifications.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx new file mode 100644 index 0000000000..63886e7894 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx @@ -0,0 +1,77 @@ +import React, { useEffect, useState } 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 } from "api"; + +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) + ); + + // Handle switch toggle to enable/disable all endpoints + const handlePkgToggle = () => { + const newEnabledState = !pkgNotificationsEnabled; + setEndpointsGatus((prevGatusEndpoints) => prevGatusEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState }))); + setEndpointsCustom((prevCustomEndpoints) => prevCustomEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState }))); + setPkgNotificationsEnabled(newEnabledState); + }; + + useEffect(() => { + api.notificationsUpdateEndpoints({ dnpName, notificationsConfig: { endpoints: endpointsGatus }, isCore: isCore }); + }, [endpointsGatus]); + useEffect(() => { + api.notificationsUpdateEndpoints({ + dnpName, + notificationsConfig: { customEndpoints: endpointsCustom }, + isCore: isCore + }); + }, [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} /> + </div> + {pkgNotificationsEnabled && ( + <div className="endpoint-list-card"> + {endpointsGatus.map((endpoint, i) => ( + <GatusEndpointItem + key={endpoint.name} + endpoint={endpoint} + index={i} + numEndpoints={endpointsGatus.length} + setGatusEndpoints={setEndpointsGatus} + /> + ))} + {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/Settings.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx new file mode 100644 index 0000000000..052720ce5a --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx @@ -0,0 +1,48 @@ +import SubTitle from "components/SubTitle"; +import React, { useState } from "react"; +import Switch from "components/Switch"; +import { ManagePackageNotifications } from "./ManagePackageNotifications.js"; +import { useApi } from "api"; +import "./settings.scss"; + +export function NotificationsSettings() { + const [notificationsEnabled, setNotificationsEnabled] = useState(true); + const endpointsCall = useApi.notificationsGetEndpoints(); + + 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]) => ( + <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..ea10207531 --- /dev/null +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/settings.scss @@ -0,0 +1,59 @@ +.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); + } + } + } + \ No newline at end of file From b230971b7929f6a2bbbc4503f68f2b6233c6dc49 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:19:00 +0100 Subject: [PATCH 23/90] Ensure notificationsEndpoints by package is initalized (#2118) --- packages/notifications/src/manifest.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts index 265102717a..631862b321 100644 --- a/packages/notifications/src/manifest.ts +++ b/packages/notifications/src/manifest.ts @@ -22,10 +22,12 @@ export class NotificationsManifest { if (!manifest.notifications) continue; + if (!notificationsEndpoints[pkg.dnpName]) { + notificationsEndpoints[pkg.dnpName] = { endpoints: [], customEndpoints: [], isCore: isCore }; + } const { endpoints, customEndpoints } = manifest.notifications; if (endpoints) notificationsEndpoints[dnpName].endpoints = endpoints; if (customEndpoints) notificationsEndpoints[dnpName].customEndpoints = customEndpoints; - notificationsEndpoints[dnpName].isCore = isCore; } return notificationsEndpoints; From e3f80aa30e244e7460441432565c62c555ba632a Mon Sep 17 00:00:00 2001 From: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:43:43 +0100 Subject: [PATCH 24/90] Initialize in single line (#2119) Co-authored-by: Pablo Mendez <pablo@dappnode.io> --- packages/notifications/src/manifest.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts index 631862b321..5f1a13d44d 100644 --- a/packages/notifications/src/manifest.ts +++ b/packages/notifications/src/manifest.ts @@ -22,12 +22,8 @@ export class NotificationsManifest { if (!manifest.notifications) continue; - if (!notificationsEndpoints[pkg.dnpName]) { - notificationsEndpoints[pkg.dnpName] = { endpoints: [], customEndpoints: [], isCore: isCore }; - } const { endpoints, customEndpoints } = manifest.notifications; - if (endpoints) notificationsEndpoints[dnpName].endpoints = endpoints; - if (customEndpoints) notificationsEndpoints[dnpName].customEndpoints = customEndpoints; + notificationsEndpoints[dnpName] = { endpoints: endpoints || [], customEndpoints: customEndpoints || [], isCore }; } return notificationsEndpoints; From ff315a00c42df17517b303c25be68c92adbbbdd0 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Date: Tue, 25 Mar 2025 15:05:07 +0100 Subject: [PATCH 25/90] Return avatarUrl in `/package-manifest` (#2120) * return avatar in package-manifest api * improve getIsCore * fix add missing return * add avatarurl setter --------- Co-authored-by: Pablo Mendez <pablo@dappnode.io> --- .../src/api/routes/packageManifest.ts | 19 ++++++++++++------- packages/httpsPortal/src/exposable/index.ts | 2 +- packages/installer/src/calls/packageGet.ts | 2 +- packages/installer/src/dappnodeInstaller.ts | 13 +++++++++---- packages/types/src/manifest.ts | 1 + packages/utils/src/getIsCore.ts | 18 ++++++++++++++++-- packages/utils/src/readManifestIfExists.ts | 4 +++- 7 files changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/dappmanager/src/api/routes/packageManifest.ts b/packages/dappmanager/src/api/routes/packageManifest.ts index bbc63afb0c..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, [ @@ -55,7 +59,8 @@ export const packageManifest = wrapHandler<Params>(async (req, res) => { "repository", "bugs", "license", - "notifications" + "notifications", + "avatarUrl" ]); res.status(200).send(filteredManifest); 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/dappnodeInstaller.ts b/packages/installer/src/dappnodeInstaller.ts index f3dbee7fe0..457dfe74a9 100644 --- a/packages/installer/src/dappnodeInstaller.ts +++ b/packages/installer/src/dappnodeInstaller.ts @@ -18,7 +18,7 @@ import { DappGetState, DappgetOptions, dappGet } from "./dappGet/index.js"; import { validateDappnodeCompose, validateManifestSchema } 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"; @@ -74,7 +74,8 @@ export class DappnodeInstaller extends DappnodeRepository { gettingStarted: pkgRelease.gettingStarted, grafanaDashboards: pkgRelease.grafanaDashboards, prometheusTargets: pkgRelease.prometheusTargets, - notifications: pkgRelease.notifications + notifications: pkgRelease.notifications, + avatarFile: pkgRelease.avatarFile }); // set compose to custom dappnode compose in release @@ -110,7 +111,8 @@ export class DappnodeInstaller extends DappnodeRepository { gettingStarted: pkgRelease.gettingStarted, grafanaDashboards: pkgRelease.grafanaDashboards, prometheusTargets: pkgRelease.prometheusTargets, - notifications: pkgRelease.notifications + notifications: pkgRelease.notifications, + avatarFile: pkgRelease.avatarFile }); }); @@ -155,7 +157,8 @@ export class DappnodeInstaller extends DappnodeRepository { gettingStarted, prometheusTargets, grafanaDashboards, - notifications + notifications, + avatarFile }: { manifest: Manifest; SetupWizard?: SetupWizard; @@ -164,6 +167,7 @@ export class DappnodeInstaller extends DappnodeRepository { prometheusTargets?: PrometheusTarget[]; grafanaDashboards?: GrafanaDashboard[]; notifications?: NotificationsConfig; + avatarFile?: DistributedFile; }): Manifest { if (SetupWizard) manifest.setupWizard = SetupWizard; if (disclaimer) manifest.disclaimer = { message: disclaimer }; @@ -171,6 +175,7 @@ export class DappnodeInstaller extends DappnodeRepository { 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; } diff --git a/packages/types/src/manifest.ts b/packages/types/src/manifest.ts index 1619eab2b0..ef392d186c 100644 --- a/packages/types/src/manifest.ts +++ b/packages/types/src/manifest.ts @@ -14,6 +14,7 @@ export interface Manifest { author?: string; license?: string; avatar?: string; + avatarUrl?: string; repository?: { type?: string; url?: string; 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/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); From ef5825151e0a02df51bd0aeddc6917267bfd6c9b Mon Sep 17 00:00:00 2001 From: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Date: Tue, 25 Mar 2025 17:17:56 +0100 Subject: [PATCH 26/90] Ensure directory exists (#2124) Co-authored-by: Pablo Mendez <pablo@dappnode.io> --- packages/notifications/src/manifest.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts index 5f1a13d44d..aec3524fb1 100644 --- a/packages/notifications/src/manifest.ts +++ b/packages/notifications/src/manifest.ts @@ -18,8 +18,9 @@ export class NotificationsManifest { for (const pkg of packages) { const { dnpName, isCore } = pkg; const manifestPath = getManifestPath(dnpName, isCore); - const manifest: Manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (!fs.existsSync(manifestPath)) continue; + const manifest: Manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); if (!manifest.notifications) continue; const { endpoints, customEndpoints } = manifest.notifications; From 372e58f787ae731015d7dd0dee955baefa6be4df Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Wed, 26 Mar 2025 15:41:36 +0100 Subject: [PATCH 27/90] Notifications step in installer (#2121) * Notifications step v1 * set Notifications config on installer * installer styles * review fixes --- packages/admin-ui/src/components/slider.scss | 1 - .../installer/components/InstallDnpView.tsx | 53 ++++++++++++++++++- .../components/Steps/Notifications.tsx | 42 +++++++++++++++ .../pages/notifications/NotificationsRoot.tsx | 6 +-- .../tabs/Settings/InstallerEndpointsList.tsx | 49 +++++++++++++++++ .../notifications/tabs/Settings/settings.scss | 3 +- packages/admin-ui/src/params.ts | 3 ++ 7 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 packages/admin-ui/src/pages/installer/components/Steps/Notifications.tsx create mode 100644 packages/admin-ui/src/pages/notifications/tabs/Settings/InstallerEndpointsList.tsx diff --git a/packages/admin-ui/src/components/slider.scss b/packages/admin-ui/src/components/slider.scss index ff698e4186..a3d1121cfa 100644 --- a/packages/admin-ui/src/components/slider.scss +++ b/packages/admin-ui/src/components/slider.scss @@ -33,7 +33,6 @@ } .slider-value { - color: #c0c0c0; font-weight: bold; min-width: fit-content; } diff --git a/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx b/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx index 251ccdb55b..1ddf21f557 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 { notificationsPkgName } from "params"; interface InstallDnpViewProps { dnp: RequestedDnp; @@ -66,6 +68,12 @@ const InstallDnpView: React.FC<InstallDnpViewProps> = ({ dnp, progressLogs }) => const isWizardEmpty = isSetupWizardEmpty(setupWizard); const oldEditorAvailable = Boolean(userSettings); + const [endpoints, setEndpoints] = React.useState<GatusEndpoint[]>(manifest.notifications?.endpoints || []); + + const [customEndpoints, setCustomEndpoints] = React.useState<CustomEndpoint[]>( + manifest.notifications?.customEndpoints || [] + ); + useEffect(() => { setUserSettings(settings || {}); }, [settings, setUserSettings]); @@ -112,6 +120,9 @@ const InstallDnpView: React.FC<InstallDnpViewProps> = ({ 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 +144,21 @@ const InstallDnpView: React.FC<InstallDnpViewProps> = ({ 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 +198,10 @@ const InstallDnpView: React.FC<InstallDnpViewProps> = ({ dnp, progressLogs }) => } ].filter((option) => option.available); + const dnpsRequest = useApi.packagesGet(); + const installedDnps = dnpsRequest.data; + const isNotificationsPkgInstalled = installedDnps?.some((dnp) => dnp.dnpName === notificationsPkgName); + const disableInstallation = !isEmpty(progressLogs) || requiresCoreUpdate || requiresDockerUpdate || packagesToBeUninstalled.length > 0; @@ -179,8 +209,12 @@ const InstallDnpView: React.FC<InstallDnpViewProps> = ({ 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 +262,21 @@ const InstallDnpView: React.FC<InstallDnpViewProps> = ({ dnp, progressLogs }) => render: () => <Disclaimer disclaimers={disclaimers} onAccept={goNext} goBack={goBack} />, available: disclaimers.length > 0 }, + { + name: "Notifications", + subPath: notificationsSubPath, + render: () => ( + <Notifications + endpointsGatus={endpoints} + endpointsCustom={customEndpoints} + setEndpointsGatus={setEndpoints} + setEndpointsCustom={setCustomEndpoints} + goNext={goNext} + goBack={goBack} + /> + ), + 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<React.SetStateAction<GatusEndpoint[]>>; + endpointsCustom: CustomEndpoint[]; + setEndpointsCustom: React.Dispatch<React.SetStateAction<CustomEndpoint[]>>; + goNext: () => void; + goBack: () => void; +} + +export const Notifications: React.FC<NotificationsProps> = ({ + endpointsGatus, + setEndpointsGatus, + endpointsCustom, + setEndpointsCustom, + goNext, + goBack +}) => { + return ( + <Card> + <InstallerEndpointsList + endpointsGatus={endpointsGatus} + setEndpointsGatus={setEndpointsGatus} + endpointsCustom={endpointsCustom} + setEndpointsCustom={setEndpointsCustom} + /> + + <div className="button-group"> + <Button onClick={goBack}>Back</Button> + <Button variant="dappnode" onClick={goNext}> + Accept + </Button> + </div> + </Card> + ); +}; diff --git a/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx index f6e2698251..44c114fbd3 100644 --- a/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx +++ b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx @@ -9,6 +9,7 @@ import Title from "components/Title"; import { renderResponse } from "components/SwrRender"; import { Inbox } from "./tabs/Inbox/Inbox"; import { NotificationsSettings } from "./tabs/Settings/Settings"; +import { notificationsPkgName } from "params"; export const NotificationsRoot: React.FC = () => { const availableRoutes: { @@ -32,14 +33,13 @@ export const NotificationsRoot: React.FC = () => { const dnpsRequest = useApi.packagesGet(); return renderResponse(dnpsRequest, ["Loading notifications"], (dnps) => { - const notificationsDnpName = "notifications.dnp.dappnode.eth"; - const isNotificationsPkgInstalled = dnps?.some((dnp) => dnp.dnpName === notificationsDnpName); + const isNotificationsPkgInstalled = dnps?.some((dnp) => dnp.dnpName === notificationsPkgName); return ( <> <Title title={title} /> {!isNotificationsPkgInstalled ? ( - <InstallNotificationsPkg pkgName={notificationsDnpName} /> + <InstallNotificationsPkg pkgName={notificationsPkgName} /> ) : ( <> <div className="horizontal-navbar"> 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/settings.scss b/packages/admin-ui/src/pages/notifications/tabs/Settings/settings.scss index ea10207531..76a490e77a 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/settings.scss +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/settings.scss @@ -23,7 +23,8 @@ .endpoint-list-card { border-radius: 10px; background-color: #e9ecef; - padding: 20px 15px; + margin: 10px 0; + padding: 15px; display: flex; flex-direction: column; diff --git a/packages/admin-ui/src/params.ts b/packages/admin-ui/src/params.ts index dcf623071e..82d20b7c05 100755 --- a/packages/admin-ui/src/params.ts +++ b/packages/admin-ui/src/params.ts @@ -130,3 +130,6 @@ export const IPFS_GATEWAY_CHECKER = "https://ipfs.github.io/public-gateway-check export const MAIN_ADMIN_NAME = "dappnode_admin"; // Support, where to send issues + +// Notifications +export const notificationsPkgName = "notifications.dnp.dappnode.eth"; From 22b05bf75d631eed81856fddfa4c563d0ddf07b0 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Thu, 27 Mar 2025 09:21:22 +0100 Subject: [PATCH 28/90] Prevent endpoints autoupdate (#2125) --- .../Settings/ManagePackageNotifications.tsx | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx index 63886e7894..04ceb67ab6 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import SubTitle from "components/SubTitle"; import Switch from "components/Switch"; import { GatusEndpointItem } from "./GatusEndpointItem.js"; @@ -26,24 +26,44 @@ export function ManagePackageNotifications({ gatusEndpoints.some((ep) => ep.enabled) || customEndpoints.some((ep) => ep.enabled) ); + // Needed to track if the state was updated by the user. Otherwise, api.notificationsUpdateEndpoints will be called when rendering the component + const isStateUpdatedByUser = useRef(false); + + // Synchronize state with the latest data from props + 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; - setEndpointsGatus((prevGatusEndpoints) => prevGatusEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState }))); - setEndpointsCustom((prevCustomEndpoints) => prevCustomEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState }))); + setEndpointsGatus((prevGatusEndpoints) => { + isStateUpdatedByUser.current = true; + return prevGatusEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState })); + }); + setEndpointsCustom((prevCustomEndpoints) => { + isStateUpdatedByUser.current = true; + return prevCustomEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState })); + }); setPkgNotificationsEnabled(newEnabledState); }; useEffect(() => { - api.notificationsUpdateEndpoints({ dnpName, notificationsConfig: { endpoints: endpointsGatus }, isCore: isCore }); - }, [endpointsGatus]); - useEffect(() => { - api.notificationsUpdateEndpoints({ - dnpName, - notificationsConfig: { customEndpoints: endpointsCustom }, - isCore: isCore - }); - }, [endpointsCustom]); + if (isStateUpdatedByUser.current) { + isStateUpdatedByUser.current = false; + api.notificationsUpdateEndpoints({ + dnpName, + notificationsConfig: { + endpoints: endpointsGatus.length > 0 ? endpointsGatus : undefined, + customEndpoints: endpointsCustom.length > 0 ? endpointsCustom : undefined + }, + isCore: isCore + }); + } + }, [endpointsGatus, endpointsCustom]); + return ( <div key={String(dnpName)} className="notifications-settings"> <div className="title-switch-row"> From 5c3b23a3dc1555c5542375cc9bef6a37aa4ea239 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Thu, 27 Mar 2025 17:34:00 +0100 Subject: [PATCH 29/90] Revert "Prevent endpoints autoupdate (#2125)" This reverts commit 22b05bf75d631eed81856fddfa4c563d0ddf07b0. --- .../Settings/ManagePackageNotifications.tsx | 44 +++++-------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx index 04ceb67ab6..63886e7894 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef } from "react"; +import React, { useEffect, useState } from "react"; import SubTitle from "components/SubTitle"; import Switch from "components/Switch"; import { GatusEndpointItem } from "./GatusEndpointItem.js"; @@ -26,44 +26,24 @@ export function ManagePackageNotifications({ gatusEndpoints.some((ep) => ep.enabled) || customEndpoints.some((ep) => ep.enabled) ); - // Needed to track if the state was updated by the user. Otherwise, api.notificationsUpdateEndpoints will be called when rendering the component - const isStateUpdatedByUser = useRef(false); - - // Synchronize state with the latest data from props - 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; - setEndpointsGatus((prevGatusEndpoints) => { - isStateUpdatedByUser.current = true; - return prevGatusEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState })); - }); - setEndpointsCustom((prevCustomEndpoints) => { - isStateUpdatedByUser.current = true; - return prevCustomEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState })); - }); + setEndpointsGatus((prevGatusEndpoints) => prevGatusEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState }))); + setEndpointsCustom((prevCustomEndpoints) => prevCustomEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState }))); setPkgNotificationsEnabled(newEnabledState); }; useEffect(() => { - if (isStateUpdatedByUser.current) { - isStateUpdatedByUser.current = false; - api.notificationsUpdateEndpoints({ - dnpName, - notificationsConfig: { - endpoints: endpointsGatus.length > 0 ? endpointsGatus : undefined, - customEndpoints: endpointsCustom.length > 0 ? endpointsCustom : undefined - }, - isCore: isCore - }); - } - }, [endpointsGatus, endpointsCustom]); - + api.notificationsUpdateEndpoints({ dnpName, notificationsConfig: { endpoints: endpointsGatus }, isCore: isCore }); + }, [endpointsGatus]); + useEffect(() => { + api.notificationsUpdateEndpoints({ + dnpName, + notificationsConfig: { customEndpoints: endpointsCustom }, + isCore: isCore + }); + }, [endpointsCustom]); return ( <div key={String(dnpName)} className="notifications-settings"> <div className="title-switch-row"> From 27507de212bda721ec6e3ab281a41cc555dec6a7 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Fri, 28 Mar 2025 15:33:52 +0100 Subject: [PATCH 30/90] filter error notifications on `Inbox` (#2128) --- .../src/pages/notifications/tabs/Inbox/Inbox.tsx | 13 +++++++------ packages/types/src/notifications.ts | 1 + 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx index 6ccd59dee2..b442a3f133 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx @@ -30,7 +30,11 @@ export function Inbox() { const filteredNotifications = useMemo(() => { if (!notifications.data) return []; - return notifications.data.filter( + // 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())) && @@ -51,11 +55,8 @@ export function Inbox() { const findPkgAvatar = (dnpName: string) => { const dnp = installedDnps?.find((dnp) => dnp.dnpName === dnpName); - if (!dnp) { - return defaultAvatar; - } else if (dnp.isCore) { - return dappnodeIcon; - } + if (!dnp) return defaultAvatar; + else if (dnp.isCore) return dappnodeIcon; return dnp.avatarUrl; }; diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts index 855092910c..f862d9fbc5 100644 --- a/packages/types/src/notifications.ts +++ b/packages/types/src/notifications.ts @@ -13,6 +13,7 @@ export interface NotificationPayload { body: string; dnpName: string; category: NotificationCategory; + errors?: string; callToAction?: { title: string; url: string; From 2197daf24441e4938f059f9ec89176781d0adc58 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Mon, 31 Mar 2025 09:49:51 +0200 Subject: [PATCH 31/90] Retrieve notification icon from the notification itself (#2129) * get notification icon from itself * icon in NotificationPayload interface * optional icon --- .../pages/notifications/tabs/Inbox/Inbox.tsx | 25 +++---------------- .../tabs/Inbox/NotificationsCard.tsx | 11 +++++--- packages/types/src/notifications.ts | 1 + 3 files changed, 12 insertions(+), 25 deletions(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx index b442a3f133..ca8f4e98a0 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx @@ -6,11 +6,8 @@ import { NotificationCard } from "./NotificationsCard"; import { useApi } from "api"; import { Searchbar } from "components/Searchbar"; 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.notificationsGetAll(); const [search, setSearch] = useState(""); @@ -50,15 +47,7 @@ export function Inbox() { .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; - }; + const loading = notifications.isValidating; return loading ? ( <Loading steps={["Loading data"]} /> @@ -90,11 +79,7 @@ export function Inbox() { <> <SubTitle>New Notifications</SubTitle> {newNotifications.map((notification) => ( - <NotificationCard - key={notification.timestamp} - notification={notification} - avatarUrl={findPkgAvatar(notification.dnpName)} - /> + <NotificationCard key={notification.timestamp} notification={notification} /> ))} </> )} @@ -104,11 +89,7 @@ export function Inbox() { <Card>No notifications</Card> ) : ( seenNotifications.map((notification) => ( - <NotificationCard - key={notification.timestamp} - notification={notification} - avatarUrl={findPkgAvatar(notification.dnpName)} - /> + <NotificationCard key={notification.timestamp} notification={notification} /> )) )} </> diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx index e145155d17..8b88414b71 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx @@ -3,20 +3,25 @@ import { Accordion } from "react-bootstrap"; import { Notification } from "@dappnode/types"; import { IoIosArrowDown, IoIosArrowUp } from "react-icons/io"; import { prettyDnpName } from "utils/format"; +import defaultAvatar from "img/defaultAvatar.png"; interface NotificationCardProps { notification: Notification; - avatarUrl: string; } -export function NotificationCard({ notification, avatarUrl }: NotificationCardProps) { +export function NotificationCard({ notification }: NotificationCardProps) { const [isOpen, setIsOpen] = useState(false); + const notificationAvatar = () => { + if (notification.icon) return notification.icon; + else return defaultAvatar; + }; + 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} /> + <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"> diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts index f862d9fbc5..2fe47296ea 100644 --- a/packages/types/src/notifications.ts +++ b/packages/types/src/notifications.ts @@ -13,6 +13,7 @@ export interface NotificationPayload { body: string; dnpName: string; category: NotificationCategory; + icon?: string; errors?: string; callToAction?: { title: string; From a84ddab52f385ded301322622a7ace3f386f71a4 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Wed, 2 Apr 2025 10:44:16 +0200 Subject: [PATCH 32/90] ensure state update (#2132) --- packages/admin-ui/src/components/Slider.tsx | 12 +++-- .../tabs/Settings/GatusEndpointItem.tsx | 6 ++- .../Settings/ManagePackageNotifications.tsx | 53 ++++++++++++++----- .../notifications/tabs/Settings/Settings.tsx | 23 ++++++-- 4 files changed, 73 insertions(+), 21 deletions(-) diff --git a/packages/admin-ui/src/components/Slider.tsx b/packages/admin-ui/src/components/Slider.tsx index 08fa70646f..80110ac46b 100644 --- a/packages/admin-ui/src/components/Slider.tsx +++ b/packages/admin-ui/src/components/Slider.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import "./slider.scss"; interface SliderProps { @@ -18,10 +18,14 @@ const Slider: React.FC<SliderProps> = ({ value = 50, unit = "%", onChange, - onChangeComplete, // In order to trigger an action when the user releases the slider + 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<HTMLInputElement>) => { const newValue = Number(e.target.value); setSliderValue(newValue); @@ -49,7 +53,9 @@ const Slider: React.FC<SliderProps> = ({ onTouchEnd={handleTouchEnd} // For mobile support className="slider-component" /> - <span className="slider-value">{sliderValue} {unit && unit}</span> + <span className="slider-value"> + {sliderValue} {unit && unit} + </span> </div> ); }; diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx index d983860b06..2af16b72c4 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { GatusEndpoint } from "@dappnode/types"; import { EndpointItem } from "./EndpointItem"; @@ -26,6 +26,10 @@ export function GatusEndpointItem({ endpoint, index, numEndpoints, setGatusEndpo 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)) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx index 63886e7894..3eaaff58da 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import SubTitle from "components/SubTitle"; import Switch from "components/Switch"; import { GatusEndpointItem } from "./GatusEndpointItem.js"; @@ -25,25 +25,44 @@ export function ManagePackageNotifications({ const [pkgNotificationsEnabled, setPkgNotificationsEnabled] = useState( gatusEndpoints.some((ep) => ep.enabled) || customEndpoints.some((ep) => ep.enabled) ); + const isStateUpdatedByUser = useRef(false); + + // 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; - setEndpointsGatus((prevGatusEndpoints) => prevGatusEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState }))); - setEndpointsCustom((prevCustomEndpoints) => prevCustomEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState }))); + isStateUpdatedByUser.current = true; + setEndpointsGatus((prevGatusEndpoints) => + prevGatusEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState })) + ); + setEndpointsCustom((prevCustomEndpoints) => + prevCustomEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState })) + ); setPkgNotificationsEnabled(newEnabledState); }; useEffect(() => { - api.notificationsUpdateEndpoints({ dnpName, notificationsConfig: { endpoints: endpointsGatus }, isCore: isCore }); - }, [endpointsGatus]); - useEffect(() => { - api.notificationsUpdateEndpoints({ - dnpName, - notificationsConfig: { customEndpoints: endpointsCustom }, - isCore: isCore - }); - }, [endpointsCustom]); + if (isStateUpdatedByUser.current) { + isStateUpdatedByUser.current = false; + api.notificationsUpdateEndpoints({ + dnpName, + notificationsConfig: { + endpoints: endpointsGatus.length > 0 ? endpointsGatus : undefined, + customEndpoints: endpointsCustom.length > 0 ? endpointsCustom : undefined + }, + isCore: isCore + }); + } + }, [endpointsGatus, endpointsCustom]); + return ( <div key={String(dnpName)} className="notifications-settings"> <div className="title-switch-row"> @@ -58,7 +77,10 @@ export function ManagePackageNotifications({ endpoint={endpoint} index={i} numEndpoints={endpointsGatus.length} - setGatusEndpoints={setEndpointsGatus} + setGatusEndpoints={(updatedEndpoints) => { + isStateUpdatedByUser.current = true; + setEndpointsGatus(updatedEndpoints); + }} /> ))} {endpointsCustom.map((endpoint, i) => ( @@ -67,7 +89,10 @@ export function ManagePackageNotifications({ endpoint={endpoint} index={i} numEndpoints={endpointsCustom.length} - setCustomEndpoints={setEndpointsCustom} + setCustomEndpoints={(updatedEndpoints) => { + isStateUpdatedByUser.current = true; + setEndpointsCustom(updatedEndpoints); + }} /> ))} </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 index 052720ce5a..2195f6f513 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx @@ -1,14 +1,31 @@ import SubTitle from "components/SubTitle"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import Switch from "components/Switch"; import { ManagePackageNotifications } from "./ManagePackageNotifications.js"; import { useApi } from "api"; +import { CustomEndpoint, GatusEndpoint } from "@dappnode/types"; import "./settings.scss"; +interface EndpointsData { + [dnpName: string]: { + endpoints: GatusEndpoint[]; + customEndpoints: CustomEndpoint[]; + isCore: boolean; + }; +} + export function NotificationsSettings() { const [notificationsEnabled, setNotificationsEnabled] = useState(true); + const [endpointsData, setEndpointsData] = useState<EndpointsData | undefined>(); const endpointsCall = useApi.notificationsGetEndpoints(); + useEffect(() => { + // Fetch the latest endpoints data when the component is mounted + if (endpointsCall.data) { + setEndpointsData(endpointsCall.data as EndpointsData); + } + }, [endpointsCall.data]); + return ( <div className="notifications-settings"> <div> @@ -30,8 +47,8 @@ export function NotificationsSettings() { <div>Enable, disable and customize notifications individually.</div> <br /> <div className="manage-notifications-wrapper"> - {endpointsCall.data && - Object.entries(endpointsCall.data).map(([dnpName, endpoints]) => ( + {endpointsData && + Object.entries(endpointsData).map(([dnpName, endpoints]) => ( <ManagePackageNotifications key={dnpName} dnpName={dnpName} From aced3092e729c91b1f446c22b1582e5ba54598c9 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Date: Thu, 3 Apr 2025 10:04:04 +0200 Subject: [PATCH 33/90] Persist user settings in notifications (#2134) * apply user settings in notifications * fix typo --- .../admin-ui/src/__mock-backend__/index.ts | 2 +- .../notifications/tabs/Settings/Settings.tsx | 2 +- .../src/autoUpdates/sendUpdateNotification.ts | 6 +- packages/dappmanager/src/calls/index.ts | 2 +- .../dappmanager/src/calls/notifications.ts | 4 +- .../src/installer/getInstallerPackageData.ts | 8 +++ packages/notifications/src/index.ts | 22 +++++- packages/notifications/src/manifest.ts | 70 ++++++++++++++++++- packages/types/src/routes.ts | 4 +- 9 files changed, 107 insertions(+), 13 deletions(-) diff --git a/packages/admin-ui/src/__mock-backend__/index.ts b/packages/admin-ui/src/__mock-backend__/index.ts index e76f938add..367cec8321 100644 --- a/packages/admin-ui/src/__mock-backend__/index.ts +++ b/packages/admin-ui/src/__mock-backend__/index.ts @@ -387,7 +387,7 @@ export const otherCalls: Omit<Routes, keyof typeof namedSpacedCalls> = { }), getIsConnectedToInternet: async () => false, getCoreVersion: async () => "0.2.92", - notificationsGetEndpoints: async () => { + notificationsGetAllEndpoints: async () => { return { "geth.dnp.dappnode.eth": { endpoints: [], customEndpoints: [], isCore: false } }; }, notificationsUpdateEndpoints: async () => {}, diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx index 2195f6f513..18a552d3a4 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx @@ -17,7 +17,7 @@ interface EndpointsData { export function NotificationsSettings() { const [notificationsEnabled, setNotificationsEnabled] = useState(true); const [endpointsData, setEndpointsData] = useState<EndpointsData | undefined>(); - const endpointsCall = useApi.notificationsGetEndpoints(); + const endpointsCall = useApi.notificationsGetAllEndpoints(); useEffect(() => { // Fetch the latest endpoints data when the component is mounted diff --git a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts index 7e079df7a7..8274bd45ee 100644 --- a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts +++ b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts @@ -22,9 +22,9 @@ export async function sendUpdatePackageNotificationMaybe({ newVersion: string; }): Promise<void> { // Check if auto-update notifications are enabled - const dappmanagerCustomEndpoint = (await notifications.getEndpoints())[ - params.dappmanagerDnpName - ].customEndpoints.find((customEndpoint) => customEndpoint.name === "auto-updates"); + const dappmanagerCustomEndpoint = notifications + .getEndpointsIfExists(params.dappmanagerDnpName, true) + ?.customEndpoints?.find((customEndpoint) => customEndpoint.name === "auto-updates"); if (!dappmanagerCustomEndpoint || !dappmanagerCustomEndpoint.enabled) return; diff --git a/packages/dappmanager/src/calls/index.ts b/packages/dappmanager/src/calls/index.ts index 004277a48f..7e117228b6 100644 --- a/packages/dappmanager/src/calls/index.ts +++ b/packages/dappmanager/src/calls/index.ts @@ -22,7 +22,7 @@ export { getCoreVersion } from "./getCoreVersion.js"; export { getUserActionLogs } from "./getUserActionLogs.js"; export { getHostUptime } from "./getHostUptime.js"; export { getIsConnectedToInternet } from "./getIsConnectedToInternet.js"; -export { notificationsGetEndpoints, notificationsUpdateEndpoints, notificationsGetAll } from "./notifications.js"; +export { notificationsGetAllEndpoints, notificationsUpdateEndpoints, notificationsGetAll } from "./notifications.js"; export * from "./httpsPortal.js"; export { ipfsTest } from "./ipfsTest.js"; export { ipfsClientTargetSet } from "./ipfsClientTargetSet.js"; diff --git a/packages/dappmanager/src/calls/notifications.ts b/packages/dappmanager/src/calls/notifications.ts index b754c803cf..3eea76d9f8 100644 --- a/packages/dappmanager/src/calls/notifications.ts +++ b/packages/dappmanager/src/calls/notifications.ts @@ -12,10 +12,10 @@ export async function notificationsGetAll(): Promise<Notification[]> { /** * Get gatus and custom endpoints indexed by dnpName */ -export async function notificationsGetEndpoints(): Promise<{ +export async function notificationsGetAllEndpoints(): Promise<{ [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; }> { - return await notifications.getEndpoints(); + return await notifications.getAllEndpoints(); } /** diff --git a/packages/installer/src/installer/getInstallerPackageData.ts b/packages/installer/src/installer/getInstallerPackageData.ts index 9c6b291210..139a111772 100644 --- a/packages/installer/src/installer/getInstallerPackageData.ts +++ b/packages/installer/src/installer/getInstallerPackageData.ts @@ -13,6 +13,7 @@ import { import { getBackupPath, getDockerComposePath, getImagePath, getManifestPath } from "@dappnode/utils"; import { gt } from "semver"; import { logs } from "@dappnode/logger"; +import { notifications } from "@dappnode/notifications"; interface GetInstallerPackageDataArg { releases: PackageRelease[]; @@ -96,6 +97,13 @@ function getInstallerPackageData( imagePath, // Data to write compose: compose.output(), + manifest: release.manifest.notifications + ? { + ...release.manifest, + // Apply notitications user settings if any + notifications: notifications.applyPreviousEndpoints(dnpName, isCore, release.manifest.notifications) + } + : release.manifest, // User settings to be applied by the installer fileUploads: userSettings?.fileUploads, dockerTimeout, diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index 409210e2cd..99e7c3d46e 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -28,10 +28,28 @@ class Notifications { /** * Get gatus and custom endpoints indexed by dnpName */ - async getEndpoints(): Promise<{ + async getAllEndpoints(): Promise<{ [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; }> { - return await this.manifest.getEndpoints(); + 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); } /** diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts index aec3524fb1..37d4464fa3 100644 --- a/packages/notifications/src/manifest.ts +++ b/packages/notifications/src/manifest.ts @@ -7,7 +7,7 @@ export class NotificationsManifest { /** * Get gatus and custom endpoints indexed by dnpName from filesystem */ - async getEndpoints(): Promise<{ + async getAllEndpoints(): Promise<{ [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; }> { const packages = await listPackages(); @@ -30,6 +30,74 @@ export class NotificationsManifest { 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 || [] }; + } + + /** + * Joins new endpoints with previous ones. If there are repeated endpoints: + * - GatusEndpoint: iterate over the conditions properties and split with regex matching any operator. If the right side is a number or a string, keep the old one. + * - CustomEndpoint: check the metric.treshold and keep the old one. + * + * Do not keep old endpoints that are not present in the new ones. + */ + applyPreviousEndpoints( + dnpName: string, + isCore: boolean, + newNotificationsConfig: NotificationsConfig + ): NotificationsConfig { + const oldNotificationsConfig = this.getEndpointsIfExists(dnpName, isCore); + if (!oldNotificationsConfig) return newNotificationsConfig; + + const { endpoints: oldEndpoints, customEndpoints: oldCustomEndpoints } = oldNotificationsConfig; + const { endpoints: newEndpoints, customEndpoints: newCustomEndpoints } = newNotificationsConfig; + + const mergedEndpoints = oldEndpoints + ?.map((oldEndpoint) => { + const newEndpoint = newEndpoints?.find((newEndpoint) => newEndpoint.name === oldEndpoint.name); + if (!newEndpoint) return null; + + const mergedEndpoint = { ...oldEndpoint, ...newEndpoint }; + if (mergedEndpoint.conditions) { + mergedEndpoint.conditions = mergedEndpoint.conditions.map((condition) => { + const [left, operator, right] = condition.split(/([=<>]+)/); + if (left && operator && right && (Number(right) || right === "0")) return condition; + return condition; + }); + } + + return mergedEndpoint; + }) + .filter((endpoint) => endpoint !== null); + + const mergedCustomEndpoints = oldCustomEndpoints + ?.map((oldCustomEndpoint) => { + const newCustomEndpoint = newCustomEndpoints?.find( + (newCustomEndpoint) => newCustomEndpoint.name === oldCustomEndpoint.name + ); + if (!newCustomEndpoint) return null; + + const mergedCustomEndpoint = { ...oldCustomEndpoint, ...newCustomEndpoint }; + if (mergedCustomEndpoint.metric && oldCustomEndpoint.metric) + mergedCustomEndpoint.metric.treshold = oldCustomEndpoint.metric.treshold; + + return mergedCustomEndpoint; + }) + .filter((customEndpoint) => customEndpoint !== null); + + return { endpoints: mergedEndpoints, customEndpoints: mergedCustomEndpoints }; + } + /** * Update endpoint properties in filesystem */ diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index f7b2615828..068f6e1a90 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -270,7 +270,7 @@ export interface Routes { /** * Gatus get endpoints */ - notificationsGetEndpoints(): Promise<{ + notificationsGetAllEndpoints(): Promise<{ [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; }>; @@ -713,7 +713,7 @@ export const routesData: { [P in keyof Routes]: RouteData } = { fetchRegistry: {}, fetchDnpRequest: {}, notificationsGetAll: { log: true }, - notificationsGetEndpoints: { log: true }, + notificationsGetAllEndpoints: { log: true }, notificationsUpdateEndpoints: { log: true }, getUserActionLogs: {}, getHostUptime: {}, From c720af52596e3131a15212ef38c176de78d1fa73 Mon Sep 17 00:00:00 2001 From: Pablo <mendez4a@gmail.com> Date: Thu, 3 Apr 2025 11:03:05 +0200 Subject: [PATCH 34/90] fix merge conditions --- packages/notifications/src/manifest.ts | 77 +++++++++++++++----------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts index 37d4464fa3..91f6d89a41 100644 --- a/packages/notifications/src/manifest.ts +++ b/packages/notifications/src/manifest.ts @@ -62,38 +62,51 @@ export class NotificationsManifest { const { endpoints: oldEndpoints, customEndpoints: oldCustomEndpoints } = oldNotificationsConfig; const { endpoints: newEndpoints, customEndpoints: newCustomEndpoints } = newNotificationsConfig; - const mergedEndpoints = oldEndpoints - ?.map((oldEndpoint) => { - const newEndpoint = newEndpoints?.find((newEndpoint) => newEndpoint.name === oldEndpoint.name); - if (!newEndpoint) return null; - - const mergedEndpoint = { ...oldEndpoint, ...newEndpoint }; - if (mergedEndpoint.conditions) { - mergedEndpoint.conditions = mergedEndpoint.conditions.map((condition) => { - const [left, operator, right] = condition.split(/([=<>]+)/); - if (left && operator && right && (Number(right) || right === "0")) return condition; - return condition; - }); - } - - return mergedEndpoint; - }) - .filter((endpoint) => endpoint !== null); - - const mergedCustomEndpoints = oldCustomEndpoints - ?.map((oldCustomEndpoint) => { - const newCustomEndpoint = newCustomEndpoints?.find( - (newCustomEndpoint) => newCustomEndpoint.name === oldCustomEndpoint.name - ); - if (!newCustomEndpoint) return null; - - const mergedCustomEndpoint = { ...oldCustomEndpoint, ...newCustomEndpoint }; - if (mergedCustomEndpoint.metric && oldCustomEndpoint.metric) - mergedCustomEndpoint.metric.treshold = oldCustomEndpoint.metric.treshold; - - return mergedCustomEndpoint; - }) - .filter((customEndpoint) => customEndpoint !== null); + const mergedEndpoints = newEndpoints?.map((newEndpoint) => { + const oldEndpoint = oldEndpoints?.find((e) => e.name === newEndpoint.name); + // If no previous version exists, simply use the new endpoint. + if (!oldEndpoint) return newEndpoint; + + // Start with new endpoint properties but persist the old "enabled" flag. + const mergedEndpoint = { ...newEndpoint, ...oldEndpoint }; + mergedEndpoint.enabled = oldEndpoint.enabled; + + // For each condition in the new endpoint, if there is a corresponding old condition, + // persist its right-hand side value. + if (newEndpoint.conditions && Array.isArray(newEndpoint.conditions)) { + mergedEndpoint.conditions = newEndpoint.conditions.map((condition, index) => { + // Split the new condition into parts using any operator as separator. + const newParts = condition.split(/([=<>]+)/); + // If we don't have a complete condition format, use it as is. + if (newParts.length < 3) return condition; + const newLeft = newParts[0]; + const newOperator = newParts[1]; + // Default right-hand value from the new condition. + let newRight = newParts.slice(2).join(""); + + // If there's an old condition at the same index, use its right-hand side. + if (oldEndpoint.conditions && oldEndpoint.conditions[index]) { + const oldParts = oldEndpoint.conditions[index].split(/([=<>]+)/); + if (oldParts.length >= 3) newRight = oldParts.slice(2).join(""); + } + return `${newLeft}${newOperator}${newRight}`; + }); + } + return mergedEndpoint; + }); + + const mergedCustomEndpoints = newCustomEndpoints?.map((newCustomEndpoint) => { + const oldCustomEndpoint = oldCustomEndpoints?.find((e) => e.name === newCustomEndpoint.name); + if (!oldCustomEndpoint) return newCustomEndpoint; + + // Merge and persist the old "enabled" flag and metric.treshold. + const mergedCustomEndpoint = { ...newCustomEndpoint, ...oldCustomEndpoint }; + mergedCustomEndpoint.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 }; } From f8cad7f1c0b87ff806f2d13489df5c8a984fd105 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Date: Thu, 3 Apr 2025 11:25:17 +0200 Subject: [PATCH 35/90] Add unit testing to nofiications merge userr settings (#2135) --- packages/notifications/package.json | 1 + packages/notifications/src/manifest.ts | 5 +- .../test/unit/notifications.test.ts | 416 ++++++++++++++++++ packages/types/src/notifications.ts | 2 +- 4 files changed, 421 insertions(+), 3 deletions(-) create mode 100644 packages/notifications/test/unit/notifications.test.ts diff --git a/packages/notifications/package.json b/packages/notifications/package.json index b76b0bec21..fb6f6ce0ae 100644 --- a/packages/notifications/package.json +++ b/packages/notifications/package.json @@ -14,6 +14,7 @@ }, "scripts": { "build": "tsc -p tsconfig.json", + "test": "mocha --config ./.mocharc.yaml --recursive ./test/unit", "dev": "tsc -w" }, "dependencies": { diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts index 91f6d89a41..6bffa74486 100644 --- a/packages/notifications/src/manifest.ts +++ b/packages/notifications/src/manifest.ts @@ -54,9 +54,10 @@ export class NotificationsManifest { applyPreviousEndpoints( dnpName: string, isCore: boolean, - newNotificationsConfig: NotificationsConfig + newNotificationsConfig: NotificationsConfig, + oldNotificationsConfig?: NotificationsConfig | null ): NotificationsConfig { - const oldNotificationsConfig = this.getEndpointsIfExists(dnpName, isCore); + if (!oldNotificationsConfig) oldNotificationsConfig = this.getEndpointsIfExists(dnpName, isCore); if (!oldNotificationsConfig) return newNotificationsConfig; const { endpoints: oldEndpoints, customEndpoints: oldCustomEndpoints } = oldNotificationsConfig; diff --git a/packages/notifications/test/unit/notifications.test.ts b/packages/notifications/test/unit/notifications.test.ts new file mode 100644 index 0000000000..224f8b95af --- /dev/null +++ b/packages/notifications/test/unit/notifications.test.ts @@ -0,0 +1,416 @@ +import "mocha"; +import { expect } from "chai"; + +import { NotificationsManifest } from "../../src/manifest.js"; +import { NotificationsConfig, Alert } 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, + url: "http://example.com", + method: "GET", + conditions: ["[BODY].value < 80"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [ + { + name: "Test Custom", + enabled: true, + 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, + url: "http://example.com", + method: "GET", + conditions: ["[BODY].value < 80"], + interval: "30s", + group: "host", + alerts: [dummyAlert], + definition: dummyDefinition, + metric: dummyMetric + } + ], + customEndpoints: [ + { + name: "Test Custom", + enabled: true, + 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, + 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, + 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", + enabled: true, + description: "Custom check description", + metric: { treshold: 50, min: 0, max: 100, unit: "%" } + } + ] + }; + + const oldConfig: NotificationsConfig = { + endpoints: [], + customEndpoints: [ + { + name: "Custom Check", + enabled: 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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", + enabled: true, + description: "Custom Check A description", + metric: { treshold: 50, min: 0, max: 100, unit: "%" } + }, + { + name: "Custom Check B", + enabled: true, + description: "Custom Check B description", + metric: { treshold: 60, min: 0, max: 100, unit: "%" } + } + ] + }; + + const oldConfig: NotificationsConfig = { + endpoints: [ + { + name: "High CPU Usage Check", + enabled: false, + 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, + 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, + 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, + 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.name === "High CPU Usage Check"); + const memEndpoint = result.endpoints!.find((e) => e.name === "Host out of memory check"); + expect(cpuEndpoint?.enabled).to.equal(false); + expect(cpuEndpoint?.conditions[0]).to.equal("[BODY].cpu < 70"); + expect(memEndpoint?.enabled).to.equal(false); + expect(memEndpoint?.conditions[0]).to.equal("[BODY].memory > 20"); + + // Verify custom endpoints merging. + expect(result.customEndpoints).to.have.lengthOf(2); + const customA = result.customEndpoints!.find((e) => e.name === "Custom Check A"); + const customB = result.customEndpoints!.find((e) => e.name === "Custom Check B"); + expect(customA?.enabled).to.equal(false); + expect(customA?.metric?.treshold).to.equal(40); + // Custom Check B remains as defined in the new config. + expect(customB?.enabled).to.equal(true); + expect(customB?.metric?.treshold).to.equal(60); + }); +}); diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts index 2fe47296ea..ce017139d8 100644 --- a/packages/types/src/notifications.ts +++ b/packages/types/src/notifications.ts @@ -66,7 +66,7 @@ export interface GatusEndpoint { }; } -interface Alert { +export interface Alert { type: string; "failure-threshold": number; "success-threshold": number; From f9def81b9ee1604c15ff969de3bed7e567781d98 Mon Sep 17 00:00:00 2001 From: Pablo <mendez4a@gmail.com> Date: Thu, 3 Apr 2025 12:26:15 +0200 Subject: [PATCH 36/90] update not name --- packages/daemons/src/autoUpdates/sendUpdateNotification.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts index 8274bd45ee..6f2012eb13 100644 --- a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts +++ b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts @@ -24,7 +24,7 @@ export async function sendUpdatePackageNotificationMaybe({ // Check if auto-update notifications are enabled const dappmanagerCustomEndpoint = notifications .getEndpointsIfExists(params.dappmanagerDnpName, true) - ?.customEndpoints?.find((customEndpoint) => customEndpoint.name === "auto-updates"); + ?.customEndpoints?.find((customEndpoint) => customEndpoint.name === "Package updates notifications"); if (!dappmanagerCustomEndpoint || !dappmanagerCustomEndpoint.enabled) return; From 93f81f679f97c88dff67805eacedf4c0a198e2d1 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Thu, 3 Apr 2025 13:21:13 +0200 Subject: [PATCH 37/90] fix NotificationCategory enum --- .../src/autoUpdates/sendUpdateNotification.ts | 4 ++-- packages/daemons/src/diskUsage/index.ts | 2 +- packages/types/src/notifications.ts | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts index 6f2012eb13..24f0e756a4 100644 --- a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts +++ b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts @@ -51,7 +51,7 @@ export async function sendUpdatePackageNotificationMaybe({ upstreamVersion, autoUpdatesEnabled: isDnpUpdateEnabled(dnpName) }), - category: NotificationCategory.CORE + category: NotificationCategory.core }) .catch((e) => logs.error("Error sending package update notification", e)); @@ -77,7 +77,7 @@ export async function sendUpdateSystemNotificationMaybe(data: CoreUpdateDataAvai packages: data.packages, autoUpdatesEnabled: isCoreUpdateEnabled() }), - category: NotificationCategory.CORE + category: NotificationCategory.core }) .catch((e) => logs.error("Error sending system update notification", e)); diff --git a/packages/daemons/src/diskUsage/index.ts b/packages/daemons/src/diskUsage/index.ts index 95716a3b47..80d9d0da04 100644 --- a/packages/daemons/src/diskUsage/index.ts +++ b/packages/daemons/src/diskUsage/index.ts @@ -105,7 +105,7 @@ async function monitorDiskUsage(): Promise<void> { stoppedDnpNames.map((dnpName) => ` - ${prettyDnpName(dnpName)}`).join("\n"), `Please, free up enough disk space and start them again.` ].join("\n\n"), - category: NotificationCategory.CORE + category: NotificationCategory.core }) .catch((e) => logs.error("Error sending disk usage notification", e)); diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts index ce017139d8..16a49b2feb 100644 --- a/packages/types/src/notifications.ts +++ b/packages/types/src/notifications.ts @@ -22,14 +22,14 @@ export interface NotificationPayload { } export enum NotificationCategory { - CORE = "CORE", - ETHEREUM = "ETHEREUM", - HOLESKY = "HOLESKY", - LUKSO = "LUKSO", - GNOSIS = "GNOSIS", - HOODI = "HOODI", - HOST = "HOST", - OTHER = "OTHER" + core = "core", + ethereum = "ethereum", + holesky = "holesky", + lukso = "lukso", + gnosis = "gnosis", + hoodi = "hoodi", + host = "host", + other = "other" } export interface CustomEndpoint { From dcfe425a707f95b40113e86f4f0c228d29536538 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Thu, 3 Apr 2025 16:48:24 +0200 Subject: [PATCH 38/90] fix NotificationCategory ref --- packages/dappmanager/src/calls/setStaticIp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dappmanager/src/calls/setStaticIp.ts b/packages/dappmanager/src/calls/setStaticIp.ts index 9a9bf2eb01..64b4180819 100644 --- a/packages/dappmanager/src/calls/setStaticIp.ts +++ b/packages/dappmanager/src/calls/setStaticIp.ts @@ -33,7 +33,7 @@ export async function setStaticIp({ staticIp }: { staticIp: string }): Promise<v title: "Static IP updated", body: `Your static IP was changed to ${staticIp}.`, dnpName: params.dappmanagerDnpName, - category: NotificationCategory.CORE + category: NotificationCategory.core }) .catch((e) => logs.error("Error sending static IP updated notification", e)); From 6ee17f013fcb2c4c9c82a92c5e6e091ef007a83c Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Mon, 7 Apr 2025 10:10:12 +0200 Subject: [PATCH 39/90] Merge endpoints config on installer (#2136) * Merge endpoints config on installer * add mock-backend response --- packages/admin-ui/src/__mock-backend__/index.ts | 5 ++++- .../pages/installer/components/InstallDnpView.tsx | 14 ++++++++++++++ packages/dappmanager/src/calls/index.ts | 2 +- packages/dappmanager/src/calls/notifications.ts | 15 +++++++++++++++ packages/types/src/routes.ts | 10 ++++++++++ 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/admin-ui/src/__mock-backend__/index.ts b/packages/admin-ui/src/__mock-backend__/index.ts index 367cec8321..3630563d97 100644 --- a/packages/admin-ui/src/__mock-backend__/index.ts +++ b/packages/admin-ui/src/__mock-backend__/index.ts @@ -391,7 +391,10 @@ export const otherCalls: Omit<Routes, keyof typeof namedSpacedCalls> = { return { "geth.dnp.dappnode.eth": { endpoints: [], customEndpoints: [], isCore: false } }; }, notificationsUpdateEndpoints: async () => {}, - notificationsGetAll: async () => [] + notificationsGetAll: async () => [], + notificationsApplyPreviousEndpoints: async () => { + return { endpoints: [], customEndpoints: [] }; + }, }; export const calls: Routes = { diff --git a/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx b/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx index 0f6744e5a7..08647066c3 100644 --- a/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx +++ b/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx @@ -74,6 +74,20 @@ const InstallDnpView: React.FC<InstallDnpViewProps> = ({ dnp, progressLogs }) => manifest.notifications?.customEndpoints || [] ); + const mergedNotificationsConfig = useApi.notificationsApplyPreviousEndpoints({ + dnpName: dnpName, + isCore, + newNotificationsConfig: manifest.notifications || {} + }); + + useEffect(() => { + if (mergedNotificationsConfig.data) { + const { endpoints: newEndpoints, customEndpoints: newCustomEndpoints } = mergedNotificationsConfig.data; + setEndpoints(newEndpoints || []); + setCustomEndpoints(newCustomEndpoints || []); + } + }, [mergedNotificationsConfig.data]); + useEffect(() => { setUserSettings(settings || {}); }, [settings, setUserSettings]); diff --git a/packages/dappmanager/src/calls/index.ts b/packages/dappmanager/src/calls/index.ts index 7e117228b6..7ab371373a 100644 --- a/packages/dappmanager/src/calls/index.ts +++ b/packages/dappmanager/src/calls/index.ts @@ -22,7 +22,7 @@ export { getCoreVersion } from "./getCoreVersion.js"; export { getUserActionLogs } from "./getUserActionLogs.js"; export { getHostUptime } from "./getHostUptime.js"; export { getIsConnectedToInternet } from "./getIsConnectedToInternet.js"; -export { notificationsGetAllEndpoints, notificationsUpdateEndpoints, notificationsGetAll } from "./notifications.js"; +export { notificationsGetAllEndpoints, notificationsUpdateEndpoints, notificationsGetAll, notificationsApplyPreviousEndpoints } from "./notifications.js"; export * from "./httpsPortal.js"; export { ipfsTest } from "./ipfsTest.js"; export { ipfsClientTargetSet } from "./ipfsClientTargetSet.js"; diff --git a/packages/dappmanager/src/calls/notifications.ts b/packages/dappmanager/src/calls/notifications.ts index 3eea76d9f8..f83d34c6ba 100644 --- a/packages/dappmanager/src/calls/notifications.ts +++ b/packages/dappmanager/src/calls/notifications.ts @@ -32,3 +32,18 @@ export async function notificationsUpdateEndpoints({ }): 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/types/src/routes.ts b/packages/types/src/routes.ts index 068f6e1a90..eca3e902d8 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -283,6 +283,15 @@ export interface Routes { 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 @@ -715,6 +724,7 @@ export const routesData: { [P in keyof Routes]: RouteData } = { notificationsGetAll: { log: true }, notificationsGetAllEndpoints: { log: true }, notificationsUpdateEndpoints: { log: true }, + notificationsApplyPreviousEndpoints: {}, getUserActionLogs: {}, getHostUptime: {}, httpsPortalMappingAdd: { log: true }, From ff3cb0fd966a61ab708a4b9cd67317b76d5c644b Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Tue, 8 Apr 2025 11:34:20 +0200 Subject: [PATCH 40/90] Update "pkg updates" endpoint desc --- notifications.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notifications.yaml b/notifications.yaml index 4657b0356b..ee500858a5 100644 --- a/notifications.yaml +++ b/notifications.yaml @@ -1,4 +1,4 @@ customEndpoints: - name: "Package updates notifications" - description: string + description: "This endpoint notifies users about available package updates." enabled: true From 407c029762d095032b517d947c2ec553bc991f47 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:11:01 +0200 Subject: [PATCH 41/90] Deprecate old push notifications (#2138) * Deprecate old push notifications * Track notifictions count --- .../admin-ui/src/__mock-backend__/index.ts | 1 + packages/admin-ui/src/api/subscriptions.ts | 5 --- .../topbar/dropdownMenus/Notifications.tsx | 44 +++++++++++-------- .../topbar/dropdownMenus/dropdown.scss | 3 -- packages/admin-ui/src/rootReducer.ts | 2 - .../src/services/notifications/actions.ts | 34 -------------- .../src/services/notifications/reducer.ts | 24 ---------- .../src/services/notifications/selectors.ts | 5 --- packages/dappmanager/src/calls/index.ts | 2 +- .../dappmanager/src/calls/notifications.ts | 7 +++ packages/notifications/src/api.ts | 7 +++ packages/notifications/src/index.ts | 7 +++ packages/types/src/routes.ts | 6 +++ 13 files changed, 55 insertions(+), 92 deletions(-) delete mode 100644 packages/admin-ui/src/services/notifications/actions.ts delete mode 100644 packages/admin-ui/src/services/notifications/reducer.ts delete mode 100644 packages/admin-ui/src/services/notifications/selectors.ts diff --git a/packages/admin-ui/src/__mock-backend__/index.ts b/packages/admin-ui/src/__mock-backend__/index.ts index 3630563d97..d27b739fc3 100644 --- a/packages/admin-ui/src/__mock-backend__/index.ts +++ b/packages/admin-ui/src/__mock-backend__/index.ts @@ -390,6 +390,7 @@ export const otherCalls: Omit<Routes, keyof typeof namedSpacedCalls> = { notificationsGetAllEndpoints: async () => { return { "geth.dnp.dappnode.eth": { endpoints: [], customEndpoints: [], isCore: false } }; }, + notificationsGetUnseenCount: async () => 2, notificationsUpdateEndpoints: async () => {}, notificationsGetAll: async () => [], notificationsApplyPreviousEndpoints: async () => { 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/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 ( - <BaseDropdown - name="Notifications" - messages={notifications} - Icon={FaRegBell} - onClick={() => dispatch(viewedNotifications())} - moreVisible={true} - className={"notifications"} - placeholder="No notifications yet" - /> + <div onClick={() => navigate("/notifications/inbox")} className="tn-dropdown tn-dropdown-toggle"> + <FaRegBell size={"1.4em"} /> + {newNotifications && <div className={`icon-bubble success`} />} + </div> ); } diff --git a/packages/admin-ui/src/components/topbar/dropdownMenus/dropdown.scss b/packages/admin-ui/src/components/topbar/dropdownMenus/dropdown.scss index 2cb3e87593..ec9e6007b8 100644 --- a/packages/admin-ui/src/components/topbar/dropdownMenus/dropdown.scss +++ b/packages/admin-ui/src/components/topbar/dropdownMenus/dropdown.scss @@ -84,9 +84,6 @@ &.profile > .menu { right: -0.5rem; } - &.notifications > .menu { - right: -3.1rem; - } &.chainstatus > .menu { right: -5.6rem; } 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/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/dappmanager/src/calls/index.ts b/packages/dappmanager/src/calls/index.ts index 7ab371373a..32ee3461fb 100644 --- a/packages/dappmanager/src/calls/index.ts +++ b/packages/dappmanager/src/calls/index.ts @@ -22,7 +22,7 @@ export { getCoreVersion } from "./getCoreVersion.js"; export { getUserActionLogs } from "./getUserActionLogs.js"; export { getHostUptime } from "./getHostUptime.js"; export { getIsConnectedToInternet } from "./getIsConnectedToInternet.js"; -export { notificationsGetAllEndpoints, notificationsUpdateEndpoints, notificationsGetAll, notificationsApplyPreviousEndpoints } from "./notifications.js"; +export { notificationsGetAllEndpoints, notificationsUpdateEndpoints, notificationsGetAll, notificationsApplyPreviousEndpoints, notificationsGetUnseenCount } from "./notifications.js"; export * from "./httpsPortal.js"; export { ipfsTest } from "./ipfsTest.js"; export { ipfsClientTargetSet } from "./ipfsClientTargetSet.js"; diff --git a/packages/dappmanager/src/calls/notifications.ts b/packages/dappmanager/src/calls/notifications.ts index f83d34c6ba..53c5c0a956 100644 --- a/packages/dappmanager/src/calls/notifications.ts +++ b/packages/dappmanager/src/calls/notifications.ts @@ -9,6 +9,13 @@ export async function notificationsGetAll(): Promise<Notification[]> { return await notifications.getAllNotifications(); } +/** + * Get unseen notifications count + */ +export async function notificationsGetUnseenCount(): Promise<number> { + return await notifications.getUnseenNotificationsCount(); +} + /** * Get gatus and custom endpoints indexed by dnpName */ diff --git a/packages/notifications/src/api.ts b/packages/notifications/src/api.ts index c9d8c64cb5..bacbb65a27 100644 --- a/packages/notifications/src/api.ts +++ b/packages/notifications/src/api.ts @@ -27,6 +27,13 @@ export class NotificationsApi { return await (await fetch(new URL("/api/v1/notifications", `${this.rootUrl}:8080`).toString())).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 */ diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index 99e7c3d46e..2f53948a43 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -25,6 +25,13 @@ class Notifications { return await this.api.getAllNotifications(); } + /** + * Get the count of unseen notifications + */ + async getUnseenNotificationsCount(): Promise<number> { + return (await this.api.getUnseenNotificationsCount()).unseenCount; + } + /** * Get gatus and custom endpoints indexed by dnpName */ diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index eca3e902d8..c9b8b91ebb 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -266,6 +266,11 @@ export interface Routes { * Get all the notifications */ notificationsGetAll(): Promise<Notification[]>; + + /** + * Get unseen notifications count + */ + notificationsGetUnseenCount(): Promise<number>; /** * Gatus get endpoints @@ -722,6 +727,7 @@ export const routesData: { [P in keyof Routes]: RouteData } = { fetchRegistry: {}, fetchDnpRequest: {}, notificationsGetAll: { log: true }, + notificationsGetUnseenCount: { log: true }, notificationsGetAllEndpoints: { log: true }, notificationsUpdateEndpoints: { log: true }, notificationsApplyPreviousEndpoints: {}, From 462ac72237bcebe32884007ba935b69899e4e651 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Thu, 10 Apr 2025 12:17:50 +0200 Subject: [PATCH 42/90] Request to mark notifications as seen (#2142) * Deprecate old push notifications * Track notifictions count * Request notifications to seen * Merge branch 'pablo-mateu/refactor-notifications' into mateu/set-notis-to-seen * fix format --- packages/admin-ui/src/__mock-backend__/index.ts | 1 + .../src/pages/notifications/tabs/Inbox/Inbox.tsx | 3 ++- packages/dappmanager/src/calls/index.ts | 2 +- packages/dappmanager/src/calls/notifications.ts | 7 +++++++ packages/notifications/src/api.ts | 16 ++++++++++++++-- packages/notifications/src/index.ts | 7 +++++++ packages/types/src/routes.ts | 6 ++++++ 7 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/admin-ui/src/__mock-backend__/index.ts b/packages/admin-ui/src/__mock-backend__/index.ts index d27b739fc3..19cd2a08ea 100644 --- a/packages/admin-ui/src/__mock-backend__/index.ts +++ b/packages/admin-ui/src/__mock-backend__/index.ts @@ -391,6 +391,7 @@ export const otherCalls: Omit<Routes, keyof typeof namedSpacedCalls> = { return { "geth.dnp.dappnode.eth": { endpoints: [], customEndpoints: [], isCore: false } }; }, notificationsGetUnseenCount: async () => 2, + notificationsSetAllSeen: async () => {}, notificationsUpdateEndpoints: async () => {}, notificationsGetAll: async () => [], notificationsApplyPreviousEndpoints: async () => { diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx index ca8f4e98a0..5cb9480530 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useState } from "react"; import Card from "components/Card"; import "./inbox.scss"; import { NotificationCard } from "./NotificationsCard"; -import { useApi } from "api"; +import { useApi, api } from "api"; import { Searchbar } from "components/Searchbar"; import Loading from "components/Loading"; @@ -22,6 +22,7 @@ export function Inbox() { const uniqueCategories = Array.from(new Set(notifications.data.map((n) => n.category).filter(Boolean))); setCategories(uniqueCategories); + api.notificationsSetAllSeen(); }, [notifications.data]); const filteredNotifications = useMemo(() => { diff --git a/packages/dappmanager/src/calls/index.ts b/packages/dappmanager/src/calls/index.ts index 32ee3461fb..fd77a06790 100644 --- a/packages/dappmanager/src/calls/index.ts +++ b/packages/dappmanager/src/calls/index.ts @@ -22,7 +22,7 @@ export { getCoreVersion } from "./getCoreVersion.js"; export { getUserActionLogs } from "./getUserActionLogs.js"; export { getHostUptime } from "./getHostUptime.js"; export { getIsConnectedToInternet } from "./getIsConnectedToInternet.js"; -export { notificationsGetAllEndpoints, notificationsUpdateEndpoints, notificationsGetAll, notificationsApplyPreviousEndpoints, notificationsGetUnseenCount } from "./notifications.js"; +export { notificationsGetAllEndpoints, notificationsUpdateEndpoints, notificationsGetAll, notificationsApplyPreviousEndpoints, notificationsGetUnseenCount, notificationsSetAllSeen } from "./notifications.js"; export * from "./httpsPortal.js"; export { ipfsTest } from "./ipfsTest.js"; export { ipfsClientTargetSet } from "./ipfsClientTargetSet.js"; diff --git a/packages/dappmanager/src/calls/notifications.ts b/packages/dappmanager/src/calls/notifications.ts index 53c5c0a956..17e93a01ca 100644 --- a/packages/dappmanager/src/calls/notifications.ts +++ b/packages/dappmanager/src/calls/notifications.ts @@ -16,6 +16,13 @@ export async function notificationsGetUnseenCount(): Promise<number> { return await notifications.getUnseenNotificationsCount(); } +/** + * Set all notifications as seen + */ +export async function notificationsSetAllSeen(): Promise<void> { + return await notifications.setAllNotificationsSeen(); +} + /** * Get gatus and custom endpoints indexed by dnpName */ diff --git a/packages/notifications/src/api.ts b/packages/notifications/src/api.ts index bacbb65a27..26197cc51e 100644 --- a/packages/notifications/src/api.ts +++ b/packages/notifications/src/api.ts @@ -30,8 +30,8 @@ export class NotificationsApi { /** * 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(); + async getUnseenNotificationsCount(): Promise<{ unseenCount: number }> { + return await (await fetch(new URL("/api/v1/notifications/unseen", `${this.rootUrl}:8080`).toString())).json(); } /** @@ -45,4 +45,16 @@ export class NotificationsApi { } }); } + + /** + * Set all notifications as seen + */ + async setAllNotificationsSeen(): Promise<void> { + await fetch(new URL("/api/v1/notifications/seen", `${this.rootUrl}:8080`).toString(), { + method: "PUT", + headers: { + "Content-Type": "application/json" + } + }); + } } diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index 2f53948a43..53d3317b87 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -32,6 +32,13 @@ class Notifications { return (await this.api.getUnseenNotificationsCount()).unseenCount; } + /** + * Set all notifications as seen + */ + async setAllNotificationsSeen(): Promise<void> { + return await this.api.setAllNotificationsSeen(); + } + /** * Get gatus and custom endpoints indexed by dnpName */ diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index c9b8b91ebb..49c043c490 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -279,6 +279,11 @@ export interface Routes { [dnpName: string]: { endpoints: GatusEndpoint[]; customEndpoints: CustomEndpoint[]; isCore: boolean }; }>; + /** + * Set all notifications as seen + */ + notificationsSetAllSeen(): Promise<void>; + /** * Gatus update endpoint */ @@ -729,6 +734,7 @@ export const routesData: { [P in keyof Routes]: RouteData } = { notificationsGetAll: { log: true }, notificationsGetUnseenCount: { log: true }, notificationsGetAllEndpoints: { log: true }, + notificationsSetAllSeen: { log: true }, notificationsUpdateEndpoints: { log: true }, notificationsApplyPreviousEndpoints: {}, getUserActionLogs: {}, From ad891ed1ec4631cf623507fc2a4705d710060f09 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Date: Mon, 21 Apr 2025 13:45:02 +0200 Subject: [PATCH 43/90] Add priority field (#2149) * Add priority field * fix unit test * add priority --- notifications.yaml | 1 + .../src/autoUpdates/sendUpdateNotification.ts | 13 ++++++++++--- packages/daemons/src/diskUsage/index.ts | 5 +++-- packages/dappmanager/src/calls/setStaticIp.ts | 5 +++-- .../schemas/src/schemas/notifications.schema.json | 7 +++++-- packages/schemas/test/unit/validateSchema.test.ts | 13 ++++++++++--- packages/types/src/notifications.ts | 10 ++++++++++ 7 files changed, 42 insertions(+), 12 deletions(-) diff --git a/notifications.yaml b/notifications.yaml index ee500858a5..6efa158fde 100644 --- a/notifications.yaml +++ b/notifications.yaml @@ -1,4 +1,5 @@ customEndpoints: - name: "Package updates notifications" description: "This endpoint notifies users about available package updates." + priority: low enabled: true diff --git a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts index 24f0e756a4..7d9de6bb72 100644 --- a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts +++ b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts @@ -3,7 +3,12 @@ import { params } from "@dappnode/params"; import * as db from "@dappnode/db"; import { DappnodeInstaller } from "@dappnode/installer"; import { prettyDnpName } from "@dappnode/utils"; -import { CoreUpdateDataAvailable, NotificationCategory, upstreamVersionToString } from "@dappnode/types"; +import { + CoreUpdateDataAvailable, + NotificationCategory, + NotificationPriority, + upstreamVersionToString +} from "@dappnode/types"; import { formatPackageUpdateNotification, formatSystemUpdateNotification } from "./formatNotificationBody.js"; import { isCoreUpdateEnabled } from "./isCoreUpdateEnabled.js"; import { isDnpUpdateEnabled } from "./isDnpUpdateEnabled.js"; @@ -51,7 +56,8 @@ export async function sendUpdatePackageNotificationMaybe({ upstreamVersion, autoUpdatesEnabled: isDnpUpdateEnabled(dnpName) }), - category: NotificationCategory.core + category: NotificationCategory.core, + priority: NotificationPriority.low }) .catch((e) => logs.error("Error sending package update notification", e)); @@ -77,7 +83,8 @@ export async function sendUpdateSystemNotificationMaybe(data: CoreUpdateDataAvai packages: data.packages, autoUpdatesEnabled: isCoreUpdateEnabled() }), - category: NotificationCategory.core + category: NotificationCategory.core, + priority: NotificationPriority.low }) .catch((e) => logs.error("Error sending system update notification", e)); diff --git a/packages/daemons/src/diskUsage/index.ts b/packages/daemons/src/diskUsage/index.ts index 80d9d0da04..502dbacf95 100644 --- a/packages/daemons/src/diskUsage/index.ts +++ b/packages/daemons/src/diskUsage/index.ts @@ -4,7 +4,7 @@ import { params } from "@dappnode/params"; import { eventBus } from "@dappnode/eventbus"; import { logs } from "@dappnode/logger"; import { notifications } from "@dappnode/notifications"; -import { NotificationCategory } from "@dappnode/types"; +import { NotificationCategory, NotificationPriority } from "@dappnode/types"; /** * Commands @@ -105,7 +105,8 @@ async function monitorDiskUsage(): Promise<void> { stoppedDnpNames.map((dnpName) => ` - ${prettyDnpName(dnpName)}`).join("\n"), `Please, free up enough disk space and start them again.` ].join("\n\n"), - category: NotificationCategory.core + category: NotificationCategory.core, + priority: NotificationPriority.critical }) .catch((e) => logs.error("Error sending disk usage notification", e)); diff --git a/packages/dappmanager/src/calls/setStaticIp.ts b/packages/dappmanager/src/calls/setStaticIp.ts index 64b4180819..898cb23fc9 100644 --- a/packages/dappmanager/src/calls/setStaticIp.ts +++ b/packages/dappmanager/src/calls/setStaticIp.ts @@ -4,7 +4,7 @@ import { eventBus } from "@dappnode/eventbus"; import { logs } from "@dappnode/logger"; import { notifications } from "@dappnode/notifications"; import { params } from "@dappnode/params"; -import { NotificationCategory } from "@dappnode/types"; +import { NotificationCategory, NotificationPriority } from "@dappnode/types"; /** * Sets the static IP @@ -33,7 +33,8 @@ export async function setStaticIp({ staticIp }: { staticIp: string }): Promise<v title: "Static IP updated", body: `Your static IP was changed to ${staticIp}.`, dnpName: params.dappmanagerDnpName, - category: NotificationCategory.core + category: NotificationCategory.core, + priority: NotificationPriority.low }) .catch((e) => logs.error("Error sending static IP updated notification", e)); diff --git a/packages/schemas/src/schemas/notifications.schema.json b/packages/schemas/src/schemas/notifications.schema.json index d00af4ee9e..3319595dcb 100644 --- a/packages/schemas/src/schemas/notifications.schema.json +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -9,7 +9,7 @@ "type": "array", "items": { "type": "object", - "required": ["name", "enabled", "url", "method", "conditions", "interval", "group", "alerts", "definition"], + "required": ["name", "enabled", "url", "method", "conditions", "interval", "group", "alerts", "definition", "priority"], "properties": { "name": { "type": "string" }, "enabled": { "type": "boolean" }, @@ -21,8 +21,11 @@ }, "interval": { "type": "string", "pattern": "^[0-9]+[smhd]$" }, "group": { "type": "string" }, + "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, "alerts": { "type": "array", + "minItems": 1, + "maxItems": 1, "items": { "type": "object", "required": [ @@ -34,7 +37,7 @@ "enabled" ], "properties": { - "type": { "type": "string" }, + "type": { "type": "string", "enum": ["custom"] }, "failure-threshold": { "type": "integer", "minimum": 1 }, "success-threshold": { "type": "integer", "minimum": 1 }, "send-on-resolved": { "type": "boolean" }, diff --git a/packages/schemas/test/unit/validateSchema.test.ts b/packages/schemas/test/unit/validateSchema.test.ts index 9939d47570..2db32f3734 100644 --- a/packages/schemas/test/unit/validateSchema.test.ts +++ b/packages/schemas/test/unit/validateSchema.test.ts @@ -9,6 +9,7 @@ import fs from "fs"; import path from "path"; import { cleanTestDir, testDir } from "../testUtils.js"; import { Manifest, SetupWizard, NotificationsConfig, GatusEndpoint, CustomEndpoint } from "@dappnode/types"; +import { NotificationPriority } from "../../../types/src/notifications.js"; describe("schemaValidation", function () { this.timeout(10000); @@ -452,13 +453,14 @@ volumes: conditions: ["response-time < 500ms", "status == 200"], interval: "1m", group: "example-group", + priority: NotificationPriority.low, alerts: [ { - type: "response-time", + type: "custom", "failure-threshold": 3, "success-threshold": 2, "send-on-resolved": true, - description: "Response time exceeded", + description: "Custom alert description", enabled: true } ], @@ -507,6 +509,7 @@ volumes: conditions: ["response-time < 500ms"], interval: "1m", group: "example-group", + priority: NotificationPriority.low, alerts: [ { type: "response-time", @@ -539,6 +542,7 @@ volumes: conditions: ["response-time < 500ms"], interval: "invalid-interval", group: "example-group", + priority: NotificationPriority.low, alerts: [ { type: "response-time", @@ -599,6 +603,7 @@ volumes: enabled: true, name: "custom-endpoint", description: "A custom endpoint for testing", // Added required description + priority: NotificationPriority.high, metric: { treshold: 90, min: 0, @@ -658,9 +663,10 @@ volumes: conditions: ["response-time < 500ms", "status == 200"], interval: "1m", group: "example-group", + priority: NotificationPriority.low, alerts: [ { - type: "response-time", + type: "custom", "failure-threshold": 3, "success-threshold": 2, "send-on-resolved": true, @@ -684,6 +690,7 @@ volumes: enabled: true, name: "custom-endpoint", description: "A custom endpoint for testing", // Added required description + priority: NotificationPriority.high, metric: { treshold: 90, min: 0, diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts index 16a49b2feb..358ec37eb4 100644 --- a/packages/types/src/notifications.ts +++ b/packages/types/src/notifications.ts @@ -13,6 +13,7 @@ export interface NotificationPayload { body: string; dnpName: string; category: NotificationCategory; + priority: NotificationPriority; icon?: string; errors?: string; callToAction?: { @@ -21,6 +22,13 @@ export interface NotificationPayload { }; } +export enum NotificationPriority { + low = "low", + medium = "medium", + high = "high", + critical = "critical" +} + export enum NotificationCategory { core = "core", ethereum = "ethereum", @@ -36,6 +44,7 @@ export interface CustomEndpoint { name: string; enabled: boolean; description: string; + priority: NotificationPriority; metric?: { treshold: number; min: number; @@ -52,6 +61,7 @@ export interface GatusEndpoint { conditions: string[]; interval: string; // e.g., "1m" group: string; + priority: NotificationPriority; alerts: Alert[]; definition: { // dappnode specific From 43f2f3ea7d82ddab6e66771514696de758002053 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Tue, 22 Apr 2025 18:22:41 +0200 Subject: [PATCH 44/90] Relocating Legacy Notifications Tab (#2143) * Move Legacy notifications tab * Legacy deprecation banner * update deprecation legacy modal --- .../pages/notifications/NotificationsRoot.tsx | 85 ++++++++++--------- .../admin-ui/src/pages/notifications/data.ts | 3 +- .../admin-ui/src/pages/notifications/index.ts | 2 +- .../tabs/Legacy}/EthicalMetrics.tsx | 0 .../tabs/Legacy}/Telegram.tsx | 0 .../pages/notifications/tabs/Legacy/index.tsx | 43 ++++++++++ .../tabs/Legacy}/notifications.scss | 0 .../system/components/Notifications/index.tsx | 17 ---- .../pages/system/components/SystemRoot.tsx | 7 -- packages/admin-ui/src/pages/system/data.ts | 1 - packages/admin-ui/src/params.ts | 3 +- 11 files changed, 92 insertions(+), 69 deletions(-) rename packages/admin-ui/src/pages/{system/components/Notifications => notifications/tabs/Legacy}/EthicalMetrics.tsx (100%) rename packages/admin-ui/src/pages/{system/components/Notifications => notifications/tabs/Legacy}/Telegram.tsx (100%) create mode 100644 packages/admin-ui/src/pages/notifications/tabs/Legacy/index.tsx rename packages/admin-ui/src/pages/{system/components/Notifications => notifications/tabs/Legacy}/notifications.scss (100%) delete mode 100644 packages/admin-ui/src/pages/system/components/Notifications/index.tsx diff --git a/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx index 44c114fbd3..94d28aeaf4 100644 --- a/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx +++ b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx @@ -10,57 +10,60 @@ import { renderResponse } from "components/SwrRender"; import { Inbox } from "./tabs/Inbox/Inbox"; import { NotificationsSettings } from "./tabs/Settings/Settings"; import { notificationsPkgName } from "params"; +import { LegacyNotifications } from "./tabs/Legacy"; 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 isNotificationsPkgInstalled = dnps?.some((dnp) => dnp.dnpName === notificationsPkgName); + const availableRoutes: { + name: string; + subPath: string; + component: React.FC; + }[] = [ + { + name: "Inbox", + subPath: subPaths.inbox, + component: isNotificationsPkgInstalled + ? Inbox + : () => <InstallNotificationsPkg pkgName={notificationsPkgName} /> + }, + { + name: "Settings", + subPath: subPaths.settings, + component: isNotificationsPkgInstalled + ? NotificationsSettings + : () => <InstallNotificationsPkg pkgName={notificationsPkgName} /> + }, + { + name: "Legacy", + subPath: subPaths.legacy, + component: LegacyNotifications + } + ]; + return ( <> <Title title={title} /> - {!isNotificationsPkgInstalled ? ( - <InstallNotificationsPkg pkgName={notificationsPkgName} /> - ) : ( - <> - <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> - </> - )} + <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 index 20623cbf42..072073369e 100644 --- a/packages/admin-ui/src/pages/notifications/data.ts +++ b/packages/admin-ui/src/pages/notifications/data.ts @@ -7,5 +7,6 @@ export const title = "Notifications"; // SubPaths export const subPaths = { inbox: "inbox", - settings: "settings" + settings: "settings", + legacy: "legacy", }; diff --git a/packages/admin-ui/src/pages/notifications/index.ts b/packages/admin-ui/src/pages/notifications/index.ts index 37b01edfcc..a6b52d030f 100644 --- a/packages/admin-ui/src/pages/notifications/index.ts +++ b/packages/admin-ui/src/pages/notifications/index.ts @@ -1,4 +1,4 @@ import { NotificationsRoot } from "./NotificationsRoot"; -export { rootPath, relativePath } from "./data"; +export { rootPath, relativePath, subPaths } from "./data"; export const RootComponent = NotificationsRoot; 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..ea26ac4fb8 --- /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.notifications} 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/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/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 82d20b7c05..4f53eea5d1 100755 --- a/packages/admin-ui/src/params.ts +++ b/packages/admin-ui/src/params.ts @@ -95,7 +95,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`, + notifications: `${docsBaseUrl}/docs/user/notifications/overview` }; export const forumUrl = { From 9e070b024f77cdb02c62597cc043cbed04cc7ad1 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Wed, 23 Apr 2025 17:07:21 +0200 Subject: [PATCH 45/90] fix notifications unnecessary re-renders --- packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx index 94d28aeaf4..9630fca2e6 100644 --- a/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx +++ b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx @@ -20,7 +20,7 @@ export const NotificationsRoot: React.FC = () => { const availableRoutes: { name: string; subPath: string; - component: React.FC; + component: React.ComponentType; }[] = [ { name: "Inbox", From b7de5dd7ab78d8390a0d92af7223ad995d7bd466 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Date: Thu, 24 Apr 2025 11:50:03 +0200 Subject: [PATCH 46/90] Add status field to notification (#2153) * Add status field * fix test * add comment --- .../src/autoUpdates/sendUpdateNotification.ts | 17 ++++++-------- packages/daemons/src/diskUsage/index.ts | 7 +++--- packages/dappmanager/src/calls/setStaticIp.ts | 7 +++--- .../schemas/test/unit/validateSchema.test.ts | 16 +++++++------- packages/types/src/notifications.ts | 22 ++++++++++++------- 5 files changed, 37 insertions(+), 32 deletions(-) diff --git a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts index 7d9de6bb72..f7d732978d 100644 --- a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts +++ b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts @@ -3,12 +3,7 @@ import { params } from "@dappnode/params"; import * as db from "@dappnode/db"; import { DappnodeInstaller } from "@dappnode/installer"; import { prettyDnpName } from "@dappnode/utils"; -import { - CoreUpdateDataAvailable, - NotificationCategory, - NotificationPriority, - upstreamVersionToString -} from "@dappnode/types"; +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"; @@ -56,8 +51,9 @@ export async function sendUpdatePackageNotificationMaybe({ upstreamVersion, autoUpdatesEnabled: isDnpUpdateEnabled(dnpName) }), - category: NotificationCategory.core, - priority: NotificationPriority.low + category: Category.system, + priority: Priority.low, + status: Status.triggered }) .catch((e) => logs.error("Error sending package update notification", e)); @@ -83,8 +79,9 @@ export async function sendUpdateSystemNotificationMaybe(data: CoreUpdateDataAvai packages: data.packages, autoUpdatesEnabled: isCoreUpdateEnabled() }), - category: NotificationCategory.core, - priority: NotificationPriority.low + category: Category.system, + priority: Priority.low, + status: Status.triggered }) .catch((e) => logs.error("Error sending system update notification", e)); diff --git a/packages/daemons/src/diskUsage/index.ts b/packages/daemons/src/diskUsage/index.ts index 502dbacf95..9196d0f8ee 100644 --- a/packages/daemons/src/diskUsage/index.ts +++ b/packages/daemons/src/diskUsage/index.ts @@ -4,7 +4,7 @@ import { params } from "@dappnode/params"; import { eventBus } from "@dappnode/eventbus"; import { logs } from "@dappnode/logger"; import { notifications } from "@dappnode/notifications"; -import { NotificationCategory, NotificationPriority } from "@dappnode/types"; +import { Category, Priority, Status } from "@dappnode/types"; /** * Commands @@ -105,8 +105,9 @@ async function monitorDiskUsage(): Promise<void> { stoppedDnpNames.map((dnpName) => ` - ${prettyDnpName(dnpName)}`).join("\n"), `Please, free up enough disk space and start them again.` ].join("\n\n"), - category: NotificationCategory.core, - priority: NotificationPriority.critical + category: Category.hardware, + priority: Priority.critical, + status: Status.triggered }) .catch((e) => logs.error("Error sending disk usage notification", e)); diff --git a/packages/dappmanager/src/calls/setStaticIp.ts b/packages/dappmanager/src/calls/setStaticIp.ts index 898cb23fc9..4c01e94a9c 100644 --- a/packages/dappmanager/src/calls/setStaticIp.ts +++ b/packages/dappmanager/src/calls/setStaticIp.ts @@ -4,7 +4,7 @@ import { eventBus } from "@dappnode/eventbus"; import { logs } from "@dappnode/logger"; import { notifications } from "@dappnode/notifications"; import { params } from "@dappnode/params"; -import { NotificationCategory, NotificationPriority } from "@dappnode/types"; +import { Category, Priority, Status } from "@dappnode/types"; /** * Sets the static IP @@ -33,8 +33,9 @@ export async function setStaticIp({ staticIp }: { staticIp: string }): Promise<v title: "Static IP updated", body: `Your static IP was changed to ${staticIp}.`, dnpName: params.dappmanagerDnpName, - category: NotificationCategory.core, - priority: NotificationPriority.low + category: Category.system, + priority: Priority.low, + status: Status.triggered }) .catch((e) => logs.error("Error sending static IP updated notification", e)); diff --git a/packages/schemas/test/unit/validateSchema.test.ts b/packages/schemas/test/unit/validateSchema.test.ts index 2db32f3734..6c80a71ab0 100644 --- a/packages/schemas/test/unit/validateSchema.test.ts +++ b/packages/schemas/test/unit/validateSchema.test.ts @@ -9,9 +9,9 @@ import fs from "fs"; import path from "path"; import { cleanTestDir, testDir } from "../testUtils.js"; import { Manifest, SetupWizard, NotificationsConfig, GatusEndpoint, CustomEndpoint } from "@dappnode/types"; -import { NotificationPriority } from "../../../types/src/notifications.js"; +import { Priority } from "../../../types/src/notifications.js"; -describe("schemaValidation", function () { +describe.only("schemaValidation", function () { this.timeout(10000); describe("manifest", () => { before(() => { @@ -453,7 +453,7 @@ volumes: conditions: ["response-time < 500ms", "status == 200"], interval: "1m", group: "example-group", - priority: NotificationPriority.low, + priority: Priority.low, alerts: [ { type: "custom", @@ -509,7 +509,7 @@ volumes: conditions: ["response-time < 500ms"], interval: "1m", group: "example-group", - priority: NotificationPriority.low, + priority: Priority.low, alerts: [ { type: "response-time", @@ -542,7 +542,7 @@ volumes: conditions: ["response-time < 500ms"], interval: "invalid-interval", group: "example-group", - priority: NotificationPriority.low, + priority: Priority.low, alerts: [ { type: "response-time", @@ -603,7 +603,7 @@ volumes: enabled: true, name: "custom-endpoint", description: "A custom endpoint for testing", // Added required description - priority: NotificationPriority.high, + priority: Priority.high, metric: { treshold: 90, min: 0, @@ -663,7 +663,7 @@ volumes: conditions: ["response-time < 500ms", "status == 200"], interval: "1m", group: "example-group", - priority: NotificationPriority.low, + priority: Priority.low, alerts: [ { type: "custom", @@ -690,7 +690,7 @@ volumes: enabled: true, name: "custom-endpoint", description: "A custom endpoint for testing", // Added required description - priority: NotificationPriority.high, + priority: Priority.high, metric: { treshold: 90, min: 0, diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts index 358ec37eb4..96a899f832 100644 --- a/packages/types/src/notifications.ts +++ b/packages/types/src/notifications.ts @@ -12,8 +12,9 @@ export interface NotificationPayload { title: string; body: string; dnpName: string; - category: NotificationCategory; - priority: NotificationPriority; + category: Category; + priority: Priority; + status: Status; icon?: string; errors?: string; callToAction?: { @@ -22,21 +23,26 @@ export interface NotificationPayload { }; } -export enum NotificationPriority { +export enum Priority { low = "low", medium = "medium", high = "high", critical = "critical" } -export enum NotificationCategory { - core = "core", +export enum Status { + triggered = "triggered", + resolved = "resolved" +} + +export enum Category { + system = "system", ethereum = "ethereum", holesky = "holesky", lukso = "lukso", gnosis = "gnosis", hoodi = "hoodi", - host = "host", + hardware = "hardware", other = "other" } @@ -44,7 +50,7 @@ export interface CustomEndpoint { name: string; enabled: boolean; description: string; - priority: NotificationPriority; + priority: Priority; metric?: { treshold: number; min: number; @@ -61,7 +67,7 @@ export interface GatusEndpoint { conditions: string[]; interval: string; // e.g., "1m" group: string; - priority: NotificationPriority; + priority: Priority; // dappnode specific alerts: Alert[]; definition: { // dappnode specific From 57553c9217a083be23727aa67d8561efcc077cc4 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Fri, 25 Apr 2025 11:43:13 +0200 Subject: [PATCH 47/90] Notifications inbox UI fixes (#2156) * notifications inbox UI fixes * notifications inbox UI fixes * priority and category labels * prettified notification body * utils * category label improved * support MD in notifications card --- .../pages/notifications/tabs/Inbox/Inbox.tsx | 2 +- .../tabs/Inbox/NotificationsCard.tsx | 31 +++++++-- .../pages/notifications/tabs/Inbox/inbox.scss | 66 +++++++++++++++---- 3 files changed, 80 insertions(+), 19 deletions(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx index 5cb9480530..d91cbf760e 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx @@ -80,7 +80,7 @@ export function Inbox() { <> <SubTitle>New Notifications</SubTitle> {newNotifications.map((notification) => ( - <NotificationCard key={notification.timestamp} notification={notification} /> + <NotificationCard key={notification.timestamp} notification={notification} openByDefault /> ))} </> )} diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx index 8b88414b71..ebb9426920 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx @@ -4,18 +4,33 @@ import { Notification } from "@dappnode/types"; import { IoIosArrowDown, IoIosArrowUp } from "react-icons/io"; import { prettyDnpName } from "utils/format"; import defaultAvatar from "img/defaultAvatar.png"; +import { Priority } from "@dappnode/types"; +import RenderMarkdown from "components/RenderMarkdown"; interface NotificationCardProps { notification: Notification; + openByDefault?: boolean; } -export function NotificationCard({ notification }: NotificationCardProps) { - const [isOpen, setIsOpen] = useState(false); +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 defaultAvatar; }; + const [isOpen, setIsOpen] = useState(openByDefault); return ( <Accordion defaultActiveKey={isOpen ? "0" : "1"}> @@ -26,9 +41,11 @@ export function NotificationCard({ notification }: NotificationCardProps) { <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 className="category-label"> + {notification.category.charAt(0).toUpperCase() + notification.category.slice(1)} + </div> + <div className={`${notification.priority}-label`}>{priorityLabels[notification.priority]}</div> + {notification.body.includes("resolved: ") && <div className="resolved-label">Resolved</div>} </div> <i>{new Date(notification.timestamp).toLocaleString()}</i> @@ -40,7 +57,9 @@ export function NotificationCard({ notification }: NotificationCardProps) { </div> </div> <Accordion.Collapse eventKey="0"> - <div className="notification-body">{notification.body}</div> + <div className="notification-body"> + <RenderMarkdown source={prettifiedBody(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 index 42f6f4b327..ca2ba9edf0 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss @@ -93,25 +93,45 @@ } } - .group-label { + .base-label { display: flex; align-items: center; - background-color: #dcdcdc; + background-color: transparent !important; padding: 0px 5px; border-radius: 10px; font-size: 0.7rem; } - .sucess-label { - @extend .group-label; - background-color: transparent !important; - border: 1px solid var(--success-green-color); - color: var(--success-green-color) !important; + .resolved-label { + @extend .base-label; + background-color: var(--success-green-color) !important; + color: #bbffad !important; } - .trigger-label { - @extend .group-label; - background-color: transparent !important; - border: 1px solid var(--dappnode-complimentary-color); - color: var(--dappnode-complimentary-color) !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; + } + .normal-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; } } } @@ -144,6 +164,28 @@ .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; + } + } } From 48ff6a423ebd0edd520a8f95883054681637ae53 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Fri, 25 Apr 2025 13:15:25 +0200 Subject: [PATCH 48/90] fix resolved label --- .../src/pages/notifications/tabs/Inbox/NotificationsCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx index ebb9426920..dcd978e1de 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx @@ -45,7 +45,7 @@ export function NotificationCard({ notification, openByDefault = false }: Notifi {notification.category.charAt(0).toUpperCase() + notification.category.slice(1)} </div> <div className={`${notification.priority}-label`}>{priorityLabels[notification.priority]}</div> - {notification.body.includes("resolved: ") && <div className="resolved-label">Resolved</div>} + {notification.status === 'resolved' && <div className="resolved-label">Resolved</div>} </div> <i>{new Date(notification.timestamp).toLocaleString()}</i> From 97bbfc3b5889bc760e7341eaeed7f6f516782a60 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Mon, 5 May 2025 18:26:44 +0200 Subject: [PATCH 49/90] Notifications general switch (#2155) * Notifications general switch * remove logs --- .../installer/components/InstallDnpView.tsx | 4 +- .../pages/notifications/NotificationsRoot.tsx | 12 ++--- .../InstallNotifications.tsx | 10 ++-- .../notifications/tabs/Settings/Settings.tsx | 54 +++++++++++++++++-- packages/admin-ui/src/params.ts | 6 +-- .../dappmanager/src/calls/packageStartStop.ts | 2 +- packages/params/src/params.ts | 1 + 7 files changed, 63 insertions(+), 26 deletions(-) diff --git a/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx b/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx index 08647066c3..0461722f72 100644 --- a/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx +++ b/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx @@ -29,7 +29,7 @@ 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 { notificationsPkgName } from "params"; +import { notificationsDnpName } from "params"; interface InstallDnpViewProps { dnp: RequestedDnp; @@ -214,7 +214,7 @@ const InstallDnpView: React.FC<InstallDnpViewProps> = ({ dnp, progressLogs }) => const dnpsRequest = useApi.packagesGet(); const installedDnps = dnpsRequest.data; - const isNotificationsPkgInstalled = installedDnps?.some((dnp) => dnp.dnpName === notificationsPkgName); + const isNotificationsPkgInstalled = installedDnps?.some((dnp) => dnp.dnpName === notificationsDnpName); const disableInstallation = !isEmpty(progressLogs) || requiresCoreUpdate || requiresDockerUpdate || packagesToBeUninstalled.length > 0; diff --git a/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx index 9630fca2e6..dea4952bc4 100644 --- a/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx +++ b/packages/admin-ui/src/pages/notifications/NotificationsRoot.tsx @@ -9,13 +9,13 @@ import Title from "components/Title"; import { renderResponse } from "components/SwrRender"; import { Inbox } from "./tabs/Inbox/Inbox"; import { NotificationsSettings } from "./tabs/Settings/Settings"; -import { notificationsPkgName } from "params"; +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 === notificationsPkgName); + const isNotificationsPkgInstalled = dnps?.some((dnp) => dnp.dnpName === notificationsDnpName); const availableRoutes: { name: string; @@ -25,16 +25,12 @@ export const NotificationsRoot: React.FC = () => { { name: "Inbox", subPath: subPaths.inbox, - component: isNotificationsPkgInstalled - ? Inbox - : () => <InstallNotificationsPkg pkgName={notificationsPkgName} /> + component: isNotificationsPkgInstalled ? Inbox : () => <InstallNotificationsPkg /> }, { name: "Settings", subPath: subPaths.settings, - component: isNotificationsPkgInstalled - ? NotificationsSettings - : () => <InstallNotificationsPkg pkgName={notificationsPkgName} /> + component: isNotificationsPkgInstalled ? NotificationsSettings : () => <InstallNotificationsPkg /> }, { name: "Legacy", diff --git a/packages/admin-ui/src/pages/notifications/tabs/InstallNotifications/InstallNotifications.tsx b/packages/admin-ui/src/pages/notifications/tabs/InstallNotifications/InstallNotifications.tsx index 3cd16c89ac..392a5a975e 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/InstallNotifications/InstallNotifications.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/InstallNotifications/InstallNotifications.tsx @@ -6,19 +6,17 @@ import SubTitle from "components/SubTitle"; import Card from "components/Card"; import "./installNotifications.scss"; +import { notificationsDnpName } from "params"; -interface InstallNotificationsPkgProps { - pkgName: string; -} -export const InstallNotificationsPkg: React.FC<InstallNotificationsPkgProps> = ({ pkgName }) => { - const installerPath = getInstallerPath(pkgName); +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 + "/" + pkgName}> + <NavLink to={installerPath + "/" + notificationsDnpName}> <Button variant="dappnode">Install</Button> </NavLink> </Card> diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx index 18a552d3a4..649cce81ac 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx @@ -2,9 +2,13 @@ import SubTitle from "components/SubTitle"; import React, { useEffect, useState } from "react"; import Switch from "components/Switch"; import { ManagePackageNotifications } from "./ManagePackageNotifications.js"; -import { useApi } from "api"; +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]: { @@ -15,9 +19,10 @@ interface EndpointsData { } export function NotificationsSettings() { - const [notificationsEnabled, setNotificationsEnabled] = useState(true); + const [notificationsDisabled, setNotificationsDisabled] = useState<boolean>(false); 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 @@ -26,22 +31,61 @@ export function NotificationsSettings() { } }, [endpointsCall.data]); + useEffect(() => { + if (notificationsDnp.data) { + const isStopped = notificationsDnp.data.containers.some((c) => c.state !== "running"); + setNotificationsDisabled(isStopped); + } + }, [notificationsDnp.data]); + + 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 }), + 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={notificationsEnabled} + checked={!notificationsDisabled} + disabled={notificationsDnp.isValidating} onToggle={() => { - setNotificationsEnabled(!notificationsEnabled); + startStopNotifications(); }} /> </div> <div>Enable notifications to retrieve a registry of notifications on your Dappnode.</div> </div> <br /> - {notificationsEnabled && ( + {!notificationsDisabled && !notificationsDnp.isValidating && ( <div> <SubTitle className="notifications-section-title">Manage notifications</SubTitle> <div>Enable, disable and customize notifications individually.</div> diff --git a/packages/admin-ui/src/params.ts b/packages/admin-ui/src/params.ts index 4f53eea5d1..15b4c8a48a 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, @@ -130,7 +131,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 - -// Notifications -export const notificationsPkgName = "notifications.dnp.dappnode.eth"; +// Support, where to send issues \ No newline at end of file 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/params/src/params.ts b/packages/params/src/params.ts index 4877cfe917..93da110935 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", From d5ad136b7e30986f5c0c62fb434b7c6fdbce3cda Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Wed, 14 May 2025 10:12:15 +0200 Subject: [PATCH 50/90] Welcome notifications modal (#2144) * Welcome notifications modal * welcome dark styles * modal copy updated * start stop notifications on welcome * typo fix --- .../src/components/welcome/Welcome.tsx | 3 + .../welcome/features/EnableNotifications.tsx | 82 +++++++++++++++++++ .../src/components/welcome/welcome.scss | 22 ++++- .../pages/notifications/tabs/Legacy/index.tsx | 2 +- packages/admin-ui/src/params.ts | 2 +- .../dappmanager/src/calls/systemInfoGet.ts | 3 + packages/dappmanager/src/initializeDb.ts | 1 + packages/types/src/calls.ts | 3 +- 8 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx 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<RouteProps> | unde return (props: RouteProps) => <RepositoryFallback {...props} />; case "enable-ethical-metrics": return (props: RouteProps) => <EnableEthicalMetrics {...props} />; + case "enable-notifications": + return (props: RouteProps) => <EnableNotifications {...props} />; 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..3d472219a8 --- /dev/null +++ b/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from "react"; +import BottomButtons from "../BottomButtons"; +import { docsUrl } 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"; + +export default function EnableNotifications({ onBack, onNext }: { onBack?: () => void; onNext: () => void }) { + const [notificationsDisabled, setNotificationsDisabled] = useState<boolean>(false); + + 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<void> { + 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 ( + <div> + <div className="header"> + <div className="title">Enable Dappnode's Notifications</div> + <br /> + <h4>📣 Heads up! Changes are coming to Notifications</h4> + <div> + The current notification system will be <b>deprecated</b> in upcoming Dappnode core releases. + <br /> + We're transitioning to a new and improved in-app Notifications experience, designed to be more reliable, + configurable and scalable. + </div> + <SubTitle>Enable new notifications</SubTitle> + <Switch + checked={!notificationsDisabled} + disabled={notificationsDnp.isValidating} + onToggle={() => { + startStopNotifications(); + }} + /> + <br /> + <br /> + <p> + This notifications may alert you to critical issues if they arise. Disabling them could result in missing + critical notifications + </p> + <p> + Learn more about notifications package and how to configure it in the{" "} + <a href={docsUrl.notificationsOverview}>Dappnode's documentation</a> + </p> + </div> + + <BottomButtons onBack={onBack} onNext={() => onNext()} /> + <br /> + <br /> + </div> + ); +} 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/notifications/tabs/Legacy/index.tsx b/packages/admin-ui/src/pages/notifications/tabs/Legacy/index.tsx index ea26ac4fb8..b3cec5ebc3 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Legacy/index.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Legacy/index.tsx @@ -26,7 +26,7 @@ export function LegacyNotifications() { </Link>{" "} <br /> 📘 For full details about the new system, see our{" "} - <Link to={docsUrl.notifications} target="_blank" rel="noopener noreferrer"> + <Link to={docsUrl.notificationsOverview} target="_blank" rel="noopener noreferrer"> Notifications Documentation </Link> . diff --git a/packages/admin-ui/src/params.ts b/packages/admin-ui/src/params.ts index 15b4c8a48a..4bf71dbe1a 100755 --- a/packages/admin-ui/src/params.ts +++ b/packages/admin-ui/src/params.ts @@ -97,7 +97,7 @@ export const docsUrl = { stakers: `${docsBaseUrl}/docs/user/staking/overview`, rollups: `${docsBaseUrl}/docs/user/rollups/overview`, ethicalMetricsOverview: `${docsBaseUrl}/docs/user/ethical-metrics/overview`, - notifications: `${docsBaseUrl}/docs/user/notifications/overview` + notificationsOverview: `${docsBaseUrl}/docs/user/notifications/overview` }; export const forumUrl = { 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/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/types/src/calls.ts b/packages/types/src/calls.ts index 32254d41dc..09916aac32 100644 --- a/packages/types/src/calls.ts +++ b/packages/types/src/calls.ts @@ -1081,7 +1081,8 @@ export type NewFeatureId = | "repository-fallback" | "system-auto-updates" | "enable-ethical-metrics" - | "change-host-password"; + | "change-host-password" + | "enable-notifications"; /** * ======= From 63ab9441389ee2cf5ad6d085679b923e1ed28535 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Wed, 14 May 2025 10:13:25 +0200 Subject: [PATCH 51/90] Add pagination to history (#2160) * Add pagination * fix comparison --- .../pages/notifications/tabs/Inbox/Inbox.tsx | 67 +++++++++++++++++-- .../pages/notifications/tabs/Inbox/inbox.scss | 47 ++++++++++++- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx index d91cbf760e..ab72a1d5c2 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx @@ -1,11 +1,11 @@ import SubTitle from "components/SubTitle"; import React, { useEffect, useMemo, useState } from "react"; import Card from "components/Card"; -import "./inbox.scss"; 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(); @@ -13,6 +13,8 @@ export function Inbox() { 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) { @@ -48,6 +50,30 @@ export function Inbox() { .filter((notification) => notification.seen) .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + const paginatedSeenNotifications = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return seenNotifications.slice(startIndex, endIndex); + }, [seenNotifications, currentPage]); + + const totalPages = Math.ceil(seenNotifications.length / itemsPerPage); + + 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 ? ( @@ -89,9 +115,42 @@ export function Inbox() { {!seenNotifications || seenNotifications.length === 0 ? ( <Card>No notifications</Card> ) : ( - seenNotifications.map((notification) => ( - <NotificationCard key={notification.timestamp} notification={notification} /> - )) + <> + {paginatedSeenNotifications.map((notification) => ( + <NotificationCard key={notification.timestamp} notification={notification} /> + ))} + <div className="pagination"> + {currentPage !== 1 && ( + <> + <button onClick={handleFirstPage} className="page-item"> + 1 + </button> + {currentPage > 2 && <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 - 1 > 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/inbox.scss b/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss index ca2ba9edf0..608275a102 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss @@ -185,7 +185,6 @@ border: 1px solid gray; color: gray !important; } - } } @@ -193,3 +192,49 @@ 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; + } + } +} From 3c9d0a77e94274ab382a80b3f6e3419221ef5a30 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <mendez4a@gmail.com> Date: Thu, 15 May 2025 09:52:27 +0200 Subject: [PATCH 52/90] remove priority --- notifications.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/notifications.yaml b/notifications.yaml index 6efa158fde..ee500858a5 100644 --- a/notifications.yaml +++ b/notifications.yaml @@ -1,5 +1,4 @@ customEndpoints: - name: "Package updates notifications" description: "This endpoint notifies users about available package updates." - priority: low enabled: true From 6407b6360db991ddb485b7d574f4092e7051c4d1 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Thu, 15 May 2025 10:45:15 +0200 Subject: [PATCH 53/90] Display alerts component (#2164) * Implement getInternetConnection cron * implement reboot required script * remove initial calls for reboot required * rebase * Implement password is secure notification * fix password is secure mssg * implemented core update notif * remove wifi notif from notifications view * remove unused import * use get is password secure from backend * Implement wifi as notification high * implement repository health daemon * set maximum listener to 12 * remove log true from notification endpoints. logs are too heavy * update prios * Implement call to action button in notifications card (#2157) * call2action in notifications card * fixing mobile styles * is banner prop * prio label fix * getBannerNotifications call * WIP: Notifications Banner component * Alerts copies review (#2159) * package updates notification * disk usage notification * host reboot notification * internet connection notification * repository notifications * host password notification * staticIp notification * wifi password * notifications banner update * set notifications as seen by ID * Collapsable banners and improved filtering * set resolved-banner notifications as seen * timestamp fix * add correlationId * schemas and types update * update schemas with latest changes * remove unused fields --------- Co-authored-by: pablomendezroyo <mendez4a@gmail.com> Co-authored-by: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Co-authored-by: Pablo Mendez <pablo@dappnode.io> --- .../admin-ui/src/__mock-backend__/index.ts | 9 +- .../admin-ui/src/__mock-backend__/wifi.ts | 4 +- packages/admin-ui/src/api/initialCalls.ts | 14 +- .../src/components/NotificationsMain.tsx | 224 ++++++----- .../src/components/notificationsMain.scss | 115 +++++- .../pages/notifications/tabs/Inbox/Inbox.tsx | 2 +- .../tabs/Inbox/NotificationsCard.tsx | 36 +- .../pages/notifications/tabs/Inbox/inbox.scss | 29 +- packages/admin-ui/src/pages/system/actions.ts | 355 +++++++++--------- .../Security/securityIssues/index.tsx | 14 +- .../src/services/coreUpdate/selectors.ts | 5 - .../src/services/dappnodeStatus/actions.ts | 35 -- .../src/services/dappnodeStatus/reducer.ts | 10 +- .../src/services/dappnodeStatus/selectors.ts | 3 - .../src/autoUpdates/formatNotificationBody.ts | 27 +- .../src/autoUpdates/sendUpdateNotification.ts | 30 +- packages/daemons/src/diskUsage/index.ts | 12 +- packages/daemons/src/hostReboot/index.ts | 76 ++++ packages/daemons/src/index.ts | 10 + .../daemons/src/internetConnection/index.ts | 90 +++++ .../daemons/src/repositoryHealth/index.ts | 147 ++++++++ packages/daemons/src/wifiPassword/index.ts | 0 .../src/calls/getIsConnectedToInternet.ts | 21 -- packages/dappmanager/src/calls/index.ts | 13 +- .../dappmanager/src/calls/notifications.ts | 17 +- .../dappmanager/src/calls/passwordManager.ts | 30 ++ .../src/calls/rebootHostIsRequiredGet.ts | 10 - packages/dappmanager/src/calls/setStaticIp.ts | 9 +- packages/dappmanager/src/calls/wifi.ts | 38 +- packages/dappmanager/src/index.ts | 2 +- packages/notifications/src/api.ts | 31 +- packages/notifications/src/index.ts | 15 +- packages/params/src/params.ts | 1 + .../src/schemas/notifications.schema.json | 44 ++- .../schemas/test/unit/validateSchema.test.ts | 85 ++++- packages/types/src/calls.ts | 2 + packages/types/src/notifications.ts | 32 +- packages/types/src/routes.ts | 39 +- 38 files changed, 1134 insertions(+), 502 deletions(-) create mode 100644 packages/daemons/src/hostReboot/index.ts create mode 100644 packages/daemons/src/internetConnection/index.ts create mode 100644 packages/daemons/src/repositoryHealth/index.ts create mode 100644 packages/daemons/src/wifiPassword/index.ts delete mode 100644 packages/dappmanager/src/calls/getIsConnectedToInternet.ts delete mode 100644 packages/dappmanager/src/calls/rebootHostIsRequiredGet.ts diff --git a/packages/admin-ui/src/__mock-backend__/index.ts b/packages/admin-ui/src/__mock-backend__/index.ts index 19cd2a08ea..17461e2b64 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<Routes, keyof typeof namedSpacedCalls> = { newFeatureStatusSet: async () => {}, poweroffHost: async () => {}, rebootHost: async () => {}, - rebootHostIsRequiredGet: async () => ({ - rebootRequired: true, - pkgs: "docker" - }), setStaticIp: async () => {}, systemInfoGet: async () => ({ @@ -385,18 +381,19 @@ export const otherCalls: Omit<Routes, keyof typeof namedSpacedCalls> = { dockerHostVersion: "20.10.7", dockerLatestVersion: "20.10.8" }), - getIsConnectedToInternet: async () => false, getCoreVersion: async () => "0.2.92", notificationsGetAllEndpoints: async () => { return { "geth.dnp.dappnode.eth": { endpoints: [], customEndpoints: [], isCore: false } }; }, notificationsGetUnseenCount: async () => 2, notificationsSetAllSeen: async () => {}, + notificationSetSeenByID: 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<Routes, "wifiCredentialsGet" | "wifiReportGet"> = { 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<any>(fetchDnpInstalled()); store.dispatch<any>(fetchCoreUpdateData()); store.dispatch<any>(fetchSystemInfo()); store.dispatch<any>(fetchVolumes()); - store.dispatch<any>(fetchPasswordIsSecure()); - store.dispatch<any>(fetchWifiCredentials()); - store.dispatch<any>(fetchRebootIsRequired()); store.dispatch<any>(fetchShouldShowSmooth()); - store.dispatch<any>(fetchIsConnectedToInternet()); } diff --git a/packages/admin-ui/src/components/NotificationsMain.tsx b/packages/admin-ui/src/components/NotificationsMain.tsx index 4dde6297cf..c0af06e58c 100644 --- a/packages/admin-ui/src/components/NotificationsMain.tsx +++ b/packages/admin-ui/src/components/NotificationsMain.tsx @@ -1,116 +1,134 @@ -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, Status } 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"; /** - * 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<Notification[]>([]); + + 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 title, keeping the most recent one + * 3. Filters out notifications that are not triggered status + * 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<string, Notification>(); + + notifications + .filter((n) => !n.errors) // Filter out notifications with errors + .forEach((notification) => { + const existing = map.get(notification.title); + + if (!existing || new Date(notification.timestamp) > new Date(existing.timestamp)) { + map.set(notification.title, notification); + } + }); + + return Array.from(map.values()) + .filter((n) => n.status === Status.triggered) // Filter by triggered status after deduplication + .filter((n) => n.seen === false) // Filter out seen notifications + .sort((a, b) => priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority)); + } return ( - <div> - {notifications - .filter(({ active }) => active) - .map(({ id, linkText, linkPath, body }) => ( - <AlertDismissible key={id} className="main-notification" variant="warning"> - <RenderMarkdown source={body} /> - {linkText && linkPath ? ( - <NavLink to={linkPath}> - <Button variant="warning">{linkText}</Button> - </NavLink> - ) : null} - </AlertDismissible> + notifications && + notifications.length > 0 && ( + <div className="banner-notifications-col"> + {notifications.slice(0, numOfBannersShown).map((notification) => ( + <CollapsableBannerNotification + notification={notification} + key={notification.id} + onClose={() => setNotifications((prev) => prev.filter((n) => n.id !== notification.id))} + /> ))} - </div> + </div> + ) + ); +} + +const priorityBtnVariants: Record<Priority, ButtonVariant> = { + [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.notificationSetSeenByID(notification.id); + setHasClosed(true); + onClose(); + }; + + return ( + !hasClosed && ( + <Accordion defaultActiveKey={isOpen ? "0" : "1"}> + <Accordion.Toggle + as={"div"} + eventKey="0" + onClick={() => setIsOpen(!isOpen)} + className={`banner-card ${notification.priority}-priority`} + > + <div className="banner-header"> + <h5>{notification.title}</h5> + <button className="close-btn" onClick={handleClose}> + <MdClose /> + </button> + </div> + <Accordion.Collapse eventKey="0"> + <div className="banner-body"> + <RenderMarkdown source={notification.body} /> + {notification.callToAction && ( + <NavLink to={notification.callToAction.url}> + <Button variant={priorityBtnVariants[notification.priority]}> + {notification.callToAction.title} + </Button> + </NavLink> + )} + </div> + </Accordion.Collapse> + </Accordion.Toggle> + </Accordion> + ) ); } diff --git a/packages/admin-ui/src/components/notificationsMain.scss b/packages/admin-ui/src/components/notificationsMain.scss index c9a604f048..a7b3aa011a 100644 --- a/packages/admin-ui/src/components/notificationsMain.scss +++ b/packages/admin-ui/src/components/notificationsMain.scss @@ -1,26 +1,105 @@ -.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; + } - @media screen and (max-width: 40rem) { - display: block; - p { - margin-bottom: 0.5rem; + &.medium-priority { + background-color: #d1ecf1; + color: #0c5460 !important; } - a { - margin-left: 0; + + &.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; + + .close-btn { + padding: 0px; + border: none; + background: none; + + cursor: pointer; + color: #000; + font-size: 1.2rem; + font-weight: 700; + + &:hover { + opacity: 0.5; + } + } + } + + .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; + } + } + } +} + +#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/pages/notifications/tabs/Inbox/Inbox.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx index ab72a1d5c2..d97f27693f 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx @@ -106,7 +106,7 @@ export function Inbox() { <> <SubTitle>New Notifications</SubTitle> {newNotifications.map((notification) => ( - <NotificationCard key={notification.timestamp} notification={notification} openByDefault /> + <NotificationCard key={notification.id} notification={notification} openByDefault /> ))} </> )} diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx index dcd978e1de..ec2d919fcd 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Accordion } from "react-bootstrap"; import { Notification } from "@dappnode/types"; import { IoIosArrowDown, IoIosArrowUp } from "react-icons/io"; @@ -6,6 +6,9 @@ import { prettyDnpName } from "utils/format"; import defaultAvatar from "img/defaultAvatar.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"; interface NotificationCardProps { notification: Notification; @@ -32,6 +35,12 @@ export function NotificationCard({ notification, openByDefault = false }: Notifi }; const [isOpen, setIsOpen] = useState(openByDefault); + useEffect(() => { + if (!notification.seen && notification.isBanner && notification.status === "resolved") { + api.notificationSetSeenByID(notification.id); + } + }, []); + return ( <Accordion defaultActiveKey={isOpen ? "0" : "1"}> <Accordion.Toggle as={"div"} eventKey="0" onClick={() => setIsOpen(!isOpen)} className="notification-card"> @@ -41,14 +50,24 @@ export function NotificationCard({ notification, openByDefault = false }: Notifi <div className="notification-header-row secondary-text"> <div className="notification-name-row"> <div>{prettyDnpName(notification.dnpName)}</div> - <div className="category-label"> - {notification.category.charAt(0).toUpperCase() + notification.category.slice(1)} + <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 className={`${notification.priority}-label`}>{priorityLabels[notification.priority]}</div> - {notification.status === 'resolved' && <div className="resolved-label">Resolved</div>} </div> - <i>{new Date(notification.timestamp).toLocaleString()}</i> + <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> @@ -59,6 +78,11 @@ export function NotificationCard({ notification, openByDefault = false }: Notifi <Accordion.Collapse eventKey="0"> <div className="notification-body"> <RenderMarkdown source={prettifiedBody(notification.body)} /> + {notification.callToAction && ( + <NavLink to={notification.callToAction.url}> + <Button variant="dappnode">{notification.callToAction.title}</Button>{" "} + </NavLink> + )} </div> </Accordion.Collapse> </Accordion.Toggle> diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss b/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss index 608275a102..50f67d3f83 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss @@ -49,6 +49,10 @@ align-items: center; width: 100%; + @media (max-width: 60rem) { + gap: 10px; + } + .avatar { width: 40px; height: 40px; @@ -82,6 +86,16 @@ .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 { @@ -100,6 +114,10 @@ padding: 0px 5px; border-radius: 10px; font-size: 0.7rem; + + @media (max-width: 60rem) { + font-size: 0.65rem; + } } .resolved-label { @extend .base-label; @@ -117,7 +135,7 @@ border: 1px solid rgb(228, 156, 0); color: rgb(228, 156, 0) !important; } - .normal-label { + .medium-label { @extend .base-label; border: 1px solid var(--success-color); color: var(--success-color) !important; @@ -139,6 +157,15 @@ .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; + } } } .notification-card:hover { 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/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/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/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/sendUpdateNotification.ts b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts index f7d732978d..e60b1b03c1 100644 --- a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts +++ b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts @@ -2,11 +2,10 @@ import { valid, lte } from "semver"; import { params } from "@dappnode/params"; import * as db from "@dappnode/db"; import { DappnodeInstaller } from "@dappnode/installer"; -import { prettyDnpName } from "@dappnode/utils"; +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"; @@ -39,6 +38,8 @@ export async function sendUpdatePackageNotificationMaybe({ upstream: release.manifest.upstream }); + const adminUiInstallPackageUrl = "http://my.dappnode/installer"; + // Send notification about new version available await notifications .sendNotification({ @@ -48,12 +49,18 @@ export async function sendUpdatePackageNotificationMaybe({ dnpName, currentVersion, newVersion, - upstreamVersion, - autoUpdatesEnabled: isDnpUpdateEnabled(dnpName) + upstreamVersion }), category: Category.system, priority: Priority.low, - status: Status.triggered + status: Status.triggered, + callToAction: { + title: "Update", + url: urlJoin(adminUiInstallPackageUrl, dnpName) + }, + isBanner: false, + isRemote: false, + correlationId : 'core-update-pkg', }) .catch((e) => logs.error("Error sending package update notification", e)); @@ -66,6 +73,8 @@ 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 @@ -80,8 +89,15 @@ export async function sendUpdateSystemNotificationMaybe(data: CoreUpdateDataAvai autoUpdatesEnabled: isCoreUpdateEnabled() }), category: Category.system, - priority: Priority.low, - status: Status.triggered + priority: Priority.high, + status: Status.triggered, + callToAction: { + title: "Update", + url: adminUiUpdateCoreUrl + }, + isBanner: true, + isRemote: false, + correlationId : 'core-update-system-pkg', }) .catch((e) => logs.error("Error sending system update notification", e)); diff --git a/packages/daemons/src/diskUsage/index.ts b/packages/daemons/src/diskUsage/index.ts index 9196d0f8ee..e518d94e87 100644 --- a/packages/daemons/src/diskUsage/index.ts +++ b/packages/daemons/src/diskUsage/index.ts @@ -16,13 +16,13 @@ import { Category, Priority, Status } from "@dappnode/types"; 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" @@ -97,17 +97,19 @@ async function monitorDiskUsage(): Promise<void> { await notifications .sendNotification({ - title: `Disk space is running out, ${threshold.id.split(" ")[0]}`, + title: `Available disk space is less than a ${threshold.id}`, dnpName: params.dappmanagerDnpName, 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"), category: Category.hardware, priority: Priority.critical, - status: Status.triggered + status: Status.triggered, + isBanner: true, + isRemote: false, + correlationId : 'core-disk-usage', }) .catch((e) => logs.error("Error sending disk usage notification", e)); 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 b0ec32fc63..d27e0df590 100644 --- a/packages/daemons/src/index.ts +++ b/packages/daemons/src/index.ts @@ -8,10 +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 { 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); @@ -21,6 +28,9 @@ export function startDaemons(dappnodeInstaller: DappnodeInstaller, signal: Abort startStakerDaemon(dappnodeInstaller); startTelegramBotDaemon(); startBindDaemon(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..360a416114 --- /dev/null +++ b/packages/daemons/src/internetConnection/index.ts @@ -0,0 +1,90 @@ +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> { + try { + // Simulate fetching public IP to check connectivity + await new Promise((resolve, _) => setTimeout(resolve, 3000)); // Mock delay + return true; + } catch (error) { + logs.error(`Error while checking DAppNode internet connectivity: ${error}`); + 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..bed313e399 --- /dev/null +++ b/packages/daemons/src/repositoryHealth/index.ts @@ -0,0 +1,147 @@ +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 = 0.2 * 60 * 1000; // 10 minutes + +let ipfsNotificationSent = false; +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`); + + 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}`); + + if (!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`); + + 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}`); + + if (!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: `Change to ${ethClientTarget && ethClientTarget === "on" ? "Full Node" : "Remote"}`, + url: "http://my.dappnode/repository/ethereum" + }, + 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/wifiPassword/index.ts b/packages/daemons/src/wifiPassword/index.ts new file mode 100644 index 0000000000..e69de29bb2 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 fd77a06790..1ef8cbc34b 100644 --- a/packages/dappmanager/src/calls/index.ts +++ b/packages/dappmanager/src/calls/index.ts @@ -21,8 +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, notificationsUpdateEndpoints, notificationsGetAll, notificationsApplyPreviousEndpoints, notificationsGetUnseenCount, notificationsSetAllSeen } from "./notifications.js"; +export { + notificationsGetAllEndpoints, + notificationsGetBanner, + notificationsUpdateEndpoints, + notificationsGetAll, + notificationsApplyPreviousEndpoints, + notificationsGetUnseenCount, + notificationsSetAllSeen, + notificationSetSeenByID +} from "./notifications.js"; export * from "./httpsPortal.js"; export { ipfsTest } from "./ipfsTest.js"; export { ipfsClientTargetSet } from "./ipfsClientTargetSet.js"; @@ -54,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 index 17e93a01ca..c47577d8bf 100644 --- a/packages/dappmanager/src/calls/notifications.ts +++ b/packages/dappmanager/src/calls/notifications.ts @@ -9,6 +9,14 @@ 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 */ @@ -17,12 +25,19 @@ export async function notificationsGetUnseenCount(): Promise<number> { } /** - * Set all notifications as seen + * 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 ID + */ +export async function notificationSetSeenByID(id:number): Promise<void> { + return await notifications.setNotificationSeenByID(id); +} + /** * Get gatus and custom endpoints indexed by dnpName */ 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 4c01e94a9c..499c2b9f60 100644 --- a/packages/dappmanager/src/calls/setStaticIp.ts +++ b/packages/dappmanager/src/calls/setStaticIp.ts @@ -30,12 +30,15 @@ export async function setStaticIp({ staticIp }: { staticIp: string }): Promise<v await notifications .sendNotification({ - title: "Static IP updated", - body: `Your static IP was changed to ${staticIp}.`, + 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 + status: Status.triggered, + isBanner: true, + isRemote: false, + correlationId: "core-static-ip-update" }) .catch((e) => logs.error("Error sending static IP updated notification", e)); diff --git a/packages/dappmanager/src/calls/wifi.ts b/packages/dappmanager/src/calls/wifi.ts index a6bf9241ad..e4cfdf3579 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/system/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/notifications/src/api.ts b/packages/notifications/src/api.ts index 26197cc51e..cea944676e 100644 --- a/packages/notifications/src/api.ts +++ b/packages/notifications/src/api.ts @@ -27,6 +27,16 @@ export class NotificationsApi { 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 */ @@ -47,10 +57,27 @@ export class NotificationsApi { } /** - * Set all notifications as seen + * Set all non-banner notifications as seen */ async setAllNotificationsSeen(): Promise<void> { - await fetch(new URL("/api/v1/notifications/seen", `${this.rootUrl}:8080`).toString(), { + 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 ID + */ + async setNotificationSeenByID(id: number): Promise<void> { + const url = new URL(`/api/v1/notifications/${id}/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 index 53d3317b87..6edcdfe8b3 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -24,6 +24,13 @@ class 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 @@ -33,11 +40,17 @@ class Notifications { } /** - * Set all notifications as seen + * 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 setNotificationSeenByID(id:number): Promise<void> { + return await this.api.setNotificationSeenByID(id); + } /** * Get gatus and custom endpoints indexed by dnpName diff --git a/packages/params/src/params.ts b/packages/params/src/params.ts index 6c831faa65..d584f7b666 100644 --- a/packages/params/src/params.ts +++ b/packages/params/src/params.ts @@ -254,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/src/schemas/notifications.schema.json b/packages/schemas/src/schemas/notifications.schema.json index 3319595dcb..884c7c93b1 100644 --- a/packages/schemas/src/schemas/notifications.schema.json +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -9,9 +9,22 @@ "type": "array", "items": { "type": "object", - "required": ["name", "enabled", "url", "method", "conditions", "interval", "group", "alerts", "definition", "priority"], + "required": [ + "name", + "correlationId", + "enabled", + "url", + "method", + "conditions", + "interval", + "group", + "alerts", + "definition", + "priority" + ], "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]*$" }, "method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"] }, @@ -22,6 +35,7 @@ "interval": { "type": "string", "pattern": "^[0-9]+[smhd]$" }, "group": { "type": "string" }, "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, + "isBanner": { "type": "boolean" }, "alerts": { "type": "array", "minItems": 1, @@ -61,6 +75,34 @@ "max": { "type": "number" }, "unit": { "type": "string" } } + }, + "callToAction": { + "type": "object", + "required": ["title", "url"], + "properties": { + "title": { "type": "string" }, + "url": { "type": "string" } + } + }, + "requirements": { + "type": "object", + "required": ["pkgsInstalled", "pkgsNotInstalled"], + "properties": { + "pkgsInstalled": { + "type": "object", + "patternProperties": { + ".*": { + "type": "string", + "pattern": "^(\\^|~|>|>=|<|<=)?\\d+\\.\\d+\\.\\d+$" + } + }, + "additionalProperties": false + }, + "pkgsNotInstalled": { + "type": "array", + "items": { "type": "string" } + } + } } } } diff --git a/packages/schemas/test/unit/validateSchema.test.ts b/packages/schemas/test/unit/validateSchema.test.ts index 6c80a71ab0..1fd227de69 100644 --- a/packages/schemas/test/unit/validateSchema.test.ts +++ b/packages/schemas/test/unit/validateSchema.test.ts @@ -447,6 +447,7 @@ volumes: endpoints: [ { name: "example-endpoint", + correlationId: "example-correlation-id", enabled: true, url: "http://example.com", method: "POST", @@ -503,6 +504,7 @@ volumes: endpoints: [ { name: "example-endpoint", + correlationId: "example-correlation-id", enabled: true, url: "invalid-url", method: "POST", @@ -536,6 +538,7 @@ volumes: endpoints: [ { name: "example-endpoint", + correlationId: "example-correlation-id", enabled: true, url: "http://example.com", method: "POST", @@ -603,7 +606,6 @@ volumes: enabled: true, name: "custom-endpoint", description: "A custom endpoint for testing", // Added required description - priority: Priority.high, metric: { treshold: 90, min: 0, @@ -657,6 +659,7 @@ volumes: endpoints: [ { name: "example-endpoint", + correlationId: "example-correlation-id", enabled: true, url: "http://example.com", method: "POST", @@ -690,7 +693,6 @@ volumes: enabled: true, name: "custom-endpoint", description: "A custom endpoint for testing", // Added required description - priority: Priority.high, metric: { treshold: 90, min: 0, @@ -703,5 +705,84 @@ volumes: 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, + 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, + 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/types/src/calls.ts b/packages/types/src/calls.ts index 09916aac32..902e393414 100644 --- a/packages/types/src/calls.ts +++ b/packages/types/src/calls.ts @@ -75,6 +75,8 @@ export interface LoginStatusReturn { export interface WifiReport { info: string; + isDefaultPassphrase: boolean; + isRunning: boolean; report?: { lastLog: string; exitCode: number | null; diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts index 96a899f832..ea3d375daf 100644 --- a/packages/types/src/notifications.ts +++ b/packages/types/src/notifications.ts @@ -4,7 +4,8 @@ export interface NotificationsConfig { } export interface Notification extends NotificationPayload { - timestamp: string; + id: number; + timestamp: number; seen: boolean; } @@ -15,12 +16,17 @@ export interface NotificationPayload { category: Category; priority: Priority; status: Status; + isBanner: boolean; + isRemote: boolean; icon?: string; errors?: string; - callToAction?: { - title: string; - url: string; - }; + callToAction?: CallToAction; + correlationId: string; +} + +export interface CallToAction { + title: string; + url: string; } export enum Priority { @@ -50,7 +56,6 @@ export interface CustomEndpoint { name: string; enabled: boolean; description: string; - priority: Priority; metric?: { treshold: number; min: number; @@ -67,21 +72,30 @@ export interface GatusEndpoint { conditions: string[]; interval: string; // e.g., "1m" group: string; - priority: Priority; // dappnode specific alerts: Alert[]; + + // Dappnode specific + correlationId: string; + priority: Priority; + isBanner?: boolean; + callToAction?: CallToAction; + requirements?: Requirements; definition: { - // dappnode specific title: string; description: string; }; metric?: { - // dappnode specific 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; diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index e7f073307c..03d1aab195 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, @@ -237,11 +236,6 @@ export interface Routes { */ getEthicalMetricsConfig: () => Promise<EthicalMetricsConfig | null>; - /** - * Returns true if dappnode connected to internet - */ - getIsConnectedToInternet: () => Promise<boolean>; - /** * Return formated core update data */ @@ -266,7 +260,12 @@ export interface Routes { * 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 */ @@ -280,9 +279,14 @@ export interface Routes { }>; /** - * Set all notifications as seen + * Set all non-banner notifications as seen */ notificationsSetAllSeen(): Promise<void>; + + /** + * Set a notification as seen by providing its ID + */ + notificationSetSeenByID(id:number): Promise<void>; /** * Gatus update endpoint @@ -560,11 +564,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 */ @@ -725,17 +724,18 @@ export const routesData: { [P in keyof Routes]: RouteData } = { enableEthicalMetrics: { log: true }, getCoreVersion: {}, getEthicalMetricsConfig: { log: true }, - getIsConnectedToInternet: {}, disableEthicalMetrics: { log: true }, fetchCoreUpdateData: {}, fetchDirectory: {}, fetchRegistry: {}, fetchDnpRequest: {}, - notificationsGetAll: { log: true }, - notificationsGetUnseenCount: { log: true }, - notificationsGetAllEndpoints: { log: true }, - notificationsSetAllSeen: { log: true }, - notificationsUpdateEndpoints: { log: true }, + notificationsGetAll: {}, + notificationsGetBanner: {}, + notificationsGetUnseenCount: {}, + notificationsGetAllEndpoints: {}, + notificationsSetAllSeen: {}, + notificationSetSeenByID: {}, + notificationsUpdateEndpoints: {}, notificationsApplyPreviousEndpoints: {}, getUserActionLogs: {}, getHostUptime: {}, @@ -779,7 +779,6 @@ export const routesData: { [P in keyof Routes]: RouteData } = { portsUpnpStatusGet: {}, portsApiStatusGet: {}, rebootHost: { log: true }, - rebootHostIsRequiredGet: {}, releaseTrustedKeyAdd: { log: true }, releaseTrustedKeyList: {}, releaseTrustedKeyRemove: { log: true }, From 595a8d6f893b14e8de86fd40d9e27a3b725ccb8f Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Thu, 15 May 2025 17:40:03 +0200 Subject: [PATCH 54/90] remove triggered filter in banner component --- packages/admin-ui/src/components/NotificationsMain.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/admin-ui/src/components/NotificationsMain.tsx b/packages/admin-ui/src/components/NotificationsMain.tsx index c0af06e58c..5fc07cc77c 100644 --- a/packages/admin-ui/src/components/NotificationsMain.tsx +++ b/packages/admin-ui/src/components/NotificationsMain.tsx @@ -35,9 +35,8 @@ export default function NotificationsView() { * filters notifications: * 1. Filters out notifications that have errors * 2. Filters out duplicate notifications by title, keeping the most recent one - * 3. Filters out notifications that are not triggered status - * 4. Filters out seen notifications - * 5. Sorts notifications by priority + * 3. Filters out seen notifications + * 4. Sorts notifications by priority */ function filterNotifications(notifications: Notification[]): Notification[] { @@ -56,7 +55,6 @@ export default function NotificationsView() { }); return Array.from(map.values()) - .filter((n) => n.status === Status.triggered) // Filter by triggered status after deduplication .filter((n) => n.seen === false) // Filter out seen notifications .sort((a, b) => priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority)); } From 807f4802106ad834f6089705a2f654d878163a81 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Thu, 15 May 2025 17:40:27 +0200 Subject: [PATCH 55/90] fix linter --- packages/admin-ui/src/components/NotificationsMain.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/admin-ui/src/components/NotificationsMain.tsx b/packages/admin-ui/src/components/NotificationsMain.tsx index 5fc07cc77c..076875eb87 100644 --- a/packages/admin-ui/src/components/NotificationsMain.tsx +++ b/packages/admin-ui/src/components/NotificationsMain.tsx @@ -3,7 +3,7 @@ import { NavLink } from "react-router-dom"; import RenderMarkdown from "components/RenderMarkdown"; import Button, { ButtonVariant } from "components/Button"; import { api, useApi } from "api"; -import { Notification, Priority, Status } from "@dappnode/types"; +import { Notification, Priority } from "@dappnode/types"; import "./notificationsMain.scss"; import { MdClose } from "react-icons/md"; import { Accordion } from "react-bootstrap"; From 5966e44999d4e6c4942126746891380a7b7aa0d8 Mon Sep 17 00:00:00 2001 From: Pablo Mendez <pablo@dappnode.io> Date: Thu, 15 May 2025 17:42:04 +0200 Subject: [PATCH 56/90] update eth repository not copy --- packages/daemons/src/repositoryHealth/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/daemons/src/repositoryHealth/index.ts b/packages/daemons/src/repositoryHealth/index.ts index bed313e399..3cd1543b8c 100644 --- a/packages/daemons/src/repositoryHealth/index.ts +++ b/packages/daemons/src/repositoryHealth/index.ts @@ -6,7 +6,7 @@ import * as db from "@dappnode/db"; import { getEthUrl, getIpfsUrl } from "@dappnode/installer"; import { params } from "@dappnode/params"; -const CHECK_INTERVAL = 0.2 * 60 * 1000; // 10 minutes +const CHECK_INTERVAL = 10 * 60 * 1000; // 10 minutes let ipfsNotificationSent = false; let ethNotificationSent = false; @@ -129,7 +129,7 @@ Syncing and access to Ethereum chain data should now resume normally.`, priority: Priority.high, status: Status.triggered, callToAction: { - title: `Change to ${ethClientTarget && ethClientTarget === "on" ? "Full Node" : "Remote"}`, + title: (ethClientTarget && ethClientTarget === "off") ? "Change to Remote" : "Make sure your Ethereum RPC is reachable", url: "http://my.dappnode/repository/ethereum" }, isBanner: true, From 76b16ccd73f06df2d99d13b38f0f678f71c97c41 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Tue, 20 May 2025 10:01:13 +0200 Subject: [PATCH 57/90] pagination fix --- .../pages/notifications/tabs/Inbox/Inbox.tsx | 67 ++++++++++--------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx index d97f27693f..a14ce36445 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/Inbox.tsx @@ -50,13 +50,18 @@ export function Inbox() { .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]); - const totalPages = Math.ceil(seenNotifications.length / itemsPerPage); + // Reset to first page when filters change + useEffect(() => { + setCurrentPage(1); + }, [search, selectedCategory]); const handleNextPage = () => { if (currentPage < totalPages) setCurrentPage((prev) => prev + 1); @@ -119,37 +124,39 @@ export function Inbox() { {paginatedSeenNotifications.map((notification) => ( <NotificationCard key={notification.timestamp} notification={notification} /> ))} - <div className="pagination"> - {currentPage !== 1 && ( - <> - <button onClick={handleFirstPage} className="page-item"> - 1 + {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> - {currentPage > 2 && <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 - 1 > currentPage && <span className="dots">. . .</span>} - <button onClick={handleLastPage} className="page-item"> - {totalPages} + )} + <span className="active">{currentPage}</span> + {totalPages - 1 > currentPage && ( + <button onClick={handleNextPage} className="page-item"> + {currentPage + 1} </button> - </> - )} - </div> + )} + + {currentPage !== totalPages && ( + <> + {totalPages - 2 > currentPage && <span className="dots">. . .</span>} + <button onClick={handleLastPage} className="page-item"> + {totalPages} + </button> + </> + )} + </div> + )} </> )} </> From 7492116d88fa2cecb2f11e5819f3f68c7f46b664 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Tue, 20 May 2025 11:13:29 +0200 Subject: [PATCH 58/90] fix types & schemas --- packages/schemas/src/schemas/notifications.schema.json | 3 ++- packages/schemas/test/unit/validateSchema.test.ts | 9 +++++++++ packages/types/src/notifications.ts | 3 ++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/schemas/src/schemas/notifications.schema.json b/packages/schemas/src/schemas/notifications.schema.json index 884c7c93b1..0256d79a50 100644 --- a/packages/schemas/src/schemas/notifications.schema.json +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -20,7 +20,8 @@ "group", "alerts", "definition", - "priority" + "priority", + "isBanner" ], "properties": { "name": { "type": "string" }, diff --git a/packages/schemas/test/unit/validateSchema.test.ts b/packages/schemas/test/unit/validateSchema.test.ts index 1fd227de69..9616c678a5 100644 --- a/packages/schemas/test/unit/validateSchema.test.ts +++ b/packages/schemas/test/unit/validateSchema.test.ts @@ -455,6 +455,7 @@ volumes: interval: "1m", group: "example-group", priority: Priority.low, + isBanner: false, alerts: [ { type: "custom", @@ -512,6 +513,7 @@ volumes: interval: "1m", group: "example-group", priority: Priority.low, + isBanner: false, alerts: [ { type: "response-time", @@ -546,6 +548,7 @@ volumes: interval: "invalid-interval", group: "example-group", priority: Priority.low, + isBanner: false, alerts: [ { type: "response-time", @@ -578,6 +581,7 @@ volumes: conditions: ["response-time < 500ms"], interval: "1m", group: "example-group", + isBanner: false, alerts: [ { type: "response-time", @@ -606,6 +610,7 @@ volumes: enabled: true, name: "custom-endpoint", description: "A custom endpoint for testing", // Added required description + isBanner: false, metric: { treshold: 90, min: 0, @@ -667,6 +672,7 @@ volumes: interval: "1m", group: "example-group", priority: Priority.low, + isBanner: false, alerts: [ { type: "custom", @@ -693,6 +699,7 @@ volumes: enabled: true, name: "custom-endpoint", description: "A custom endpoint for testing", // Added required description + isBanner: false, metric: { treshold: 90, min: 0, @@ -719,6 +726,7 @@ volumes: interval: "1m", group: "example-group", priority: Priority.low, + isBanner: false, alerts: [ { type: "custom", @@ -759,6 +767,7 @@ volumes: interval: "1m", group: "example-group", priority: Priority.low, + isBanner: false, alerts: [ { type: "custom", diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts index ea3d375daf..27e0b99bcb 100644 --- a/packages/types/src/notifications.ts +++ b/packages/types/src/notifications.ts @@ -56,6 +56,7 @@ export interface CustomEndpoint { name: string; enabled: boolean; description: string; + isBanner: boolean; metric?: { treshold: number; min: number; @@ -77,7 +78,7 @@ export interface GatusEndpoint { // Dappnode specific correlationId: string; priority: Priority; - isBanner?: boolean; + isBanner: boolean; callToAction?: CallToAction; requirements?: Requirements; definition: { From 98c631867cafe29c6700547e47fc5be5e2ad1738 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Tue, 20 May 2025 11:15:08 +0200 Subject: [PATCH 59/90] isBanner in custom shcemas --- packages/schemas/src/schemas/notifications.schema.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/schemas/src/schemas/notifications.schema.json b/packages/schemas/src/schemas/notifications.schema.json index 0256d79a50..98958b194c 100644 --- a/packages/schemas/src/schemas/notifications.schema.json +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -112,11 +112,12 @@ "type": "array", "items": { "type": "object", - "required": ["enabled", "name", "description"], + "required": ["enabled", "name", "description", "isBanner"], "properties": { "enabled": { "type": "boolean" }, "name": { "type": "string" }, "description": { "type": "string" }, + "isBanner": { "type": "boolean" }, "metric": { "type": "object", "properties": { From 2e5be3876f821a5509406e083655838903d230df Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Tue, 20 May 2025 13:13:03 +0200 Subject: [PATCH 60/90] update avatar --- avatar-dappmanager.png | Bin 46861 -> 39796 bytes .../tabs/Inbox/NotificationsCard.tsx | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/avatar-dappmanager.png b/avatar-dappmanager.png index 16d2283db75c1abca817bc851566db2bd3b54495..f136d285674e32b9cc9f2cf0803acafb334617bc 100644 GIT binary patch literal 39796 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 literal 46861 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 diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx index ec2d919fcd..65bf73e63f 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx @@ -3,7 +3,7 @@ import { Accordion } from "react-bootstrap"; import { Notification } from "@dappnode/types"; import { IoIosArrowDown, IoIosArrowUp } from "react-icons/io"; import { prettyDnpName } from "utils/format"; -import defaultAvatar from "img/defaultAvatar.png"; +import dappnodeLogo from "img/dappnode-logo-only.png"; import { Priority } from "@dappnode/types"; import RenderMarkdown from "components/RenderMarkdown"; import Button from "components/Button"; @@ -31,7 +31,7 @@ const prettifiedBody = (body: string) => { export function NotificationCard({ notification, openByDefault = false }: NotificationCardProps) { const notificationAvatar = () => { if (notification.icon) return notification.icon; - else return defaultAvatar; + else return dappnodeLogo; }; const [isOpen, setIsOpen] = useState(openByDefault); From d2df7d6aecb15217f8dafd336a899a11d5983837 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Wed, 21 May 2025 10:34:14 +0200 Subject: [PATCH 61/90] Update set seen by correlationId endpoint (#2167) --- packages/admin-ui/src/__mock-backend__/index.ts | 2 +- packages/admin-ui/src/components/NotificationsMain.tsx | 2 +- .../pages/notifications/tabs/Inbox/NotificationsCard.tsx | 2 +- packages/dappmanager/src/calls/index.ts | 2 +- packages/dappmanager/src/calls/notifications.ts | 6 +++--- packages/notifications/src/api.ts | 6 +++--- packages/notifications/src/index.ts | 4 ++-- packages/types/src/routes.ts | 6 +++--- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/admin-ui/src/__mock-backend__/index.ts b/packages/admin-ui/src/__mock-backend__/index.ts index 17461e2b64..a17d282511 100644 --- a/packages/admin-ui/src/__mock-backend__/index.ts +++ b/packages/admin-ui/src/__mock-backend__/index.ts @@ -387,7 +387,7 @@ export const otherCalls: Omit<Routes, keyof typeof namedSpacedCalls> = { }, notificationsGetUnseenCount: async () => 2, notificationsSetAllSeen: async () => {}, - notificationSetSeenByID: async () => {}, + notificationSetSeenByCorrelationID: async () => {}, notificationsUpdateEndpoints: async () => {}, notificationsGetAll: async () => [], notificationsGetBanner: async () => [], diff --git a/packages/admin-ui/src/components/NotificationsMain.tsx b/packages/admin-ui/src/components/NotificationsMain.tsx index 076875eb87..732a0fd756 100644 --- a/packages/admin-ui/src/components/NotificationsMain.tsx +++ b/packages/admin-ui/src/components/NotificationsMain.tsx @@ -93,7 +93,7 @@ export function CollapsableBannerNotification({ const [hasClosed, setHasClosed] = useState(false); const handleClose = () => { - api.notificationSetSeenByID(notification.id); + api.notificationSetSeenByCorrelationID(notification.correlationId); setHasClosed(true); onClose(); }; diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx index 65bf73e63f..5e19f8da72 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx @@ -37,7 +37,7 @@ export function NotificationCard({ notification, openByDefault = false }: Notifi useEffect(() => { if (!notification.seen && notification.isBanner && notification.status === "resolved") { - api.notificationSetSeenByID(notification.id); + api.notificationSetSeenByCorrelationID(notification.correlationId); } }, []); diff --git a/packages/dappmanager/src/calls/index.ts b/packages/dappmanager/src/calls/index.ts index 1ef8cbc34b..fa227ad763 100644 --- a/packages/dappmanager/src/calls/index.ts +++ b/packages/dappmanager/src/calls/index.ts @@ -29,7 +29,7 @@ export { notificationsApplyPreviousEndpoints, notificationsGetUnseenCount, notificationsSetAllSeen, - notificationSetSeenByID + notificationSetSeenByCorrelationID } from "./notifications.js"; export * from "./httpsPortal.js"; export { ipfsTest } from "./ipfsTest.js"; diff --git a/packages/dappmanager/src/calls/notifications.ts b/packages/dappmanager/src/calls/notifications.ts index c47577d8bf..cd5d21abd3 100644 --- a/packages/dappmanager/src/calls/notifications.ts +++ b/packages/dappmanager/src/calls/notifications.ts @@ -32,10 +32,10 @@ export async function notificationsSetAllSeen(): Promise<void> { } /** - * Set a notification as seen by providing its ID + * Set a notification as seen by providing its correlationId */ -export async function notificationSetSeenByID(id:number): Promise<void> { - return await notifications.setNotificationSeenByID(id); +export async function notificationSetSeenByCorrelationID(correlationId:string): Promise<void> { + return await notifications.setNotificationSeenByCorrelationID(correlationId); } /** diff --git a/packages/notifications/src/api.ts b/packages/notifications/src/api.ts index cea944676e..560ec294ba 100644 --- a/packages/notifications/src/api.ts +++ b/packages/notifications/src/api.ts @@ -72,10 +72,10 @@ export class NotificationsApi { } /** - * Set a notification as seen by providing its ID + * Set a notification as seen by providing its correlationId */ - async setNotificationSeenByID(id: number): Promise<void> { - const url = new URL(`/api/v1/notifications/${id}/seen`, `${this.rootUrl}:8080`); + async setNotificationSeenByCorrelationID(correlationId: string): Promise<void> { + const url = new URL(`/api/v1/notifications/${correlationId}/seen`, `${this.rootUrl}:8080`); await fetch(url.toString(), { method: "PUT", diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index 6edcdfe8b3..01eeb66076 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -48,8 +48,8 @@ class Notifications { /** * Set a notification as seen by providing its ID */ - async setNotificationSeenByID(id:number): Promise<void> { - return await this.api.setNotificationSeenByID(id); + async setNotificationSeenByCorrelationID(correlationId:string): Promise<void> { + return await this.api.setNotificationSeenByCorrelationID(correlationId); } /** diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index 03d1aab195..548e1a8664 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -284,9 +284,9 @@ export interface Routes { notificationsSetAllSeen(): Promise<void>; /** - * Set a notification as seen by providing its ID + * Set a notification as seen by providing its correlationId */ - notificationSetSeenByID(id:number): Promise<void>; + notificationSetSeenByCorrelationID(correlationId:string): Promise<void>; /** * Gatus update endpoint @@ -734,7 +734,7 @@ export const routesData: { [P in keyof Routes]: RouteData } = { notificationsGetUnseenCount: {}, notificationsGetAllEndpoints: {}, notificationsSetAllSeen: {}, - notificationSetSeenByID: {}, + notificationSetSeenByCorrelationID: {}, notificationsUpdateEndpoints: {}, notificationsApplyPreviousEndpoints: {}, getUserActionLogs: {}, From b02ecda9118231f65ab51c9ee0d29d96deb65953 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Wed, 21 May 2025 12:40:38 +0200 Subject: [PATCH 62/90] start-stop notifications services fix --- .../notifications/tabs/Settings/Settings.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx index 649cce81ac..59f94883ca 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/Settings.tsx @@ -19,7 +19,7 @@ interface EndpointsData { } export function NotificationsSettings() { - const [notificationsDisabled, setNotificationsDisabled] = useState<boolean>(false); + const [notRunningServices, setNotRunningServices] = useState<string[]>([]); const [endpointsData, setEndpointsData] = useState<EndpointsData | undefined>(); const endpointsCall = useApi.notificationsGetAllEndpoints(); const notificationsDnp = useApi.packageGet({ dnpName: notificationsDnpName }); @@ -33,11 +33,15 @@ export function NotificationsSettings() { useEffect(() => { if (notificationsDnp.data) { - const isStopped = notificationsDnp.data.containers.some((c) => c.state !== "running"); - setNotificationsDisabled(isStopped); + 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) { @@ -53,7 +57,13 @@ export function NotificationsSettings() { await withToast( continueIfCalleDisconnected( - () => api.packageStartStop({ dnpName: notificationsDnpName }), + () => + api.packageStartStop({ + dnpName: notificationsDnpName, + serviceNames: notificationsDisabled + ? notRunningServices + : notificationsDnp.data!.containers.map((c) => c.serviceName) + }), notificationsDnpName ), { From 863564170b90f6b92cd133030b8b8418cb4afd58 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Wed, 21 May 2025 12:47:54 +0200 Subject: [PATCH 63/90] scrollable categories mobile view --- .../admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss b/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss index 50f67d3f83..9f1103cc6b 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss @@ -3,6 +3,10 @@ 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; @@ -11,6 +15,7 @@ font-size: 1rem; color: var(--light-text-color); border: #ced4da 1px solid; + flex-shrink: 0; } .selected { @@ -239,7 +244,7 @@ background-color: #ced4da; } } - .dots{ + .dots { padding-bottom: 8px; } From cbea9ee0c77621501279f50b673264d85b49e35d Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Wed, 21 May 2025 13:20:13 +0200 Subject: [PATCH 64/90] update CTA urls --- packages/daemons/src/repositoryHealth/index.ts | 2 +- packages/dappmanager/src/calls/wifi.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/daemons/src/repositoryHealth/index.ts b/packages/daemons/src/repositoryHealth/index.ts index 3cd1543b8c..91639d2bc3 100644 --- a/packages/daemons/src/repositoryHealth/index.ts +++ b/packages/daemons/src/repositoryHealth/index.ts @@ -130,7 +130,7 @@ Syncing and access to Ethereum chain data should now resume normally.`, status: Status.triggered, callToAction: { title: (ethClientTarget && ethClientTarget === "off") ? "Change to Remote" : "Make sure your Ethereum RPC is reachable", - url: "http://my.dappnode/repository/ethereum" + url: "http://my.dappnode/repository/eth" }, isBanner: true, isRemote: false, diff --git a/packages/dappmanager/src/calls/wifi.ts b/packages/dappmanager/src/calls/wifi.ts index e4cfdf3579..a0d88250aa 100644 --- a/packages/dappmanager/src/calls/wifi.ts +++ b/packages/dappmanager/src/calls/wifi.ts @@ -66,7 +66,7 @@ export async function wifiReportGet(): Promise<WifiReport> { status: Status.triggered, callToAction: { title: "Change", - url: "http://my.dappnode/system/wifi" + url: "http://my.dappnode/wireless-network/wifi" }, isBanner: true, isRemote: false, From 0f4dae410141f8244b5c68180e719317d19825d0 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Wed, 21 May 2025 15:35:43 +0200 Subject: [PATCH 65/90] resolved banner notifications fix --- packages/admin-ui/src/components/NotificationsMain.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/admin-ui/src/components/NotificationsMain.tsx b/packages/admin-ui/src/components/NotificationsMain.tsx index 732a0fd756..d9b60bf29c 100644 --- a/packages/admin-ui/src/components/NotificationsMain.tsx +++ b/packages/admin-ui/src/components/NotificationsMain.tsx @@ -35,8 +35,9 @@ export default function NotificationsView() { * filters notifications: * 1. Filters out notifications that have errors * 2. Filters out duplicate notifications by title, keeping the most recent one - * 3. Filters out seen notifications - * 4. Sorts notifications by priority + * 3. Filters out resolved notifications + * 4. Filters out seen notifications + * 5. Sorts notifications by priority */ function filterNotifications(notifications: Notification[]): Notification[] { @@ -55,6 +56,7 @@ export default function NotificationsView() { }); 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)); } From 14e2dd2aac49db60da7c391dfad6fa7f2dc78d99 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Date: Wed, 21 May 2025 18:21:33 +0200 Subject: [PATCH 66/90] validate notifications and setupwizard schemas too (#2168) Co-authored-by: Pablo Mendez <pablo@dappnode.io> --- packages/installer/src/dappnodeInstaller.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/installer/src/dappnodeInstaller.ts b/packages/installer/src/dappnodeInstaller.ts index 457dfe74a9..1b72d3835d 100644 --- a/packages/installer/src/dappnodeInstaller.ts +++ b/packages/installer/src/dappnodeInstaller.ts @@ -15,7 +15,7 @@ import { 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 { fileToGatewayUrl, getIsCore } from "@dappnode/utils"; @@ -64,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({ @@ -97,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 @@ -181,11 +181,13 @@ export class DappnodeInstaller extends DappnodeRepository { } /** - * 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); } /** From a080882039d55a0a89c8524bfc627869ee7a45e7 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Wed, 21 May 2025 18:23:40 +0200 Subject: [PATCH 67/90] condition operator fix --- .../src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx index 2af16b72c4..156a8a67d7 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/GatusEndpointItem.tsx @@ -12,7 +12,7 @@ interface GatusEndpointItemProps { export function GatusEndpointItem({ endpoint, index, numEndpoints, setGatusEndpoints }: GatusEndpointItemProps) { const endpointEnabled = endpoint.enabled; - const operators = ["<", ">", "==", "!=", ">=", "<="]; + 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]; From d5b542299f93ee63d9a0cb5266cd511324f3adff Mon Sep 17 00:00:00 2001 From: Pablo Mendez <pablo@dappnode.io> Date: Thu, 22 May 2025 08:35:27 +0200 Subject: [PATCH 68/90] be more flexible in url notifications schema pattern --- packages/schemas/src/schemas/notifications.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/schemas/src/schemas/notifications.schema.json b/packages/schemas/src/schemas/notifications.schema.json index 98958b194c..d5aec1c0c0 100644 --- a/packages/schemas/src/schemas/notifications.schema.json +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -27,7 +27,7 @@ "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]*$" }, + "url": { "type": "string", "pattern": "^(https?|ftp)://\\S+$" }, "method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"] }, "conditions": { "type": "array", From 7469db94cc22e562da106463a95b6dd9922a6e1e Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Thu, 22 May 2025 11:17:32 +0200 Subject: [PATCH 69/90] Filter banner by correlationId --- packages/admin-ui/src/components/NotificationsMain.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/admin-ui/src/components/NotificationsMain.tsx b/packages/admin-ui/src/components/NotificationsMain.tsx index d9b60bf29c..1d1063f9b2 100644 --- a/packages/admin-ui/src/components/NotificationsMain.tsx +++ b/packages/admin-ui/src/components/NotificationsMain.tsx @@ -34,7 +34,7 @@ export default function NotificationsView() { /** * filters notifications: * 1. Filters out notifications that have errors - * 2. Filters out duplicate notifications by title, keeping the most recent one + * 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 @@ -48,10 +48,10 @@ export default function NotificationsView() { notifications .filter((n) => !n.errors) // Filter out notifications with errors .forEach((notification) => { - const existing = map.get(notification.title); + const existing = map.get(notification.correlationId); if (!existing || new Date(notification.timestamp) > new Date(existing.timestamp)) { - map.set(notification.title, notification); + map.set(notification.correlationId, notification); } }); From dcb758bdd23205e8e19356048838fd494f146596 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Thu, 22 May 2025 13:56:27 +0200 Subject: [PATCH 70/90] external cta urls --- packages/admin-ui/src/components/NotificationsMain.tsx | 6 +++++- .../pages/notifications/tabs/Inbox/NotificationsCard.tsx | 6 +++++- packages/admin-ui/src/params.ts | 7 +++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/admin-ui/src/components/NotificationsMain.tsx b/packages/admin-ui/src/components/NotificationsMain.tsx index 1d1063f9b2..11a7491d15 100644 --- a/packages/admin-ui/src/components/NotificationsMain.tsx +++ b/packages/admin-ui/src/components/NotificationsMain.tsx @@ -7,6 +7,7 @@ import { Notification, Priority } from "@dappnode/types"; import "./notificationsMain.scss"; import { MdClose } from "react-icons/md"; import { Accordion } from "react-bootstrap"; +import { dappmanagerAliases, externalUrlProps } from "params"; /** * Displays banner notifications among all tabs @@ -100,6 +101,9 @@ export function CollapsableBannerNotification({ onClose(); }; + const isExternalUrl = + notification.callToAction && !dappmanagerAliases.some((alias) => notification.callToAction!.url.includes(alias)); + return ( !hasClosed && ( <Accordion defaultActiveKey={isOpen ? "0" : "1"}> @@ -119,7 +123,7 @@ export function CollapsableBannerNotification({ <div className="banner-body"> <RenderMarkdown source={notification.body} /> {notification.callToAction && ( - <NavLink to={notification.callToAction.url}> + <NavLink to={notification.callToAction.url} {...(isExternalUrl ? externalUrlProps : {})}> <Button variant={priorityBtnVariants[notification.priority]}> {notification.callToAction.title} </Button> diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx index 5e19f8da72..074d5d6f53 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx @@ -9,6 +9,7 @@ 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; @@ -41,6 +42,9 @@ export function NotificationCard({ notification, openByDefault = false }: Notifi } }, []); + 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"> @@ -79,7 +83,7 @@ export function NotificationCard({ notification, openByDefault = false }: Notifi <div className="notification-body"> <RenderMarkdown source={prettifiedBody(notification.body)} /> {notification.callToAction && ( - <NavLink to={notification.callToAction.url}> + <NavLink to={notification.callToAction.url} {...isExternalUrl ? externalUrlProps : {}}> <Button variant="dappnode">{notification.callToAction.title}</Button>{" "} </NavLink> )} diff --git a/packages/admin-ui/src/params.ts b/packages/admin-ui/src/params.ts index 4bf71dbe1a..d483e1b7d8 100755 --- a/packages/admin-ui/src/params.ts +++ b/packages/admin-ui/src/params.ts @@ -106,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 = From 9f8afc474d9fe61bf914dcbee5e2dd6c13a63472 Mon Sep 17 00:00:00 2001 From: Pablo Mendez <pablo@dappnode.io> Date: Thu, 22 May 2025 16:51:38 +0200 Subject: [PATCH 71/90] more flexibility on regex url notifications --- packages/schemas/src/schemas/notifications.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/schemas/src/schemas/notifications.schema.json b/packages/schemas/src/schemas/notifications.schema.json index d5aec1c0c0..f4e675217e 100644 --- a/packages/schemas/src/schemas/notifications.schema.json +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -27,7 +27,7 @@ "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+$" }, + "url": { "type": "string", "pattern": "^(https?|ftp):\\/\\/\\S+\\/api\\/v1\\/query\\?query=.+$" }, "method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"] }, "conditions": { "type": "array", From b70424bf991c473283197c6149520ffbede13418 Mon Sep 17 00:00:00 2001 From: Pablo Mendez <pablo@dappnode.io> Date: Fri, 23 May 2025 13:15:03 +0200 Subject: [PATCH 72/90] fix lint --- packages/dappmanager/src/calls/notifications.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dappmanager/src/calls/notifications.ts b/packages/dappmanager/src/calls/notifications.ts index cd5d21abd3..b3de6e45cd 100644 --- a/packages/dappmanager/src/calls/notifications.ts +++ b/packages/dappmanager/src/calls/notifications.ts @@ -13,7 +13,7 @@ export async function notificationsGetAll(): Promise<Notification[]> { * Get all the notifications * @returns all the notifications */ -export async function notificationsGetBanner(timestamp:number): Promise<Notification[]> { +export async function notificationsGetBanner(timestamp: number): Promise<Notification[]> { return await notifications.getBannerNotifications(timestamp); } @@ -34,7 +34,7 @@ export async function notificationsSetAllSeen(): Promise<void> { /** * Set a notification as seen by providing its correlationId */ -export async function notificationSetSeenByCorrelationID(correlationId:string): Promise<void> { +export async function notificationSetSeenByCorrelationID(correlationId: string): Promise<void> { return await notifications.setNotificationSeenByCorrelationID(correlationId); } From 8def1495f0f268e6c045b77ecd54f11dbc6bb54d Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Fri, 23 May 2025 17:54:38 +0200 Subject: [PATCH 73/90] schemas url fix --- packages/schemas/src/schemas/notifications.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/schemas/src/schemas/notifications.schema.json b/packages/schemas/src/schemas/notifications.schema.json index f4e675217e..b2e5ac8332 100644 --- a/packages/schemas/src/schemas/notifications.schema.json +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -27,7 +27,7 @@ "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+\\/api\\/v1\\/query\\?query=.+$" }, + "url": { "type": "string", "pattern": "^(https?|ftp):\\/\\/\\S+[\\s\\S]*$" }, "method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"] }, "conditions": { "type": "array", From f66ee89db1afc44b554a50fbd5b381abd5750a4e Mon Sep 17 00:00:00 2001 From: pablomendezroyo <mendez4a@gmail.com> Date: Sun, 25 May 2025 21:34:13 +0200 Subject: [PATCH 74/90] add failure treeshold of 3 --- .../daemons/src/repositoryHealth/index.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/daemons/src/repositoryHealth/index.ts b/packages/daemons/src/repositoryHealth/index.ts index 91639d2bc3..ebf45a64b5 100644 --- a/packages/daemons/src/repositoryHealth/index.ts +++ b/packages/daemons/src/repositoryHealth/index.ts @@ -8,7 +8,9 @@ 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> { @@ -32,6 +34,8 @@ async function checkIpfsHealth(): Promise<void> { 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", @@ -50,7 +54,9 @@ async function checkIpfsHealth(): Promise<void> { clearTimeout(timeout); logs.error(`IPFS endpoint (${ipfsClientTarget}) at ${ipfsUrl} is unhealthy: ${error}`); - if (!ipfsNotificationSent) { + // 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, @@ -101,6 +107,8 @@ async function checkEthHealth(): Promise<void> { 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", @@ -120,7 +128,9 @@ Syncing and access to Ethereum chain data should now resume normally.`, clearTimeout(timeout); logs.error(`Ethereum endpoint (${ethClientTarget}) at ${ethUrl} is unhealthy: ${error}`); - if (!ethNotificationSent) { + // increment failure count and send notification after threshold + ethFailureCount += 1; + if (ethFailureCount >= 3 && !ethNotificationSent) { await notifications.sendNotification({ title: "Ethereum Repository Unreachable", dnpName: params.dappmanagerDnpName, @@ -129,7 +139,10 @@ Syncing and access to Ethereum chain data should now resume normally.`, priority: Priority.high, status: Status.triggered, callToAction: { - title: (ethClientTarget && ethClientTarget === "off") ? "Change to Remote" : "Make sure your Ethereum RPC is reachable", + title: + ethClientTarget && ethClientTarget === "off" + ? "Change to Remote" + : "Make sure your Ethereum RPC is reachable", url: "http://my.dappnode/repository/eth" }, isBanner: true, From f5413a397443a3bc5375130787ad6e91d0f16947 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <41727368+pablomendezroyo@users.noreply.github.com> Date: Mon, 26 May 2025 10:13:00 +0200 Subject: [PATCH 75/90] Implement notifications settings allDnps (#2170) * Implement notifications settings allDnps * merge user settings in dnp request * apply user notifications settings on install * props fix * fix test --------- Co-authored-by: Pablo Mendez <pablo@dappnode.io> Co-authored-by: mateumiralles <mateumiralles714@gmail.com> --- .../installer/components/InstallDnpView.tsx | 33 +++++++----- .../dappmanager/src/calls/fetchDnpRequest.ts | 9 +++- .../dappmanager/src/calls/packageInstall.ts | 2 + .../installer/src/calls/packageInstall.ts | 9 +++- .../src/installer/getInstallerPackageData.ts | 15 ++++-- packages/notifications/src/manifest.ts | 4 +- .../test/unit/notifications.test.ts | 54 ++++++++++++++++++- packages/types/src/calls.ts | 2 + packages/types/src/notifications.ts | 4 ++ packages/types/src/routes.ts | 15 ++++-- 10 files changed, 122 insertions(+), 25 deletions(-) diff --git a/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx b/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx index 0461722f72..b9f1fc06e2 100644 --- a/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx +++ b/packages/admin-ui/src/pages/installer/components/InstallDnpView.tsx @@ -55,7 +55,17 @@ const InstallDnpView: React.FC<InstallDnpViewProps> = ({ 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; @@ -74,19 +84,12 @@ const InstallDnpView: React.FC<InstallDnpViewProps> = ({ dnp, progressLogs }) => manifest.notifications?.customEndpoints || [] ); - const mergedNotificationsConfig = useApi.notificationsApplyPreviousEndpoints({ - dnpName: dnpName, - isCore, - newNotificationsConfig: manifest.notifications || {} - }); - useEffect(() => { - if (mergedNotificationsConfig.data) { - const { endpoints: newEndpoints, customEndpoints: newCustomEndpoints } = mergedNotificationsConfig.data; - setEndpoints(newEndpoints || []); - setCustomEndpoints(newCustomEndpoints || []); + if (notificationsSettings && notificationsSettings[dnpName]) { + setEndpoints(notificationsSettings[dnpName].endpoints || []); + setCustomEndpoints(notificationsSettings[dnpName].customEndpoints || []); } - }, [mergedNotificationsConfig.data]); + }, [notificationsSettings]); useEffect(() => { setUserSettings(settings || {}); @@ -121,6 +124,12 @@ const InstallDnpView: React.FC<InstallDnpViewProps> = ({ 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 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/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/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/installer/getInstallerPackageData.ts b/packages/installer/src/installer/getInstallerPackageData.ts index 139a111772..48ca09cb0b 100644 --- a/packages/installer/src/installer/getInstallerPackageData.ts +++ b/packages/installer/src/installer/getInstallerPackageData.ts @@ -8,16 +8,18 @@ import { UserSettings, PackageRelease, InstallPackageData, - ContainersStatus + ContainersStatus, + NotificationsConfig, + NotificationsSettingsAllDnps } from "@dappnode/types"; import { getBackupPath, getDockerComposePath, getImagePath, getManifestPath } from "@dappnode/utils"; import { gt } from "semver"; import { logs } from "@dappnode/logger"; -import { notifications } from "@dappnode/notifications"; interface GetInstallerPackageDataArg { releases: PackageRelease[]; userSettings: UserSettingsAllDnps; + notificationsSettings: NotificationsSettingsAllDnps; currentVersions: { [dnpName: string]: string | undefined }; reqName: string; } @@ -25,6 +27,7 @@ interface GetInstallerPackageDataArg { export async function getInstallerPackagesData({ releases, userSettings, + notificationsSettings, currentVersions, reqName }: GetInstallerPackageDataArg): Promise<InstallPackageData[]> { @@ -38,6 +41,7 @@ export async function getInstallerPackagesData({ getInstallerPackageData( release, userSettings[release.dnpName], + notificationsSettings?.[release.dnpName], currentVersions[release.dnpName], await getContainersStatus({ dnpName: release.dnpName, @@ -59,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); @@ -101,9 +106,9 @@ function getInstallerPackageData( ? { ...release.manifest, // Apply notitications user settings if any - notifications: notifications.applyPreviousEndpoints(dnpName, isCore, release.manifest.notifications) + notifications: notificationsSettings } - : release.manifest, + : manifest, // User settings to be applied by the installer fileUploads: userSettings?.fileUploads, dockerTimeout, diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts index 6bffa74486..3afc82113e 100644 --- a/packages/notifications/src/manifest.ts +++ b/packages/notifications/src/manifest.ts @@ -50,6 +50,8 @@ export class NotificationsManifest { * - CustomEndpoint: check the metric.treshold and keep the old one. * * Do not keep old endpoints that are not present in the new ones. + * + * @returns New NotificationsConfig with merged endpoints. */ applyPreviousEndpoints( dnpName: string, @@ -64,7 +66,7 @@ export class NotificationsManifest { const { endpoints: newEndpoints, customEndpoints: newCustomEndpoints } = newNotificationsConfig; const mergedEndpoints = newEndpoints?.map((newEndpoint) => { - const oldEndpoint = oldEndpoints?.find((e) => e.name === newEndpoint.name); + const oldEndpoint = oldEndpoints?.find((e) => e.correlationId === newEndpoint.correlationId); // If no previous version exists, simply use the new endpoint. if (!oldEndpoint) return newEndpoint; diff --git a/packages/notifications/test/unit/notifications.test.ts b/packages/notifications/test/unit/notifications.test.ts index 224f8b95af..1fdc8e4fb9 100644 --- a/packages/notifications/test/unit/notifications.test.ts +++ b/packages/notifications/test/unit/notifications.test.ts @@ -2,7 +2,7 @@ import "mocha"; import { expect } from "chai"; import { NotificationsManifest } from "../../src/manifest.js"; -import { NotificationsConfig, Alert } from "@dappnode/types"; +import { NotificationsConfig, Alert, Priority } from "@dappnode/types"; // Dummy objects to satisfy required properties. const dummyAlert: Alert = { @@ -38,6 +38,9 @@ describe("applyPreviousEndpoints", () => { { name: "Test Endpoint", enabled: true, + isBanner: false, + correlationId: "Test Endpoint", + priority: Priority.medium, url: "http://example.com", method: "GET", conditions: ["[BODY].value < 80"], @@ -52,6 +55,7 @@ describe("applyPreviousEndpoints", () => { { name: "Test Custom", enabled: true, + isBanner: false, description: "Test custom endpoint", metric: { treshold: 50, min: 0, max: 100, unit: "%" } } @@ -68,6 +72,9 @@ describe("applyPreviousEndpoints", () => { { name: "Test Endpoint", enabled: true, + isBanner: false, + correlationId: "Test Endpoint", + priority: Priority.medium, url: "http://example.com", method: "GET", conditions: ["[BODY].value < 80"], @@ -82,6 +89,7 @@ describe("applyPreviousEndpoints", () => { { name: "Test Custom", enabled: true, + isBanner: false, description: "Test custom endpoint", metric: { treshold: 50, min: 0, max: 100, unit: "%" } } @@ -98,6 +106,9 @@ describe("applyPreviousEndpoints", () => { { 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"], @@ -116,6 +127,9 @@ describe("applyPreviousEndpoints", () => { { 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"], @@ -145,6 +159,7 @@ describe("applyPreviousEndpoints", () => { { name: "Custom Check", enabled: true, + isBanner: false, description: "Custom check description", metric: { treshold: 50, min: 0, max: 100, unit: "%" } } @@ -157,6 +172,7 @@ describe("applyPreviousEndpoints", () => { { name: "Custom Check", enabled: false, + isBanner: false, description: "Custom check description", metric: { treshold: 25, min: 0, max: 100, unit: "%" } } @@ -176,6 +192,9 @@ describe("applyPreviousEndpoints", () => { { name: "New Endpoint", enabled: true, + isBanner: false, + correlationId: "New Endpoint", + priority: Priority.medium, url: "http://new.example.com", method: "GET", conditions: ["[BODY].value < 50"], @@ -194,6 +213,9 @@ describe("applyPreviousEndpoints", () => { { name: "Old Endpoint", enabled: false, + isBanner: false, + correlationId: "Old Endpoint", + priority: Priority.medium, url: "http://old.example.com", method: "GET", conditions: ["[BODY].value < 30"], @@ -218,6 +240,9 @@ describe("applyPreviousEndpoints", () => { { 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"], @@ -236,6 +261,9 @@ describe("applyPreviousEndpoints", () => { { 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"], @@ -260,6 +288,9 @@ describe("applyPreviousEndpoints", () => { { 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"], @@ -278,6 +309,9 @@ describe("applyPreviousEndpoints", () => { { name: "Multiple Conditions Endpoint", enabled: false, + isBanner: false, + correlationId: "Multiple Conditions Endpoint", + priority: Priority.medium, url: "http://multiple.example.com", method: "GET", conditions: [ @@ -304,6 +338,9 @@ describe("applyPreviousEndpoints", () => { { 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].cpu < 80"], @@ -316,6 +353,9 @@ describe("applyPreviousEndpoints", () => { { name: "Host out of memory check", enabled: true, + isBanner: false, + correlationId: "Host out of memory check", + priority: Priority.medium, url: "http://memory.example.com", method: "GET", conditions: ["[BODY].memory > 10"], @@ -330,12 +370,14 @@ describe("applyPreviousEndpoints", () => { { name: "Custom Check A", enabled: true, + isBanner: false, description: "Custom Check A description", metric: { treshold: 50, min: 0, max: 100, unit: "%" } }, { name: "Custom Check B", enabled: true, + isBanner: false, description: "Custom Check B description", metric: { treshold: 60, min: 0, max: 100, unit: "%" } } @@ -347,6 +389,9 @@ describe("applyPreviousEndpoints", () => { { 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].cpu < 70"], @@ -359,6 +404,9 @@ describe("applyPreviousEndpoints", () => { { name: "Host out of memory check", enabled: false, + isBanner: false, + correlationId: "Host out of memory check", + priority: Priority.medium, url: "http://memory.example.com", method: "GET", conditions: ["[BODY].memory > 20"], @@ -371,6 +419,9 @@ describe("applyPreviousEndpoints", () => { { name: "Obsolete Endpoint", enabled: false, + isBanner: false, + correlationId: "Obsolete Endpoint", + priority: Priority.medium, url: "http://obsolete.example.com", method: "GET", conditions: ["[BODY].obsolete < 10"], @@ -385,6 +436,7 @@ describe("applyPreviousEndpoints", () => { { name: "Custom Check A", enabled: false, + isBanner: false, description: "Custom Check A description", metric: { treshold: 40, min: 0, max: 100, unit: "%" } } diff --git a/packages/types/src/calls.ts b/packages/types/src/calls.ts index 902e393414..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 @@ -278,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; diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts index 27e0b99bcb..981b6d5c3c 100644 --- a/packages/types/src/notifications.ts +++ b/packages/types/src/notifications.ts @@ -3,6 +3,10 @@ export interface NotificationsConfig { customEndpoints?: CustomEndpoint[]; } +export interface NotificationsSettingsAllDnps { + [dnpName: string]: NotificationsConfig; +} + export interface Notification extends NotificationPayload { id: number; timestamp: number; diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index 548e1a8664..11fbdc608a 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -44,7 +44,13 @@ import { } from "./calls.js"; import { PackageEnvs } from "./compose.js"; import { PackageBackup } from "./manifest.js"; -import { CustomEndpoint, GatusEndpoint, Notification, NotificationsConfig } from "./notifications.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"; @@ -260,7 +266,7 @@ export interface Routes { * Get all the notifications */ notificationsGetAll(): Promise<Notification[]>; - + /** * Get banner notifications that should be displayed within the given timestamp range */ @@ -282,11 +288,11 @@ export interface Routes { * Set all non-banner notifications as seen */ notificationsSetAllSeen(): Promise<void>; - + /** * Set a notification as seen by providing its correlationId */ - notificationSetSeenByCorrelationID(correlationId:string): Promise<void>; + notificationSetSeenByCorrelationID(correlationId: string): Promise<void>; /** * Gatus update endpoint @@ -434,6 +440,7 @@ export interface Routes { name: string; version?: string; userSettings?: UserSettingsAllDnps; + notificationsSettings?: NotificationsSettingsAllDnps; options?: { /** * Forwarded option to dappGet From 3cacca4ee8436cd5f87607398bc0947887d869e7 Mon Sep 17 00:00:00 2001 From: Pablo Mendez <pablo@dappnode.io> Date: Mon, 26 May 2025 10:31:09 +0200 Subject: [PATCH 76/90] add correlationid prop --- notifications.yaml | 2 ++ packages/schemas/src/schemas/notifications.schema.json | 3 ++- packages/types/src/notifications.ts | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/notifications.yaml b/notifications.yaml index ee500858a5..af077c7d0f 100644 --- a/notifications.yaml +++ b/notifications.yaml @@ -1,4 +1,6 @@ customEndpoints: - name: "Package updates notifications" + isBanner: false + correlationId: "dappmanager-packages-updates" description: "This endpoint notifies users about available package updates." enabled: true diff --git a/packages/schemas/src/schemas/notifications.schema.json b/packages/schemas/src/schemas/notifications.schema.json index b2e5ac8332..5192c9f95b 100644 --- a/packages/schemas/src/schemas/notifications.schema.json +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -112,8 +112,9 @@ "type": "array", "items": { "type": "object", - "required": ["enabled", "name", "description", "isBanner"], + "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" }, diff --git a/packages/types/src/notifications.ts b/packages/types/src/notifications.ts index 981b6d5c3c..9fb90ac8df 100644 --- a/packages/types/src/notifications.ts +++ b/packages/types/src/notifications.ts @@ -61,6 +61,7 @@ export interface CustomEndpoint { enabled: boolean; description: string; isBanner: boolean; + correlationId: string; metric?: { treshold: number; min: number; From ef6586cd4b1fdd9a577dd39e2be48ef1383fb303 Mon Sep 17 00:00:00 2001 From: Pablo Mendez <pablo@dappnode.io> Date: Mon, 26 May 2025 11:08:25 +0200 Subject: [PATCH 77/90] fix merging notifications settings --- packages/notifications/src/manifest.ts | 58 +++-- .../test/unit/notifications.test.ts | 203 ++++++++++++++++-- 2 files changed, 212 insertions(+), 49 deletions(-) diff --git a/packages/notifications/src/manifest.ts b/packages/notifications/src/manifest.ts index 3afc82113e..61386686c3 100644 --- a/packages/notifications/src/manifest.ts +++ b/packages/notifications/src/manifest.ts @@ -45,12 +45,11 @@ export class NotificationsManifest { } /** - * Joins new endpoints with previous ones. If there are repeated endpoints: - * - GatusEndpoint: iterate over the conditions properties and split with regex matching any operator. If the right side is a number or a string, keep the old one. - * - CustomEndpoint: check the metric.treshold and keep the old one. + * 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` * - * Do not keep old endpoints that are not present in the new ones. - * * @returns New NotificationsConfig with merged endpoints. */ applyPreviousEndpoints( @@ -67,47 +66,40 @@ export class NotificationsManifest { const mergedEndpoints = newEndpoints?.map((newEndpoint) => { const oldEndpoint = oldEndpoints?.find((e) => e.correlationId === newEndpoint.correlationId); - // If no previous version exists, simply use the new endpoint. + // If no previous version exists, return the new endpoint as-is if (!oldEndpoint) return newEndpoint; - // Start with new endpoint properties but persist the old "enabled" flag. - const mergedEndpoint = { ...newEndpoint, ...oldEndpoint }; - mergedEndpoint.enabled = oldEndpoint.enabled; - - // For each condition in the new endpoint, if there is a corresponding old condition, - // persist its right-hand side value. - if (newEndpoint.conditions && Array.isArray(newEndpoint.conditions)) { + // 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) => { - // Split the new condition into parts using any operator as separator. - const newParts = condition.split(/([=<>]+)/); - // If we don't have a complete condition format, use it as is. - if (newParts.length < 3) return condition; - const newLeft = newParts[0]; - const newOperator = newParts[1]; - // Default right-hand value from the new condition. - let newRight = newParts.slice(2).join(""); - - // If there's an old condition at the same index, use its right-hand side. - if (oldEndpoint.conditions && oldEndpoint.conditions[index]) { - const oldParts = oldEndpoint.conditions[index].split(/([=<>]+)/); - if (oldParts.length >= 3) newRight = oldParts.slice(2).join(""); + 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 `${newLeft}${newOperator}${newRight}`; + return `${left}${operator}${right}`; }); } return mergedEndpoint; }); const mergedCustomEndpoints = newCustomEndpoints?.map((newCustomEndpoint) => { - const oldCustomEndpoint = oldCustomEndpoints?.find((e) => e.name === newCustomEndpoint.name); + 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; - // Merge and persist the old "enabled" flag and metric.treshold. - const mergedCustomEndpoint = { ...newCustomEndpoint, ...oldCustomEndpoint }; - mergedCustomEndpoint.enabled = oldCustomEndpoint.enabled; - if (mergedCustomEndpoint.metric && oldCustomEndpoint.metric && oldCustomEndpoint.metric.treshold !== undefined) + // 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; }); diff --git a/packages/notifications/test/unit/notifications.test.ts b/packages/notifications/test/unit/notifications.test.ts index 1fdc8e4fb9..888fb28528 100644 --- a/packages/notifications/test/unit/notifications.test.ts +++ b/packages/notifications/test/unit/notifications.test.ts @@ -54,6 +54,7 @@ describe("applyPreviousEndpoints", () => { customEndpoints: [ { name: "Test Custom", + correlationId: "Test Custom", enabled: true, isBanner: false, description: "Test custom endpoint", @@ -88,6 +89,7 @@ describe("applyPreviousEndpoints", () => { customEndpoints: [ { name: "Test Custom", + correlationId: "Test Custom", enabled: true, isBanner: false, description: "Test custom endpoint", @@ -158,6 +160,7 @@ describe("applyPreviousEndpoints", () => { customEndpoints: [ { name: "Custom Check", + correlationId: "Custom Check", enabled: true, isBanner: false, description: "Custom check description", @@ -171,6 +174,7 @@ describe("applyPreviousEndpoints", () => { customEndpoints: [ { name: "Custom Check", + correlationId: "Custom Check", enabled: false, isBanner: false, description: "Custom check description", @@ -339,7 +343,7 @@ describe("applyPreviousEndpoints", () => { name: "High CPU Usage Check", enabled: true, isBanner: false, - correlationId: "High CPU Usage Check", + correlationId: "dms-host-cpu-check", priority: Priority.medium, url: "http://cpu.example.com", method: "GET", @@ -354,7 +358,7 @@ describe("applyPreviousEndpoints", () => { name: "Host out of memory check", enabled: true, isBanner: false, - correlationId: "Host out of memory check", + correlationId: "dms-host-out-of-memory-check", priority: Priority.medium, url: "http://memory.example.com", method: "GET", @@ -369,6 +373,7 @@ describe("applyPreviousEndpoints", () => { customEndpoints: [ { name: "Custom Check A", + correlationId: "custom-check-a", enabled: true, isBanner: false, description: "Custom Check A description", @@ -376,6 +381,7 @@ describe("applyPreviousEndpoints", () => { }, { name: "Custom Check B", + correlationId: "custom-check-b", enabled: true, isBanner: false, description: "Custom Check B description", @@ -390,7 +396,7 @@ describe("applyPreviousEndpoints", () => { name: "High CPU Usage Check", enabled: false, isBanner: false, - correlationId: "High CPU Usage Check", + correlationId: "dms-host-cpu-check", priority: Priority.medium, url: "http://cpu.example.com", method: "GET", @@ -405,7 +411,7 @@ describe("applyPreviousEndpoints", () => { name: "Host out of memory check", enabled: false, isBanner: false, - correlationId: "Host out of memory check", + correlationId: "dms-host-out-of-memory-check", priority: Priority.medium, url: "http://memory.example.com", method: "GET", @@ -437,6 +443,7 @@ describe("applyPreviousEndpoints", () => { 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: "%" } } @@ -448,21 +455,185 @@ describe("applyPreviousEndpoints", () => { // Verify endpoints merging. expect(result.endpoints).to.have.lengthOf(2); - const cpuEndpoint = result.endpoints!.find((e) => e.name === "High CPU Usage Check"); - const memEndpoint = result.endpoints!.find((e) => e.name === "Host out of memory check"); - expect(cpuEndpoint?.enabled).to.equal(false); - expect(cpuEndpoint?.conditions[0]).to.equal("[BODY].cpu < 70"); - expect(memEndpoint?.enabled).to.equal(false); - expect(memEndpoint?.conditions[0]).to.equal("[BODY].memory > 20"); + 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.name === "Custom Check A"); - const customB = result.customEndpoints!.find((e) => e.name === "Custom Check B"); - expect(customA?.enabled).to.equal(false); - expect(customA?.metric?.treshold).to.equal(40); + 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).to.equal(true); - expect(customB?.metric?.treshold).to.equal(60); + 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); }); }); From 2847ce94ea596f545ea4fcc4f47b381da9341693 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Mon, 26 May 2025 11:12:02 +0200 Subject: [PATCH 78/90] Allow MD in endpoint descr --- .../src/pages/notifications/tabs/Settings/EndpointItem.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/EndpointItem.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/EndpointItem.tsx index 00a3e3da64..5ae9381436 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/EndpointItem.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/EndpointItem.tsx @@ -1,6 +1,7 @@ import React from "react"; import Switch from "components/Switch"; import Slider from "components/Slider"; +import RenderMarkdown from "components/RenderMarkdown"; interface EndpointItemProps { title: string; @@ -30,7 +31,7 @@ export function EndpointItem({ <div key={index} className="endpoint-row"> <div> <strong>{title}</strong> - <div>{description}</div> + <RenderMarkdown source={description} /> </div> <Switch checked={endpointEnabled} onToggle={handleEndpointToggle} /> </div> From 6871c583c5089fa7bffcb5495ada86a403413bd0 Mon Sep 17 00:00:00 2001 From: Pablo Mendez <pablo@dappnode.io> Date: Mon, 26 May 2025 11:25:13 +0200 Subject: [PATCH 79/90] fix schema test --- packages/schemas/test/unit/validateSchema.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/schemas/test/unit/validateSchema.test.ts b/packages/schemas/test/unit/validateSchema.test.ts index 9616c678a5..1e4515c827 100644 --- a/packages/schemas/test/unit/validateSchema.test.ts +++ b/packages/schemas/test/unit/validateSchema.test.ts @@ -609,6 +609,7 @@ volumes: { enabled: true, name: "custom-endpoint", + correlationId: "custom-correlation-id", description: "A custom endpoint for testing", // Added required description isBanner: false, metric: { @@ -698,6 +699,7 @@ volumes: { enabled: true, name: "custom-endpoint", + correlationId: "custom-correlation-id", description: "A custom endpoint for testing", // Added required description isBanner: false, metric: { From 95274957112822c926c56e288a8855aa54f92f23 Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Mon, 26 May 2025 11:29:22 +0200 Subject: [PATCH 80/90] Notifications settings tooltip (#2169) * notifications settings tooltip * Package tooltip in notification settings * using every --- .../Settings/ManagePackageNotifications.tsx | 33 ++++-- .../notifications/tabs/Settings/settings.scss | 111 ++++++++++-------- 2 files changed, 88 insertions(+), 56 deletions(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx index 3eaaff58da..da9b89213d 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx @@ -5,7 +5,9 @@ import { GatusEndpointItem } from "./GatusEndpointItem.js"; import { CustomEndpointItem } from "./CustomEndpointItem.js"; import { CustomEndpoint, GatusEndpoint } from "@dappnode/types"; import { prettyDnpName } from "utils/format"; -import { api } from "api"; +import { api, useApi } from "api"; +import Tooltip from "react-bootstrap/Tooltip"; +import OverlayTrigger from "react-bootstrap/OverlayTrigger"; interface ManagePackageNotificationsProps { dnpName: string; @@ -27,25 +29,28 @@ export function ManagePackageNotifications({ ); 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) - ); + 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 })) - ); + setEndpointsGatus((prevGatusEndpoints) => prevGatusEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState }))); + setEndpointsCustom((prevCustomEndpoints) => prevCustomEndpoints.map((ep) => ({ ...ep, enabled: newEnabledState }))); setPkgNotificationsEnabled(newEnabledState); }; @@ -68,6 +73,14 @@ export function ManagePackageNotifications({ <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"> diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/settings.scss b/packages/admin-ui/src/pages/notifications/tabs/Settings/settings.scss index 76a490e77a..2028f3202c 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/settings.scss +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/settings.scss @@ -1,60 +1,79 @@ .notifications-settings { - .title-switch-row { + .title-switch-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + + .not-running-label { + color: rgb(228, 156, 0) !important; display: flex; - flex-direction: row; align-items: center; - gap: 10px; + 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; } - .notifications-section-title { - margin: 0.5rem 0; + + .switch-container { + margin-left: 5px; } - - .manage-notifications-wrapper { + } + + .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: column; - gap: 15px; + flex-direction: row; + justify-content: space-between; } - - .notifications-pkg-name { - font-size: 1.2rem; - margin: 0.5rem 0; + + hr { + width: 100%; } - - .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 { + + .slider-wrapper { + padding-top: 15px; + width: 30%; + + @media (max-width: 60rem) { 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); - } +} + +#dark { + .notifications-settings { + .endpoint-list-card { + background-color: var(--color-dark-card); + color: var(--color-dark-maintext); } } - \ No newline at end of file +} \ No newline at end of file From 7b98826156562fe9efca7a29832b11f09c3dc772 Mon Sep 17 00:00:00 2001 From: Pablo Mendez <pablo@dappnode.io> Date: Mon, 26 May 2025 11:45:05 +0200 Subject: [PATCH 81/90] use correlation id in dappmanager --- notifications.yaml | 2 +- packages/daemons/src/autoUpdates/sendUpdateNotification.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/notifications.yaml b/notifications.yaml index af077c7d0f..1062718899 100644 --- a/notifications.yaml +++ b/notifications.yaml @@ -1,6 +1,6 @@ customEndpoints: - name: "Package updates notifications" isBanner: false - correlationId: "dappmanager-packages-updates" + correlationId: "dappmanager-update-pkg" description: "This endpoint notifies users about available package updates." enabled: true diff --git a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts index e60b1b03c1..10c01f3aff 100644 --- a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts +++ b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts @@ -23,7 +23,7 @@ export async function sendUpdatePackageNotificationMaybe({ // Check if auto-update notifications are enabled const dappmanagerCustomEndpoint = notifications .getEndpointsIfExists(params.dappmanagerDnpName, true) - ?.customEndpoints?.find((customEndpoint) => customEndpoint.name === "Package updates notifications"); + ?.customEndpoints?.find((customEndpoint) => customEndpoint.correlationId === "dappmanager-update-pkg"); if (!dappmanagerCustomEndpoint || !dappmanagerCustomEndpoint.enabled) return; @@ -60,7 +60,7 @@ export async function sendUpdatePackageNotificationMaybe({ }, isBanner: false, isRemote: false, - correlationId : 'core-update-pkg', + correlationId: "dappmanager-update-pkg" }) .catch((e) => logs.error("Error sending package update notification", e)); @@ -97,7 +97,7 @@ export async function sendUpdateSystemNotificationMaybe(data: CoreUpdateDataAvai }, isBanner: true, isRemote: false, - correlationId : 'core-update-system-pkg', + correlationId: "dappmanager-update-systemPkg" }) .catch((e) => logs.error("Error sending system update notification", e)); From a2bb59cd33903f4bb2baca282d09f8dc22cfe62e Mon Sep 17 00:00:00 2001 From: Pablo Mendez <pablo@dappnode.io> Date: Mon, 26 May 2025 11:56:08 +0200 Subject: [PATCH 82/90] add /dnp to installer url --- packages/daemons/src/autoUpdates/sendUpdateNotification.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts index 10c01f3aff..048853b654 100644 --- a/packages/daemons/src/autoUpdates/sendUpdateNotification.ts +++ b/packages/daemons/src/autoUpdates/sendUpdateNotification.ts @@ -38,7 +38,7 @@ export async function sendUpdatePackageNotificationMaybe({ upstream: release.manifest.upstream }); - const adminUiInstallPackageUrl = "http://my.dappnode/installer"; + const adminUiInstallPackageUrl = "http://my.dappnode/installer/dnp"; // Send notification about new version available await notifications From 8cb61d6c63cc231702d5f1067fc27e30a382b8fe Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Mon, 26 May 2025 12:06:50 +0200 Subject: [PATCH 83/90] onboarding link opens new tab --- .../src/components/welcome/features/EnableNotifications.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx b/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx index 3d472219a8..b892688bad 100644 --- a/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx +++ b/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import BottomButtons from "../BottomButtons"; -import { docsUrl } from "params"; +import { docsUrl, externalUrlProps } from "params"; import SubTitle from "components/SubTitle"; import Switch from "components/Switch"; @@ -70,7 +70,7 @@ export default function EnableNotifications({ onBack, onNext }: { onBack?: () => </p> <p> Learn more about notifications package and how to configure it in the{" "} - <a href={docsUrl.notificationsOverview}>Dappnode's documentation</a> + <a href={docsUrl.notificationsOverview} {...externalUrlProps}>Dappnode's documentation</a> </p> </div> From 2edcbdde698624c7a07a25e980082e5d45fcdc7f Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Mon, 26 May 2025 16:01:38 +0200 Subject: [PATCH 84/90] internet notification fix (#2172) --- .../daemons/src/internetConnection/index.ts | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/daemons/src/internetConnection/index.ts b/packages/daemons/src/internetConnection/index.ts index 360a416114..a6a5ba1458 100644 --- a/packages/daemons/src/internetConnection/index.ts +++ b/packages/daemons/src/internetConnection/index.ts @@ -11,14 +11,33 @@ let notificationSent = false; * Checks whether the DAppNode is connected to the internet. */ async function getIsConnectedToInternet(): Promise<boolean> { - try { - // Simulate fetching public IP to check connectivity - await new Promise((resolve, _) => setTimeout(resolve, 3000)); // Mock delay - return true; - } catch (error) { - logs.error(`Error while checking DAppNode internet connectivity: ${error}`); - return false; + 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; } /** @@ -29,7 +48,6 @@ async function getIsConnectedToInternet(): Promise<boolean> { async function monitorInternetConnection(): Promise<void> { try { const isConnected = await getIsConnectedToInternet(); - const correlationId = "core-internet-connection"; if (!isConnected) { From 7190d5a1be154a76bc921945d7c750020fd64c7a Mon Sep 17 00:00:00 2001 From: Mateu Miralles <52827122+mateumiralles@users.noreply.github.com> Date: Tue, 27 May 2025 09:18:58 +0200 Subject: [PATCH 85/90] Improve notifications onboarding (#2171) * onboarding installs notis pkg * keeping track of installation * fix notifications pkg calls * typo --- .../src/components/welcome/BottomButtons.tsx | 6 +- .../welcome/features/EnableNotifications.tsx | 100 ++++++++++++++---- 2 files changed, 84 insertions(+), 22 deletions(-) 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 ( <div className="bottom-buttons"> @@ -24,7 +26,7 @@ export default function BottomButtons({ </Button> )} {onNext && ( - <Button onClick={onNext} variant={nextVariant} className="next"> + <Button onClick={onNext} variant={nextVariant} className="next" disabled={nextDisabled}> {nextTag} </Button> )} diff --git a/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx b/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx index b892688bad..c3528e33b7 100644 --- a/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx +++ b/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx @@ -9,11 +9,59 @@ 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<boolean>(false); + const [notificationsNotInstalled, setNotificationsNotInstalled] = useState<boolean>(false); + const [isNotificationsInstalling, setIsNotificationsInstalling] = useState<boolean>(false); - const notificationsDnp = useApi.packageGet({ dnpName: notificationsDnpName }); + 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, + // TODO: Delete the version once the notifications package is released + version: "/ipfs/QmUMZfGt15CE8yifCAbeUybm75qUxAe1SucnqsbGjGEiKn", + options: { + BYPASS_SIGNED_RESTRICTION: true + } + }), + 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"); @@ -54,27 +102,39 @@ export default function EnableNotifications({ onBack, onNext }: { onBack?: () => We're transitioning to a new and improved in-app Notifications experience, designed to be more reliable, configurable and scalable. </div> - <SubTitle>Enable new notifications</SubTitle> - <Switch - checked={!notificationsDisabled} - disabled={notificationsDnp.isValidating} - onToggle={() => { - startStopNotifications(); - }} - /> - <br /> - <br /> - <p> - This notifications may alert you to critical issues if they arise. Disabling them could result in missing - critical notifications - </p> - <p> - Learn more about notifications package and how to configure it in the{" "} - <a href={docsUrl.notificationsOverview} {...externalUrlProps}>Dappnode's documentation</a> - </p> + {notificationsNotInstalled ? ( + isNotificationsInstalling ? ( + <Loading steps={["Installing notifications package"]} /> + ) : ( + notificationsDnp.error && <SubTitle>Error while installing notifications package</SubTitle> + ) + ) : ( + <> + <SubTitle>Enable new notifications</SubTitle> + <Switch + checked={!notificationsDisabled} + disabled={notificationsDnp.isValidating} + onToggle={() => { + startStopNotifications(); + }} + /> + <br /> + <br /> + <p> + This notifications may alert you to critical issues if they arise. Disabling them could result in missing + critical notifications + </p> + <p> + Learn more about notifications package and how to configure it in the{" "} + <a href={docsUrl.notificationsOverview} {...externalUrlProps}> + Dappnode's documentation + </a> + </p> + </> + )} </div> - <BottomButtons onBack={onBack} onNext={() => onNext()} /> + <BottomButtons onBack={onBack} onNext={() => onNext()} nextDisabled={isNotificationsInstalling} /> <br /> <br /> </div> From c946fb7a2fc93c958905792ce48427d7c9e6d772 Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Tue, 27 May 2025 11:39:38 +0200 Subject: [PATCH 86/90] notifications CTA button fix --- packages/admin-ui/src/components/NotificationsMain.tsx | 2 +- packages/admin-ui/src/components/notificationsMain.scss | 6 ++++++ .../pages/notifications/tabs/Inbox/NotificationsCard.tsx | 2 +- .../admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss | 6 ++++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/admin-ui/src/components/NotificationsMain.tsx b/packages/admin-ui/src/components/NotificationsMain.tsx index 11a7491d15..831e56dae0 100644 --- a/packages/admin-ui/src/components/NotificationsMain.tsx +++ b/packages/admin-ui/src/components/NotificationsMain.tsx @@ -125,7 +125,7 @@ export function CollapsableBannerNotification({ {notification.callToAction && ( <NavLink to={notification.callToAction.url} {...(isExternalUrl ? externalUrlProps : {})}> <Button variant={priorityBtnVariants[notification.priority]}> - {notification.callToAction.title} + <div>{notification.callToAction.title}</div> </Button> </NavLink> )} diff --git a/packages/admin-ui/src/components/notificationsMain.scss b/packages/admin-ui/src/components/notificationsMain.scss index a7b3aa011a..29682c71b8 100644 --- a/packages/admin-ui/src/components/notificationsMain.scss +++ b/packages/admin-ui/src/components/notificationsMain.scss @@ -68,6 +68,12 @@ @media (max-width: 60rem) { flex-direction: column; } + + button { + > div { + white-space: nowrap; // Prevent CTA text from wrapping + } + } } } } diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx index 074d5d6f53..ff46f180b2 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/NotificationsCard.tsx @@ -84,7 +84,7 @@ export function NotificationCard({ notification, openByDefault = false }: Notifi <RenderMarkdown source={prettifiedBody(notification.body)} /> {notification.callToAction && ( <NavLink to={notification.callToAction.url} {...isExternalUrl ? externalUrlProps : {}}> - <Button variant="dappnode">{notification.callToAction.title}</Button>{" "} + <Button variant="dappnode"><div>{notification.callToAction.title}</div></Button> </NavLink> )} </div> diff --git a/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss b/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss index 9f1103cc6b..596b0a360e 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss +++ b/packages/admin-ui/src/pages/notifications/tabs/Inbox/inbox.scss @@ -171,6 +171,12 @@ @media (max-width: 60rem) { flex-direction: column; } + + button { + > div { + white-space: nowrap; // Prevent CTA text from wrapping + } + } } } .notification-card:hover { From d074ddd7bf0db6f748161ade5e9d35d57aef6eed Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Wed, 28 May 2025 12:32:21 +0200 Subject: [PATCH 87/90] toast feedback on settings update --- .../Settings/ManagePackageNotifications.tsx | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx index da9b89213d..e4f0107f45 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx @@ -8,6 +8,7 @@ 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; @@ -55,17 +56,32 @@ export function ManagePackageNotifications({ }; useEffect(() => { - if (isStateUpdatedByUser.current) { - isStateUpdatedByUser.current = false; - api.notificationsUpdateEndpoints({ - dnpName, - notificationsConfig: { - endpoints: endpointsGatus.length > 0 ? endpointsGatus : undefined, - customEndpoints: endpointsCustom.length > 0 ? endpointsCustom : undefined - }, - isCore: isCore - }); - } + const updateEndpoints = async () => { + if (isStateUpdatedByUser.current) { + isStateUpdatedByUser.current = false; + try { + 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` + } + ); + } catch (error) { + console.error("Error updating endpoints:", error); + } + } + }; + + updateEndpoints(); }, [endpointsGatus, endpointsCustom]); return ( From 48408bb555fc1b8b61d04b0b4055f781c0c04c2f Mon Sep 17 00:00:00 2001 From: mateumiralles <mateumiralles714@gmail.com> Date: Wed, 28 May 2025 12:39:54 +0200 Subject: [PATCH 88/90] fix notifications update toast --- .../tabs/Settings/ManagePackageNotifications.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx index e4f0107f45..74e0fa2164 100644 --- a/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx +++ b/packages/admin-ui/src/pages/notifications/tabs/Settings/ManagePackageNotifications.tsx @@ -59,7 +59,6 @@ export function ManagePackageNotifications({ const updateEndpoints = async () => { if (isStateUpdatedByUser.current) { isStateUpdatedByUser.current = false; - try { await withToast( () => api.notificationsUpdateEndpoints({ @@ -72,12 +71,10 @@ export function ManagePackageNotifications({ }), { message: `Updating settings for ${prettyDnpName(dnpName)}...`, - onSuccess: `${prettyDnpName(dnpName)} settings updated` + onSuccess: `${prettyDnpName(dnpName)} settings updated`, + onError: `Error updating settings for ${prettyDnpName(dnpName)}` } ); - } catch (error) { - console.error("Error updating endpoints:", error); - } } }; From a61fe2a190ce48ac37d6c29c4adcc59918b7ac65 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <mendez4a@gmail.com> Date: Wed, 28 May 2025 12:47:32 +0200 Subject: [PATCH 89/90] remove hardcoded ipfs --- .../components/welcome/features/EnableNotifications.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx b/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx index c3528e33b7..da5d228ed5 100644 --- a/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx +++ b/packages/admin-ui/src/components/welcome/features/EnableNotifications.tsx @@ -32,12 +32,7 @@ export default function EnableNotifications({ onBack, onNext }: { onBack?: () => continueIfCalleDisconnected( () => api.packageInstall({ - name: notificationsDnpName, - // TODO: Delete the version once the notifications package is released - version: "/ipfs/QmUMZfGt15CE8yifCAbeUybm75qUxAe1SucnqsbGjGEiKn", - options: { - BYPASS_SIGNED_RESTRICTION: true - } + name: notificationsDnpName }), notificationsDnpName ), From b21ff84bc0369c269466cafe716c11d2c37826c0 Mon Sep 17 00:00:00 2001 From: pablomendezroyo <mendez4a@gmail.com> Date: Wed, 28 May 2025 13:11:41 +0200 Subject: [PATCH 90/90] add missing patterns --- packages/schemas/src/schemas/notifications.schema.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/schemas/src/schemas/notifications.schema.json b/packages/schemas/src/schemas/notifications.schema.json index 5192c9f95b..45f3821876 100644 --- a/packages/schemas/src/schemas/notifications.schema.json +++ b/packages/schemas/src/schemas/notifications.schema.json @@ -34,7 +34,7 @@ "items": { "type": "string" } }, "interval": { "type": "string", "pattern": "^[0-9]+[smhd]$" }, - "group": { "type": "string" }, + "group": { "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" }, "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, "isBanner": { "type": "boolean" }, "alerts": { @@ -71,6 +71,7 @@ }, "metric": { "type": "object", + "required": ["min", "max"], "properties": { "min": { "type": "number" }, "max": { "type": "number" }, @@ -87,12 +88,11 @@ }, "requirements": { "type": "object", - "required": ["pkgsInstalled", "pkgsNotInstalled"], "properties": { "pkgsInstalled": { "type": "object", "patternProperties": { - ".*": { + "^[a-zA-Z0-9._-]+\\.(dnp|public)\\.dappnode\\.eth$": { "type": "string", "pattern": "^(\\^|~|>|>=|<|<=)?\\d+\\.\\d+\\.\\d+$" } @@ -101,7 +101,7 @@ }, "pkgsNotInstalled": { "type": "array", - "items": { "type": "string" } + "items": { "type": "string", "pattern": "^[a-zA-Z0-9._-]+\\.(dnp|public)\\.dappnode\\.eth$" } } } } @@ -121,6 +121,7 @@ "isBanner": { "type": "boolean" }, "metric": { "type": "object", + "required": ["treshold", "min", "max"], "properties": { "treshold": { "type": "number" }, "min": { "type": "number" },