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 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f93d16874
commit
06e8e64a0b
4 files changed
+719
No files matched your search
Executable
+195
@@ -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://<host>:3001 [options]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --server-url <URL> SkyWatch server URL (required)
|
||||||
|
# --interval <seconds> Metrics report interval (default: 60)
|
||||||
|
# --name <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 <URL> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--server-url <URL> SkyWatch server URL (required)
|
||||||
|
--interval <secs> How often to report metrics in seconds (default: 60)
|
||||||
|
--name <name> Agent name shown in dashboard (default: hostname)
|
||||||
|
--registration-key <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
|
||||||
Executable
+301
@@ -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://<your-server>:3001\""
|
||||||
|
echo ""
|
||||||
|
echo "Or use the installer:"
|
||||||
|
echo " sudo bash install-agent.sh --server-url http://<your-server>: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 "$@"
|
||||||
@@ -330,6 +330,44 @@ try { db.prepare("ALTER TABLE resources ADD COLUMN last_heartbeat_at DATETIME").
|
|||||||
// Feature: status page per-resource visibility
|
// Feature: status page per-resource visibility
|
||||||
try { db.prepare("ALTER TABLE resources ADD COLUMN is_public INTEGER DEFAULT 1").run(); } catch(e) {}
|
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
|
// Helpful indexes for time-range queries
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_checks_resource_time ON checks(resource_id, checked_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_checks_resource_time ON checks(resource_id, checked_at DESC);
|
||||||
|
|||||||
+185
@@ -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
|
// Serve React app with proper MIME types
|
||||||
const mimeTypes = {
|
const mimeTypes = {
|
||||||
'.js': 'application/javascript',
|
'.js': 'application/javascript',
|
||||||
|
|||||||
Reference in new issue
Block a user