From 06e8e64a0ba709c204f7344af29cdde121e734af Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:01:45 -0400 Subject: [PATCH] Add Linux agent: metrics collection + server API + installer - agent/skywatch-agent.sh: bash daemon that collects CPU, memory, disk, network, load, and uptime, then POSTs to /api/agents/report every 60 s - agent/install-agent.sh: systemd installer; registers agent on first run - server/database.js: adds `agents` and `agent_metrics` tables with indexes - server/index.js: adds agent API routes (register, report, list, history, delete, script/installer download); optional AGENT_REGISTRATION_KEY guard Co-Authored-By: Claude Sonnet 4.6 --- agent/install-agent.sh | 195 ++++++++++++++++++++++++++ agent/skywatch-agent.sh | 301 ++++++++++++++++++++++++++++++++++++++++ server/database.js | 38 +++++ server/index.js | 185 ++++++++++++++++++++++++ 4 files changed, 719 insertions(+) create mode 100755 agent/install-agent.sh create mode 100755 agent/skywatch-agent.sh diff --git a/agent/install-agent.sh b/agent/install-agent.sh new file mode 100755 index 0000000..99af495 --- /dev/null +++ b/agent/install-agent.sh @@ -0,0 +1,195 @@ +#!/bin/bash +# SkyWatch Linux Agent Installer +# Installs the agent as a systemd service on Ubuntu/Debian/RHEL/CentOS/Fedora +# +# Usage: +# sudo bash install-agent.sh --server-url http://:3001 [options] +# +# Options: +# --server-url SkyWatch server URL (required) +# --interval Metrics report interval (default: 60) +# --name Agent display name (default: hostname) +# --uninstall Remove the agent and all its files + +set -e + +AGENT_DIR="/opt/skywatch-agent" +CONFIG_FILE="/etc/skywatch-agent.conf" +SERVICE_FILE="/etc/systemd/system/skywatch-agent.service" +SCRIPT_NAME="skywatch-agent.sh" +SERVICE_NAME="skywatch-agent" + +# ── Colours ────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; NC='\033[0m' + +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } +die() { error "$*"; exit 1; } + +usage() { + cat << EOF +Usage: sudo bash install-agent.sh --server-url [options] + +Options: + --server-url SkyWatch server URL (required) + --interval How often to report metrics in seconds (default: 60) + --name Agent name shown in dashboard (default: hostname) + --registration-key Secret key required by the server to allow registration + --uninstall Stop, disable, and remove the agent + +Examples: + sudo bash install-agent.sh --server-url https://skywatch.necloud.us + sudo bash install-agent.sh --server-url https://skywatch.necloud.us --interval 30 --name web-01 + sudo bash install-agent.sh --server-url https://skywatch.necloud.us --registration-key mySecret + sudo bash install-agent.sh --uninstall +EOF + exit 1 +} + +# ── Argument parsing ────────────────────────────────────────────────────────── +SKYWATCH_URL="" +INTERVAL=60 +AGENT_NAME="" +REGISTRATION_KEY="" +UNINSTALL=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --server-url) SKYWATCH_URL="${2:?'--server-url requires a value'}"; shift 2 ;; + --interval) INTERVAL="${2:?'--interval requires a value'}"; shift 2 ;; + --name) AGENT_NAME="${2:?'--name requires a value'}"; shift 2 ;; + --registration-key) REGISTRATION_KEY="${2:?'--registration-key requires a value'}"; shift 2 ;; + --uninstall) UNINSTALL=true; shift ;; + -h|--help) usage ;; + *) error "Unknown option: $1"; usage ;; + esac +done + +# ── Root check ──────────────────────────────────────────────────────────────── +if [ "$(id -u)" -ne 0 ]; then + die "This installer must be run as root. Try: sudo bash $0 $*" +fi + +# ── Uninstall path ──────────────────────────────────────────────────────────── +if [ "$UNINSTALL" = true ]; then + info "Uninstalling SkyWatch Agent..." + systemctl stop "$SERVICE_NAME" 2>/dev/null || true + systemctl disable "$SERVICE_NAME" 2>/dev/null || true + rm -f "$SERVICE_FILE" + systemctl daemon-reload 2>/dev/null || true + rm -rf "$AGENT_DIR" + rm -f "$CONFIG_FILE" + rm -f /var/log/skywatch-agent.log + ok "SkyWatch Agent removed." + exit 0 +fi + +# ── Validate required args ──────────────────────────────────────────────────── +[ -z "$SKYWATCH_URL" ] && usage + +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ SkyWatch Linux Agent Installer ║" +echo "╚══════════════════════════════════════════╝" +echo "" +info "Server URL : $SKYWATCH_URL" +info "Interval : ${INTERVAL}s" +info "Agent name : ${AGENT_NAME:-$(hostname -f 2>/dev/null || hostname)}" +[ -n "$REGISTRATION_KEY" ] && info "Reg. key : (set)" +echo "" + +# ── Dependency check ────────────────────────────────────────────────────────── +for cmd in curl awk df; do + command -v "$cmd" &>/dev/null || die "Required command '$cmd' not found. Install it and retry." +done + +if ! command -v systemctl &>/dev/null; then + die "systemd not found. This installer requires a systemd-based Linux distro (Ubuntu 16.04+, Debian 9+, RHEL/CentOS 7+)." +fi + +# ── Locate the agent script ─────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_SRC="$SCRIPT_DIR/$SCRIPT_NAME" + +mkdir -p "$AGENT_DIR" + +if [ -f "$SCRIPT_SRC" ]; then + cp "$SCRIPT_SRC" "$AGENT_DIR/$SCRIPT_NAME" + ok "Agent script copied from $SCRIPT_SRC" +else + info "Downloading agent script from server..." + if ! curl -fsSL "${SKYWATCH_URL}/api/agents/script" -o "$AGENT_DIR/$SCRIPT_NAME"; then + die "Could not find $SCRIPT_NAME locally or download it from the server.\nPlace install-agent.sh and skywatch-agent.sh in the same directory." + fi + ok "Agent script downloaded." +fi + +chmod +x "$AGENT_DIR/$SCRIPT_NAME" + +# ── Write config ────────────────────────────────────────────────────────────── +cat > "$CONFIG_FILE" << CONF +# SkyWatch Agent configuration +SKYWATCH_URL="${SKYWATCH_URL}" +AGENT_TOKEN="" +REPORT_INTERVAL="${INTERVAL}" +AGENT_NAME="${AGENT_NAME:-$(hostname -f 2>/dev/null || hostname)}" +REGISTRATION_KEY="${REGISTRATION_KEY}" +CONF +chmod 600 "$CONFIG_FILE" +ok "Config written to $CONFIG_FILE" + +# ── Create systemd unit ─────────────────────────────────────────────────────── +cat > "$SERVICE_FILE" << UNIT +[Unit] +Description=SkyWatch System Metrics Agent +Documentation=https://github.com/nmemmert/monitor +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/bin/bash ${AGENT_DIR}/${SCRIPT_NAME} +Restart=always +RestartSec=30 +Environment=CONFIG_FILE=${CONFIG_FILE} +Environment=LOG_FILE=/var/log/skywatch-agent.log +StandardOutput=journal +StandardError=journal +SyslogIdentifier=skywatch-agent + +[Install] +WantedBy=multi-user.target +UNIT + +ok "Systemd unit written to $SERVICE_FILE" + +# ── Enable and start ────────────────────────────────────────────────────────── +systemctl daemon-reload +systemctl enable "$SERVICE_NAME" +systemctl restart "$SERVICE_NAME" + +sleep 2 # give it a moment to register + +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Agent installed and running! ║" +echo "╚══════════════════════════════════════════╝" +echo "" +ok "Service: $SERVICE_NAME" +ok "Config: $CONFIG_FILE" +ok "Logs: /var/log/skywatch-agent.log" +echo "" +echo "Useful commands:" +echo " sudo systemctl status $SERVICE_NAME" +echo " sudo journalctl -u $SERVICE_NAME -f" +echo " sudo systemctl stop $SERVICE_NAME" +echo " sudo bash $0 --uninstall" +echo "" +echo "The agent will appear in your SkyWatch dashboard under the Agents section." +echo "" + +# ── Show live status ────────────────────────────────────────────────────────── +systemctl --no-pager status "$SERVICE_NAME" || true diff --git a/agent/skywatch-agent.sh b/agent/skywatch-agent.sh new file mode 100755 index 0000000..3376a1d --- /dev/null +++ b/agent/skywatch-agent.sh @@ -0,0 +1,301 @@ +#!/bin/bash +# SkyWatch Linux Agent v1.0.0 +# Collects system metrics and sends to SkyWatch monitoring server +# +# Metrics: CPU %, Memory, Disk, Network I/O, Load Average, Process Count +# Requirements: bash 4+, curl, awk, df, /proc filesystem (standard Linux) +# +# Usage: +# Configured via CONFIG_FILE (default /etc/skywatch-agent.conf) +# Run via systemd (see install-agent.sh) or: bash skywatch-agent.sh + +VERSION="1.0.0" +CONFIG_FILE="${CONFIG_FILE:-/etc/skywatch-agent.conf}" +LOG_FILE="${LOG_FILE:-/var/log/skywatch-agent.log}" + +# Fall back to home dir if system paths aren't writable +if [ ! -w "$(dirname "$CONFIG_FILE")" ] 2>/dev/null; then + CONFIG_FILE="$HOME/.skywatch-agent.conf" + LOG_FILE="$HOME/.skywatch-agent.log" +fi + +log() { + local ts + ts=$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "") + local msg="[$ts] $*" + echo "$msg" >> "$LOG_FILE" 2>/dev/null || true + echo "$msg" +} + +load_config() { + if [ -f "$CONFIG_FILE" ]; then + # shellcheck disable=SC1090 + source "$CONFIG_FILE" + fi +} + +save_config() { + cat > "$CONFIG_FILE" << CONF +SKYWATCH_URL="${SKYWATCH_URL:-}" +AGENT_TOKEN="${AGENT_TOKEN:-}" +REPORT_INTERVAL="${REPORT_INTERVAL:-60}" +AGENT_NAME="${AGENT_NAME:-}" +REGISTRATION_KEY="${REGISTRATION_KEY:-}" +CONF + chmod 600 "$CONFIG_FILE" 2>/dev/null || true +} + +# CPU usage % — reads /proc/stat twice 1 second apart (that 1s is intentional) +get_cpu_percent() { + local s1 s2 + s1=$(grep '^cpu ' /proc/stat 2>/dev/null) || { echo "0.0"; return; } + sleep 1 + s2=$(grep '^cpu ' /proc/stat 2>/dev/null) || { echo "0.0"; return; } + awk -v s1="$s1" -v s2="$s2" 'BEGIN { + n = split(s1, a) + split(s2, b) + t1 = 0; t2 = 0 + for (i = 2; i <= n; i++) { t1 += a[i]; t2 += b[i] } + # idle + iowait (indices 5 and 6 in the array, which is index 4+5 in /proc/stat) + idle1 = a[5] + a[6] + idle2 = b[5] + b[6] + dt = t2 - t1 + di = idle2 - idle1 + if (dt <= 0) { printf "0.0"; exit } + printf "%.1f", (dt - di) * 100 / dt + }' +} + +# Memory stats in MB +get_memory() { + awk ' + /^MemTotal:/ { total = int($2 / 1024) } + /^MemAvailable:/{ avail = int($2 / 1024) } + END { + used = total - avail + pct = (total > 0) ? used * 100.0 / total : 0 + printf "%d %d %.1f", total, used, pct + } + ' /proc/meminfo 2>/dev/null || echo "0 0 0.0" +} + +# Disk info as a JSON array — real block devices only, no tmpfs/overlay +get_disk_json() { + df -P -k \ + -x tmpfs -x devtmpfs -x squashfs -x overlay \ + 2>/dev/null \ + | tail -n +2 \ + | grep '^/' \ + | awk ' + BEGIN { printf "["; c = 0 } + { + sub(/%/, "", $5) + mp = $6 + # JSON-escape the mount path + gsub(/\\/, "\\\\", mp) + gsub(/"/, "\\\"", mp) + if (c > 0) printf "," + printf "{\"path\":\"%s\",\"total_mb\":%d,\"used_mb\":%d,\"percent\":%d}", + mp, int($2 / 1024), int($3 / 1024), $5 + c++ + } + END { printf "]" } + ' +} + +# Cumulative network bytes since boot (all interfaces except loopback) +get_net_bytes() { + awk ' + /:/ && !/lo:/ { + split($0, parts, ":") + split(parts[2], f) + recv += f[1] # receive bytes + sent += f[9] # transmit bytes + } + END { printf "%d %d", sent, recv } + ' /proc/net/dev 2>/dev/null || echo "0 0" +} + +# 1/5/15-minute load averages +get_load() { + awk '{ printf "%s %s %s", $1, $2, $3 }' /proc/loadavg 2>/dev/null || echo "0.00 0.00 0.00" +} + +# System uptime in whole seconds +get_uptime_seconds() { + awk '{ printf "%d", $1 }' /proc/uptime 2>/dev/null || echo "0" +} + +# Number of running processes +get_process_count() { + ls -d /proc/[0-9]* 2>/dev/null | wc -l | tr -d ' \n' +} + +# Primary non-loopback IPv4 address +get_ip_address() { + if command -v ip &>/dev/null; then + ip -4 route get 1.1.1.1 2>/dev/null \ + | awk '/src/ { for(i=1;i<=NF;i++) if($i=="src"){ print $(i+1); exit } }' + elif command -v hostname &>/dev/null; then + hostname -I 2>/dev/null | awk '{print $1}' + fi +} + +# OS description string +get_os_info() { + if [ -f /etc/os-release ]; then + # shellcheck disable=SC1091 + . /etc/os-release + echo "${PRETTY_NAME:-${NAME:-Linux}}" + else + uname -sr + fi +} + +# Register this host with the server; saves token to config on success +register_agent() { + local name="${AGENT_NAME:-$(hostname -f 2>/dev/null || hostname)}" + local hostname_val + hostname_val=$(hostname -f 2>/dev/null || hostname) + local ip + ip=$(get_ip_address || echo "") + local os + os=$(get_os_info) + + # Sanitize values for JSON string embedding + local esc_name esc_host esc_os + esc_name="${name//\"/\\\"}" + esc_host="${hostname_val//\"/\\\"}" + esc_os="${os//\"/\\\"}" + + log "Registering agent '${name}' with ${SKYWATCH_URL} ..." + + # Build curl args as an array so we can conditionally add the registration key header + local -a curl_args + curl_args=(-sf -m 30 -X POST -H 'Content-Type: application/json') + [ -n "${REGISTRATION_KEY:-}" ] && curl_args+=(-H "X-Registration-Key: ${REGISTRATION_KEY}") + curl_args+=(-d "{\"name\":\"${esc_name}\",\"hostname\":\"${esc_host}\",\"ip_address\":\"${ip}\",\"os_info\":\"${esc_os}\"}") + curl_args+=("${SKYWATCH_URL}/api/agents/register") + + local response + response=$(curl "${curl_args[@]}" 2>&1) || { + log "ERROR: Could not reach ${SKYWATCH_URL} during registration" + return 1 + } + + local token + token=$(echo "$response" | grep -o '"token":"[^"]*"' | sed 's/"token":"//; s/"$//') + + if [ -z "$token" ]; then + log "ERROR: Registration failed. Server said: ${response}" + return 1 + fi + + AGENT_TOKEN="$token" + save_config + log "Registered. Token stored in ${CONFIG_FILE}" +} + +# Collect all metrics and POST to /api/agents/report +report_metrics() { + local cpu + cpu=$(get_cpu_percent) # includes built-in 1 s sleep + + local mem_total mem_used mem_percent + read -r mem_total mem_used mem_percent <<< "$(get_memory)" + + local disk + disk=$(get_disk_json) + + local net_sent net_recv + read -r net_sent net_recv <<< "$(get_net_bytes)" + + local load1 load5 load15 + read -r load1 load5 load15 <<< "$(get_load)" + + local uptime_secs + uptime_secs=$(get_uptime_seconds) + + local procs + procs=$(get_process_count) + + local ip + ip=$(get_ip_address || echo "") + + # Fallbacks so we always produce valid JSON + cpu="${cpu:-0.0}" + mem_total="${mem_total:-0}" + mem_used="${mem_used:-0}" + mem_percent="${mem_percent:-0.0}" + disk="${disk:-[]}" + net_sent="${net_sent:-0}" + net_recv="${net_recv:-0}" + load1="${load1:-0.00}" + load5="${load5:-0.00}" + load15="${load15:-0.00}" + uptime_secs="${uptime_secs:-0}" + procs="${procs:-0}" + + local payload + payload=$(printf \ + '{"cpu_percent":%s,"mem_total":%d,"mem_used":%d,"mem_percent":%s,"disk":%s,"load_1":%s,"load_5":%s,"load_15":%s,"uptime_seconds":%d,"process_count":%d,"net_bytes_sent":%s,"net_bytes_recv":%s,"ip_address":"%s"}' \ + "$cpu" "$mem_total" "$mem_used" "$mem_percent" \ + "$disk" "$load1" "$load5" "$load15" \ + "$uptime_secs" "$procs" \ + "$net_sent" "$net_recv" \ + "${ip}") + + local response + response=$(curl -sf -m 15 \ + -X POST \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer ${AGENT_TOKEN}" \ + -d "$payload" \ + "${SKYWATCH_URL}/api/agents/report" 2>&1) || { + log "WARNING: Failed to send metrics (network error)" + return 0 + } + + if echo "$response" | grep -q '"ok":true'; then + log "OK cpu=${cpu}% mem=${mem_used}/${mem_total}MB load=${load1}" + else + log "WARNING: Server rejected metrics: ${response}" + fi +} + +main() { + load_config + + if [ -z "${SKYWATCH_URL:-}" ]; then + echo "ERROR: SKYWATCH_URL is not set." + echo "" + echo "Create ${CONFIG_FILE} containing:" + echo " SKYWATCH_URL=\"http://:3001\"" + echo "" + echo "Or use the installer:" + echo " sudo bash install-agent.sh --server-url http://:3001" + exit 1 + fi + + # Register on first run (no token stored yet) + if [ -z "${AGENT_TOKEN:-}" ]; then + if ! register_agent; then + log "Retrying registration in 60 s..." + sleep 60 + register_agent || exit 1 + fi + fi + + local interval="${REPORT_INTERVAL:-60}" + log "SkyWatch Agent v${VERSION} started — server=${SKYWATCH_URL} interval=${interval}s" + + while true; do + report_metrics + # get_cpu_percent already consumed ~1 s; subtract it from the sleep + local sleep_time=$(( interval - 1 )) + [ "$sleep_time" -lt 1 ] && sleep_time=1 + sleep "$sleep_time" + done +} + +main "$@" diff --git a/server/database.js b/server/database.js index ea8e4b5..d06cb69 100644 --- a/server/database.js +++ b/server/database.js @@ -330,6 +330,44 @@ try { db.prepare("ALTER TABLE resources ADD COLUMN last_heartbeat_at DATETIME"). // Feature: status page per-resource visibility try { db.prepare("ALTER TABLE resources ADD COLUMN is_public INTEGER DEFAULT 1").run(); } catch(e) {} +// Feature: Linux agent monitoring +try { + db.exec(` + CREATE TABLE IF NOT EXISTS agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + hostname TEXT, + ip_address TEXT, + os_info TEXT, + last_seen_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS agent_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id INTEGER NOT NULL, + cpu_percent REAL, + mem_total INTEGER, + mem_used INTEGER, + mem_percent REAL, + disk_data TEXT, + load_1 REAL, + load_5 REAL, + load_15 REAL, + uptime_seconds INTEGER, + process_count INTEGER, + net_bytes_sent INTEGER, + net_bytes_recv INTEGER, + recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_agents_token ON agents(token); + CREATE INDEX IF NOT EXISTS idx_agent_metrics_agent_time ON agent_metrics(agent_id, recorded_at DESC); + `); +} catch(e) {} + // Helpful indexes for time-range queries db.exec(` CREATE INDEX IF NOT EXISTS idx_checks_resource_time ON checks(resource_id, checked_at DESC); diff --git a/server/index.js b/server/index.js index 2cf383e..5f1a201 100644 --- a/server/index.js +++ b/server/index.js @@ -2168,6 +2168,191 @@ app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res) } }); +// ===== Agent API ===== + +// Serve the agent install script so remote hosts can curl it +app.get('/api/agents/script', (req, res) => { + const fs = require('fs'); + const scriptPath = path.join(__dirname, '../agent/skywatch-agent.sh'); + if (fs.existsSync(scriptPath)) { + res.setHeader('Content-Type', 'text/x-sh'); + res.setHeader('Content-Disposition', 'attachment; filename="skywatch-agent.sh"'); + res.sendFile(scriptPath); + } else { + res.status(404).json({ error: 'Agent script not found on this server' }); + } +}); + +// Register a new Linux agent — returns a bearer token the agent uses for all future calls +app.post('/api/agents/register', rateLimit(5), (req, res) => { + // Optional: if AGENT_REGISTRATION_KEY is set in env, require it as X-Registration-Key header + const requiredKey = process.env.AGENT_REGISTRATION_KEY; + if (requiredKey) { + const provided = req.headers['x-registration-key'] || ''; + if (provided !== requiredKey) { + return res.status(403).json({ error: 'Invalid or missing registration key' }); + } + } + + const { name, hostname, ip_address, os_info } = req.body; + if (!name) return res.status(400).json({ error: 'Agent name is required' }); + + try { + const token = randomUUID(); + const result = db.prepare(` + INSERT INTO agents (name, token, hostname, ip_address, os_info) + VALUES (?, ?, ?, ?, ?) + `).run(name, token, hostname || name, ip_address || null, os_info || null); + + res.json({ id: result.lastInsertRowid, token, name }); + } catch (err) { + res.status(500).json({ error: 'Failed to register agent', details: err.message }); + } +}); + +// Receive a metrics snapshot from an agent (authenticated by bearer token) +app.post('/api/agents/report', rateLimit(120), (req, res) => { + const auth = req.headers.authorization || ''; + const token = auth.startsWith('Bearer ') ? auth.slice(7) : auth; + if (!token) return res.status(401).json({ error: 'Authorization token required' }); + + const agent = db.prepare('SELECT id FROM agents WHERE token = ?').get(token); + if (!agent) return res.status(401).json({ error: 'Invalid token' }); + + const { + cpu_percent, mem_total, mem_used, mem_percent, + disk, load_1, load_5, load_15, + uptime_seconds, process_count, net_bytes_sent, net_bytes_recv, ip_address, + } = req.body; + + try { + db.prepare(` + UPDATE agents SET last_seen_at = CURRENT_TIMESTAMP, ip_address = COALESCE(?, ip_address) + WHERE id = ? + `).run(ip_address || null, agent.id); + + db.prepare(` + INSERT INTO agent_metrics + (agent_id, cpu_percent, mem_total, mem_used, mem_percent, disk_data, + load_1, load_5, load_15, uptime_seconds, process_count, net_bytes_sent, net_bytes_recv) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + agent.id, + cpu_percent ?? null, + mem_total ?? null, + mem_used ?? null, + mem_percent ?? null, + disk ? JSON.stringify(disk) : null, + load_1 ?? null, + load_5 ?? null, + load_15 ?? null, + uptime_seconds ?? null, + process_count ?? null, + net_bytes_sent ?? null, + net_bytes_recv ?? null, + ); + + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ error: 'Failed to store metrics' }); + } +}); + +// List all agents with their most-recent metrics snapshot +app.get('/api/agents', (req, res) => { + try { + const rows = db.prepare(` + SELECT + a.id, a.name, a.hostname, a.ip_address, a.os_info, + REPLACE(a.last_seen_at, ' ', 'T') || 'Z' AS last_seen_at, + REPLACE(a.created_at, ' ', 'T') || 'Z' AS created_at, + m.cpu_percent, m.mem_total, m.mem_used, m.mem_percent, + m.disk_data, m.load_1, m.load_5, m.load_15, + m.uptime_seconds, m.process_count, m.net_bytes_sent, m.net_bytes_recv, + REPLACE(m.recorded_at, ' ', 'T') || 'Z' AS last_metric_at + FROM agents a + LEFT JOIN agent_metrics m ON m.id = ( + SELECT id FROM agent_metrics WHERE agent_id = a.id ORDER BY recorded_at DESC LIMIT 1 + ) + ORDER BY a.name ASC + `).all(); + + const agents = rows.map(({ disk_data, ...a }) => ({ + ...a, + disk: disk_data ? JSON.parse(disk_data) : null, + })); + + res.json({ agents }); + } catch (err) { + res.status(500).json({ error: 'Failed to list agents' }); + } +}); + +// Get a single agent with its latest metrics +app.get('/api/agents/:id', (req, res) => { + try { + const agent = db.prepare(` + SELECT id, name, hostname, ip_address, os_info, + REPLACE(last_seen_at, ' ', 'T') || 'Z' AS last_seen_at, + REPLACE(created_at, ' ', 'T') || 'Z' AS created_at + FROM agents WHERE id = ? + `).get(req.params.id); + if (!agent) return res.status(404).json({ error: 'Agent not found' }); + + const raw = db.prepare( + 'SELECT * FROM agent_metrics WHERE agent_id = ? ORDER BY recorded_at DESC LIMIT 1' + ).get(agent.id); + + const latest = raw + ? (() => { const { disk_data, ...r } = raw; return { ...r, disk: disk_data ? JSON.parse(disk_data) : null }; })() + : null; + + res.json({ ...agent, latest }); + } catch (err) { + res.status(500).json({ error: 'Failed to get agent' }); + } +}); + +// Historical metrics for an agent (default: last hour, up to 500 rows) +app.get('/api/agents/:id/metrics', (req, res) => { + const { limit = 60, hours = 1 } = req.query; + try { + const agent = db.prepare('SELECT id FROM agents WHERE id = ?').get(req.params.id); + if (!agent) return res.status(404).json({ error: 'Agent not found' }); + + const rows = db.prepare(` + SELECT cpu_percent, mem_total, mem_used, mem_percent, disk_data, + load_1, load_5, load_15, uptime_seconds, process_count, + net_bytes_sent, net_bytes_recv, + REPLACE(recorded_at, ' ', 'T') || 'Z' AS recorded_at + FROM agent_metrics + WHERE agent_id = ? AND recorded_at > datetime('now', ?) + ORDER BY recorded_at ASC + LIMIT ? + `).all(req.params.id, `-${parseInt(hours)} hours`, Math.min(parseInt(limit), 500)); + + const metrics = rows.map(({ disk_data, ...r }) => ({ + ...r, + disk: disk_data ? JSON.parse(disk_data) : null, + })); + + res.json({ metrics }); + } catch (err) { + res.status(500).json({ error: 'Failed to get metrics' }); + } +}); + +// Delete an agent and all its stored metrics +app.delete('/api/agents/:id', (req, res) => { + try { + const result = db.prepare('DELETE FROM agents WHERE id = ?').run(req.params.id); + if (result.changes === 0) return res.status(404).json({ error: 'Agent not found' }); + res.json({ message: 'Agent deleted' }); + } catch (err) { + res.status(500).json({ error: 'Failed to delete agent' }); + } +}); + // Serve React app with proper MIME types const mimeTypes = { '.js': 'application/javascript',