767 lines
25 KiB
JavaScript
767 lines
25 KiB
JavaScript
const http = require("node:http");
|
|
const fs = require("node:fs/promises");
|
|
const path = require("node:path");
|
|
const url = require("node:url");
|
|
const { spawn } = require("node:child_process");
|
|
|
|
const ROOT = __dirname;
|
|
const CONFIG_PATH = process.env.CONFIG_PATH || path.join(ROOT, "config.json");
|
|
const DEFAULT_CONFIG_PATH = path.join(ROOT, "config.json");
|
|
const PORT = Number(process.env.PORT || 8787);
|
|
const AUTH_USERNAME = process.env.MONITOR_USERNAME || "";
|
|
const AUTH_PASSWORD = process.env.MONITOR_PASSWORD || "";
|
|
|
|
let config = null;
|
|
let latestStatus = null;
|
|
let scanTimer = null;
|
|
const previousNetworkSamples = new Map();
|
|
const dashboardClients = new Map();
|
|
let scanInProgress = false;
|
|
|
|
const contentTypes = {
|
|
".html": "text/html; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".json": "application/json; charset=utf-8"
|
|
};
|
|
|
|
async function ensureConfigFile() {
|
|
try {
|
|
await fs.access(CONFIG_PATH);
|
|
} catch {
|
|
await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
|
|
const defaultConfig = await fs.readFile(DEFAULT_CONFIG_PATH, "utf8");
|
|
await fs.writeFile(CONFIG_PATH, defaultConfig, "utf8");
|
|
}
|
|
}
|
|
|
|
async function readConfig() {
|
|
await ensureConfigFile();
|
|
const raw = await fs.readFile(CONFIG_PATH, "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
parsed.refreshIntervalSeconds = Number(parsed.refreshIntervalSeconds || 30);
|
|
parsed.idleScanIntervalSeconds = Number(parsed.idleScanIntervalSeconds || 300);
|
|
parsed.sshTimeoutSeconds = Number(parsed.sshTimeoutSeconds || 8);
|
|
parsed.temperatureThresholdsC = normalizeTemperatureThresholds(parsed.temperatureThresholdsC);
|
|
parsed.metricThresholdsPercent = normalizeMetricThresholds(parsed.metricThresholdsPercent);
|
|
parsed.devices = Array.isArray(parsed.devices) ? parsed.devices : [];
|
|
return parsed;
|
|
}
|
|
|
|
async function writeConfig(nextConfig) {
|
|
const normalized = {
|
|
...nextConfig,
|
|
refreshIntervalSeconds: Number(nextConfig.refreshIntervalSeconds || 30),
|
|
idleScanIntervalSeconds: Number(nextConfig.idleScanIntervalSeconds || 300),
|
|
sshTimeoutSeconds: Number(nextConfig.sshTimeoutSeconds || 8),
|
|
temperatureThresholdsC: normalizeTemperatureThresholds(nextConfig.temperatureThresholdsC),
|
|
metricThresholdsPercent: normalizeMetricThresholds(nextConfig.metricThresholdsPercent),
|
|
devices: Array.isArray(nextConfig.devices) ? nextConfig.devices : []
|
|
};
|
|
await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
|
|
await fs.writeFile(CONFIG_PATH, JSON.stringify(normalized, null, 2) + "\n", "utf8");
|
|
config = normalized;
|
|
restartScanner();
|
|
}
|
|
|
|
function classifyTemp(tempC) {
|
|
const thresholds = config.temperatureThresholdsC || {};
|
|
if (tempC >= (thresholds.critical || 80)) return "critical";
|
|
if (tempC >= (thresholds.hot || 70)) return "hot";
|
|
if (tempC >= (thresholds.warning || 60)) return "warning";
|
|
return "normal";
|
|
}
|
|
|
|
function normalizeTemperatureThresholds(thresholds = {}) {
|
|
return {
|
|
warning: Number(thresholds.warning ?? 60),
|
|
hot: Number(thresholds.hot ?? 70),
|
|
critical: Number(thresholds.critical ?? 80)
|
|
};
|
|
}
|
|
|
|
function normalizeMetricThresholds(thresholds = {}) {
|
|
const defaults = {
|
|
cpu: { warning: 60, hot: 75, critical: 90 },
|
|
memory: { warning: 70, hot: 85, critical: 95 },
|
|
disk: { warning: 70, hot: 85, critical: 95 },
|
|
swap: { warning: 50, hot: 75, critical: 90 }
|
|
};
|
|
|
|
return Object.fromEntries(
|
|
Object.entries(defaults).map(([key, value]) => [
|
|
key,
|
|
{
|
|
warning: Number(thresholds[key]?.warning ?? value.warning),
|
|
hot: Number(thresholds[key]?.hot ?? value.hot),
|
|
critical: Number(thresholds[key]?.critical ?? value.critical)
|
|
}
|
|
])
|
|
);
|
|
}
|
|
|
|
function seededNumber(seed, min, max) {
|
|
let hash = 0;
|
|
for (let i = 0; i < seed.length; i += 1) {
|
|
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
|
}
|
|
const wave = (Math.sin(Date.now() / 45000 + hash) + 1) / 2;
|
|
return min + wave * (max - min);
|
|
}
|
|
|
|
function mockMetrics(device) {
|
|
const baseTemp = device.model === "RPi 5" ? 50 : device.model === "RPi 4" ? 47 : 44;
|
|
const temp = seededNumber(`${device.id}:temp`, baseTemp - 4, baseTemp + 18);
|
|
const dockerRole = /docker|gitea|nginx/i.test(device.role || "");
|
|
const haRole = /home assistant/i.test(device.role || "");
|
|
|
|
return {
|
|
cpuTempC: Number(temp.toFixed(1)),
|
|
tempLevel: classifyTemp(temp),
|
|
cpuCores: device.model === "RPi 3" ? 4 : 4,
|
|
cpuUsagePercent: Math.round(seededNumber(`${device.id}:cpu`, 6, dockerRole || haRole ? 62 : 38)),
|
|
memoryTotalBytes: device.model === "RPi 5" ? 8 * 1024 ** 3 : device.model === "RPi 4" ? 4 * 1024 ** 3 : 1024 ** 3,
|
|
memoryUsedPercent: Math.round(seededNumber(`${device.id}:mem`, 24, dockerRole || haRole ? 78 : 58)),
|
|
swapTotalBytes: 100 * 1024 ** 2,
|
|
swapUsedPercent: Math.round(seededNumber(`${device.id}:swap`, 0, dockerRole || haRole ? 34 : 18)),
|
|
diskTotalBytes: 32 * 1024 ** 3,
|
|
diskUsedPercent: Math.round(seededNumber(`${device.id}:disk`, 18, dockerRole ? 84 : 66)),
|
|
loadAverage: [
|
|
Number(seededNumber(`${device.id}:load1`, 0.05, 1.8).toFixed(2)),
|
|
Number(seededNumber(`${device.id}:load5`, 0.05, 1.4).toFixed(2)),
|
|
Number(seededNumber(`${device.id}:load15`, 0.05, 1.1).toFixed(2))
|
|
],
|
|
uptime: `${Math.round(seededNumber(`${device.id}:uptime`, 2, 64))} dias`,
|
|
docker: dockerRole
|
|
? {
|
|
installed: true,
|
|
running: Math.round(seededNumber(`${device.id}:docker-running`, 3, 12)),
|
|
stopped: Math.round(seededNumber(`${device.id}:docker-stopped`, 0, 2)),
|
|
unhealthy: 0
|
|
}
|
|
: { installed: false, running: 0, stopped: 0, unhealthy: 0 },
|
|
homeAssistant: haRole ? { detected: true, status: "running" } : { detected: false, status: "n/a" },
|
|
network: {
|
|
interface: device.model === "RPi 3" ? "wlan0" : "eth0",
|
|
type: device.model === "RPi 3" ? "WiFi" : "Cable",
|
|
state: "up",
|
|
ipAddress: device.host,
|
|
speedMbps: device.model === "RPi 3" ? null : 1000,
|
|
rxBytesPerSecond: Math.round(seededNumber(`${device.id}:rx`, 1200, 95000)),
|
|
txBytesPerSecond: Math.round(seededNumber(`${device.id}:tx`, 700, 55000))
|
|
}
|
|
};
|
|
}
|
|
|
|
const REMOTE_METRICS_SCRIPT = `
|
|
set +e
|
|
temp_raw="$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null)"
|
|
if [ -n "$temp_raw" ]; then
|
|
temp_c="$(awk "BEGIN { printf \\"%.1f\\", $temp_raw / 1000 }")"
|
|
else
|
|
temp_c=""
|
|
fi
|
|
|
|
read cpu user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat
|
|
idle_a=$((idle + iowait))
|
|
total_a=$((user + nice + system + idle + iowait + irq + softirq + steal))
|
|
sleep 0.4
|
|
read cpu user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat
|
|
idle_b=$((idle + iowait))
|
|
total_b=$((user + nice + system + idle + iowait + irq + softirq + steal))
|
|
total_delta=$((total_b - total_a))
|
|
idle_delta=$((idle_b - idle_a))
|
|
if [ "$total_delta" -gt 0 ]; then
|
|
cpu_usage="$(awk "BEGIN { printf \\"%.0f\\", (100 * ($total_delta - $idle_delta)) / $total_delta }")"
|
|
else
|
|
cpu_usage=""
|
|
fi
|
|
|
|
cpu_cores="$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || grep -c '^processor' /proc/cpuinfo 2>/dev/null)"
|
|
memory_total_kb="$(awk '/MemTotal/ { print $2 }' /proc/meminfo)"
|
|
memory_usage="$(awk '/MemTotal/ { total=$2 } /MemAvailable/ { available=$2 } END { if (total > 0) printf "%.0f", (100 * (total - available)) / total }' /proc/meminfo)"
|
|
swap_total_kb="$(awk '/SwapTotal/ { print $2 }' /proc/meminfo)"
|
|
swap_usage="$(awk '/SwapTotal/ { total=$2 } /SwapFree/ { free=$2 } END { if (total > 0) printf "%.0f", (100 * (total - free)) / total; else printf "0" }' /proc/meminfo)"
|
|
disk_usage="$(df -P / 2>/dev/null | awk 'NR==2 { gsub(/%/, "", $5); print $5 }')"
|
|
disk_total_kb="$(df -P / 2>/dev/null | awk 'NR==2 { print $2 }')"
|
|
load_average="$(cut -d ' ' -f 1-3 /proc/loadavg 2>/dev/null)"
|
|
uptime_text="$(uptime -p 2>/dev/null | sed 's/^up //')"
|
|
|
|
default_iface="$(ip route show default 2>/dev/null | awk 'NR==1 { print $5 }')"
|
|
if [ -z "$default_iface" ]; then
|
|
default_iface="$(ls /sys/class/net 2>/dev/null | grep -v '^lo$' | head -n 1)"
|
|
fi
|
|
net_type=""
|
|
net_state=""
|
|
net_carrier=""
|
|
net_speed=""
|
|
net_rx=""
|
|
net_tx=""
|
|
net_ip=""
|
|
if [ -n "$default_iface" ] && [ -d "/sys/class/net/$default_iface" ]; then
|
|
if [ -d "/sys/class/net/$default_iface/wireless" ] || echo "$default_iface" | grep -Eq '^(wl|wlan)'; then
|
|
net_type="WiFi"
|
|
else
|
|
net_type="Cable"
|
|
fi
|
|
net_state="$(cat "/sys/class/net/$default_iface/operstate" 2>/dev/null)"
|
|
net_carrier="$(cat "/sys/class/net/$default_iface/carrier" 2>/dev/null)"
|
|
net_speed="$(cat "/sys/class/net/$default_iface/speed" 2>/dev/null)"
|
|
net_rx="$(cat "/sys/class/net/$default_iface/statistics/rx_bytes" 2>/dev/null)"
|
|
net_tx="$(cat "/sys/class/net/$default_iface/statistics/tx_bytes" 2>/dev/null)"
|
|
net_ip="$(ip -4 addr show "$default_iface" 2>/dev/null | awk '/inet / { sub(/\/.*/, "", $2); print $2; exit }')"
|
|
fi
|
|
|
|
docker_installed=0
|
|
docker_running=0
|
|
docker_stopped=0
|
|
docker_unhealthy=0
|
|
if command -v docker >/dev/null 2>&1; then
|
|
docker_installed=1
|
|
docker_running="$(docker ps -q 2>/dev/null | wc -l | tr -d ' ')"
|
|
docker_stopped="$(docker ps -aq -f status=exited -f status=created -f status=dead 2>/dev/null | wc -l | tr -d ' ')"
|
|
docker_unhealthy="$(docker ps -q -f health=unhealthy 2>/dev/null | wc -l | tr -d ' ')"
|
|
fi
|
|
|
|
ha_status="n/a"
|
|
if command -v systemctl >/dev/null 2>&1; then
|
|
for svc in home-assistant@homeassistant home-assistant homeassistant hassio-supervisor; do
|
|
svc_state="$(systemctl is-active "$svc" 2>/dev/null)"
|
|
if [ "$svc_state" = "active" ]; then
|
|
ha_status="running"
|
|
break
|
|
elif [ "$svc_state" = "failed" ]; then
|
|
ha_status="$svc_state"
|
|
fi
|
|
done
|
|
fi
|
|
if [ "$ha_status" = "n/a" ] && [ "$docker_installed" = "1" ]; then
|
|
if docker ps --format '{{.Names}}' 2>/dev/null | grep -Eiq 'homeassistant|home-assistant|hassio'; then
|
|
ha_status="running"
|
|
elif docker ps -a --format '{{.Names}}' 2>/dev/null | grep -Eiq 'homeassistant|home-assistant|hassio'; then
|
|
ha_status="stopped"
|
|
fi
|
|
fi
|
|
|
|
printf 'cpuTempC=%s\\n' "$temp_c"
|
|
printf 'cpuCores=%s\\n' "$cpu_cores"
|
|
printf 'cpuUsagePercent=%s\\n' "$cpu_usage"
|
|
printf 'memoryTotalBytes=%s\\n' "$((memory_total_kb * 1024))"
|
|
printf 'memoryUsedPercent=%s\\n' "$memory_usage"
|
|
printf 'swapTotalBytes=%s\\n' "$((swap_total_kb * 1024))"
|
|
printf 'swapUsedPercent=%s\\n' "$swap_usage"
|
|
printf 'diskTotalBytes=%s\\n' "$((disk_total_kb * 1024))"
|
|
printf 'diskUsedPercent=%s\\n' "$disk_usage"
|
|
printf 'loadAverage=%s\\n' "$load_average"
|
|
printf 'uptime=%s\\n' "$uptime_text"
|
|
printf 'networkInterface=%s\\n' "$default_iface"
|
|
printf 'networkType=%s\\n' "$net_type"
|
|
printf 'networkState=%s\\n' "$net_state"
|
|
printf 'networkCarrier=%s\\n' "$net_carrier"
|
|
printf 'networkSpeedMbps=%s\\n' "$net_speed"
|
|
printf 'networkRxBytes=%s\\n' "$net_rx"
|
|
printf 'networkTxBytes=%s\\n' "$net_tx"
|
|
printf 'networkIpAddress=%s\\n' "$net_ip"
|
|
printf 'dockerInstalled=%s\\n' "$docker_installed"
|
|
printf 'dockerRunning=%s\\n' "$docker_running"
|
|
printf 'dockerStopped=%s\\n' "$docker_stopped"
|
|
printf 'dockerUnhealthy=%s\\n' "$docker_unhealthy"
|
|
printf 'homeAssistantStatus=%s\\n' "$ha_status"
|
|
`;
|
|
|
|
function parseKeyValueOutput(output) {
|
|
const values = {};
|
|
for (const line of output.split(/\r?\n/)) {
|
|
const separator = line.indexOf("=");
|
|
if (separator === -1) continue;
|
|
values[line.slice(0, separator)] = line.slice(separator + 1);
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function toNumber(value, fallback = 0) {
|
|
if (value === "" || value === null || value === undefined) return fallback;
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
}
|
|
|
|
function networkRates(device, values) {
|
|
const sampleKey = device.id || `${device.host}:${device.port || 22}`;
|
|
const now = Date.now();
|
|
const rxBytes = toNumber(values.networkRxBytes, 0);
|
|
const txBytes = toNumber(values.networkTxBytes, 0);
|
|
const previous = previousNetworkSamples.get(sampleKey);
|
|
previousNetworkSamples.set(sampleKey, { rxBytes, txBytes, at: now });
|
|
|
|
let rxBytesPerSecond = null;
|
|
let txBytesPerSecond = null;
|
|
if (previous) {
|
|
const seconds = (now - previous.at) / 1000;
|
|
if (seconds > 0 && rxBytes >= previous.rxBytes && txBytes >= previous.txBytes) {
|
|
rxBytesPerSecond = Math.round((rxBytes - previous.rxBytes) / seconds);
|
|
txBytesPerSecond = Math.round((txBytes - previous.txBytes) / seconds);
|
|
}
|
|
}
|
|
|
|
return {
|
|
interface: values.networkInterface || "--",
|
|
type: values.networkType || "--",
|
|
state: values.networkState || "--",
|
|
carrier: values.networkCarrier === "1",
|
|
ipAddress: values.networkIpAddress || "",
|
|
speedMbps: toNumber(values.networkSpeedMbps, null),
|
|
rxBytes,
|
|
txBytes,
|
|
rxBytesPerSecond,
|
|
txBytesPerSecond
|
|
};
|
|
}
|
|
|
|
function runCommandWithInput(command, args, input, timeoutMs) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let settled = false;
|
|
|
|
const timer = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
child.kill("SIGTERM");
|
|
reject(new Error(`Timeout SSH despues de ${Math.round(timeoutMs / 1000)}s`));
|
|
}, timeoutMs);
|
|
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk.toString("utf8");
|
|
if (stdout.length > 128 * 1024) stdout = stdout.slice(-128 * 1024);
|
|
});
|
|
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk.toString("utf8");
|
|
if (stderr.length > 32 * 1024) stderr = stderr.slice(-32 * 1024);
|
|
});
|
|
|
|
child.on("error", (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
});
|
|
|
|
child.on("close", (code) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
if (code === 0) {
|
|
resolve({ stdout, stderr });
|
|
} else {
|
|
reject(new Error((stderr || `Comando SSH termino con codigo ${code}`).trim()));
|
|
}
|
|
});
|
|
|
|
child.stdin.end(input);
|
|
});
|
|
}
|
|
|
|
async function sshMetrics(device) {
|
|
if (!device.password) {
|
|
throw new Error("Password SSH no configurada");
|
|
}
|
|
|
|
const timeoutMs = Math.max(2, config.sshTimeoutSeconds || 8) * 1000;
|
|
const port = Number(device.port || 22);
|
|
const destination = `${device.username || "pi"}@${device.host}`;
|
|
const args = [
|
|
"-p",
|
|
device.password,
|
|
"ssh",
|
|
"-o",
|
|
"StrictHostKeyChecking=no",
|
|
"-o",
|
|
"UserKnownHostsFile=/tmp/monitor-rpi-known-hosts",
|
|
"-o",
|
|
"ConnectTimeout=5",
|
|
"-p",
|
|
String(port),
|
|
destination,
|
|
"sh",
|
|
"-s"
|
|
];
|
|
|
|
const { stdout } = await runCommandWithInput("sshpass", args, REMOTE_METRICS_SCRIPT, timeoutMs);
|
|
const values = parseKeyValueOutput(stdout);
|
|
const temp = toNumber(values.cpuTempC, 0);
|
|
const loadAverage = String(values.loadAverage || "")
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.map((value) => toNumber(value, 0));
|
|
while (loadAverage.length < 3) loadAverage.push(0);
|
|
|
|
return {
|
|
cpuTempC: Number(temp.toFixed(1)),
|
|
tempLevel: classifyTemp(temp),
|
|
cpuCores: Math.round(toNumber(values.cpuCores, 0)),
|
|
cpuUsagePercent: Math.round(toNumber(values.cpuUsagePercent, 0)),
|
|
memoryTotalBytes: toNumber(values.memoryTotalBytes, 0),
|
|
memoryUsedPercent: Math.round(toNumber(values.memoryUsedPercent, 0)),
|
|
swapTotalBytes: toNumber(values.swapTotalBytes, 0),
|
|
swapUsedPercent: Math.round(toNumber(values.swapUsedPercent, 0)),
|
|
diskTotalBytes: toNumber(values.diskTotalBytes, 0),
|
|
diskUsedPercent: Math.round(toNumber(values.diskUsedPercent, 0)),
|
|
loadAverage: loadAverage.slice(0, 3),
|
|
uptime: values.uptime || "--",
|
|
docker: {
|
|
installed: values.dockerInstalled === "1",
|
|
running: Math.round(toNumber(values.dockerRunning, 0)),
|
|
stopped: Math.round(toNumber(values.dockerStopped, 0)),
|
|
unhealthy: Math.round(toNumber(values.dockerUnhealthy, 0))
|
|
},
|
|
homeAssistant: {
|
|
detected: values.homeAssistantStatus && values.homeAssistantStatus !== "n/a",
|
|
status: values.homeAssistantStatus || "n/a"
|
|
},
|
|
network: networkRates(device, values)
|
|
};
|
|
}
|
|
|
|
async function scanDevice(device) {
|
|
if (!device.active) {
|
|
return {
|
|
...publicDeviceConfig(device),
|
|
online: false,
|
|
status: "disabled",
|
|
metrics: null,
|
|
checkedAt: null,
|
|
error: null
|
|
};
|
|
}
|
|
|
|
if (config.mockMode) {
|
|
return {
|
|
...publicDeviceConfig(device),
|
|
online: true,
|
|
status: "online",
|
|
metrics: mockMetrics(device),
|
|
checkedAt: new Date().toISOString(),
|
|
error: null
|
|
};
|
|
}
|
|
|
|
try {
|
|
return {
|
|
...publicDeviceConfig(device),
|
|
online: true,
|
|
status: "online",
|
|
metrics: await sshMetrics(device),
|
|
checkedAt: new Date().toISOString(),
|
|
error: null
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
...publicDeviceConfig(device),
|
|
online: false,
|
|
status: "ssh-error",
|
|
metrics: null,
|
|
checkedAt: new Date().toISOString(),
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
function publicDeviceConfig(device) {
|
|
const { password, ...safeDevice } = device;
|
|
return safeDevice;
|
|
}
|
|
|
|
function buildSummary(devices) {
|
|
const active = devices.filter((device) => device.active);
|
|
const online = active.filter((device) => device.online);
|
|
const temps = online
|
|
.filter((device) => device.metrics && Number.isFinite(device.metrics.cpuTempC))
|
|
.map((device) => ({ name: device.name, value: device.metrics.cpuTempC }));
|
|
const hottest = temps.sort((a, b) => b.value - a.value)[0] || null;
|
|
const alerts = [];
|
|
|
|
for (const device of devices) {
|
|
if (!device.active) continue;
|
|
if (!device.online) alerts.push(`${device.name}: sin conexion`);
|
|
if (device.metrics?.tempLevel === "critical") alerts.push(`${device.name}: temperatura critica`);
|
|
if (device.metrics?.diskUsedPercent >= 85) alerts.push(`${device.name}: disco alto`);
|
|
if (device.metrics?.swapUsedPercent >= (config.metricThresholdsPercent?.swap?.critical || 90)) alerts.push(`${device.name}: swap alto`);
|
|
if (device.metrics?.docker?.unhealthy > 0) alerts.push(`${device.name}: contenedores unhealthy`);
|
|
}
|
|
|
|
return {
|
|
total: devices.length,
|
|
active: active.length,
|
|
online: online.length,
|
|
offline: active.length - online.length,
|
|
hottest,
|
|
alerts
|
|
};
|
|
}
|
|
|
|
function activeClientCount() {
|
|
const now = Date.now();
|
|
const maxAgeMs = Math.max(30, config?.refreshIntervalSeconds || 30) * 1500;
|
|
for (const [clientId, client] of dashboardClients) {
|
|
if (now - client.lastSeen > maxAgeMs) dashboardClients.delete(clientId);
|
|
}
|
|
return dashboardClients.size;
|
|
}
|
|
|
|
function clientSnapshot() {
|
|
const now = Date.now();
|
|
activeClientCount();
|
|
return [...dashboardClients.entries()].map(([clientId, client]) => ({
|
|
clientId,
|
|
remoteAddress: client.remoteAddress || "",
|
|
userAgent: client.userAgent || "",
|
|
lastSeen: new Date(client.lastSeen).toISOString(),
|
|
ageSeconds: Math.round((now - client.lastSeen) / 1000)
|
|
}));
|
|
}
|
|
|
|
function currentScanIntervalSeconds() {
|
|
return activeClientCount() > 0
|
|
? Math.max(5, config.refreshIntervalSeconds || 30)
|
|
: Math.max(60, config.idleScanIntervalSeconds || 300);
|
|
}
|
|
|
|
function registerClient(clientId, req) {
|
|
const userAgent = req.headers["user-agent"] || "";
|
|
if (userAgent.includes("Codex/")) {
|
|
return null;
|
|
}
|
|
|
|
const hadClients = activeClientCount() > 0;
|
|
const id = clientId || `client-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
dashboardClients.set(id, {
|
|
lastSeen: Date.now(),
|
|
remoteAddress: req.socket.remoteAddress || "",
|
|
userAgent
|
|
});
|
|
if (!hadClients) {
|
|
scheduleNextScan(1000);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
async function scanAll() {
|
|
if (scanInProgress) return latestStatus;
|
|
scanInProgress = true;
|
|
if (!config) config = await readConfig();
|
|
try {
|
|
const devices = await Promise.all(config.devices.map(scanDevice));
|
|
latestStatus = {
|
|
mockMode: Boolean(config.mockMode),
|
|
refreshIntervalSeconds: config.refreshIntervalSeconds,
|
|
idleScanIntervalSeconds: config.idleScanIntervalSeconds,
|
|
activeClients: activeClientCount(),
|
|
currentScanIntervalSeconds: currentScanIntervalSeconds(),
|
|
temperatureThresholdsC: config.temperatureThresholdsC,
|
|
metricThresholdsPercent: config.metricThresholdsPercent,
|
|
lastUpdated: new Date().toISOString(),
|
|
summary: buildSummary(devices),
|
|
devices
|
|
};
|
|
return latestStatus;
|
|
} finally {
|
|
scanInProgress = false;
|
|
}
|
|
}
|
|
|
|
function scheduleNextScan(delayMs = currentScanIntervalSeconds() * 1000) {
|
|
if (scanTimer) clearTimeout(scanTimer);
|
|
scanTimer = setTimeout(runScheduledScan, delayMs);
|
|
}
|
|
|
|
async function runScheduledScan() {
|
|
try {
|
|
await scanAll();
|
|
} catch (error) {
|
|
latestStatus = {
|
|
error: error.message,
|
|
lastUpdated: new Date().toISOString(),
|
|
devices: []
|
|
};
|
|
} finally {
|
|
scheduleNextScan();
|
|
}
|
|
}
|
|
|
|
function restartScanner() {
|
|
if (scanTimer) clearTimeout(scanTimer);
|
|
runScheduledScan();
|
|
}
|
|
|
|
function sendJson(res, statusCode, payload) {
|
|
const body = JSON.stringify(payload, null, 2);
|
|
res.writeHead(statusCode, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"Cache-Control": "no-store",
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
"Access-Control-Allow-Headers": "Content-Type"
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function isAuthEnabled() {
|
|
return Boolean(AUTH_USERNAME && AUTH_PASSWORD);
|
|
}
|
|
|
|
function isAuthorized(req) {
|
|
if (!isAuthEnabled()) return true;
|
|
const header = req.headers.authorization || "";
|
|
const [scheme, encoded] = header.split(" ");
|
|
if (scheme !== "Basic" || !encoded) return false;
|
|
const decoded = Buffer.from(encoded, "base64").toString("utf8");
|
|
const separatorIndex = decoded.indexOf(":");
|
|
const username = decoded.slice(0, separatorIndex);
|
|
const password = decoded.slice(separatorIndex + 1);
|
|
return username === AUTH_USERNAME && password === AUTH_PASSWORD;
|
|
}
|
|
|
|
function requestAuth(res) {
|
|
res.writeHead(401, {
|
|
"WWW-Authenticate": 'Basic realm="Monitor RPi"',
|
|
"Content-Type": "text/plain; charset=utf-8"
|
|
});
|
|
res.end("Authentication required");
|
|
}
|
|
|
|
async function readBody(req) {
|
|
const chunks = [];
|
|
for await (const chunk of req) chunks.push(chunk);
|
|
return Buffer.concat(chunks).toString("utf8");
|
|
}
|
|
|
|
async function serveStatic(res, pathname) {
|
|
const safePath = pathname === "/" ? "/dashboard.html" : pathname;
|
|
const filePath = path.normalize(path.join(ROOT, safePath));
|
|
if (!filePath.startsWith(ROOT)) {
|
|
res.writeHead(403);
|
|
res.end("Forbidden");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = await fs.readFile(filePath);
|
|
res.writeHead(200, {
|
|
"Content-Type": contentTypes[path.extname(filePath)] || "application/octet-stream",
|
|
"Cache-Control": "no-store"
|
|
});
|
|
res.end(data);
|
|
} catch {
|
|
res.writeHead(404);
|
|
res.end("Not found");
|
|
}
|
|
}
|
|
|
|
async function handleRequest(req, res) {
|
|
if (req.method === "OPTIONS") {
|
|
res.writeHead(204, {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
"Access-Control-Allow-Headers": "Content-Type"
|
|
});
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const { pathname } = url.parse(req.url);
|
|
|
|
try {
|
|
if (!isAuthorized(req)) {
|
|
requestAuth(res);
|
|
return;
|
|
}
|
|
|
|
if (req.method === "GET" && pathname === "/api/status") {
|
|
if (!latestStatus) await scanAll();
|
|
latestStatus.activeClients = activeClientCount();
|
|
latestStatus.currentScanIntervalSeconds = currentScanIntervalSeconds();
|
|
latestStatus.clients = clientSnapshot();
|
|
sendJson(res, 200, latestStatus);
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && pathname === "/api/client-heartbeat") {
|
|
const body = await readBody(req);
|
|
const payload = body ? JSON.parse(body) : {};
|
|
const clientId = registerClient(payload.clientId, req);
|
|
sendJson(res, 200, {
|
|
ok: true,
|
|
ignored: clientId === null,
|
|
clientId,
|
|
activeClients: activeClientCount(),
|
|
currentScanIntervalSeconds: currentScanIntervalSeconds()
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && pathname === "/api/clients/clear") {
|
|
dashboardClients.clear();
|
|
scheduleNextScan();
|
|
sendJson(res, 200, {
|
|
ok: true,
|
|
activeClients: activeClientCount(),
|
|
currentScanIntervalSeconds: currentScanIntervalSeconds()
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.method === "GET" && pathname === "/api/config") {
|
|
sendJson(res, 200, config);
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && pathname === "/api/config") {
|
|
const body = await readBody(req);
|
|
await writeConfig(JSON.parse(body));
|
|
sendJson(res, 200, { ok: true, config });
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && pathname === "/api/reload") {
|
|
config = await readConfig();
|
|
restartScanner();
|
|
sendJson(res, 200, { ok: true, config });
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && pathname === "/api/scan-now") {
|
|
await scanAll();
|
|
scheduleNextScan();
|
|
sendJson(res, 200, { ok: true, status: latestStatus });
|
|
return;
|
|
}
|
|
|
|
if (req.method === "GET") {
|
|
await serveStatic(res, pathname);
|
|
return;
|
|
}
|
|
|
|
sendJson(res, 405, { error: "Method not allowed" });
|
|
} catch (error) {
|
|
sendJson(res, 500, { error: error.message });
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
config = await readConfig();
|
|
restartScanner();
|
|
http.createServer(handleRequest).listen(PORT, () => {
|
|
console.log(`Monitor RPi disponible en http://localhost:${PORT}`);
|
|
console.log(`API de estado: http://localhost:${PORT}/api/status`);
|
|
console.log(`Configuracion: ${CONFIG_PATH}`);
|
|
console.log(`Autenticacion: ${isAuthEnabled() ? "activa" : "desactivada"}`);
|
|
});
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|