Files
p2ns/includes/admin/routes/settings.js
T
Raven Scott 398e042e81 Fix: Support floating point numbers in settings (Consensus Quorum Threshold)
Changed number type settings parsing from parseInt to parseFloat to
properly handle decimal values. Added step="any" attribute to number
inputs in the frontend when the setting has decimal min/max/default
values.

This ensures that Consensus Quorum Threshold and other floating point
settings (like METRICS_SAMPLING_RATE) can accept and save decimal
values like 0.5, 0.67, etc. instead of being truncated to integers.

- Updated includes/admin/admin-backend/routes/settings.js
- Updated includes/admin/routes/settings.js
- Updated includes/admin/admin-frontend/ui/settings.js
2025-12-26 21:20:58 -05:00

299 lines
11 KiB
JavaScript

const fs = require('fs').promises;
const state = require('../../infrastructure/state');
const { logError, logWarn } = require('../../infrastructure/logger');
const { getAvailableIPsForSubnet } = require('../../networking/virtual_interfaces');
const { settingsMetadata, restartRequiredSettings, liveReloadableSettings, envWhitelist, applyLiveSettings } = require('../settings');
const { broadcast } = require('../websocket');
async function handleSettingsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/settings') {
try {
const settings = {};
const metadata = {};
envWhitelist.forEach(key => {
const value = process.env[key] || '';
settings[key] = value;
if (settingsMetadata[key]) {
metadata[key] = {
...settingsMetadata[key],
currentValue: value
};
}
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ settings, metadata }));
} catch (err) {
logError('Admin', `Failed to fetch settings: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch settings' }));
}
return true;
}
if (method === 'GET' && urlPath === '/api/subnets') {
try {
let subnets = [];
if (process.env.SUBNETS) {
try {
subnets = JSON.parse(process.env.SUBNETS);
if (!Array.isArray(subnets)) {
subnets = [];
}
} catch (err) {
logWarn('Admin', `Failed to parse SUBNETS: ${err.message}`);
subnets = [];
}
}
if (subnets.length === 0) {
const subnetBase = process.env.SUBNET_BASE || '192.168.3';
const baseParts = subnetBase.split('.');
if (baseParts.length === 3) {
subnets = [{
base: `${subnetBase}.0`,
cidr: 24,
startIndex: parseInt(process.env.INITIAL_IP_INDEX || '2', 10),
name: 'Default Subnet'
}];
}
}
const subnetInfo = subnets.map((subnet, index) => {
const available = getAvailableIPsForSubnet(subnet);
const used = Array.from(state.domainToIPMap.values()).filter(ip => {
const ipParts = ip.split('.');
const subnetParts = subnet.base.split('.');
return ipParts[0] === subnetParts[0] &&
ipParts[1] === subnetParts[1] &&
ipParts[2] === subnetParts[2];
}).length;
return {
...subnet,
index,
available,
used,
remaining: Math.max(0, available - used)
};
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ subnets: subnetInfo }));
} catch (err) {
logError('Admin', `Failed to fetch subnets: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch subnets' }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/subnets') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { subnets } = JSON.parse(body);
if (!Array.isArray(subnets)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'subnets must be an array' }));
return;
}
const errors = [];
subnets.forEach((subnet, index) => {
if (!subnet || typeof subnet !== 'object') {
errors.push(`subnets[${index}]: must be an object`);
return;
}
if (!subnet.base || typeof subnet.base !== 'string') {
errors.push(`subnets[${index}]: base is required and must be a string`);
} else if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(subnet.base)) {
errors.push(`subnets[${index}]: base must be a valid IPv4 address`);
}
const cidr = parseInt(subnet.cidr, 10);
if (isNaN(cidr) || cidr < 1 || cidr > 32) {
errors.push(`subnets[${index}]: cidr must be between 1 and 32`);
}
const startIndex = parseInt(subnet.startIndex || process.env.INITIAL_IP_INDEX || '2', 10);
const maxIPs = Math.pow(2, 32 - cidr) - 2;
if (isNaN(startIndex) || startIndex < 1 || startIndex > Math.min(254, maxIPs)) {
errors.push(`subnets[${index}]: startIndex must be between 1 and ${Math.min(254, maxIPs)}`);
}
if (!subnet.name || typeof subnet.name !== 'string') {
subnet.name = `Subnet ${index + 1}`;
}
});
if (errors.length > 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Validation failed', errors }));
return;
}
process.env.SUBNETS = JSON.stringify(subnets);
const envContent = envWhitelist.map(key => {
if (key === 'SUBNETS') {
return `${key}=${JSON.stringify(subnets)}`;
}
return `${key}=${process.env[key] || ''}`;
}).join('\n');
await fs.writeFile('.env', envContent);
state.subnets = subnets;
state.currentSubnetIndex = 0;
state.subnetIPCounters.clear();
subnets.forEach((subnet, index) => {
state.subnetIPCounters.set(index, subnet.startIndex || parseInt(process.env.INITIAL_IP_INDEX || '2', 10));
});
broadcast({ type: 'update-settings' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Subnets updated. Restart required to fully apply changes.',
restartRequired: true
}));
} catch (err) {
logError('Admin', `Failed to update subnets: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: err.message }));
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/update-settings') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { settings } = JSON.parse(body);
const errors = [];
for (const [key, value] of Object.entries(settings)) {
if (!envWhitelist.includes(key)) {
errors.push(`Setting ${key} is not whitelisted`);
continue;
}
const meta = settingsMetadata[key];
if (meta) {
if (meta.type === 'number') {
// Use parseFloat to support both integers and floating point numbers
// parseFloat works for integers too (e.g., parseFloat("5") returns 5)
const numValue = parseFloat(value);
if (isNaN(numValue)) {
errors.push(`${meta.label}: must be a number`);
continue;
}
if (meta.min !== undefined && numValue < meta.min) {
errors.push(`${meta.label}: must be at least ${meta.min}`);
continue;
}
if (meta.max !== undefined && numValue > meta.max) {
errors.push(`${meta.label}: must be at most ${meta.max}`);
continue;
}
process.env[key] = numValue.toString();
} else if (meta.type === 'checkbox') {
process.env[key] = (value === true || value === 'true' || value === '1') ? 'true' : 'false';
} else if (key === 'SUBNETS') {
try {
const subnets = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(subnets)) {
errors.push('SUBNETS must be an array');
continue;
}
process.env[key] = JSON.stringify(subnets);
} catch (err) {
errors.push(`SUBNETS: invalid JSON - ${err.message}`);
continue;
}
} else {
process.env[key] = value;
}
} else {
if (key === 'SUBNETS') {
try {
const subnets = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(subnets)) {
errors.push('SUBNETS must be an array');
continue;
}
process.env[key] = JSON.stringify(subnets);
} catch (err) {
errors.push(`SUBNETS: invalid JSON - ${err.message}`);
continue;
}
} else {
process.env[key] = value;
}
}
}
if (errors.length > 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Validation failed', errors }));
return;
}
const envContent = envWhitelist.map(key => {
if (key === 'SUBNETS') {
return `${key}=${process.env[key] || '[]'}`;
}
return `${key}=${process.env[key] || ''}`;
}).join('\n');
await fs.writeFile('.env', envContent);
const restartRequired = Object.keys(settings).some(key => restartRequiredSettings.includes(key));
const liveSettings = {};
for (const [key, value] of Object.entries(settings)) {
if (liveReloadableSettings.includes(key)) {
liveSettings[key] = value;
}
}
if (Object.keys(liveSettings).length > 0) {
await applyLiveSettings(liveSettings);
}
broadcast({ type: 'update-settings' });
if (restartRequired) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Settings saved. Some settings require restart to take effect.',
restartRequired: true,
restartRequiredSettings: Object.keys(settings).filter(k => restartRequiredSettings.includes(k))
}));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Settings saved and applied successfully.',
restartRequired: false
}));
}
} catch (err) {
logError('Admin', `Failed to update settings: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
return false;
}
module.exports = { handleSettingsRoutes };