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; });