const mqtt = require('mqtt');
const { exec, spawn } = require('child_process');
const pty = require('node-pty');
const express = require('express');
const WebSocket = require('ws');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const app = express();
const port = 3001;
const brokerUrl = 'mqtt://localhost'; // Replace with your broker's URL
const client = mqtt.connect(brokerUrl);
const mainTopic = 'apu_tb';
const commandTopic = 'apu_tb/cmd/all';
const ackTopicPrefix = 'apu_tb/ack/';
const nodeData = Array.from({ length: 5 }, () => Array(5).fill(null));
const nodeLastActive = {}; // Track the last active timestamp for each node
let testbedServerData = null;
let failureTimeoutMs = 30000;
const nodePositionsById = {};
const nodeNeighborsById = {};
const nodePathsById = {};
// Define the path to the script
const scriptPath = path.join(__dirname, 'node_addresses.sh');
// Read the bash script
const scriptContent = fs.readFileSync(scriptPath, 'utf8');
const AUTH_FILE = path.join(__dirname, 'data', 'auth.json');
const DEFAULT_AUTH = {
user: process.env.DASHBOARD_USER || 'admin',
pass: process.env.DASHBOARD_PASS || 'admin',
enabled: false
};
const AUTH_COOKIE = 'dashboard_auth';
const authTokens = new Set();
function loadAuthFile() {
try {
const raw = fs.readFileSync(AUTH_FILE, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.user === 'string' && typeof parsed.pass === 'string') {
if (typeof parsed.enabled !== 'boolean') {
parsed.enabled = false;
}
return parsed;
}
} catch (err) {
return { ...DEFAULT_AUTH };
}
return { ...DEFAULT_AUTH };
}
function saveAuthFile(auth) {
try {
fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true });
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth));
} catch (err) {
console.error('Failed to save auth file:', err);
}
}
let authConfig = loadAuthFile();
authConfig.enabled = false;
const PXE_CONFIG_PATH = path.join(__dirname, '..', 'apu-tb-configuration', 'pxelinux.cfg', 'default');
function loadPxeConfig() {
return fs.readFileSync(PXE_CONFIG_PATH, 'utf8');
}
function parsePxeConfig(content) {
const lines = content.split(/\r?\n/);
let defaultLabel = null;
const blocks = [];
let current = null;
lines.forEach((line) => {
const defMatch = line.match(/^\s*#?\s*DEFAULT\s+(\S+)/);
if (defMatch) {
const trimmed = line.trim();
if (!trimmed.startsWith('#')) {
defaultLabel = defMatch[1];
}
}
const labelMatch = line.match(/^\s*LABEL\s+(\S+)/);
if (labelMatch) {
if (current) {
blocks.push(current);
}
current = { name: labelMatch[1], lines: [line] };
return;
}
if (current) {
const normalized = /^\s+/.test(line) ? line.replace(/^\s+/, ' ') : line;
current.lines.push(normalized);
}
});
if (current) {
blocks.push(current);
}
let selected = null;
if (defaultLabel) {
selected = blocks.find((block) => block.name === defaultLabel) || null;
}
if (!selected && blocks.length) {
selected = blocks[0];
}
return {
defaultLabel,
labelsText: selected ? selected.lines.join('\n') : ''
};
}
function updatePxeDefault(content, newDefault) {
const lines = content.split(/\r?\n/);
let found = false;
const updated = lines.map((line) => {
const match = line.match(/^(\s*)#?\s*DEFAULT\s+(\S+)/);
if (!match) {
return line;
}
const indent = match[1] || '';
const label = match[2];
if (label === newDefault) {
found = true;
return `${indent}DEFAULT ${label}`;
}
return `${indent}# DEFAULT ${label}`;
});
if (!found) {
updated.unshift(`DEFAULT ${newDefault}`);
}
return updated.join('\n');
}
const INIT_NODE_PATH = path.join(__dirname, '..', 'apu-tb-opt', 'init_node.sh');
const NETCONF_DEFAULTS_PATH = path.join(__dirname, 'config', 'netconf-defaults.json');
function updateMeshInitTopology(content, updates) {
const lines = content.split(/\r?\n/);
const updated = lines.map((line) => {
if (/^\s*#/.test(line)) {
return line;
}
const match = line.match(/^(\s*\/opt\/scripts\/mesh\/meshinit\.sh\s+"[^"]+"\s+"(mesh[01])"\s+)"([^"]+)"\s+"([^"]+)"\s+"([^"]+)"\s+"([^"]+)"(.*)$/);
if (!match) {
return line;
}
const dev = match[2];
const newTopo = updates[dev] && updates[dev].topology;
const newChannel = updates[dev] && updates[dev].channel;
const newTx = updates[dev] && updates[dev].txpower;
const newNoise = updates[dev] && updates[dev].noisefloor;
const channelValue = match[3];
const txValue = newTx || match[4];
const noiseValue = newNoise || match[5];
const topoValue = newTopo || match[6];
const channelUpdated = newChannel || channelValue;
return `${match[1]}"${channelUpdated}" "${txValue}" "${noiseValue}" "${topoValue}"${match[7]}`;
});
return updated.join('\n');
}
function parseMeshInit(content) {
const result = {
mesh0: { topology: null, channel: null, txpower: null, noisefloor: null },
mesh1: { topology: null, channel: null, txpower: null, noisefloor: null }
};
const lines = content.split(/\r?\n/);
lines.forEach((line) => {
if (/^\s*#/.test(line)) {
return;
}
const match = line.match(/^\s*\/opt\/scripts\/mesh\/meshinit\.sh\s+"[^"]+"\s+"(mesh[01])"\s+"([^"]+)"\s+"([^"]+)"\s+"([^"]+)"\s+"([^"]+)"/);
if (!match) {
return;
}
const dev = match[1];
const channel = match[2];
const txpower = match[3];
const noisefloor = match[4];
const topo = match[5];
if (result[dev]) {
result[dev].channel = channel;
result[dev].topology = topo;
result[dev].txpower = txpower;
result[dev].noisefloor = noisefloor;
}
});
return result;
}
function parseMeshChannels(content) {
const lines = content.split(/\r?\n/);
const entries = [];
lines.forEach((line) => {
const match = line.match(/^\s*\["(\d+)"\]\s*=\s*"(\d+)"/);
if (!match) {
return;
}
entries.push({ channel: match[1], frequency: match[2] });
});
return entries.sort((a, b) => Number(a.channel) - Number(b.channel));
}
const BACKUP_PLAN_PATH = path.join(__dirname, '..', 'apu-tb-scripts', 'apu_backup_plan.sh');
const BACKUP_ACTIONS = [
'A_UROM',
'A_SWOS',
'A_INIT',
'A_PULL',
'A_GRUB',
'A_PART',
'A_SWKL',
'A_PUSH'
];
function parseBackupVersions(content) {
const getValue = (key) => {
const match = content.match(new RegExp(`^\\s*${key}\\s*=\\s*"?([^"\\n]+)"?`, 'm'));
return match ? match[1] : null;
};
return {
V_BACKUP: getValue('V_BACKUP'),
V_KERNEL: getValue('V_KERNEL'),
V_ROM: getValue('V_ROM')
};
}
function extractBackupVersionOptions(content) {
const values = {
V_BACKUP: [],
V_KERNEL: [],
V_ROM: []
};
const patterns = [
/^\s*#?\s*(V_BACKUP|V_KERNEL|V_ROM)\s*=\s*"([^"]+)"/gm,
/^\s*#?\s*(V_BACKUP|V_KERNEL|V_ROM)\s*=\s*'([^']+)'/gm
];
patterns.forEach((pattern) => {
let match = null;
while ((match = pattern.exec(content)) !== null) {
const key = match[1];
const value = match[2];
if (values[key]) {
values[key].push(value);
}
}
});
return values;
}
function mergeUniqueOptions(...lists) {
const seen = new Set();
const result = [];
lists.forEach((list) => {
if (!Array.isArray(list)) {
return;
}
list.forEach((item) => {
if (!item || seen.has(item)) {
return;
}
seen.add(item);
result.push(item);
});
});
return result;
}
function updateBackupVersion(content, key, value) {
const regex = new RegExp(`^(\\s*${key}\\s*=).*$`, 'm');
if (regex.test(content)) {
return content.replace(regex, `$1"${value}"`);
}
return `${key}="${value}"\n${content}`;
}
function listDirectories(root) {
try {
return fs.readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
} catch (err) {
return [];
}
}
function listRomFiles(root) {
const results = [];
const walk = (dir, prefix = '') => {
let entries = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (err) {
return;
}
entries.forEach((entry) => {
if (entry.isDirectory()) {
walk(path.join(dir, entry.name), path.join(prefix, entry.name));
} else if (entry.isFile() && entry.name.endsWith('.rom')) {
results.push(path.join(prefix, entry.name));
}
});
};
walk(root);
return results.sort();
}
function loadBackupPlan() {
return fs.readFileSync(BACKUP_PLAN_PATH, 'utf8');
}
function parseBackupPlan(content) {
const status = {};
BACKUP_ACTIONS.forEach((action) => {
const match = content.match(new RegExp(`^\\s*${action}\\s*=\\s*(\\d+)`, 'm'));
status[action] = match ? Number(match[1]) : 0;
});
return status;
}
function parseBackupReference(content) {
const match = content.match(/^\s*D_REFND\s*=\s*"?([^"\n]+)"?/m);
return match ? match[1] : null;
}
function updateBackupReference(content, referenceNode) {
const normalized = normalizeNodeId(referenceNode);
if (!normalized || !expectedNodeIds.has(normalized)) {
return content;
}
const regex = /^(\s*D_REFND\s*=).*/m;
if (regex.test(content)) {
return content.replace(regex, `$1"${normalized}"`);
}
return `D_REFND="${normalized}"\n${content}`;
}
function updateBackupPlan(content, selectedAction) {
let updated = content;
BACKUP_ACTIONS.forEach((action) => {
const value = action === selectedAction ? 1 : 0;
const regex = new RegExp(`^(\\s*${action}\\s*=)\\s*\\d+`, 'm');
if (regex.test(updated)) {
updated = updated.replace(regex, `$1${value}`);
}
});
return updated;
}
function parseCookies(header = '') {
return header.split(';').reduce((acc, part) => {
const [key, ...rest] = part.trim().split('=');
if (!key) {
return acc;
}
acc[key] = decodeURIComponent(rest.join('='));
return acc;
}, {});
}
function isAuthenticated(req) {
if (authConfig.enabled === false) {
return true;
}
const cookies = parseCookies(req.headers.cookie || '');
const token = cookies[AUTH_COOKIE];
return token && authTokens.has(token);
}
function requireAuth(req, res, next) {
if (authConfig.enabled === false) {
return next();
}
if (req.path === '/login' || req.path === '/logout') {
return next();
}
if (!isAuthenticated(req)) {
if (req.path.startsWith('/api/')) {
return res.status(401).json({ error: 'unauthorized' });
}
return res.redirect('/login');
}
return next();
}
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(requireAuth);
app.get('/login', (req, res) => {
if (authConfig.enabled === false) {
return res.redirect('/');
}
res.send(`
Login
APU Dashboard Login
Configure credentials via DASHBOARD_USER / DASHBOARD_PASS.
`);
});
app.post('/login', (req, res) => {
if (authConfig.enabled === false) {
return res.redirect('/');
}
const { username, password } = req.body || {};
if (username === authConfig.user && password === authConfig.pass) {
const token = crypto.randomBytes(16).toString('hex');
authTokens.clear();
authTokens.add(token);
res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${token}; HttpOnly; Path=/; SameSite=Lax`);
return res.redirect('/');
}
return res.status(401).send('Invalid credentials');
});
app.get('/logout', (req, res) => {
if (authConfig.enabled === false) {
return res.redirect('/');
}
const cookies = parseCookies(req.headers.cookie || '');
const token = cookies[AUTH_COOKIE];
if (token) {
authTokens.delete(token);
}
res.setHeader('Set-Cookie', `${AUTH_COOKIE}=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`);
res.redirect('/login');
});
app.post('/api/change-password', (req, res) => {
if (!isAuthenticated(req)) {
return res.status(401).json({ error: 'unauthorized' });
}
const { currentPassword, newPassword, newUsername } = req.body || {};
if (currentPassword !== authConfig.pass) {
return res.status(400).json({ error: 'invalid_password' });
}
if (newUsername && typeof newUsername === 'string') {
authConfig.user = newUsername.trim();
}
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 4) {
return res.status(400).json({ error: 'weak_password' });
}
authConfig.pass = newPassword;
saveAuthFile(authConfig);
authTokens.clear();
return res.json({ ok: true });
});
app.get('/api/auth-status', (req, res) => {
res.json({ enabled: authConfig.enabled !== false });
});
app.post('/api/auth-toggle', (req, res) => {
if (authConfig.enabled !== false && !isAuthenticated(req)) {
return res.status(401).json({ error: 'unauthorized' });
}
const { enabled } = req.body || {};
authConfig.enabled = enabled !== false;
saveAuthFile(authConfig);
if (authConfig.enabled === false) {
authTokens.clear();
}
return res.json({ ok: true, enabled: authConfig.enabled });
});
app.get('/netconf', (req, res) => {
res.sendFile(path.join(__dirname, 'public/netconf.html'));
});
app.get('/br', (req, res) => {
res.sendFile(path.join(__dirname, 'public/br.html'));
});
app.get('/api/backup-plan', (req, res) => {
try {
const content = loadBackupPlan();
const status = parseBackupPlan(content);
status.referenceNode = parseBackupReference(content);
return res.json(status);
} catch (err) {
console.error('Failed to read backup plan:', err);
return res.status(500).json({ error: 'read_failed' });
}
});
app.post('/api/backup-plan', (req, res) => {
const { action, referenceNode } = req.body || {};
if (!action && !referenceNode) {
return res.status(400).json({ error: 'invalid_action' });
}
if (action && !BACKUP_ACTIONS.includes(action)) {
return res.status(400).json({ error: 'invalid_action' });
}
try {
const content = loadBackupPlan();
let updated = content;
if (action) {
updated = updateBackupPlan(updated, action);
}
if (referenceNode) {
updated = updateBackupReference(updated, referenceNode);
}
fs.writeFileSync(BACKUP_PLAN_PATH, updated);
const status = parseBackupPlan(updated);
status.referenceNode = parseBackupReference(updated);
return res.json(status);
} catch (err) {
console.error('Failed to update backup plan:', err);
return res.status(500).json({ error: 'write_failed' });
}
});
app.get('/api/pxe-config', (req, res) => {
try {
const content = loadPxeConfig();
return res.json(parsePxeConfig(content));
} catch (err) {
console.error('Failed to read PXE config:', err);
return res.status(500).json({ error: 'read_failed' });
}
});
app.post('/api/pxe-config', (req, res) => {
const { defaultLabel } = req.body || {};
if (!defaultLabel || typeof defaultLabel !== 'string') {
return res.status(400).json({ error: 'invalid_label' });
}
try {
const content = loadPxeConfig();
const updated = updatePxeDefault(content, defaultLabel.trim());
fs.writeFileSync(PXE_CONFIG_PATH, updated);
return res.json(parsePxeConfig(updated));
} catch (err) {
console.error('Failed to update PXE config:', err);
return res.status(500).json({ error: 'write_failed' });
}
});
app.post('/api/ssh-reboot', (req, res) => {
const { user, targets } = req.body || {};
const allowedUsers = new Set(['root', 'tc']);
if (!allowedUsers.has(user)) {
return res.status(400).json({ error: 'invalid_user' });
}
if (!Array.isArray(targets) || targets.length === 0) {
return res.status(400).json({ error: 'invalid_targets' });
}
const normalized = targets
.map((target) => normalizeNodeId(target))
.filter((target) => target && expectedNodeIds.has(target));
normalized.forEach((target) => {
const host = `${user}@${target}`;
const proc = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', host, 'sudo', 'reboot'], {
stdio: 'ignore'
});
proc.on('error', (err) => {
console.error(`SSH reboot failed for ${host}:`, err);
});
});
return res.json({ ok: true, targets: normalized });
});
app.post('/api/ssh-command', (req, res) => {
const { command, targets } = req.body || {};
if (typeof command !== 'string' || !command.trim()) {
return res.status(400).json({ error: 'invalid_command' });
}
if (!Array.isArray(targets) || targets.length === 0) {
return res.status(400).json({ error: 'invalid_targets' });
}
const normalized = targets
.map((target) => normalizeNodeId(target))
.filter((target) => target && expectedNodeIds.has(target));
normalized.forEach((target) => {
runSshCommand(target, command.trim());
});
return res.json({ ok: true, targets: normalized });
});
app.post('/api/ssh-console', (req, res) => {
const { node, command } = req.body || {};
if (typeof command !== 'string' || !command.trim()) {
return res.status(400).json({ error: 'invalid_command' });
}
const normalized = normalizeNodeId(node);
if (!normalized || !expectedNodeIds.has(normalized)) {
return res.status(400).json({ error: 'invalid_target' });
}
const host = resolveNodeIp(normalized) || normalized;
const detectUser = () => new Promise((resolve) => {
const tryUser = (user, next) => {
const sshHost = `${user}@${host}`;
const proc = spawn('ssh', [
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=3',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
sshHost,
'true'
], { stdio: 'ignore' });
proc.on('close', (code) => {
if (code === 0) {
resolve(user);
} else if (next) {
next();
} else {
resolve(null);
}
});
proc.on('error', () => {
if (next) {
next();
} else {
resolve(null);
}
});
};
tryUser('root', () => tryUser('tc'));
});
detectUser().then((user) => {
if (!user) {
return res.status(500).json({ error: 'ssh_failed', message: 'No reachable SSH user.' });
}
const sshHost = `${user}@${host}`;
const args = [
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=5',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
sshHost,
command.trim()
];
let stdout = '';
let stderr = '';
const proc = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
const timeout = setTimeout(() => {
proc.kill('SIGKILL');
}, 8000);
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
clearTimeout(timeout);
res.json({
ok: code === 0,
code,
user,
stdout,
stderr
});
});
proc.on('error', (err) => {
clearTimeout(timeout);
res.status(500).json({ error: 'ssh_failed', message: err.message });
});
});
});
app.post('/api/backup-status', (req, res) => {
const { targets } = req.body || {};
if (!Array.isArray(targets) || targets.length === 0) {
return res.status(400).json({ error: 'invalid_targets' });
}
const normalized = targets
.map((target) => normalizeNodeId(target))
.filter((target) => target && expectedNodeIds.has(target));
const checkNode = (nodeId) => new Promise((resolve) => {
const host = `tc@${nodeId}`;
const cmd = 'LOG=$(ls -t /home/tc/*_backup.log 2>/dev/null | head -n1); ' +
'if [ -z "$LOG" ]; then echo "missing"; exit 0; fi; ' +
'if grep -q "Backup plan done" "$LOG"; then echo "done"; else echo "running"; fi';
let output = '';
const proc = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', host, cmd], {
stdio: ['ignore', 'pipe', 'ignore']
});
proc.stdout.on('data', (data) => {
output += data.toString();
});
proc.on('close', () => {
const status = output.trim() || 'missing';
resolve({ nodeId, status });
});
proc.on('error', () => {
resolve({ nodeId, status: 'missing' });
});
});
Promise.all(normalized.map(checkNode)).then((results) => {
const status = {};
results.forEach((result) => {
status[result.nodeId.replace('apu', '')] = result.status;
});
res.json({ status });
});
});
app.post('/api/os-status', (req, res) => {
const { targets } = req.body || {};
if (!Array.isArray(targets) || targets.length === 0) {
return res.status(400).json({ error: 'invalid_targets' });
}
const normalized = targets
.map((target) => normalizeNodeId(target))
.filter((target) => target && expectedNodeIds.has(target));
const checkNode = (nodeId) => new Promise((resolve) => {
const runCheck = (user, label, next) => {
const host = `${user}@${nodeId}`;
const proc = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=3', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', host, 'true'], {
stdio: 'ignore'
});
proc.on('close', (code) => {
if (code === 0) {
resolve({ nodeId, status: label });
} else if (next) {
next();
} else {
resolve({ nodeId, status: '' });
}
});
proc.on('error', () => {
if (next) {
next();
} else {
resolve({ nodeId, status: '' });
}
});
};
runCheck('root', 'Kali', () => runCheck('tc', 'TC'));
});
Promise.all(normalized.map(checkNode)).then((results) => {
const status = {};
results.forEach((result) => {
status[result.nodeId.replace('apu', '')] = result.status;
});
res.json({ status });
});
});
app.get('/api/backup-versions', (req, res) => {
try {
const content = loadBackupPlan();
const current = parseBackupVersions(content);
const scriptOptions = extractBackupVersionOptions(content);
const options = {
backup: mergeUniqueOptions(
listDirectories('/home/apu/backup/full_backup'),
scriptOptions.V_BACKUP,
current.V_BACKUP ? [current.V_BACKUP] : []
),
kernel: mergeUniqueOptions(
listDirectories('/home/apu/backup/apu_kernel'),
scriptOptions.V_KERNEL,
current.V_KERNEL ? [current.V_KERNEL] : []
),
rom: mergeUniqueOptions(
listRomFiles('/home/apu/roms'),
scriptOptions.V_ROM,
current.V_ROM ? [current.V_ROM] : []
)
};
return res.json({ current, options });
} catch (err) {
console.error('Failed to read backup versions:', err);
return res.status(500).json({ error: 'read_failed' });
}
});
app.post('/api/backup-versions', (req, res) => {
const { key, value } = req.body || {};
const allowed = new Set(['V_BACKUP', 'V_KERNEL', 'V_ROM']);
if (!allowed.has(key) || typeof value !== 'string') {
return res.status(400).json({ error: 'invalid_value' });
}
try {
const content = loadBackupPlan();
const updated = updateBackupVersion(content, key, value.trim());
fs.writeFileSync(BACKUP_PLAN_PATH, updated);
return res.json(parseBackupVersions(updated));
} catch (err) {
console.error('Failed to update backup versions:', err);
return res.status(500).json({ error: 'write_failed' });
}
});
app.get('/api/manage-targets/state', (req, res) => {
const totalNodes = expectedNodeIds.size || 0;
const selected = Array.isArray(manageTargetsState.selected) ? manageTargetsState.selected : [];
return res.json({
selected,
all: totalNodes > 0 ? selected.length >= totalNodes : false,
checkStatus: Boolean(manageTargetsState.checkStatus),
checkOs: Boolean(manageTargetsState.checkOs)
});
});
app.post('/api/manage-targets/state', (req, res) => {
const { selected, checkStatus, checkOs } = req.body || {};
if (!Array.isArray(selected)) {
return res.status(400).json({ error: 'invalid_value' });
}
const normalized = Array.from(new Set(selected.map(normalizeManageTargetId).filter(Boolean)));
manageTargetsState = {
selected: normalized,
checkStatus: Boolean(checkStatus),
checkOs: Boolean(checkOs)
};
saveManageTargetsState();
const totalNodes = expectedNodeIds.size || 0;
return res.json({
selected: manageTargetsState.selected,
all: totalNodes > 0 ? manageTargetsState.selected.length >= totalNodes : false,
checkStatus: Boolean(manageTargetsState.checkStatus),
checkOs: Boolean(manageTargetsState.checkOs)
});
});
app.get('/api/netconf/mesh', (req, res) => {
try {
const content = fs.readFileSync(INIT_NODE_PATH, 'utf8');
return res.json(parseMeshInit(content));
} catch (err) {
console.error('Failed to read init_node.sh:', err);
return res.status(500).json({ error: 'read_failed' });
}
});
app.get('/api/netconf/defaults', (req, res) => {
try {
const raw = fs.readFileSync(NETCONF_DEFAULTS_PATH, 'utf8');
const defaults = JSON.parse(raw);
return res.json(defaults);
} catch (err) {
console.error('Failed to read netconf defaults:', err);
return res.status(500).json({ error: 'defaults_read_failed' });
}
});
app.get('/api/netconf/channels', (req, res) => {
try {
const content = fs.readFileSync(path.join(__dirname, '..', 'apu-tb-opt', 'scripts', 'mesh', 'freq.sh'), 'utf8');
return res.json(parseMeshChannels(content));
} catch (err) {
console.error('Failed to read mesh channels:', err);
return res.status(500).json({ error: 'channels_read_failed' });
}
});
app.post('/api/netconf/mesh', (req, res) => {
const { mesh0, mesh1 } = req.body || {};
const updates = {};
if (mesh0 && typeof mesh0 === 'string') {
updates.mesh0 = { topology: mesh0.trim() };
} else if (mesh0 && typeof mesh0 === 'object') {
updates.mesh0 = {
topology: typeof mesh0.topology === 'string' ? mesh0.topology.trim() : null,
channel: typeof mesh0.channel === 'string' ? mesh0.channel.trim() : null,
txpower: typeof mesh0.txpower === 'string' ? mesh0.txpower.trim() : null,
noisefloor: typeof mesh0.noisefloor === 'string' ? mesh0.noisefloor.trim() : null
};
}
if (mesh1 && typeof mesh1 === 'string') {
updates.mesh1 = { topology: mesh1.trim() };
} else if (mesh1 && typeof mesh1 === 'object') {
updates.mesh1 = {
topology: typeof mesh1.topology === 'string' ? mesh1.topology.trim() : null,
channel: typeof mesh1.channel === 'string' ? mesh1.channel.trim() : null,
txpower: typeof mesh1.txpower === 'string' ? mesh1.txpower.trim() : null,
noisefloor: typeof mesh1.noisefloor === 'string' ? mesh1.noisefloor.trim() : null
};
}
if (!updates.mesh0 && !updates.mesh1) {
return res.status(400).json({ error: 'invalid_value' });
}
try {
const content = fs.readFileSync(INIT_NODE_PATH, 'utf8');
const updated = updateMeshInitTopology(content, updates);
fs.writeFileSync(INIT_NODE_PATH, updated);
return res.json(parseMeshInit(updated));
} catch (err) {
console.error('Failed to update init_node.sh:', err);
return res.status(500).json({ error: 'write_failed' });
}
});
app.post('public/index.html')
// Serve static files from the template's public folder
app.use(express.static(path.join(__dirname, 'public')));
// app.use(express.static(__dirname));
// Route to serve the main dashboard HTML file
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public/index.html'));
});
// Example API endpoint for dashboard data
app.get('/api/data', (req, res) => {
res.json({
users: 150,
sales: 2500,
revenue: 50000,
active_sessions: 45
});
});
app.get('/api/testbed-history', (req, res) => {
res.json(testbedHistory);
});
const server = require('http').createServer(app);
const ws = new WebSocket.Server({ noServer: true });
const consoleWs = new WebSocket.Server({ noServer: true });
server.on('upgrade', (request, socket, head) => {
const url = request.url || '';
if (url.startsWith('/ws/console')) {
consoleWs.handleUpgrade(request, socket, head, (client) => {
consoleWs.emit('connection', client, request);
});
return;
}
ws.handleUpgrade(request, socket, head, (client) => {
ws.emit('connection', client, request);
});
});
ws.on('connection', (socket, request) => {
if (authConfig.enabled !== false) {
const cookies = parseCookies(request.headers.cookie || '');
const token = cookies[AUTH_COOKIE];
if (!token || !authTokens.has(token)) {
socket.close();
return;
}
}
socket.on('message', (message) => {
try {
const payload = JSON.parse(message.toString());
if (payload.type === 'failureTimeout') {
const allowed = new Set([10, 20, 30, 40, 50, 60]);
if (allowed.has(payload.value)) {
failureTimeoutMs = payload.value * 1000;
}
}
if (payload.type === 'hwmpProactive') {
const enabled = Boolean(payload.enabled);
const command = JSON.stringify({
action: 'hwmp_proactive',
enabled
});
client.publish(commandTopic, command, { qos: 1, retain: false });
}
if (payload.type === 'hwmpPing') {
const enabled = payload.enabled !== false;
const sourceId = payload.sourceId;
const targetId = payload.targetId;
const pingTargets = targetId ? {
mesh0: mesh0IP[targetId],
mesh1: mesh1IP[targetId]
} : {};
const command = JSON.stringify({
action: 'hwmp_ping',
enabled,
ping: {
source: sourceId,
targets: pingTargets
}
});
client.publish(commandTopic, command, { qos: 1, retain: false });
}
if (payload.type === 'rebootAll') {
publishCommand('reboot', { scope: 'all' }, { maxRetries: 0 });
}
if (payload.type === 'reinitAll') {
const command = '/usr/bin/pkill -9 -f mqtt_publisher.py || true; sleep 1; /opt/start_script.sh';
expectedNodeIds.forEach((nodeId) => runSshRootCommand(nodeId, command));
}
if (payload.type === 'restartDashboard') {
exec('systemctl restart apu-dashboard.service', (err, stdout, stderr) => {
if (err) {
console.error('Failed to restart dashboard service:', err);
return;
}
if (stderr) {
console.warn('Dashboard restart stderr:', stderr);
}
if (stdout) {
console.log('Dashboard restart stdout:', stdout);
}
});
}
if (payload.type === 'sshCommand') {
const command = typeof payload.command === 'string' ? payload.command.trim() : '';
const targets = Array.isArray(payload.targets) ? payload.targets : [];
if (!command || !targets.length) {
return;
}
const normalizedTargets = targets
.map((target) => normalizeNodeId(target))
.filter((target) => target && expectedNodeIds.has(target));
normalizedTargets.forEach((target) => {
runSshCommand(target, command);
});
}
if (payload.type === 'nodeCommand') {
const action = payload.action;
const targetId = payload.targetId;
if (!action || !targetId) {
return;
}
if (action === 're-init') {
const normalizedTarget = normalizeNodeId(targetId);
if (!normalizedTarget || !expectedNodeIds.has(normalizedTarget)) {
return;
}
const command = '/usr/bin/pkill -9 -f mqtt_publisher.py || true; sleep 1; /opt/start_script.sh';
runSshRootCommand(normalizedTarget, command);
return;
}
const id = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
const command = JSON.stringify({
action,
target: targetId,
scope: 'node',
id,
ts: Date.now()
});
client.publish(commandTopic, command, { qos: 1, retain: false });
}
} catch (err) {
console.error('WebSocket message error:', err);
}
});
});
consoleWs.on('connection', (socket, request) => {
if (authConfig.enabled !== false) {
const cookies = parseCookies(request.headers.cookie || '');
const token = cookies[AUTH_COOKIE];
if (!token || !authTokens.has(token)) {
socket.close();
return;
}
}
let url = null;
try {
url = new URL(request.url, `http://${request.headers.host}`);
} catch (err) {
socket.close();
return;
}
const nodeParam = url.searchParams.get('node');
const normalized = normalizeNodeId(nodeParam);
if (!normalized || !expectedNodeIds.has(normalized)) {
socket.close();
return;
}
const host = resolveNodeIp(normalized) || normalized;
const detectUser = () => new Promise((resolve) => {
const tryUser = (user, next) => {
const sshHost = `${user}@${host}`;
const proc = spawn('ssh', [
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=3',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
sshHost,
'true'
], { stdio: 'ignore' });
proc.on('close', (code) => {
if (code === 0) {
resolve(user);
} else if (next) {
next();
} else {
resolve(null);
}
});
proc.on('error', () => {
if (next) {
next();
} else {
resolve(null);
}
});
};
tryUser('root', () => tryUser('tc'));
});
detectUser().then((user) => {
if (!user) {
socket.close();
return;
}
const sshHost = `${user}@${host}`;
const term = pty.spawn('ssh', [
'-o', 'BatchMode=yes',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
sshHost
], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: process.env.HOME,
env: { ...process.env, TERM: 'xterm-256color' }
});
term.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
}
});
socket.on('message', (message) => {
let text = '';
if (Buffer.isBuffer(message)) {
text = message.toString();
} else if (typeof message === 'string') {
text = message;
} else {
text = String(message);
}
if (text.startsWith('{')) {
try {
const payload = JSON.parse(text);
if (payload && payload.type === 'resize') {
const cols = Number(payload.cols);
const rows = Number(payload.rows);
if (Number.isFinite(cols) && Number.isFinite(rows)) {
term.resize(cols, rows);
}
return;
}
} catch (err) {
// fall through to write
}
}
term.write(text);
});
socket.on('close', () => {
try {
term.kill();
} catch (err) {
// ignore
}
});
});
});
// Start the server
server.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
// Function to parse associative arrays from the bash script
function parseAssociativeArray(script, arrayName) {
const regex = new RegExp(`declare -A ${arrayName}\\s*=\\s*\\(([^)]+)\\)`, 's');
const match = script.match(regex);
if (!match) return {};
const entries = match[1].trim().split('\n').map(line => line.trim()).filter(line => line.startsWith('["'));
const obj = {};
entries.forEach(entry => {
const parts = entry.match(/\["(.+?)"\]="(.+?)"/);
if (parts) {
obj[parts[1]] = parts[2];
}
});
return obj;
}
const mesh0MAC = parseAssociativeArray(scriptContent, 'phy0MAC');
const mesh1MAC = parseAssociativeArray(scriptContent, 'phy1MAC');
const eth0IP = parseAssociativeArray(scriptContent, 'eth0IP');
const mesh0IP = parseAssociativeArray(scriptContent, 'phy0IP');
const mesh1IP = parseAssociativeArray(scriptContent, 'phy1IP');
const expectedNodeIds = new Set([
...Object.keys(mesh0MAC),
...Object.keys(mesh1MAC)
]);
function normalizeManageTargetId(value) {
if (value === null || value === undefined) {
return null;
}
const raw = String(value).trim();
if (/^\d{2}$/.test(raw)) {
return raw;
}
if (/^apu\d{2}$/i.test(raw)) {
return raw.slice(-2);
}
if (/^\d{1,2}$/.test(raw)) {
return raw.padStart(2, '0');
}
return null;
}
function ensureManageTargetsDir() {
try {
fs.mkdirSync(path.dirname(MANAGE_TARGETS_STATE_PATH), { recursive: true });
} catch (err) {
console.error('Failed to create manage targets directory:', err);
}
}
function loadManageTargetsState() {
try {
const raw = fs.readFileSync(MANAGE_TARGETS_STATE_PATH, 'utf8');
const parsed = JSON.parse(raw);
const selected = Array.isArray(parsed.selected) ? parsed.selected : [];
const normalized = Array.from(new Set(selected.map(normalizeManageTargetId).filter(Boolean)));
manageTargetsState = {
selected: normalized,
checkStatus: Boolean(parsed.checkStatus),
checkOs: Boolean(parsed.checkOs)
};
} catch (err) {
manageTargetsState = { selected: [], checkStatus: false, checkOs: false };
}
}
function saveManageTargetsState() {
try {
fs.writeFileSync(MANAGE_TARGETS_STATE_PATH, JSON.stringify(manageTargetsState, null, 2));
} catch (err) {
console.error('Failed to save manage targets state:', err);
}
}
function normalizeNodeId(value) {
if (value === null || value === undefined) {
return null;
}
let node = String(value).trim();
if (/^\d{1,2}$/.test(node)) {
node = node.padStart(2, '0');
}
if (/^\d{2}$/.test(node)) {
node = `apu${node}`;
}
if (!/^apu\d{2}$/.test(node)) {
return null;
}
return node;
}
function resolveNodeIp(id) {
return eth0IP[id] || mesh0IP[id] || mesh1IP[id] || null;
}
function runSshCommand(nodeId, command) {
const host = resolveNodeIp(nodeId);
if (!host) {
console.warn(`No IP found for ${nodeId}`);
return;
}
const remoteCommand = `sudo -- ${command}`;
const proc = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', host, remoteCommand], {
stdio: 'ignore'
});
proc.on('error', (err) => {
console.error(`SSH failed for ${nodeId}:`, err);
});
function runSshRootCommand(nodeId, command) {
const host = normalizeNodeId(nodeId);
if (!host) {
console.warn(`Invalid node id: ${nodeId}`);
return;
}
const ip = resolveNodeIp(host) || host;
const sshHost = `root@${ip}`;
const proc = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', sshHost, 'bash', '-lc', command], {
stdio: 'ignore'
});
proc.on('error', (err) => {
console.error(`SSH failed for ${host}:`, err);
});
}
}
const MANAGE_TARGETS_STATE_PATH = path.join(__dirname, 'data', 'manage_targets_state.json');
let manageTargetsState = { selected: [], checkStatus: false, checkOs: false };
const HISTORY_PATH = path.join(__dirname, 'data', 'testbed_history.json');
const HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
const HISTORY_WINDOWS = [
{ windowMs: 6 * 60 * 60 * 1000, bucketMs: 1000 },
{ windowMs: 24 * 60 * 60 * 1000, bucketMs: 10 * 1000 },
{ windowMs: 6 * 24 * 60 * 60 * 1000, bucketMs: 60 * 1000 }
];
let testbedHistory = [];
let historyWriteTimer = null;
function loadHistoryFromDisk() {
try {
const raw = fs.readFileSync(HISTORY_PATH, 'utf8');
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
testbedHistory = parsed;
}
} catch (err) {
testbedHistory = [];
}
}
function ensureHistoryDir() {
try {
fs.mkdirSync(path.dirname(HISTORY_PATH), { recursive: true });
} catch (err) {
console.error('Failed to create history directory:', err);
}
}
function scheduleHistoryWrite() {
if (historyWriteTimer) {
return;
}
historyWriteTimer = setTimeout(() => {
historyWriteTimer = null;
try {
fs.writeFileSync(HISTORY_PATH, JSON.stringify(testbedHistory));
} catch (err) {
console.error('Failed to write history:', err);
}
}, 5000);
}
function normalizeCpuUtil(value) {
if (Array.isArray(value)) {
return value.map((val) => Number(val));
}
return Number(value);
}
function mergeCpuUtil(existing, incoming) {
if (Array.isArray(existing) || Array.isArray(incoming)) {
const existingArr = Array.isArray(existing) ? existing : [existing];
const incomingArr = Array.isArray(incoming) ? incoming : [incoming];
const length = Math.max(existingArr.length, incomingArr.length);
const merged = [];
for (let i = 0; i < length; i += 1) {
const a = Number(existingArr[i] ?? existingArr[existingArr.length - 1] ?? 0);
const b = Number(incomingArr[i] ?? incomingArr[incomingArr.length - 1] ?? 0);
merged.push((a + b) / 2);
}
return merged;
}
return (Number(existing) + Number(incoming)) / 2;
}
function compactHistory(history) {
if (!history.length) {
return [];
}
const sorted = history
.filter((entry) => entry && typeof entry.ts === 'number')
.sort((a, b) => a.ts - b.ts);
const now = sorted[sorted.length - 1].ts;
const cutoff = now - HISTORY_RETENTION_MS;
const buckets = new Map();
sorted.forEach((entry) => {
if (entry.ts < cutoff) {
return;
}
const age = now - entry.ts;
let bucketMs = HISTORY_WINDOWS[HISTORY_WINDOWS.length - 1].bucketMs;
for (const window of HISTORY_WINDOWS) {
if (age <= window.windowMs) {
bucketMs = window.bucketMs;
break;
}
}
const bucketKey = `${bucketMs}:${Math.floor(entry.ts / bucketMs)}`;
const existing = buckets.get(bucketKey);
const normalizedUtil = normalizeCpuUtil(entry.cpu_util);
if (!existing) {
buckets.set(bucketKey, {
ts: Math.floor(entry.ts / bucketMs) * bucketMs,
cpu_util: normalizedUtil
});
return;
}
existing.ts = Math.max(existing.ts, entry.ts);
existing.cpu_util = mergeCpuUtil(existing.cpu_util, normalizedUtil);
});
return Array.from(buckets.values()).sort((a, b) => a.ts - b.ts);
}
function recordTestbedHistory(entry) {
testbedHistory.push(entry);
testbedHistory = compactHistory(testbedHistory);
scheduleHistoryWrite();
}
ensureHistoryDir();
loadHistoryFromDisk();
ensureManageTargetsDir();
loadManageTargetsState();
testbedHistory = compactHistory(testbedHistory);
const COMMAND_RETRY_INTERVAL_MS = 10000;
const COMMAND_TIMEOUT_MS = 120000;
let pendingCommand = null;
function clearPendingCommand() {
if (!pendingCommand) {
return;
}
if (pendingCommand.retryTimer) {
clearInterval(pendingCommand.retryTimer);
}
if (pendingCommand.timeoutTimer) {
clearTimeout(pendingCommand.timeoutTimer);
}
pendingCommand = null;
}
function publishCommand(action, extraPayload = {}, options = {}) {
const id = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
const payload = JSON.stringify({
action,
id,
ts: Date.now(),
...extraPayload
});
const expected = new Set(expectedNodeIds);
const maxRetries = Number.isFinite(options.maxRetries)
? Math.max(0, options.maxRetries)
: Math.floor(COMMAND_TIMEOUT_MS / COMMAND_RETRY_INTERVAL_MS);
let retryCount = 0;
pendingCommand = {
id,
action,
expected,
acked: new Set(),
retryTimer: maxRetries > 0 ? setInterval(() => {
retryCount += 1;
if (retryCount > maxRetries) {
clearPendingCommand();
return;
}
client.publish(commandTopic, payload, { retain: false, qos: 1 });
}, COMMAND_RETRY_INTERVAL_MS) : null,
timeoutTimer: setTimeout(() => {
clearPendingCommand();
}, COMMAND_TIMEOUT_MS)
};
client.publish(commandTopic, payload, { retain: false, qos: 1 });
if (expected.size === 0) {
clearPendingCommand();
}
return id;
}
function handleAck(payload) {
if (!pendingCommand || !payload || payload.id !== pendingCommand.id) {
return;
}
if (payload.node) {
pendingCommand.acked.add(payload.node);
}
if (pendingCommand.acked.size >= pendingCommand.expected.size) {
clearPendingCommand();
}
}
function idToPos(id) {
const match = /^apu(\d+)$/.exec(id);
if (!match) return null;
const num = Number(match[1]);
if (num >= 0 && num < 25) {
return { x: num % 5, y: Math.floor(num / 5) };
}
return null;
}
const mesh0MacPositionMap = {};
const mesh1MacPositionMap = {};
const mesh0MacIdMap = {};
const mesh1MacIdMap = {};
Object.keys(mesh0MAC).forEach((id) => {
const pos = idToPos(id);
if (pos) {
mesh0MacPositionMap[mesh0MAC[id]] = pos;
}
mesh0MacIdMap[mesh0MAC[id]] = id;
});
Object.keys(mesh1MAC).forEach((id) => {
const pos = idToPos(id);
if (pos) {
mesh1MacPositionMap[mesh1MAC[id]] = pos;
}
mesh1MacIdMap[mesh1MAC[id]] = id;
});
// console.log(mesh0MAC);
// console.log(mesh1MAC);
function broadcastNodeData() {
const nodeStatus = generateNodeStatus();
const message = JSON.stringify({
nodeData,
nodeStatus,
testbedServerData,
mesh0MacPositionMap,
mesh1MacPositionMap,
mesh0MacIdMap,
mesh1MacIdMap
});
// Send to WebSocket clients
ws.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
// Function to generate status colors based on activity
function generateNodeStatus() {
const now = Date.now();
return nodeData.map((row, y) =>
row.map((cell, x) =>
nodeLastActive[`${y},${x}`] && (now - nodeLastActive[`${y},${x}`] < failureTimeoutMs) ? "lightgreen" : "lightcoral"
)
);
}
client.on('connect', () => {
console.log('Connected to MQTT broker');
client.subscribe(`${mainTopic}/#`, (err) => {
if (!err) {
console.log(`Subscribed to all subtopics of: ${mainTopic}`);
} else {
console.error('Subscription error:', err);
}
});
});
client.on('message', (topic, message) => {
try {
const payload = JSON.parse(message.toString());
if (topic.startsWith(ackTopicPrefix)) {
handleAck(payload);
return;
}
if (topic.startsWith('apu_tb/cmd/')) {
return;
}
if (topic.endsWith('/neighbors')) {
const id = topic.split('/')[1];
const neighbors = Array.isArray(payload.neighbors) ? payload.neighbors : [];
nodeNeighborsById[id] = neighbors;
const pos = nodePositionsById[id];
if (pos && nodeData[pos.y] && nodeData[pos.y][pos.x]) {
nodeData[pos.y][pos.x].neighbors = neighbors;
}
return;
}
if (topic.endsWith('/paths')) {
const id = topic.split('/')[1];
nodePathsById[id] = payload.paths || {};
const pos = nodePositionsById[id];
if (pos && nodeData[pos.y] && nodeData[pos.y][pos.x]) {
nodeData[pos.y][pos.x].paths = nodePathsById[id];
}
return;
}
if (Array.isArray(payload) && payload.length === 2) {
const data = payload[0];
const metadata = payload[1];
const {
uptime,
cpu_temp,
mem_util,
cpu_util,
ptp4l_count,
phc2sys_count,
ping_stats,
XPosition,
YPosition
} = data;
const { id } = metadata;
if (id === 'apuctrl') {
testbedServerData = { id, uptime, cpu_temp, cpu_util };
recordTestbedHistory({
ts: Date.now(),
cpu_util
});
return;
}
if (XPosition >= 0 && XPosition < 5 && YPosition >= 0 && YPosition < 5) {
// nodeData[YPosition][XPosition] = `ID: ${id}
Temp: ${cpu_temp}°C
CUtil: ${cpu_util}
MUtil: ${mem_util}
UpTime: ${uptime}`;
nodeData[YPosition][XPosition] = {
id,
cpu_temp,
mem_util,
cpu_util,
ptp4l_count,
phc2sys_count,
ping_stats,
uptime,
mesh0_mac: mesh0MAC[id],
mesh1_mac: mesh1MAC[id],
neighbors: nodeNeighborsById[id] || [],
paths: nodePathsById[id] || {}
};
nodePositionsById[id] = { x: XPosition, y: YPosition };
// console.log("MAC Links:", JSON.stringify(mesh0_neighbors, null, 2));
// Update the last active timestamp for the node
nodeLastActive[`${YPosition},${XPosition}`] = Date.now();
// console.log(`Node data updated for ID ${id} at position (${XPosition}, ${YPosition})`);
// Broadcast updated data and statuses
// broadcastNodeData();
// console.log("Checkboxes known:", checkedNodeInfo); // Use this array in your app
}
} else {
console.error('Unexpected payload format:', payload);
}
} catch (err) {
console.error('Failed to process message:', err);
}
});
// Periodically broadcast the node status
setInterval(() => {
broadcastNodeData();
}, 1000);