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
299 lines
11 KiB
JavaScript
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 };
|
|
|