Version inicial monitor RPi
This commit is contained in:
@@ -0,0 +1,766 @@
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user