Skip to content
Merged
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
67 changes: 36 additions & 31 deletions docs/env-vars.md

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@
"fastq": "1.19.0",
"jsonwebtoken": "9.0.2",
"node-red": "4.0.8",
"object-hash": "3.0.0",
"pino": "9.6.0",
"pino-pretty": "13.0.0",
"pino-roll": "4.0.0",
"promise-retry": "2.0.1",
"zod": "3.24.1"
},
Expand All @@ -44,6 +46,7 @@
"@types/jsonwebtoken": "9.0.10",
"@types/node": "18.19.75",
"@types/node-red": "1.3.5",
"@types/object-hash": "3.0.5",
"@types/promise-retry": "1.1.6",
"@typescript-eslint/eslint-plugin": "6.21.0",
"eslint": "8.57.1",
Expand Down
4 changes: 4 additions & 0 deletions src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from './polkadot/polka';
import { sleep } from './util/sleep';
import { getBaseUrls } from './util/base-urls';
import { saveOperatorAddress } from './util/operator-address-cache';

export const runChecks = async (api: ApiPromise, account: KeyringPair, logger): Promise<void> => {
const shouldRetryInfinite: boolean = MAIN_CONFIG.RETRY_WORKER_CHECKS;
Expand Down Expand Up @@ -54,6 +55,9 @@ export const performInitialChecks = async (

logger.info({ operatorAddress }, 'operator address');

// Save operator address to local cache (with TTL so operator changes are reflected)
await saveOperatorAddress(account.address, operatorAddress);

const operatorSubscriptions: string[] = await retryHttpAsyncCall(
async () => await getOperatorSubscriptions(api, operatorAddress),
);
Expand Down
28 changes: 28 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,17 @@ export const ENV_SCHEMA = z.object({
.describe(
`Should pretty print logs. If you plan to use Grafana or any other log tooling it's recommended to set it to false.`,
),
LOG_FILE_PATH: z
.string()
.optional()
.describe(
'Full path to log file (e.g., /var/log/app.log or ./logs/app.log). If not provided, file logging is disabled.',
),
LOG_RETENTION_DAYS: z.coerce
.number()
.positive()
.optional()
.describe('Number of days to keep rotated logs'),
HEARTBEAT_PATH: z
.string()
.default('heartbeat_monitor.txt')
Expand Down Expand Up @@ -142,6 +153,23 @@ export const ENV_SCHEMA = z.object({
.default('https://marketplace-cdn.energyweb.org/base_urls.json')
.describe('Base URLs of EWX resources'),
BUILD_METADATA_PATH: z.string().default('./build.json').describe('Path to build metadata file'),
SHUTDOWN_TIMEOUT_MS: z.coerce
.number()
.positive()
.default(30000)
.describe('Timeout in milliseconds for graceful shutdown (default: 30000)'),
ADMIN_SERVER_PORT: z.coerce
.number()
.positive()
.default(3003)
.describe('Port number for admin server (default: 3003)'),
ADMIN_API_KEY: z
.string()
.min(32)
.optional()
.describe(
'API key for admin endpoints authentication. Must be at least 32 characters. If not set, admin endpoints will be accessible without authentication.',
),
});

export const MAIN_CONFIG: z.infer<typeof ENV_SCHEMA> = (process.env.__SKIP_PARSE_CONFIG === 'true'
Expand Down
33 changes: 33 additions & 0 deletions src/health/health.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { getAllInstalledSolutionsNames, runtimeStarted } from '../node-red/red';
import { createKeyringPair } from '../polkadot/account';
import { MAIN_CONFIG } from '../config';
import { getOperatorInfo, type OperatorInfo } from '../util/operator-info';

enum HealthStatus {
OK = 'OK',
Expand All @@ -23,6 +26,13 @@ interface NodeRedHealthStatus extends ComponentHealthStatus {
};
}

interface SolutionGroupsDetailsStatus {
timestamp: string;
rpcUrl?: string;
workerAddress?: string;
operator?: OperatorInfo | null;
}

export const isLive = (): ComponentHealthStatus => {
return {
status: HealthStatus.OK,
Expand Down Expand Up @@ -59,3 +69,26 @@ export const getNodeRedHealth = async (): Promise<NodeRedHealthStatus> => {
},
};
};

export const getSolutionGroupsDetailsStatus = async (): Promise<SolutionGroupsDetailsStatus> => {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check here

const timestamp = new Date().toISOString();
const rpcUrl = MAIN_CONFIG.PALLET_RPC_URL;

let account: ReturnType<typeof createKeyringPair>;
try {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

app should not initialize without worker address but it's ok.

I don't see a point to assign rpcUrl in both places, why not just const rpcUrl = MAIN_CONFIG.PALLET_RPC_URL ? We don't even need to reassign it, just use it in return

Copy link
Copy Markdown
Collaborator Author

@azam-ismail azam-ismail Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

account = createKeyringPair();
} catch {
// No worker identity (e.g. config not ready); return config only
return { timestamp, rpcUrl };
}

const workerAddress = account.address;
const operatorInfo = await getOperatorInfo();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic does not make sense to me, first there is a try-catch that I assume checks if worker address is present and then you fetch operator info based on worker address without any additional checks?

Please clean up this logic as it's confusing

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated check top logic


return {
timestamp,
rpcUrl,
workerAddress,
operator: operatorInfo,
};
};
20 changes: 18 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import { createVoteRouter } from './routes/vote';
import { createStatusRouter } from './routes/status';
import { createConfigRouter } from './routes/worker-config';
import { createTokenRouter } from './routes/token';
import { createAdminRouter } from './routes/admin';
import { setMainServer, setNodeRedServer, setAdminServer } from './shutdown';
import { MAIN_CONFIG } from './config';

void (async () => {
setAppState(APP_BOOTSTRAP_STATUS.STARTED);
Expand All @@ -41,9 +44,21 @@ void (async () => {
app.use(healthRouter);
}

app.listen(3002, () => {
const mainServer = app.listen(3002, () => {
logger.info(`vote API exposed on port 3002`);
});
setMainServer(mainServer);

// Start admin server separately
const adminApp = express();
adminApp.use(bodyParser.json());
adminApp.use(bodyParser.urlencoded({ extended: false }));
adminApp.use(createAdminRouter());

const adminServer = adminApp.listen(MAIN_CONFIG.ADMIN_SERVER_PORT, () => {
logger.info(`admin API exposed on port ${MAIN_CONFIG.ADMIN_SERVER_PORT}`);
});
setAdminServer(adminServer);

setAppState(APP_BOOTSTRAP_STATUS.EXPOSED_HTTP);

Expand All @@ -70,7 +85,8 @@ void (async () => {

await registerWorker(account);

await startRedServer(app);
const nodeRedServer = await startRedServer(app);
setNodeRedServer(nodeRedServer);

setAppState(APP_BOOTSTRAP_STATUS.STARTED_RED_SERVER);

Expand Down
45 changes: 35 additions & 10 deletions src/node-red/red.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type EWX_ENVS =

const redLogger = createLogger('NodeRed');

export const startRedServer = async (app: express.Express): Promise<void> => {
export const startRedServer = async (app: express.Express): Promise<http.Server> => {
const loggerConfig = {
console: {
level: 'off',
Expand Down Expand Up @@ -136,6 +136,8 @@ export const startRedServer = async (app: express.Express): Promise<void> => {

redLogger.info(`To access UI panel visit http://localhost:${port}/red`);
});

return server;
};

export const runtimeStarted = async (maxAttempts: number = 10): Promise<boolean> => {
Expand Down Expand Up @@ -381,21 +383,44 @@ export const deleteNodesBySolutionGroupId = async (solutionGroupIds: string[]):
export const getAllInstalledSolutionsNames = async (): Promise<string[]> => {
const tabNodes = await getTabNodes();

const solutionIds: Array<string | null> = await Promise.all(
tabNodes.map(async (tabNode: RedNode) => {
const solutionId = getNodeEnv(tabNode, 'EWX_SOLUTION_ID', false);
const solutionIds: Array<string | null> = tabNodes.map((tabNode: RedNode) => {
const solutionId = getNodeEnv(tabNode, 'EWX_SOLUTION_ID', false);

if (solutionId == null) {
return null;
}
if (solutionId == null) {
return null;
}

return solutionId;
}),
);
return solutionId;
});

return solutionIds.filter((x) => x !== null);
};

export interface InstalledSolutionDetails {
solutionId: string;
solutionGroupId: string;
}

export const getAllInstalledSolutionsWithGroups = async (): Promise<InstalledSolutionDetails[]> => {
const tabNodes = await getTabNodes();

const solutions: Array<InstalledSolutionDetails | null> = tabNodes.map((tabNode: RedNode) => {
const solutionId = getNodeEnv(tabNode, 'EWX_SOLUTION_ID', false);
const solutionGroupId = getNodeEnv(tabNode, 'EWX_SOLUTION_GROUP_ID', false);

if (solutionId == null || solutionGroupId == null) {
return null;
}

return {
solutionId,
solutionGroupId,
};
});

return solutions.filter((x): x is InstalledSolutionDetails => x !== null);
};

export const getTabNodes = async (): Promise<RedNodes> => {
const currentFlows: Flows = await getAllFlows();

Expand Down
15 changes: 15 additions & 0 deletions src/polkadot/vote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,18 @@ async function processVoteQueue(task: VoteTask): Promise<void> {
// We do not throw for these kind of errors so we can continue processing
});
}

// Drains the vote queue, waiting for all pending and running tasks to complete.
export const drainVoteQueue = async (): Promise<void> => {
voteQueueLogger.info(
{
pendingTasks: voteQueue.length(),
runningTasks: voteQueue.running(),
},
'draining vote queue',
);

await voteQueue.drained();

voteQueueLogger.info('vote queue drained successfully');
};
Loading