diff --git a/packages/daemons/package.json b/packages/daemons/package.json index fd460ac019..5d2a436bca 100644 --- a/packages/daemons/package.json +++ b/packages/daemons/package.json @@ -18,6 +18,7 @@ "dev": "tsc -w" }, "dependencies": { + "@dappnode/dashboard-server": "workspace:^0.1.0", "@dappnode/db": "workspace:^0.1.0", "@dappnode/dockerapi": "workspace:^0.1.0", "@dappnode/dockercompose": "workspace:^0.1.0", diff --git a/packages/daemons/src/dashboardServer/checkAndSyncValidators.ts b/packages/daemons/src/dashboardServer/checkAndSyncValidators.ts new file mode 100644 index 0000000000..3c2d4820ba --- /dev/null +++ b/packages/daemons/src/dashboardServer/checkAndSyncValidators.ts @@ -0,0 +1,201 @@ +import { logs } from "@dappnode/logger"; +import { listPackageNoThrow } from "@dappnode/dockerapi"; +import * as db from "@dappnode/db"; +import { params } from "@dappnode/params"; +import { Network } from "@dappnode/types"; +import { + fetchBrainValidators, + postValidatorsToDashboard, + parseBrainValidatorsResponseToIndices, + diffIndices, + createSnapshot, + supportedNetworks, + getWeb3signerDnpName, + PostReason, + IndicesDiff +} from "@dappnode/dashboard-server"; + +/** + * Check and sync validators to dashboard server for all supported networks. + * This is the main entry point called by the daemon scheduler. + */ +export async function checkAndSyncValidators(): Promise { + const baseUrl = params.DASHBOARD_SERVER_BASE_URL; + + // Feature is disabled if base URL is not set + if (!baseUrl) { + return; + } + + const postInterval = params.DASHBOARD_SERVER_POST_INTERVAL; + + for (const network of supportedNetworks) { + try { + await checkAndSyncNetworkValidators(network, baseUrl, postInterval); + } catch (e) { + logs.error(`Dashboard server: error processing ${network}`, e); + } + } +} + +/** + * Check and sync validators for a single network. + * Fetches current indices, detects changes, and posts to dashboard if needed. + */ +async function checkAndSyncNetworkValidators( + network: Network, + baseUrl: string, + postInterval: number +): Promise { + const isSignerInstalled = await checkSignerInstalled(network); + if (!isSignerInstalled) { + return; + } + + const indices = await fetchAndParseValidatorIndices(network); + if (!indices) { + return; + } + + if (indices.length === 0) { + logs.debug(`Dashboard server: no validators for ${network}, skipping POST`); + return; + } + + const reason = determinePostReason(network, indices, postInterval); + if (!reason) { + return; + } + + await postIndicesToDashboard(network, baseUrl, indices, reason); +} + +/** + * Check if web3signer is installed for the given network. + */ +async function checkSignerInstalled(network: Network): Promise { + const signerDnpName = getWeb3signerDnpName(network); + const signerPkg = await listPackageNoThrow({ dnpName: signerDnpName }); + return Boolean(signerPkg); +} + +/** + * Fetch validators from brain and parse them into indices. + * Returns null if fetching fails. + */ +async function fetchAndParseValidatorIndices(network: Network): Promise { + const brainResponse = await fetchBrainValidatorsSafe(network); + if (brainResponse === null) { + return null; + } + + const { indices, invalidCount } = parseBrainValidatorsResponseToIndices(brainResponse); + + if (invalidCount > 0) { + logs.warn(`Dashboard server: skipped ${invalidCount} invalid indices for ${network}`); + } + + return indices; +} + +/** + * Safely fetch brain validators, returning null on error. + */ +async function fetchBrainValidatorsSafe(network: Network): Promise> | null> { + try { + return await fetchBrainValidators(network); + } catch (e) { + logs.warn(`Dashboard server: failed to fetch brain validators for ${network}`, e); + return null; + } +} + +/** + * Determine if we need to post to dashboard server and why. + * Returns the reason for posting, or null if no post is needed. + */ +function determinePostReason( + network: Network, + indices: number[], + postInterval: number +): PostReason | null { + const lastSnapshot = db.dashboardServerLastSnapshot.get(network); + const lastPostTimestamp = db.dashboardServerLastPostTimestamp.get(network); + const now = Date.now(); + + const diff = diffIndices(lastSnapshot?.indices ?? null, indices); + + if (diff.hasChanged) { + logChangeDetected(network, diff); + return "changed"; + } + + if (isIntervalElapsed(lastPostTimestamp, now, postInterval)) { + logs.info( + `Dashboard server: interval trigger for ${network} ` + + `(${indices.length} validators)` + ); + return "interval"; + } + + return null; +} + +/** + * Check if enough time has elapsed since last post. + */ +function isIntervalElapsed( + lastPostTimestamp: number | undefined, + now: number, + postInterval: number +): boolean { + return !lastPostTimestamp || now - lastPostTimestamp >= postInterval; +} + +/** + * Log details about detected changes. + */ +function logChangeDetected(network: Network, diff: IndicesDiff): void { + logs.info( + `Dashboard server: change detected for ${network}: ` + + `${diff.oldCount} -> ${diff.newCount} validators, ` + + `added: ${diff.added.length}, removed: ${diff.removed.length}` + ); +} + +/** + * Post validator indices to dashboard server and update DB state on success. + */ +async function postIndicesToDashboard( + network: Network, + baseUrl: string, + indices: number[], + reason: PostReason +): Promise { + try { + const response = await postValidatorsToDashboard(baseUrl, indices); + logs.info( + `Dashboard server: POST successful for ${network}, ` + + `reason: ${reason}, indices_count: ${indices.length}, ` + + `set_hash: ${response.set_hash}` + ); + + updateDbStateOnSuccess(network, indices); + } catch (e) { + logs.error( + `Dashboard server: POST failed for ${network}, ` + + `reason: ${reason}, indices_count: ${indices.length}`, + e + ); + // Don't update DB state on failure - will retry next poll + } +} + +/** + * Update DB state after successful post. + */ +function updateDbStateOnSuccess(network: Network, indices: number[]): void { + const snapshot = createSnapshot(indices); + db.dashboardServerLastSnapshot.set(network, snapshot); + db.dashboardServerLastPostTimestamp.set(network, Date.now()); +} diff --git a/packages/daemons/src/dashboardServer/index.ts b/packages/daemons/src/dashboardServer/index.ts new file mode 100644 index 0000000000..d56dd01244 --- /dev/null +++ b/packages/daemons/src/dashboardServer/index.ts @@ -0,0 +1,43 @@ +import { params } from "@dappnode/params"; +import { runOnlyOneSequentially, runAtMostEvery } from "@dappnode/utils"; +import { logs } from "@dappnode/logger"; +import { checkAndSyncValidators } from "./checkAndSyncValidators.js"; + +/** + * Run the Dashboard Server daemon. + * It will periodically check for validator changes and POST to the dashboard server. + * + * The daemon is disabled if DASHBOARD_SERVER_BASE_URL is not set. + * + * Two triggers: + * 1. Every POLL_INTERVAL (default 2m): Check for changes and POST if changed + * 2. Every POST_INTERVAL (default 12h): Force POST regardless of changes + */ +export function startDashboardServerDaemon(signal: AbortSignal): void { + const baseUrl = params.DASHBOARD_SERVER_BASE_URL; + + // Feature disabled if base URL not configured + if (!baseUrl) { + logs.info("Dashboard server daemon: disabled (DASHBOARD_SERVER_BASE_URL not set)"); + return; + } + + logs.info(`Dashboard server daemon: enabled, polling every ${params.DASHBOARD_SERVER_POLL_INTERVAL / 1000}s, POST interval ${params.DASHBOARD_SERVER_POST_INTERVAL / 1000 / 60 / 60}h`); + + const runDashboardServerTaskMemo = runOnlyOneSequentially(async () => { + try { + await checkAndSyncValidators(); + } catch (e) { + logs.error("Error on dashboard server daemon", e); + } + }); + + // Run periodically at the poll interval (default 2 minutes) + // The checkAndSyncValidators function handles both change detection + // and interval-based posting internally + runAtMostEvery( + async () => runDashboardServerTaskMemo(), + params.DASHBOARD_SERVER_POLL_INTERVAL, + signal + ); +} diff --git a/packages/daemons/src/index.ts b/packages/daemons/src/index.ts index c8e6f322e5..406375072a 100644 --- a/packages/daemons/src/index.ts +++ b/packages/daemons/src/index.ts @@ -1,5 +1,6 @@ import { DappnodeInstaller } from "@dappnode/installer"; import { startAutoUpdatesDaemon } from "./autoUpdates/index.js"; +import { startDashboardServerDaemon } from "./dashboardServer/index.js"; import { startDiskUsageDaemon } from "./diskUsage/index.js"; import { startDynDnsDaemon } from "./dyndns/index.js"; import { startEthicalMetricsDaemon } from "./ethicalMetrics/index.js"; @@ -25,7 +26,7 @@ export function startDaemons( signal: AbortSignal ): void { // Increase the max listeners for AbortSignal. default is 10 - setMaxListeners(12, signal); + setMaxListeners(13, signal); startAutoUpdatesDaemon(dappnodeInstaller, signal); startDiskUsageDaemon(signal); @@ -39,6 +40,7 @@ export function startDaemons( startHostRebootDaemon(signal); startRepositoryHealthDaemon(signal); startDockerNetworkConfigsDaemon(signal, execution, consensus, signer, mevBoost); + startDashboardServerDaemon(signal); } export { startAvahiDaemon } from "./avahi/index.js"; diff --git a/packages/dashboardServer/.mocharc.yaml b/packages/dashboardServer/.mocharc.yaml new file mode 100644 index 0000000000..41e7c635de --- /dev/null +++ b/packages/dashboardServer/.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/dashboardServer/package.json b/packages/dashboardServer/package.json new file mode 100644 index 0000000000..34bccd4206 --- /dev/null +++ b/packages/dashboardServer/package.json @@ -0,0 +1,31 @@ +{ + "name": "@dappnode/dashboard-server", + "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", + "test": "TEST=true mocha --config ./.mocharc.yaml --recursive ./test/unit" + }, + "dependencies": { + "@dappnode/types": "workspace:^0.1.0" + }, + "devDependencies": { + "@types/chai": "^4", + "@types/mocha": "^10", + "chai": "^4.3.10", + "dotenv": "^16.3.1", + "mocha": "^10.7.0", + "tsx": "^4.17.0" + } +} diff --git a/packages/dashboardServer/src/brainClient.ts b/packages/dashboardServer/src/brainClient.ts new file mode 100644 index 0000000000..3bb7c7ba11 --- /dev/null +++ b/packages/dashboardServer/src/brainClient.ts @@ -0,0 +1,32 @@ +import { Network } from "@dappnode/types"; +import { BrainValidatorsResponse } from "./types.js"; +import { getBrainUrl } from "./params.js"; + +/** + * Fetches validator indices from the brain web3signer API. + * Uses format=index to get indices instead of pubkeys. + * + * @param network - The network to fetch validators for + * @returns Response mapping tags to arrays of index strings, or null on error + * @throws Error if the HTTP request fails with a non-OK status + */ +export async function fetchBrainValidators( + network: Network +): Promise { + const brainUrl = getBrainUrl(network); + const url = `${brainUrl}/api/v0/brain/validators?format=index`; + + const response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json" + } + }); + + if (!response.ok) { + throw new Error(`Brain API request failed with status: ${response.status}`); + } + + const data = await response.json(); + return data as BrainValidatorsResponse; +} diff --git a/packages/dashboardServer/src/dashboardServerClient.ts b/packages/dashboardServer/src/dashboardServerClient.ts new file mode 100644 index 0000000000..873b8292bf --- /dev/null +++ b/packages/dashboardServer/src/dashboardServerClient.ts @@ -0,0 +1,39 @@ +import { + DashboardServerPostRequest, + DashboardServerPostResponse +} from "./types.js"; + +/** + * Posts validator indices to the dashboard server. + * + * @param baseUrl - The dashboard server base URL + * @param indices - Array of validator indices to post + * @returns Response from the dashboard server + * @throws Error if the HTTP request fails + */ +export async function postValidatorsToDashboard( + baseUrl: string, + indices: number[] +): Promise { + const url = `${baseUrl}/validators`; + + const body: DashboardServerPostRequest = { indices }; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json" + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error( + `Dashboard server POST failed with status: ${response.status}` + ); + } + + const data = await response.json(); + return data as DashboardServerPostResponse; +} diff --git a/packages/dashboardServer/src/index.ts b/packages/dashboardServer/src/index.ts new file mode 100644 index 0000000000..1d19ba3fd1 --- /dev/null +++ b/packages/dashboardServer/src/index.ts @@ -0,0 +1,10 @@ +export * from "./types.js"; +export * from "./params.js"; +export { fetchBrainValidators } from "./brainClient.js"; +export { postValidatorsToDashboard } from "./dashboardServerClient.js"; +export { + parseBrainValidatorsResponseToIndices, + diffIndices, + indicesAreEqual, + createSnapshot +} from "./utils.js"; diff --git a/packages/dashboardServer/src/params.ts b/packages/dashboardServer/src/params.ts new file mode 100644 index 0000000000..34010f880f --- /dev/null +++ b/packages/dashboardServer/src/params.ts @@ -0,0 +1,24 @@ +import { Network } from "@dappnode/types"; + +/** + * Supported networks for the dashboard server feature. + * Per spec: must support both mainnet and hoodi. + */ +export const supportedNetworks: Network[] = [Network.Mainnet, Network.Hoodi]; + +/** + * Get the brain web3signer URL for a given network + */ +export function getBrainUrl(network: Network): string { + const networkSuffix = network === Network.Mainnet ? "" : `-${network}`; + return `http://brain.web3signer${networkSuffix}.dappnode:5000`; +} + +/** + * Get the web3signer DnpName for a given network + */ +export function getWeb3signerDnpName(network: Network): string { + return network === Network.Mainnet + ? "web3signer.dnp.dappnode.eth" + : `web3signer-${network}.dnp.dappnode.eth`; +} diff --git a/packages/dashboardServer/src/types.ts b/packages/dashboardServer/src/types.ts new file mode 100644 index 0000000000..60f02d9841 --- /dev/null +++ b/packages/dashboardServer/src/types.ts @@ -0,0 +1,77 @@ +import { Network } from "@dappnode/types"; + +/** + * Response from the brain web3signer API: GET /api/v0/brain/validators?format=index + * Shape: object mapping tag => array of index strings + * Example: + * { + * "lido": ["12345", "67890"], + * "solo": ["111", "222"] + * } + */ +export type BrainValidatorsResponse = Record; + +/** + * Request body for the dashboard server POST /validators endpoint + */ +export interface DashboardServerPostRequest { + indices: number[]; +} + +/** + * Response from the dashboard server POST /validators endpoint + */ +export interface DashboardServerPostResponse { + message: string; + set_hash: string; +} + +/** + * Reason for posting to the dashboard server + */ +export type PostReason = "interval" | "changed"; + +/** + * Snapshot of validator indices for a network, used for change detection + */ +export interface ValidatorSnapshot { + /** Sorted array of unique validator indices */ + indices: number[]; + /** Timestamp when this snapshot was taken */ + timestamp: number; +} + +/** + * State stored per network to track changes + */ +export interface NetworkValidatorState { + network: Network; + lastSnapshot: ValidatorSnapshot | null; + lastPostTimestamp: number | null; +} + +/** + * Result of diffing two snapshots + */ +export interface IndicesDiff { + /** Whether the sets are different */ + hasChanged: boolean; + /** Indices present in new but not in old */ + added: number[]; + /** Indices present in old but not in new */ + removed: number[]; + /** Count of indices in old snapshot */ + oldCount: number; + /** Count of indices in new snapshot */ + newCount: number; +} + +/** + * Configuration for the dashboard server feature + */ +export interface DashboardServerConfig { + /** Base URL for the dashboard server API */ + baseUrl: string; + /** Whether the feature is enabled */ + enabled: boolean; +} diff --git a/packages/dashboardServer/src/utils.ts b/packages/dashboardServer/src/utils.ts new file mode 100644 index 0000000000..b7535f7a8e --- /dev/null +++ b/packages/dashboardServer/src/utils.ts @@ -0,0 +1,129 @@ +import { BrainValidatorsResponse, IndicesDiff, ValidatorSnapshot } from "./types.js"; + +/** + * Parses the brain validators response into a de-duplicated, sorted array of indices. + * Normalizes the response by: + * - Flattening all tag arrays into a single list + * - Parsing string indices to integers + * - De-duplicating (union across all tags) + * - Sorting numerically + * - Filtering out invalid indices (non-numeric strings, negative numbers) + * + * @param response - Response from brain validators API + * @returns Sorted, de-duplicated array of valid validator indices + */ +export function parseBrainValidatorsResponseToIndices( + response: BrainValidatorsResponse | null +): { indices: number[]; invalidCount: number } { + if (!response) { + return { indices: [], invalidCount: 0 }; + } + + const allIndicesSet = new Set(); + let invalidCount = 0; + + for (const tag of Object.keys(response)) { + const indexStrings = response[tag]; + if (!Array.isArray(indexStrings)) { + continue; + } + + for (const indexStr of indexStrings) { + const parsed = parseInt(indexStr, 10); + + // Validate: must be a valid non-negative integer + if (isNaN(parsed) || parsed < 0 || !Number.isInteger(parsed)) { + invalidCount++; + continue; + } + + allIndicesSet.add(parsed); + } + } + + // Convert to sorted array + const indices = Array.from(allIndicesSet).sort((a, b) => a - b); + + return { indices, invalidCount }; +} + +/** + * Computes the difference between two sets of indices. + * Order-insensitive comparison. + * + * @param oldIndices - Previous snapshot indices (can be null for first run) + * @param newIndices - Current indices + * @returns Diff result with added, removed, and change status + */ +export function diffIndices( + oldIndices: number[] | null, + newIndices: number[] +): IndicesDiff { + const oldSet = new Set(oldIndices ?? []); + const newSet = new Set(newIndices); + + const added: number[] = []; + const removed: number[] = []; + + // Find added indices (in new but not in old) + for (const idx of newIndices) { + if (!oldSet.has(idx)) { + added.push(idx); + } + } + + // Find removed indices (in old but not in new) + if (oldIndices) { + for (const idx of oldIndices) { + if (!newSet.has(idx)) { + removed.push(idx); + } + } + } + + // Sort for consistent output + added.sort((a, b) => a - b); + removed.sort((a, b) => a - b); + + const hasChanged = added.length > 0 || removed.length > 0; + + return { + hasChanged, + added, + removed, + oldCount: oldIndices?.length ?? 0, + newCount: newIndices.length + }; +} + +/** + * Checks if two sorted index arrays are equal (set equality). + * + * @param a - First sorted array + * @param b - Second sorted array + * @returns True if arrays contain the same elements + */ +export function indicesAreEqual(a: number[], b: number[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + +/** + * Creates a snapshot from an array of indices. + * + * @param indices - Sorted, de-duplicated array of indices + * @returns Snapshot with indices and current timestamp + */ +export function createSnapshot(indices: number[]): ValidatorSnapshot { + return { + indices, + timestamp: Date.now() + }; +} diff --git a/packages/dashboardServer/test/unit/utils.test.ts b/packages/dashboardServer/test/unit/utils.test.ts new file mode 100644 index 0000000000..bf9c88d56b --- /dev/null +++ b/packages/dashboardServer/test/unit/utils.test.ts @@ -0,0 +1,160 @@ +import "mocha"; +import { expect } from "chai"; +import { + parseBrainValidatorsResponseToIndices, + diffIndices, + indicesAreEqual +} from "../../src/utils.js"; +import { BrainValidatorsResponse } from "../../src/types.js"; + +describe("dashboardServer > utils", () => { + describe("parseBrainValidatorsResponseToIndices", () => { + it("should return empty array for null response", () => { + const result = parseBrainValidatorsResponseToIndices(null); + expect(result.indices).to.deep.equal([]); + expect(result.invalidCount).to.equal(0); + }); + + it("should return empty array for empty object", () => { + const result = parseBrainValidatorsResponseToIndices({}); + expect(result.indices).to.deep.equal([]); + expect(result.invalidCount).to.equal(0); + }); + + it("should parse single tag with valid indices", () => { + const response: BrainValidatorsResponse = { + solo: ["123", "456", "789"] + }; + const result = parseBrainValidatorsResponseToIndices(response); + expect(result.indices).to.deep.equal([123, 456, 789]); + expect(result.invalidCount).to.equal(0); + }); + + it("should merge and deduplicate indices from multiple tags", () => { + const response: BrainValidatorsResponse = { + lido: ["12345", "67890"], + solo: ["111", "12345"] // 12345 is duplicated + }; + const result = parseBrainValidatorsResponseToIndices(response); + expect(result.indices).to.deep.equal([111, 12345, 67890]); + expect(result.invalidCount).to.equal(0); + }); + + it("should sort indices numerically", () => { + const response: BrainValidatorsResponse = { + tag: ["1000", "10", "100", "1"] + }; + const result = parseBrainValidatorsResponseToIndices(response); + expect(result.indices).to.deep.equal([1, 10, 100, 1000]); + }); + + it("should skip invalid indices and count them", () => { + const response: BrainValidatorsResponse = { + tag: ["123", "invalid", "-1", "456", "NaN", ""] + }; + const result = parseBrainValidatorsResponseToIndices(response); + expect(result.indices).to.deep.equal([123, 456]); + expect(result.invalidCount).to.equal(4); // "invalid", "-1", "NaN", "" + }); + + it("should parse decimal strings using parseInt (truncates to integer)", () => { + // parseInt("3.14") returns 3, which is a valid integer + const response: BrainValidatorsResponse = { + tag: ["3.14", "10.99"] + }; + const result = parseBrainValidatorsResponseToIndices(response); + expect(result.indices).to.deep.equal([3, 10]); + expect(result.invalidCount).to.equal(0); + }); + + it("should handle large indices", () => { + const response: BrainValidatorsResponse = { + tag: ["999999999", "1"] + }; + const result = parseBrainValidatorsResponseToIndices(response); + expect(result.indices).to.deep.equal([1, 999999999]); + }); + }); + + describe("diffIndices", () => { + it("should detect no change when arrays are equal", () => { + const diff = diffIndices([1, 2, 3], [1, 2, 3]); + expect(diff.hasChanged).to.be.false; + expect(diff.added).to.deep.equal([]); + expect(diff.removed).to.deep.equal([]); + expect(diff.oldCount).to.equal(3); + expect(diff.newCount).to.equal(3); + }); + + it("should detect added indices", () => { + const diff = diffIndices([1, 2], [1, 2, 3, 4]); + expect(diff.hasChanged).to.be.true; + expect(diff.added).to.deep.equal([3, 4]); + expect(diff.removed).to.deep.equal([]); + expect(diff.oldCount).to.equal(2); + expect(diff.newCount).to.equal(4); + }); + + it("should detect removed indices", () => { + const diff = diffIndices([1, 2, 3, 4], [1, 2]); + expect(diff.hasChanged).to.be.true; + expect(diff.added).to.deep.equal([]); + expect(diff.removed).to.deep.equal([3, 4]); + expect(diff.oldCount).to.equal(4); + expect(diff.newCount).to.equal(2); + }); + + it("should detect both added and removed indices", () => { + const diff = diffIndices([1, 2, 3], [2, 3, 4, 5]); + expect(diff.hasChanged).to.be.true; + expect(diff.added).to.deep.equal([4, 5]); + expect(diff.removed).to.deep.equal([1]); + }); + + it("should handle null old indices (first run)", () => { + const diff = diffIndices(null, [1, 2, 3]); + expect(diff.hasChanged).to.be.true; + expect(diff.added).to.deep.equal([1, 2, 3]); + expect(diff.removed).to.deep.equal([]); + expect(diff.oldCount).to.equal(0); + expect(diff.newCount).to.equal(3); + }); + + it("should handle empty new array", () => { + const diff = diffIndices([1, 2, 3], []); + expect(diff.hasChanged).to.be.true; + expect(diff.added).to.deep.equal([]); + expect(diff.removed).to.deep.equal([1, 2, 3]); + }); + + it("should handle completely different sets", () => { + const diff = diffIndices([1, 2, 3], [4, 5, 6]); + expect(diff.hasChanged).to.be.true; + expect(diff.added).to.deep.equal([4, 5, 6]); + expect(diff.removed).to.deep.equal([1, 2, 3]); + }); + }); + + describe("indicesAreEqual", () => { + it("should return true for identical arrays", () => { + expect(indicesAreEqual([1, 2, 3], [1, 2, 3])).to.be.true; + }); + + it("should return true for empty arrays", () => { + expect(indicesAreEqual([], [])).to.be.true; + }); + + it("should return false for different lengths", () => { + expect(indicesAreEqual([1, 2], [1, 2, 3])).to.be.false; + }); + + it("should return false for different values", () => { + expect(indicesAreEqual([1, 2, 3], [1, 2, 4])).to.be.false; + }); + + it("should return false for same values in different order", () => { + // Note: This function expects sorted arrays + expect(indicesAreEqual([1, 2, 3], [3, 2, 1])).to.be.false; + }); + }); +}); diff --git a/packages/dashboardServer/tsconfig.json b/packages/dashboardServer/tsconfig.json new file mode 100644 index 0000000000..f48cd14f08 --- /dev/null +++ b/packages/dashboardServer/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + /* Modules */ + "types": ["node", "mocha"], + + /* Emit */ + "outDir": "dist", + + /* Language and Environment */ + "lib": ["ESNext"] + }, + + "include": ["src/**/*", "src/**/*.json"], + "exclude": ["node_modules", "test/**/*", "dist"] +} diff --git a/packages/dashboardServer/tsconfig.test.json b/packages/dashboardServer/tsconfig.test.json new file mode 100644 index 0000000000..3e90560b8b --- /dev/null +++ b/packages/dashboardServer/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist-test" + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/packages/db/src/dashboardServer.ts b/packages/db/src/dashboardServer.ts new file mode 100644 index 0000000000..92213e5238 --- /dev/null +++ b/packages/db/src/dashboardServer.ts @@ -0,0 +1,37 @@ +import { dbCache } from "./dbFactory.js"; +import { Network } from "@dappnode/types"; + +const DASHBOARD_SERVER_LAST_POST_TIMESTAMP = "dashboard-server-last-post-timestamp"; +const DASHBOARD_SERVER_LAST_SNAPSHOT = "dashboard-server-last-snapshot"; + +/** + * Snapshot of validator indices stored in DB + */ +interface StoredValidatorSnapshot { + indices: number[]; + timestamp: number; +} + +/** + * Last POST timestamp per network. + * Used to enforce the 24-hour interval trigger. + */ +export const dashboardServerLastPostTimestamp = dbCache.indexedByKey< + number, + Network +>({ + rootKey: DASHBOARD_SERVER_LAST_POST_TIMESTAMP, + getKey: (network) => network +}); + +/** + * Last successful snapshot per network. + * Used for change detection. + */ +export const dashboardServerLastSnapshot = dbCache.indexedByKey< + StoredValidatorSnapshot, + Network +>({ + rootKey: DASHBOARD_SERVER_LAST_SNAPSHOT, + getKey: (network) => network +}); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 4c8d962bad..e9f806c2e7 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,6 +1,7 @@ export * from "./autoUpdateSettings.js"; export * from "./coreUpdate.js"; export * from "./counterViews.js"; +export * from "./dashboardServer.js"; export * from "./dyndns.js"; export * from "./ethicalMetrics.js"; export * from "./ipfsClient.js"; diff --git a/packages/params/src/params.ts b/packages/params/src/params.ts index 1520c55acb..3e3b23448e 100644 --- a/packages/params/src/params.ts +++ b/packages/params/src/params.ts @@ -116,6 +116,14 @@ export const params = { // Premium params PREMIUM_DNP_NAME: "premium.dnp.dappnode.eth", + // Dashboard server params + // Base URL from env, if not set feature is disabled + DASHBOARD_SERVER_BASE_URL: process.env.DASHBOARD_SERVER_BASE_URL || "", + // Poll web3signer every 2 minutes for change detection + DASHBOARD_SERVER_POLL_INTERVAL: parseInt(process.env.DASHBOARD_SERVER_POLL_INTERVAL || "", 10) || 2 * MINUTE, + // Force POST every 12 hours regardless of changes + DASHBOARD_SERVER_POST_INTERVAL: parseInt(process.env.DASHBOARD_SERVER_POST_INTERVAL || "", 10) || 24 * HOUR, + // Docker network parameters DOCKER_NETWORK_SUBNET: "172.33.0.0/16", // "10.20.0.0/24"; DOCKER_PRIVATE_NETWORK_NAME: "dncore_network", diff --git a/yarn.lock b/yarn.lock index d06440ed33..c9bfdab262 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1011,6 +1011,7 @@ __metadata: version: 0.0.0-use.local resolution: "@dappnode/daemons@workspace:packages/daemons" dependencies: + "@dappnode/dashboard-server": "workspace:^0.1.0" "@dappnode/db": "workspace:^0.1.0" "@dappnode/dockerapi": "workspace:^0.1.0" "@dappnode/dockercompose": "workspace:^0.1.0" @@ -1117,6 +1118,20 @@ __metadata: languageName: unknown linkType: soft +"@dappnode/dashboard-server@workspace:^0.1.0, @dappnode/dashboard-server@workspace:packages/dashboardServer": + version: 0.0.0-use.local + resolution: "@dappnode/dashboard-server@workspace:packages/dashboardServer" + dependencies: + "@dappnode/types": "workspace:^0.1.0" + "@types/chai": "npm:^4" + "@types/mocha": "npm:^10" + chai: "npm:^4.3.10" + dotenv: "npm:^16.3.1" + mocha: "npm:^10.7.0" + tsx: "npm:^4.17.0" + languageName: unknown + linkType: soft + "@dappnode/db@workspace:^0.1.0, @dappnode/db@workspace:packages/db": version: 0.0.0-use.local resolution: "@dappnode/db@workspace:packages/db" @@ -4106,6 +4121,13 @@ __metadata: languageName: node linkType: hard +"@types/chai@npm:^4": + version: 4.3.20 + resolution: "@types/chai@npm:4.3.20" + checksum: 10c0/4601189d611752e65018f1ecadac82e94eed29f348e1d5430e5681a60b01e1ecf855d9bcc74ae43b07394751f184f6970fac2b5561fc57a1f36e93a0f5ffb6e8 + languageName: node + linkType: hard + "@types/chai@npm:^4.3.11": version: 4.3.11 resolution: "@types/chai@npm:4.3.11" @@ -7812,6 +7834,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:^16.3.1": + version: 16.6.1 + resolution: "dotenv@npm:16.6.1" + checksum: 10c0/15ce56608326ea0d1d9414a5c8ee6dcf0fffc79d2c16422b4ac2268e7e2d76ff5a572d37ffe747c377de12005f14b3cc22361e79fc7f1061cce81f77d2c973dc + languageName: node + linkType: hard + "dotenv@npm:^8.2.0": version: 8.6.0 resolution: "dotenv@npm:8.6.0"