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 || ""; const DEFAULT_SSH_PRIVATE_KEY_PATH = "/ssh/monitor_rpi_ed25519"; const DEFAULT_SSH_PUBLIC_KEY_PATH = "/ssh/monitor_rpi_ed25519.pub"; 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.sshPrivateKeyPath = String(parsed.sshPrivateKeyPath || DEFAULT_SSH_PRIVATE_KEY_PATH); parsed.sshPublicKeyPath = String(parsed.sshPublicKeyPath || `${parsed.sshPrivateKeyPath}.pub` || DEFAULT_SSH_PUBLIC_KEY_PATH); parsed.temperatureThresholdsC = normalizeTemperatureThresholds(parsed.temperatureThresholdsC); parsed.metricThresholdsPercent = normalizeMetricThresholds(parsed.metricThresholdsPercent); parsed.devices = Array.isArray(parsed.devices) ? parsed.devices.map(normalizeDeviceConfig) : []; normalizeRackTemperatureConfig(parsed); return parsed; } async function writeConfig(nextConfig) { const sshPrivateKeyPath = String(nextConfig.sshPrivateKeyPath || DEFAULT_SSH_PRIVATE_KEY_PATH); const normalized = { ...nextConfig, refreshIntervalSeconds: Number(nextConfig.refreshIntervalSeconds || 30), idleScanIntervalSeconds: Number(nextConfig.idleScanIntervalSeconds || 300), sshTimeoutSeconds: Number(nextConfig.sshTimeoutSeconds || 8), sshPrivateKeyPath, sshPublicKeyPath: String(nextConfig.sshPublicKeyPath || `${sshPrivateKeyPath}.pub` || DEFAULT_SSH_PUBLIC_KEY_PATH), temperatureThresholdsC: normalizeTemperatureThresholds(nextConfig.temperatureThresholdsC), metricThresholdsPercent: normalizeMetricThresholds(nextConfig.metricThresholdsPercent), devices: Array.isArray(nextConfig.devices) ? nextConfig.devices.map(normalizeDeviceConfig) : [] }; normalizeRackTemperatureConfig(normalized); 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 normalizeDeviceConfig(device = {}) { const authMethod = device.authMethod || (device.privateKeyPath ? "key" : "password"); return { ...device, authMethod: authMethod === "key" ? "key" : "password", privateKeyPath: device.privateKeyPath || "" }; } function normalizeRackTemperatureConfig(nextConfig) { nextConfig.rackTemperatureSourceDeviceId = String(nextConfig.rackTemperatureSourceDeviceId || ""); nextConfig.rackTemperatureFile = String(nextConfig.rackTemperatureFile || ""); if (!nextConfig.rackTemperatureSourceDeviceId || !nextConfig.rackTemperatureFile) { const legacyDevice = nextConfig.devices.find((device) => device.rackTemperatureFile); if (legacyDevice) { nextConfig.rackTemperatureSourceDeviceId = legacyDevice.id || ""; nextConfig.rackTemperatureFile = legacyDevice.rackTemperatureFile || ""; } } } 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 || ""); const rackTemperatureFile = rackTemperatureFileForDevice(device); 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)) }, rackTemperature: rackTemperatureFile ? { temperatureC: Number(seededNumber(`${device.id}:rack-temp`, 25, 37).toFixed(1)), sensor: "rack-top", updatedAt: new Date().toISOString(), ageSeconds: 0, sourceFile: rackTemperatureFile } : null }; } const REMOTE_METRICS_SCRIPT = ` set +e rack_temperature_file="$RACK_TEMPERATURE_FILE" 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 rack_temperature_c="" rack_temperature_sensor="" rack_temperature_updated_at="" rack_temperature_age_seconds="" if [ -n "$rack_temperature_file" ] && [ -r "$rack_temperature_file" ]; then rack_payload="$(cat "$rack_temperature_file" 2>/dev/null)" rack_temperature_c="$(printf '%s' "$rack_payload" | awk -F '[:,}]' '/temperatureC/ { gsub(/[^0-9.\\-]/, "", $2); print $2; exit }')" if [ -z "$rack_temperature_c" ]; then rack_temperature_c="$(printf '%s' "$rack_payload" | awk 'NR==1 { gsub(/[^0-9.\\-]/, "", $1); print $1; exit }')" fi rack_temperature_sensor="$(printf '%s' "$rack_payload" | sed -n 's/.*"sensor"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -n 1)" rack_temperature_updated_at="$(printf '%s' "$rack_payload" | sed -n 's/.*"updatedAt"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -n 1)" if [ -n "$rack_temperature_updated_at" ]; then rack_updated_epoch="$(date -d "$rack_temperature_updated_at" +%s 2>/dev/null)" now_epoch="$(date +%s 2>/dev/null)" if [ -n "$rack_updated_epoch" ] && [ -n "$now_epoch" ] && [ "$now_epoch" -ge "$rack_updated_epoch" ]; then rack_temperature_age_seconds="$((now_epoch - rack_updated_epoch))" fi 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" printf 'rackTemperatureC=%s\\n' "$rack_temperature_c" printf 'rackTemperatureSensor=%s\\n' "$rack_temperature_sensor" printf 'rackTemperatureUpdatedAt=%s\\n' "$rack_temperature_updated_at" printf 'rackTemperatureAgeSeconds=%s\\n' "$rack_temperature_age_seconds" `; 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 shellQuote(value) { return `'${String(value || "").replace(/'/g, `'\\''`)}'`; } function rackTemperatureFileForDevice(device) { const sourceId = config.rackTemperatureSourceDeviceId || ""; const sourceMatches = sourceId && [device.id, device.host, device.name].includes(sourceId); if (sourceMatches) return config.rackTemperatureFile || ""; return device.rackTemperatureFile || ""; } 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, env = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...env } }); 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) { const usesPrivateKey = device.authMethod === "key" || Boolean(device.privateKeyPath); const privateKeyPath = device.privateKeyPath || config.sshPrivateKeyPath; if (usesPrivateKey && !privateKeyPath) { throw new Error("Clave privada SSH global no configurada"); } if (!usesPrivateKey && !device.password) { throw new Error("Credenciales SSH no configuradas"); } const timeoutMs = Math.max(2, config.sshTimeoutSeconds || 8) * 1000; const port = Number(device.port || 22); const destination = `${device.username || "pi"}@${device.host}`; const sshArgs = [ "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/tmp/monitor-rpi-known-hosts", "-o", "ConnectTimeout=5", "-p", String(port), destination, "sh", "-s" ]; let command = "ssh"; let args = sshArgs; if (usesPrivateKey) { args = [ "-i", privateKeyPath, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", ...sshArgs ]; } else { command = "sshpass"; args = [ "-p", device.password, "ssh", ...sshArgs ]; } const rackTemperatureFile = rackTemperatureFileForDevice(device); const remoteScript = `RACK_TEMPERATURE_FILE=${shellQuote(rackTemperatureFile)}\n${REMOTE_METRICS_SCRIPT}`; const { stdout } = await runCommandWithInput(command, args, remoteScript, timeoutMs); const values = parseKeyValueOutput(stdout); const temp = toNumber(values.cpuTempC, 0); const rackTemperatureC = toNumber(values.rackTemperatureC, null); 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), rackTemperature: Number.isFinite(rackTemperatureC) ? { temperatureC: Number(rackTemperatureC.toFixed(1)), sensor: values.rackTemperatureSensor || "", updatedAt: values.rackTemperatureUpdatedAt || "", ageSeconds: toNumber(values.rackTemperatureAgeSeconds, null), sourceFile: rackTemperatureFile } : null }; } 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 rackTemperature = online .map((device) => { const rack = device.metrics?.rackTemperature; return rack && Number.isFinite(rack.temperatureC) ? { ...rack, deviceName: device.name } : null; }) .filter(Boolean) .sort((a, b) => (a.ageSeconds ?? Number.MAX_SAFE_INTEGER) - (b.ageSeconds ?? Number.MAX_SAFE_INTEGER))[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, rackTemperature, 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 sendText(res, statusCode, body, headers = {}) { res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", ...headers }); res.end(body); } async function readSshPublicKey() { const publicKeyPath = config.sshPublicKeyPath || `${config.sshPrivateKeyPath || DEFAULT_SSH_PRIVATE_KEY_PATH}.pub`; const publicKey = (await fs.readFile(publicKeyPath, "utf8")).trim(); return { publicKeyPath, publicKey }; } 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 === "GET" && pathname === "/api/ssh-public-key") { try { sendJson(res, 200, await readSshPublicKey()); } catch (error) { sendJson(res, 404, { error: `No se pudo leer la clave publica SSH: ${error.message}`, publicKeyPath: config.sshPublicKeyPath || `${config.sshPrivateKeyPath || DEFAULT_SSH_PRIVATE_KEY_PATH}.pub` }); } return; } if (req.method === "GET" && pathname === "/api/ssh-public-key/download") { try { const { publicKeyPath, publicKey } = await readSshPublicKey(); sendText(res, 200, `${publicKey}\n`, { "Content-Disposition": `attachment; filename="${path.basename(publicKeyPath)}"` }); } catch (error) { sendText(res, 404, `No se pudo leer la clave publica SSH: ${error.message}\n`); } 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; });