Files
2026-06-02 17:31:57 +02:00

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