Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/daemons/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
201 changes: 201 additions & 0 deletions packages/daemons/src/dashboardServer/checkAndSyncValidators.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<boolean> {
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<number[] | null> {
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<Awaited<ReturnType<typeof fetchBrainValidators>> | 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<void> {
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());
}
43 changes: 43 additions & 0 deletions packages/daemons/src/dashboardServer/index.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
4 changes: 3 additions & 1 deletion packages/daemons/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand All @@ -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";
Expand Down
8 changes: 8 additions & 0 deletions packages/dashboardServer/.mocharc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
colors: true
exit: true
extension: [ts]
require:
- dotenv/config
node-option:
- experimental-specifier-resolution=node
- import=tsx/esm
31 changes: 31 additions & 0 deletions packages/dashboardServer/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
32 changes: 32 additions & 0 deletions packages/dashboardServer/src/brainClient.ts
Original file line number Diff line number Diff line change
@@ -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<BrainValidatorsResponse | null> {
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;
}
39 changes: 39 additions & 0 deletions packages/dashboardServer/src/dashboardServerClient.ts
Original file line number Diff line number Diff line change
@@ -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<DashboardServerPostResponse> {
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;
}
Loading
Loading