Author SHA1 Message Date
Claude dcfd9e9b65 Fix crash loop: disable keep-alive agents and add unhandledRejection guard
The previous fix (agent .on('error')) doesn't reach individual socket
objects — socket-level parse errors still escaped as uncaught exceptions.

Root fix: disable keepAlive on the HTTP/HTTPS monitoring agents. Without
connection reuse there is no stale connection to trigger "Parse Error:
Data after Connection: close". Monitoring intervals are 60s+ so the lack
of connection pooling has no meaningful performance impact.

Belt-and-suspenders: add process.on('unhandledRejection') alongside the
existing uncaughtException guard to catch any async paths that leak.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AQYLFrscmMnzMiXAAANoa
2026-08-07 00:02:01 +00:00
Claude b13a8c343f Fix crash loop: handle HTTP parse errors from keep-alive monitoring agents
Monitored services that send data after Connection: close (malformed HTTP)
were causing unhandled 'Parse Error' exceptions, crashing the process and
triggering a PM2 restart loop (191 restarts observed) which caused 502s.

Two-layer fix:
- monitorService.js: attach .on('error', noop) to the shared HTTP/HTTPS
  keep-alive agents so socket-level parse errors don't escape as uncaught
  exceptions
- index.js: process-level uncaughtException guard that swallows HTTP parse
  errors (HPE_INVALID_CONSTANT / 'Parse Error') while still crashing on
  real fatal errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AQYLFrscmMnzMiXAAANoa
2026-08-06 23:54:00 +00:00
Claude 42f224ab93 Merge remote-tracking branch 'origin/main' into claude/app-load-failure-db-rvlxbc 2026-08-06 23:53:21 +00:00
nmemmertandClaude 0b4797f38c Fix app startup crash: add hasInstallScript to better-sqlite3 lockfile entry (#1)
package-lock.json was missing "hasInstallScript": true for better-sqlite3.
npm ci reads this flag to decide whether to run a package's install scripts;
without it, the native .node binding is never compiled and the server crashes
at startup with "Cannot find module '...better_sqlite3.node'".

Running npm install regenerates the field correctly. npm ci (used in
update-server.sh) can now compile the native module during deployment.


Claude-Session: https://claude.ai/code/session_013AQYLFrscmMnzMiXAAANoa

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-06 19:48:59 -04:00
Claude b710f68d21 Fix app startup crash: add hasInstallScript to better-sqlite3 lockfile entry
package-lock.json was missing "hasInstallScript": true for better-sqlite3.
npm ci reads this flag to decide whether to run a package's install scripts;
without it, the native .node binding is never compiled and the server crashes
at startup with "Cannot find module '...better_sqlite3.node'".

Running npm install regenerates the field correctly. npm ci (used in
update-server.sh) can now compile the native module during deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AQYLFrscmMnzMiXAAANoa
2026-08-06 23:46:22 +00:00
27 changed files with 92 additions and 3185 deletions

No files matched your search

-54
View File
@@ -1,54 +0,0 @@
name: Docker Build and Push
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
env:
REGISTRY: git.necloud.us
IMAGE_NAME: nate/skywatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: gitea.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+13 -14
View File
@@ -6,8 +6,8 @@ WORKDIR /app/client
# Copy client package files
COPY client/package*.json ./
# Install dependencies (--legacy-peer-deps needed for react-scripts 5 + React 19)
RUN npm ci --legacy-peer-deps
# Install dependencies
RUN npm ci
# Copy client source
COPY client/src ./src
@@ -16,21 +16,19 @@ COPY client/public ./public
# Build React app
RUN npm run build
# Final stage - Node.js server (slim Debian for glibc native module compatibility)
FROM node:20-slim
# Final stage - Node.js server
FROM node:20-alpine
WORKDIR /app
# Install dumb-init and build tools needed by better-sqlite3
RUN apt-get update && apt-get install -y --no-install-recommends \
dumb-init python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Copy server package files
COPY package*.json ./
# Install production dependencies only
RUN npm ci --omit=dev
RUN npm ci --only=production
# Copy server code
COPY server ./server
@@ -38,11 +36,12 @@ COPY server ./server
# Copy built React client from builder stage
COPY --from=client-builder /app/client/build ./client/build
# Create non-root user and data directory
RUN groupadd -g 1001 nodejs && \
useradd -u 1001 -g nodejs nodejs && \
mkdir -p /app/data && \
chown -R nodejs:nodejs /app
# Create data directory and set permissions
RUN mkdir -p /app/data && chown -R 1001:1001 /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
USER nodejs
-130
View File
@@ -1,130 +0,0 @@
#!/bin/bash
# SkyWatch macOS Agent Installer v1.0.0
# Installs the agent as a launchd daemon (runs at boot, requires sudo)
#
# Usage:
# sudo bash install-agent-macos.sh --server-url https://skywatch.example.com
#
# Options:
# --server-url <url> SkyWatch server URL (required)
# --interval <secs> Reporting interval in seconds (default: 60)
# --name <label> Agent display name (default: hostname)
# --registration-key <key> Optional registration key
# --uninstall Remove the agent
set -e
PLIST_LABEL="us.necloud.skywatch-agent"
PLIST_PATH="/Library/LaunchDaemons/${PLIST_LABEL}.plist"
AGENT_SCRIPT="/usr/local/bin/skywatch-agent-macos.sh"
CONFIG_FILE="/etc/skywatch-agent-macos.conf"
LOG_FILE="/var/log/skywatch-agent.log"
SERVER_URL=""
INTERVAL=60
AGENT_NAME="$(hostname -f 2>/dev/null || hostname)"
REGISTRATION_KEY=""
UNINSTALL=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--server-url) SERVER_URL="$2"; shift 2 ;;
--interval) INTERVAL="$2"; shift 2 ;;
--name) AGENT_NAME="$2"; shift 2 ;;
--registration-key) REGISTRATION_KEY="$2"; shift 2 ;;
--uninstall) UNINSTALL=true; shift ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
step() { echo "==> $*"; }
ok() { echo " OK: $*"; }
# ── Uninstall ──────────────────────────────────────────────────────────────────
if [ "$UNINSTALL" = true ]; then
step "Stopping and removing SkyWatch agent ..."
launchctl unload "$PLIST_PATH" 2>/dev/null || true
rm -f "$PLIST_PATH" "$AGENT_SCRIPT" "$CONFIG_FILE"
ok "SkyWatch agent removed."
ok "Log file kept at ${LOG_FILE} — remove manually if desired."
exit 0
fi
# ── Require root ───────────────────────────────────────────────────────────────
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: This installer must be run as root: sudo bash $0 $*"
exit 1
fi
if [ -z "$SERVER_URL" ]; then
echo "ERROR: --server-url is required."
echo "Usage: sudo bash install-agent-macos.sh --server-url https://skywatch.example.com"
exit 1
fi
# ── Install ────────────────────────────────────────────────────────────────────
step "Downloading agent script from ${SERVER_URL} ..."
curl -fsSL "${SERVER_URL}/api/agents/script-macos" -o "$AGENT_SCRIPT"
chmod +x "$AGENT_SCRIPT"
ok "Agent script installed to ${AGENT_SCRIPT}"
step "Writing configuration ..."
cat > "$CONFIG_FILE" << CONF
SKYWATCH_URL="${SERVER_URL}"
AGENT_TOKEN=""
REPORT_INTERVAL="${INTERVAL}"
AGENT_NAME="${AGENT_NAME}"
REGISTRATION_KEY="${REGISTRATION_KEY}"
CONF
chmod 600 "$CONFIG_FILE"
ok "Config written to ${CONFIG_FILE}"
step "Installing launchd daemon ..."
cat > "$PLIST_PATH" << PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${PLIST_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>${AGENT_SCRIPT}</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>CONFIG_FILE</key>
<string>${CONFIG_FILE}</string>
<key>LOG_FILE</key>
<string>${LOG_FILE}</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>${LOG_FILE}</string>
<key>StandardErrorPath</key>
<string>${LOG_FILE}</string>
</dict>
</plist>
PLIST
chmod 644 "$PLIST_PATH"
ok "Daemon plist written to ${PLIST_PATH}"
step "Loading daemon ..."
launchctl unload "$PLIST_PATH" 2>/dev/null || true
launchctl load -w "$PLIST_PATH"
echo ""
echo "✅ SkyWatch Agent installed and started!"
echo " Logs: tail -f ${LOG_FILE}"
echo " Status: launchctl list | grep skywatch"
echo " Uninstall: sudo bash install-agent-macos.sh --uninstall"
echo ""
echo "The agent will register with ${SERVER_URL} and appear in your"
echo "SkyWatch dashboard within a minute."
-144
View File
@@ -1,144 +0,0 @@
#Requires -RunAsAdministrator
#Requires -Version 5.1
<#
.SYNOPSIS
SkyWatch Windows Agent Installer v1.0.0
.DESCRIPTION
Downloads and installs the SkyWatch agent as a Windows Task Scheduler job
that starts at boot and runs as SYSTEM.
.PARAMETER ServerUrl
SkyWatch server URL (required), e.g. https://skywatch.necloud.us
.PARAMETER AgentName
Display name for this agent (default: computer name)
.PARAMETER Interval
Reporting interval in seconds (default: 60)
.PARAMETER RegistrationKey
Optional registration key if server requires one
.PARAMETER Uninstall
Remove the agent and its scheduled task
.EXAMPLE
.\install-agent-windows.ps1 -ServerUrl https://skywatch.necloud.us
.EXAMPLE
.\install-agent-windows.ps1 -Uninstall
#>
[CmdletBinding()]
param(
[string]$ServerUrl,
[string]$AgentName,
[int] $Interval = 60,
[string]$RegistrationKey,
[switch]$Uninstall
)
$TaskName = "SkyWatch Agent"
$InstallDir = "C:\ProgramData\SkyWatch"
$AgentScript = "$InstallDir\skywatch-agent-windows.ps1"
$ConfigFile = "$InstallDir\agent.conf"
function Write-Step { param([string]$Msg) Write-Host "==> $Msg" -ForegroundColor Cyan }
function Write-OK { param([string]$Msg) Write-Host " $Msg" -ForegroundColor Green }
function Write-Err { param([string]$Msg) Write-Host " ERROR: $Msg" -ForegroundColor Red; exit 1 }
# ── Uninstall ──────────────────────────────────────────────────────────────────
if ($Uninstall) {
Write-Step "Removing SkyWatch Agent ..."
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
if (Test-Path $InstallDir) { Remove-Item $InstallDir -Recurse -Force }
Write-OK "SkyWatch Agent removed."
exit 0
}
# ── Validate ───────────────────────────────────────────────────────────────────
if (-not $ServerUrl) {
Write-Err "ServerUrl is required.`nExample: .\install-agent-windows.ps1 -ServerUrl https://skywatch.necloud.us"
}
# ── Install ────────────────────────────────────────────────────────────────────
Write-Step "Creating install directory ..."
if (-not (Test-Path $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
Write-OK $InstallDir
Write-Step "Downloading agent script from $ServerUrl ..."
try {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri "$ServerUrl/api/agents/script-windows" `
-OutFile $AgentScript -UseBasicParsing -TimeoutSec 30
Write-OK "Saved to $AgentScript"
} catch {
Write-Err "Download failed: $_"
}
Write-Step "Writing configuration ..."
$name = if ($AgentName) { $AgentName } else { $env:COMPUTERNAME }
@"
SKYWATCH_URL="$ServerUrl"
AGENT_TOKEN=""
REPORT_INTERVAL=$Interval
AGENT_NAME="$name"
REGISTRATION_KEY="$RegistrationKey"
"@ | Set-Content $ConfigFile -Encoding UTF8
Write-OK "Config saved to $ConfigFile"
# Allow scripts to run (per-machine, won't affect user policy)
Write-Step "Setting PowerShell execution policy ..."
try {
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine -Force -ErrorAction Stop
Write-OK "RemoteSigned (LocalMachine)"
} catch {
Write-Host " (Could not set policy — you may need to run: Set-ExecutionPolicy RemoteSigned)" -ForegroundColor Yellow
}
Write-Step "Registering scheduled task ..."
# Remove any previous install
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
$psArgs = "-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$AgentScript`" -ConfigFile `"$ConfigFile`""
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $psArgs
$triggers = @(
(New-ScheduledTaskTrigger -AtStartup),
(New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(10) `
-RepetitionInterval (New-TimeSpan -Minutes 5)) # restart every 5 min if it exits
)
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Hours 0) `
-RestartCount 5 `
-RestartInterval (New-TimeSpan -Minutes 1) `
-MultipleInstances IgnoreNew `
-StartWhenAvailable
$principal = New-ScheduledTaskPrincipal `
-UserId "SYSTEM" `
-LogonType ServiceAccount `
-RunLevel Highest
Register-ScheduledTask `
-TaskName $TaskName `
-Action $action `
-Trigger $triggers `
-Settings $settings `
-Principal $principal `
-Force | Out-Null
Write-OK "Task '$TaskName' created"
Write-Step "Starting agent ..."
Start-ScheduledTask -TaskName $TaskName
Start-Sleep -Seconds 3
$state = (Get-ScheduledTask -TaskName $TaskName).State
Write-OK "Task state: $state"
Write-Host ""
Write-Host "✅ SkyWatch Agent installed!" -ForegroundColor Green
Write-Host " Logs: Get-Content '$InstallDir\agent.log' -Wait"
Write-Host " Status: Get-ScheduledTask -TaskName '$TaskName'"
Write-Host " Uninstall: .\install-agent-windows.ps1 -Uninstall"
Write-Host ""
Write-Host "The agent will register with $ServerUrl and appear in your"
Write-Host "SkyWatch dashboard within a minute."
-195
View File
@@ -1,195 +0,0 @@
#!/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
-333
View File
@@ -1,333 +0,0 @@
#!/bin/bash
# SkyWatch macOS Agent v1.0.0
# Collects system metrics and sends to SkyWatch monitoring server
#
# Requirements: macOS 10.15+, bash 3.2+, curl
# Run via launchd (see install-agent-macos.sh) or: bash skywatch-agent-macos.sh
VERSION="1.0.0"
CONFIG_FILE="${CONFIG_FILE:-/etc/skywatch-agent-macos.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-macos.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 % — two samples from top, 1 second apart
get_cpu_percent() {
local line idle
line=$(top -l 2 -n 0 2>/dev/null | grep "^CPU usage:" | tail -1)
# "CPU usage: 8.46% user, 11.45% sys, 80.08% idle"
idle=$(echo "$line" | awk '{
for (i = 1; i <= NF; i++) {
if ($i ~ /idle/) {
v = $(i - 1); gsub(/%/, "", v); print v; exit
}
}
}')
[ -z "$idle" ] && idle=0
awk -v i="$idle" 'BEGIN { printf "%.1f", 100 - i }'
}
# Memory stats in MB — uses vm_stat + sysctl
get_memory() {
local total_bytes vm_out page_size pages_free pages_inactive pages_spec
total_bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
vm_out=$(vm_stat 2>/dev/null)
# Page size from vm_stat header (4096 on Intel, 16384 on Apple Silicon)
page_size=$(echo "$vm_out" | awk '/page size of/ {
for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+$/ && $(i+1) == "bytes") { print $i; exit }
}')
[ -z "$page_size" ] && page_size=$(sysctl -n hw.pagesize 2>/dev/null || echo 4096)
pages_free=$(echo "$vm_out" | awk '/^Pages free:/ { gsub(/\./, "", $NF); print $NF+0 }')
pages_inactive=$(echo "$vm_out" | awk '/^Pages inactive:/ { gsub(/\./, "", $NF); print $NF+0 }')
pages_spec=$(echo "$vm_out" | awk '/^Pages speculative:/ { gsub(/\./, "", $NF); print $NF+0 }')
awk -v total="$total_bytes" -v page="$page_size" \
-v free="${pages_free:-0}" -v inact="${pages_inactive:-0}" -v spec="${pages_spec:-0}" \
'BEGIN {
total_mb = int(total / 1048576)
avail_mb = int((free + inact + spec) * page / 1048576)
used_mb = total_mb - avail_mb
if (used_mb < 0) used_mb = 0
pct = (total_mb > 0) ? used_mb * 100.0 / total_mb : 0
printf "%d %d %.1f", total_mb, used_mb, pct
}'
}
# Disk info as JSON — real mounted volumes, skips macOS internal system volumes
get_disk_json() {
df -P -k 2>/dev/null | tail -n +2 | awk '
$6 ~ /^\/System\/Volumes\// { next }
$6 ~ /^\/private\/var\/vm/ { next }
NF >= 6 {
pct = $5; sub(/%/, "", pct)
mp = $6
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), int(pct)
c++
}
BEGIN { printf "[" }
END { printf "]" }
'
}
# Cumulative network bytes — hardware-level rows from netstat -ib, no loopback
get_net_bytes() {
netstat -ib 2>/dev/null | awk '
/Name/ { next }
/<Link#/ && !/^lo/ { recv += $7; sent += $10 }
END { printf "%d %d", sent, recv }
' || echo "0 0"
}
# 1/5/15-min load averages from sysctl
get_load() {
sysctl -n vm.loadavg 2>/dev/null \
| awk '{ printf "%s %s %s", $2, $3, $4 }' \
|| echo "0.00 0.00 0.00"
}
# System uptime in seconds from boot time
get_uptime_seconds() {
local boot_sec now_sec
# kern.boottime: { sec = NNNN, usec = N }
boot_sec=$(sysctl -n kern.boottime 2>/dev/null \
| awk '{ for (i=1;i<=NF;i++) if ($i=="sec") { v=$(i+2); gsub(/[^0-9]/,"",v); print v; exit } }')
now_sec=$(date +%s)
if [ -n "$boot_sec" ] && [ "$boot_sec" -gt 0 ] 2>/dev/null; then
echo $((now_sec - boot_sec))
else
echo 0
fi
}
# Running process count
get_process_count() {
ps -ax 2>/dev/null | tail -n +2 | wc -l | tr -d ' \n'
}
# Primary non-loopback IPv4
get_ip_address() {
local iface
iface=$(route -n get default 2>/dev/null | awk '/interface:/ { print $2 }')
if [ -n "$iface" ]; then
ipconfig getifaddr "$iface" 2>/dev/null \
|| ifconfig "$iface" 2>/dev/null | awk '/inet / { print $2; exit }'
fi
}
# macOS version string
get_os_info() {
local name ver
name=$(sw_vers -productName 2>/dev/null || echo "macOS")
ver=$(sw_vers -productVersion 2>/dev/null || echo "")
echo "${name} ${ver}"
}
# Register with 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)
local esc_name esc_host esc_os
esc_name="${name//\"/\\\"}"
esc_host="${hostname_val//\"/\\\"}"
esc_os="${os//\"/\\\"}"
log "Registering agent '${name}' with ${SKYWATCH_URL} ..."
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}"
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 and POST metrics
report_metrics() {
local cpu
cpu=$(get_cpu_percent) # includes ~1s sleep inside top
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 "")
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
}
poll_commands() {
local resp http_code
resp=$(curl -sf -m 10 \
-H "Authorization: Bearer ${AGENT_TOKEN}" \
-w '\n__HTTP_CODE__%{http_code}' \
"${SKYWATCH_URL}/api/agents/commands/pending" 2>/dev/null) || return 0
http_code=$(echo "$resp" | grep -o '__HTTP_CODE__[0-9]*' | tail -1 | grep -o '[0-9]*')
resp=$(echo "$resp" | sed '/__HTTP_CODE__/d')
[ "$http_code" = "204" ] && return 0
[ "$http_code" != "200" ] && return 0
local cmd_id cmd_str
cmd_id=$(echo "$resp" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
cmd_str=$(echo "$resp" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('command',''))" 2>/dev/null \
|| echo "$resp" | sed -n 's/.*"command":"\([^"]*\)".*/\1/p')
[ -z "$cmd_id" ] || [ -z "$cmd_str" ] && return 0
log "Running command #${cmd_id}: ${cmd_str}"
local tmp_out exit_code
tmp_out=$(mktemp)
gtimeout 60 bash -c "$cmd_str" > "$tmp_out" 2>&1 || timeout 60 bash -c "$cmd_str" > "$tmp_out" 2>&1
exit_code=$?
local tmp_json
tmp_json=$(mktemp)
python3 - "$tmp_out" "$exit_code" > "$tmp_json" 2>/dev/null << 'PYEOF'
import sys, json
with open(sys.argv[1]) as f:
output = f.read(131072)
print(json.dumps({"output": output, "exit_code": int(sys.argv[2])}))
PYEOF
curl -sf -m 15 -X POST \
-H "Authorization: Bearer ${AGENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "@${tmp_json}" \
"${SKYWATCH_URL}/api/agents/commands/${cmd_id}/result" >/dev/null 2>&1 || true
rm -f "$tmp_out" "$tmp_json"
}
main() {
load_config
if [ -z "${SKYWATCH_URL:-}" ]; then
echo "ERROR: SKYWATCH_URL is not set."
echo "Create ${CONFIG_FILE} or use the installer:"
echo " sudo bash install-agent-macos.sh --server-url https://<your-server>"
exit 1
fi
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 (macOS) — server=${SKYWATCH_URL} interval=${interval}s"
while true; do
report_metrics
poll_commands
local sleep_time=$(( interval - 1 ))
[ "$sleep_time" -lt 1 ] && sleep_time=1
sleep "$sleep_time"
done
}
main "$@"
-313
View File
@@ -1,313 +0,0 @@
#Requires -Version 5.1
<#
.SYNOPSIS
SkyWatch Windows Agent v1.0.0
.DESCRIPTION
Collects system metrics and sends to SkyWatch monitoring server.
Metrics: CPU%, Memory, Disk, Network I/O, Process count, Uptime, OS info.
Run via Task Scheduler (installed by install-agent-windows.ps1).
.PARAMETER ConfigFile
Path to the configuration file (default: C:\ProgramData\SkyWatch\agent.conf)
#>
param(
[string]$ConfigFile = "C:\ProgramData\SkyWatch\agent.conf"
)
$VERSION = "1.0.0"
$LogFile = "C:\ProgramData\SkyWatch\agent.log"
$InstallDir = Split-Path $ConfigFile
# ── Logging ───────────────────────────────────────────────────────────────────
function Write-Log {
param([string]$Message)
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$line = "[$ts] $Message"
Write-Host $line
try { Add-Content -Path $LogFile -Value $line -ErrorAction Stop } catch {}
}
# ── Config ────────────────────────────────────────────────────────────────────
function Load-Config {
$script:SKYWATCH_URL = ""
$script:AGENT_TOKEN = ""
$script:REPORT_INTERVAL = 60
$script:AGENT_NAME = ""
$script:REGISTRATION_KEY = ""
if (Test-Path $ConfigFile) {
Get-Content $ConfigFile | ForEach-Object {
if ($_ -match '^([A-Z_]+)="?(.*?)"?\s*$') {
$key = $matches[1]; $val = $matches[2]
switch ($key) {
"SKYWATCH_URL" { $script:SKYWATCH_URL = $val }
"AGENT_TOKEN" { $script:AGENT_TOKEN = $val }
"REPORT_INTERVAL" { $script:REPORT_INTERVAL = [int]$val }
"AGENT_NAME" { $script:AGENT_NAME = $val }
"REGISTRATION_KEY" { $script:REGISTRATION_KEY = $val }
}
}
}
}
}
function Save-Config {
if (-not (Test-Path $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
@"
SKYWATCH_URL="$script:SKYWATCH_URL"
AGENT_TOKEN="$script:AGENT_TOKEN"
REPORT_INTERVAL=$script:REPORT_INTERVAL
AGENT_NAME="$script:AGENT_NAME"
REGISTRATION_KEY="$script:REGISTRATION_KEY"
"@ | Set-Content $ConfigFile -Encoding UTF8
}
# ── Metrics ───────────────────────────────────────────────────────────────────
function Get-CpuPercent {
try {
# Two raw counter samples 1 second apart for accurate delta
$s1 = Get-CimInstance -ClassName Win32_PerfRawData_PerfOS_Processor -Filter "Name='_Total'"
Start-Sleep -Seconds 1
$s2 = Get-CimInstance -ClassName Win32_PerfRawData_PerfOS_Processor -Filter "Name='_Total'"
$idleDelta = $s2.PercentIdleTime - $s1.PercentIdleTime
$totalDelta = $s2.Timestamp_Sys100NS - $s1.Timestamp_Sys100NS
if ($totalDelta -gt 0) {
$idlePct = ($idleDelta / $totalDelta) * 100
return [Math]::Round(100 - $idlePct, 1)
}
} catch {}
# Fallback: WMI LoadPercentage (less accurate but always available)
try {
$avg = (Get-WmiObject Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average
return [Math]::Round($avg, 1)
} catch { return 0.0 }
}
function Get-MemoryStats {
try {
$os = Get-CimInstance Win32_OperatingSystem
$totalMB = [long][Math]::Round($os.TotalVisibleMemorySize / 1024)
$freeMB = [long][Math]::Round($os.FreePhysicalMemory / 1024)
$usedMB = $totalMB - $freeMB
$pct = if ($totalMB -gt 0) { [Math]::Round($usedMB * 100.0 / $totalMB, 1) } else { 0.0 }
return @{ total = $totalMB; used = $usedMB; percent = $pct }
} catch {
return @{ total = 0; used = 0; percent = 0.0 }
}
}
function Get-DiskStats {
$disks = [System.Collections.Generic.List[hashtable]]::new()
try {
Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Root -match '^[A-Za-z]:\\' } | ForEach-Object {
$total = $_.Used + $_.Free
$totalMB = [long][Math]::Round($total / 1MB)
$usedMB = [long][Math]::Round($_.Used / 1MB)
$pct = if ($total -gt 0) { [int][Math]::Round($_.Used * 100.0 / $total) } else { 0 }
$disks.Add(@{ path = $_.Root; total_mb = $totalMB; used_mb = $usedMB; percent = $pct })
}
} catch {}
return $disks
}
function Get-NetBytes {
try {
$up = Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | Select-Object -ExpandProperty Name
if ($up) {
$stats = Get-NetAdapterStatistics -Name $up -ErrorAction SilentlyContinue
$sent = ($stats | Measure-Object -Property SentBytes -Sum).Sum
$recv = ($stats | Measure-Object -Property ReceivedBytes -Sum).Sum
return @{ sent = [long]$sent; recv = [long]$recv }
}
} catch {}
return @{ sent = 0L; recv = 0L }
}
function Get-UptimeSeconds {
try {
$boot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
return [long](New-TimeSpan -Start $boot -End (Get-Date)).TotalSeconds
} catch { return 0L }
}
function Get-OsInfo {
try { return (Get-CimInstance Win32_OperatingSystem).Caption.Trim() }
catch { return "Windows" }
}
function Get-PrimaryIP {
try {
$route = Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
Sort-Object RouteMetric | Select-Object -First 1
if ($route) {
$ip = Get-NetIPAddress -InterfaceIndex $route.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -notmatch '^169\.254\.' } |
Select-Object -First 1 -ExpandProperty IPAddress
return "$ip"
}
} catch {}
return ""
}
# ── Registration ──────────────────────────────────────────────────────────────
function Register-Agent {
$name = if ($script:AGENT_NAME) { $script:AGENT_NAME } else { $env:COMPUTERNAME }
$hostname = $env:COMPUTERNAME
$ip = Get-PrimaryIP
$os = Get-OsInfo
Write-Log "Registering agent '$name' with $script:SKYWATCH_URL ..."
$body = @{ name = $name; hostname = $hostname; ip_address = $ip; os_info = $os } | ConvertTo-Json
$headers = @{ 'Content-Type' = 'application/json' }
if ($script:REGISTRATION_KEY) { $headers['X-Registration-Key'] = $script:REGISTRATION_KEY }
try {
$resp = Invoke-RestMethod -Uri "$script:SKYWATCH_URL/api/agents/register" `
-Method POST -Body $body -Headers $headers -TimeoutSec 30
if ($resp.token) {
$script:AGENT_TOKEN = $resp.token
Save-Config
Write-Log "Registered. Token stored in $ConfigFile"
return $true
}
} catch { Write-Log "ERROR: Registration failed: $_" }
return $false
}
# ── Metrics report ────────────────────────────────────────────────────────────
function Send-Metrics {
$cpu = Get-CpuPercent # includes 1s sleep
$mem = Get-MemoryStats
$disks = Get-DiskStats
$net = Get-NetBytes
$uptime = Get-UptimeSeconds
$procs = try { (Get-Process -ErrorAction SilentlyContinue).Count } catch { 0 }
$ip = Get-PrimaryIP
$payload = [ordered]@{
cpu_percent = $cpu
mem_total = $mem.total
mem_used = $mem.used
mem_percent = $mem.percent
disk = @($disks)
load_1 = 0.0
load_5 = 0.0
load_15 = 0.0
uptime_seconds = $uptime
process_count = $procs
net_bytes_sent = $net.sent
net_bytes_recv = $net.recv
ip_address = $ip
} | ConvertTo-Json -Depth 3
try {
$resp = Invoke-RestMethod `
-Uri "$script:SKYWATCH_URL/api/agents/report" `
-Method POST `
-Body $payload `
-Headers @{ 'Content-Type' = 'application/json'; 'Authorization' = "Bearer $script:AGENT_TOKEN" } `
-TimeoutSec 15
if ($resp.ok) {
Write-Log "OK cpu=$($cpu)% mem=$($mem.used)/$($mem.total)MB procs=$procs"
} else {
Write-Log "WARNING: Server rejected metrics"
}
} catch {
Write-Log "WARNING: Failed to send metrics: $_"
}
}
# ── Command execution ─────────────────────────────────────────────────────────
function Poll-Commands {
try {
$resp = Invoke-WebRequest `
-Uri "$script:SKYWATCH_URL/api/agents/commands/pending" `
-Headers @{ 'Authorization' = "Bearer $script:AGENT_TOKEN" } `
-UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
if ($resp.StatusCode -eq 204) { return }
if ($resp.StatusCode -ne 200) { return }
$data = $resp.Content | ConvertFrom-Json
$cmdId = $data.id
$cmdStr = $data.command
if (-not $cmdId -or -not $cmdStr) { return }
Write-Log "Running command #${cmdId}: ${cmdStr}"
$output = ""
$exitCode = -1
try {
$proc = Start-Process -FilePath "powershell.exe" `
-ArgumentList "-NonInteractive", "-Command", $cmdStr `
-Wait -PassThru -NoNewWindow `
-RedirectStandardOutput "$env:TEMP\sw_cmd_out.txt" `
-RedirectStandardError "$env:TEMP\sw_cmd_err.txt" `
-ErrorAction Stop
$exitCode = $proc.ExitCode
$stdout = if (Test-Path "$env:TEMP\sw_cmd_out.txt") { Get-Content "$env:TEMP\sw_cmd_out.txt" -Raw } else { "" }
$stderr = if (Test-Path "$env:TEMP\sw_cmd_err.txt") { Get-Content "$env:TEMP\sw_cmd_err.txt" -Raw } else { "" }
$output = ($stdout + $stderr).Substring(0, [Math]::Min(($stdout + $stderr).Length, 131072))
} catch {
$output = "ERROR: $_"
$exitCode = 1
} finally {
Remove-Item "$env:TEMP\sw_cmd_out.txt", "$env:TEMP\sw_cmd_err.txt" -ErrorAction SilentlyContinue
}
$payload = @{ output = $output; exit_code = $exitCode } | ConvertTo-Json -Compress
Invoke-RestMethod `
-Uri "$script:SKYWATCH_URL/api/agents/commands/$cmdId/result" `
-Method POST `
-Body $payload `
-Headers @{ 'Content-Type' = 'application/json'; 'Authorization' = "Bearer $script:AGENT_TOKEN" } `
-TimeoutSec 15 -ErrorAction SilentlyContinue | Out-Null
} catch [System.Net.WebException] {
# 204 No Content arrives as an exception in some PS versions — that's fine
if ($_.Exception.Response -and $_.Exception.Response.StatusCode -eq 204) { return }
} catch {
# Network errors — silently ignore
}
}
# ── Main ──────────────────────────────────────────────────────────────────────
# Ensure log directory exists
if (-not (Test-Path $InstallDir)) { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null }
Load-Config
if (-not $script:SKYWATCH_URL) {
Write-Error "SKYWATCH_URL is not set. Edit $ConfigFile or re-run the installer."
exit 1
}
if (-not $script:AGENT_TOKEN) {
if (-not (Register-Agent)) {
Write-Log "Retrying registration in 60s ..."
Start-Sleep -Seconds 60
if (-not (Register-Agent)) { exit 1 }
}
}
$interval = if ($script:REPORT_INTERVAL -gt 0) { $script:REPORT_INTERVAL } else { 60 }
Write-Log "SkyWatch Agent v$VERSION started (Windows) — server=$script:SKYWATCH_URL interval=${interval}s"
while ($true) {
Send-Metrics
Poll-Commands
Start-Sleep -Seconds ($interval - 1) # -1 accounts for the 1s CPU sample sleep
}
-355
View File
@@ -1,355 +0,0 @@
#!/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
}
# Poll for and execute one pending command from the server
poll_commands() {
local resp http_code
resp=$(curl -sf -m 10 \
-H "Authorization: Bearer ${AGENT_TOKEN}" \
-w '\n__HTTP_CODE__%{http_code}' \
"${SKYWATCH_URL}/api/agents/commands/pending" 2>/dev/null) || return 0
http_code=$(echo "$resp" | grep -o '__HTTP_CODE__[0-9]*' | tail -1 | grep -o '[0-9]*')
resp=$(echo "$resp" | sed '/__HTTP_CODE__/d')
# 204 = no pending commands
[ "$http_code" = "204" ] && return 0
[ "$http_code" != "200" ] && return 0
local cmd_id cmd_str
cmd_id=$(echo "$resp" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
# Extract command value (handles escaped quotes)
cmd_str=$(echo "$resp" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('command',''))" 2>/dev/null \
|| echo "$resp" | sed -n 's/.*"command":"\([^"]*\)".*/\1/p')
[ -z "$cmd_id" ] || [ -z "$cmd_str" ] && return 0
log "Running command #${cmd_id}: ${cmd_str}"
# Execute with 60 s timeout, capture output, cap at 128 KB
local tmp_out exit_code
tmp_out=$(mktemp)
timeout 60 bash -c "$cmd_str" > "$tmp_out" 2>&1
exit_code=$?
# Truncate to 128 KB
truncate -s -"$(( $(wc -c < "$tmp_out") > 131072 ? $(wc -c < "$tmp_out") - 131072 : 0 ))" "$tmp_out" 2>/dev/null || true
# Build JSON payload via python (handles all escaping correctly)
local tmp_json
tmp_json=$(mktemp)
python3 - "$tmp_out" "$exit_code" > "$tmp_json" 2>/dev/null << 'PYEOF'
import sys, json
with open(sys.argv[1]) as f:
output = f.read(131072)
print(json.dumps({"output": output, "exit_code": int(sys.argv[2])}))
PYEOF
curl -sf -m 15 -X POST \
-H "Authorization: Bearer ${AGENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "@${tmp_json}" \
"${SKYWATCH_URL}/api/agents/commands/${cmd_id}/result" >/dev/null 2>&1 || true
rm -f "$tmp_out" "$tmp_json"
}
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
poll_commands
# 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 "$@"
+6 -6
View File
@@ -1,15 +1,15 @@
{
"files": {
"main.css": "/static/css/main.b251a0fb.css",
"main.js": "/static/js/main.b7e3c916.js",
"main.css": "/static/css/main.d4111563.css",
"main.js": "/static/js/main.ee340b3d.js",
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
"index.html": "/index.html",
"main.b251a0fb.css.map": "/static/css/main.b251a0fb.css.map",
"main.b7e3c916.js.map": "/static/js/main.b7e3c916.js.map",
"main.d4111563.css.map": "/static/css/main.d4111563.css.map",
"main.ee340b3d.js.map": "/static/js/main.ee340b3d.js.map",
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
},
"entrypoints": [
"static/css/main.b251a0fb.css",
"static/js/main.b7e3c916.js"
"static/css/main.d4111563.css",
"static/js/main.ee340b3d.js"
]
}
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="en" data-theme="dark"><head><meta charset="utf-8"/><link rel="icon" type="image/png" href="/app-icon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SkyWatch - Infrastructure monitoring and alerting system"/><link rel="apple-touch-icon" href="/app-icon.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.b7e3c916.js"></script><link href="/static/css/main.b251a0fb.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en" data-theme="dark"><head><meta charset="utf-8"/><link rel="icon" type="image/png" href="/app-icon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SkyWatch - Infrastructure monitoring and alerting system"/><link rel="apple-touch-icon" href="/app-icon.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.ee340b3d.js"></script><link href="/static/css/main.d4111563.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
@@ -1 +0,0 @@
{"version":3,"file":"static/css/main.b251a0fb.css","mappings":"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 truncated
@@ -1,2 +1,2 @@
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#020617;color:#e2e8f0;font-family:Manrope,IBM Plex Sans,Segoe UI,sans-serif;margin:0}*{box-sizing:border-box}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}@keyframes skeleton-pulse{0%,to{opacity:.4}50%{opacity:.8}}.skeleton{animation:skeleton-pulse 1.4s ease-in-out infinite;background:#1e293b;background:var(--surface-2);border-radius:4px}.skeleton-row{min-height:72px;pointer-events:none}.skeleton-name{height:14px;margin-bottom:8px;width:40%}.skeleton-url{height:11px;width:60%}.skeleton-badge{border-radius:12px;height:22px;width:56px}.action-delete-confirm{color:#ef4444!important}.discovery-scanning{align-items:center;color:#94a3b8;color:var(--text-muted);display:flex;gap:.75rem;padding:1rem 0}@keyframes spin{to{transform:rotate(1turn)}}.discovery-spinner{animation:spin .8s linear infinite;border:2px solid #334155;border-top-color:#14b8a6;border:2px solid var(--border);border-radius:50%;border-top-color:var(--accent);flex-shrink:0;height:18px;width:18px}.discovery-list{display:flex;flex-direction:column;gap:.5rem;max-height:360px;overflow-y:auto}.discovery-row{align-items:center;background:#1e293b;background:var(--surface-2);border:1px solid #334155;border:1px solid var(--border);border-radius:6px;display:flex;gap:1rem;justify-content:space-between;padding:.6rem .75rem}.discovery-info{display:flex;flex-direction:column;gap:2px;min-width:0}.discovery-ip{color:#e2e8f0;color:var(--text);font-size:.9rem;font-weight:600}.discovery-hostname{color:#94a3b8;color:var(--text-muted);font-size:.8rem}.discovery-url{color:#14b8a6;color:var(--accent);font-family:monospace;font-size:.8rem;word-break:break-all}.discovery-ports{color:#64748b;color:var(--text-dim);font-size:.75rem}.btn-sm{font-size:.82rem;padding:.3rem .7rem;white-space:nowrap}:root{--app-bg:#020617;--surface:#0f172a;--surface-2:#1e293b;--border:#334155;--text:#e2e8f0;--text-muted:#94a3b8;--text-dim:#64748b;--accent:#14b8a6}[data-theme=light]{--app-bg:#f8fafc;--surface:#fff;--surface-2:#f1f5f9;--border:#e2e8f0;--text:#1e293b;--text-muted:#475569;--text-dim:#94a3b8;--accent:#0d9488}[data-theme=light] .App{background:#020617;background:var(--app-bg)}[data-theme=light] .navbar{background:#ffffffeb;border-bottom:1px solid #334155;border-bottom:1px solid var(--border);color:#e2e8f0;color:var(--text)}[data-theme=light] .nav-link{color:#475569}[data-theme=light] .nav-link:hover{background:#f1f5f9;box-shadow:inset 0 0 0 1px #e2e8f0;color:#1e293b}[data-theme=light] .container,[data-theme=light] .detail-section,[data-theme=light] .resource-row{background:#0f172a;background:var(--surface);color:#e2e8f0;color:var(--text)}.App{background:radial-gradient(circle at 15% 0,#1e40af33 0,#0000 42%),radial-gradient(circle at 95% 10%,#0f766e24 0,#0000 36%),#020617;min-height:100vh}.navbar{backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);background:#0f172ad1;border-bottom:1px solid #334155;box-shadow:0 4px 18px #0f172a0f;color:#e2e8f0;padding:.85rem 2rem;position:-webkit-sticky;position:sticky;top:0;z-index:30}.navbar h1{font-size:1.5rem;letter-spacing:-.02em;margin:0}.navbar-inner{gap:1rem;justify-content:space-between;width:100%}.navbar-inner,.navbar-left{align-items:center;display:flex}.navbar-left{gap:1.5rem;min-width:0}.navbar-links{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.nav-link{border-radius:7px;color:#cbd5e1;font-size:.92rem;font-weight:600;padding:.4rem .7rem;text-decoration:none;transition:background .2s,color .2s,box-shadow .2s}.nav-link:hover{background:#111827;box-shadow:inset 0 0 0 1px #334155;color:#f8fafc}.settings-link{align-items:center;display:inline-flex;gap:.4rem}.setup-badge{background:#facc15;border-radius:999px;color:#111827;font-size:.68rem;font-weight:700;padding:.15rem .5rem}.notifications-pill{align-items:center;background:#111827;border:1px solid #334155;border-radius:999px;color:#e2e8f0;display:flex;font-size:.82rem;gap:.45rem;padding:.3rem .7rem}.container{margin:0 auto;max-width:1400px;padding:2rem}.dashboard-toolbar{align-items:center;display:flex;gap:1rem;justify-content:space-between;margin-bottom:2rem}.dashboard-title{color:#f8fafc;font-size:1.85rem;letter-spacing:-.02em;margin:0}.dashboard-subtitle{color:#94a3b8;font-size:.9rem;margin:.2rem 0 0}.dashboard-actions{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.quick-filter-row{display:flex;flex-wrap:wrap;gap:.45rem;margin:.2rem 0 .8rem}.quick-chip{background:#0f172a;border:1px solid #334155;border-radius:999px;color:#cbd5e1;cursor:pointer;font-size:.74rem;font-weight:700;letter-spacing:.02em;padding:.28rem .7rem}.quick-chip:hover{background:#111f34}.quick-chip.active{background:#14b8a6;border-color:#14b8a6;color:#052e2b}.tag-filter-input{background:#111827;border:1px solid #334155;border-radius:10px;color:#e2e8f0;min-width:180px;padding:.5rem .75rem}.actions-menu-wrapper{position:relative}.actions-menu{background:#fff;border:1px solid #Line truncated
/*# sourceMappingURL=main.b251a0fb.css.map*/
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#020617;color:#e2e8f0;font-family:Manrope,IBM Plex Sans,Segoe UI,sans-serif;margin:0}*{box-sizing:border-box}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}@keyframes skeleton-pulse{0%,to{opacity:.4}50%{opacity:.8}}.skeleton{animation:skeleton-pulse 1.4s ease-in-out infinite;background:#1e293b;background:var(--surface-2);border-radius:4px}.skeleton-row{min-height:72px;pointer-events:none}.skeleton-name{height:14px;margin-bottom:8px;width:40%}.skeleton-url{height:11px;width:60%}.skeleton-badge{border-radius:12px;height:22px;width:56px}.action-delete-confirm{color:#ef4444!important}.discovery-scanning{align-items:center;color:#94a3b8;color:var(--text-muted);display:flex;gap:.75rem;padding:1rem 0}@keyframes spin{to{transform:rotate(1turn)}}.discovery-spinner{animation:spin .8s linear infinite;border:2px solid #334155;border-top-color:#14b8a6;border:2px solid var(--border);border-radius:50%;border-top-color:var(--accent);flex-shrink:0;height:18px;width:18px}.discovery-list{display:flex;flex-direction:column;gap:.5rem;max-height:360px;overflow-y:auto}.discovery-row{align-items:center;background:#1e293b;background:var(--surface-2);border:1px solid #334155;border:1px solid var(--border);border-radius:6px;display:flex;gap:1rem;justify-content:space-between;padding:.6rem .75rem}.discovery-info{display:flex;flex-direction:column;gap:2px;min-width:0}.discovery-ip{color:#e2e8f0;color:var(--text);font-size:.9rem;font-weight:600}.discovery-hostname{color:#94a3b8;color:var(--text-muted);font-size:.8rem}.discovery-url{color:#14b8a6;color:var(--accent);font-family:monospace;font-size:.8rem;word-break:break-all}.discovery-ports{color:#64748b;color:var(--text-dim);font-size:.75rem}.btn-sm{font-size:.82rem;padding:.3rem .7rem;white-space:nowrap}:root{--app-bg:#020617;--surface:#0f172a;--surface-2:#1e293b;--border:#334155;--text:#e2e8f0;--text-muted:#94a3b8;--text-dim:#64748b;--accent:#14b8a6}[data-theme=light]{--app-bg:#f8fafc;--surface:#fff;--surface-2:#f1f5f9;--border:#e2e8f0;--text:#1e293b;--text-muted:#475569;--text-dim:#94a3b8;--accent:#0d9488}[data-theme=light] .App{background:#020617;background:var(--app-bg)}[data-theme=light] .navbar{background:#ffffffeb;border-bottom:1px solid #334155;border-bottom:1px solid var(--border);color:#e2e8f0;color:var(--text)}[data-theme=light] .nav-link{color:#475569}[data-theme=light] .nav-link:hover{background:#f1f5f9;box-shadow:inset 0 0 0 1px #e2e8f0;color:#1e293b}[data-theme=light] .container,[data-theme=light] .detail-section,[data-theme=light] .resource-row{background:#0f172a;background:var(--surface);color:#e2e8f0;color:var(--text)}.App{background:radial-gradient(circle at 15% 0,#1e40af33 0,#0000 42%),radial-gradient(circle at 95% 10%,#0f766e24 0,#0000 36%),#020617;min-height:100vh}.navbar{backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);background:#0f172ad1;border-bottom:1px solid #334155;box-shadow:0 4px 18px #0f172a0f;color:#e2e8f0;padding:.85rem 2rem;position:-webkit-sticky;position:sticky;top:0;z-index:30}.navbar h1{font-size:1.5rem;letter-spacing:-.02em;margin:0}.navbar-inner{gap:1rem;justify-content:space-between;width:100%}.navbar-inner,.navbar-left{align-items:center;display:flex}.navbar-left{gap:1.5rem;min-width:0}.navbar-links{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.nav-link{border-radius:7px;color:#cbd5e1;font-size:.92rem;font-weight:600;padding:.4rem .7rem;text-decoration:none;transition:background .2s,color .2s,box-shadow .2s}.nav-link:hover{background:#111827;box-shadow:inset 0 0 0 1px #334155;color:#f8fafc}.settings-link{align-items:center;display:inline-flex;gap:.4rem}.setup-badge{background:#facc15;border-radius:999px;color:#111827;font-size:.68rem;font-weight:700;padding:.15rem .5rem}.notifications-pill{align-items:center;background:#111827;border:1px solid #334155;border-radius:999px;color:#e2e8f0;display:flex;font-size:.82rem;gap:.45rem;padding:.3rem .7rem}.container{margin:0 auto;max-width:1400px;padding:2rem}.dashboard-toolbar{align-items:center;display:flex;gap:1rem;justify-content:space-between;margin-bottom:2rem}.dashboard-title{color:#f8fafc;font-size:1.85rem;letter-spacing:-.02em;margin:0}.dashboard-subtitle{color:#94a3b8;font-size:.9rem;margin:.2rem 0 0}.dashboard-actions{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.quick-filter-row{display:flex;flex-wrap:wrap;gap:.45rem;margin:.2rem 0 .8rem}.quick-chip{background:#0f172a;border:1px solid #334155;border-radius:999px;color:#cbd5e1;cursor:pointer;font-size:.74rem;font-weight:700;letter-spacing:.02em;padding:.28rem .7rem}.quick-chip:hover{background:#111f34}.quick-chip.active{background:#14b8a6;border-color:#14b8a6;color:#052e2b}.tag-filter-input{background:#111827;border:1px solid #334155;border-radius:10px;color:#e2e8f0;min-width:180px;padding:.5rem .75rem}.actions-menu-wrapper{position:relative}.actions-menu{background:#fff;border:1px solid #Line truncated
/*# sourceMappingURL=main.d4111563.css.map*/
@@ -0,0 +1 @@
{"version":3,"file":"static/css/main.d4111563.css","mappings":"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 truncated
@@ -1 +0,0 @@
{"version":3,"file":"static/js/main.b7e3c916.js","mappings":"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 truncated
@@ -1,3 +1,3 @@
/*! For license information please see main.b7e3c916.js.LICENSE.txt */
(()=>{var e={356:(e,t,n)=>{e.exports=n(9645).last},418:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.toString=function e(t){if(null==t)return"";if("string"===typeof t)return t;if(Array.isArray(t))return t.map(e).join(",");const n=String(t);return"0"===n&&Object.is(Number(t),-0)?"-0":n}},579:(e,t,n)=>{"use strict";e.exports=n(2799)},870:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.isObject=function(e){return null!==e&&("object"===typeof e||"function"===typeof e)}},1203:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(8420);t.isArrayLike=function(e){return null!=e&&"function"!==typeof e&&r.isLength(e.length)}},1293:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(4830);t.debounce=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};"object"!==typeof n&&(n={});const{leading:a=!1,trailing:i=!0,maxWait:o}=n,l=Array(2);let s;a&&(l[0]="leading"),i&&(l[1]="trailing");let c=null;const u=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];s=e.apply(this,n),c=null},t,{edges:l}),d=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===c&&(c=Date.now()),Date.now()-c>=o)?(s=e.apply(this,n),c=Date.now(),u.cancel(),u.schedule(),s):(u.apply(this,n),s)};return d.cancel=u.cancel,d.flush=()=>(u.flush(),s),d}},1444:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(418),a=n(3272);t.toPath=function(e){if(Array.isArray(e))return e.map(a.toKey);if("symbol"===typeof e)return[e];const t=[],n=(e=r.toString(e)).length;if(0===n)return t;let i=0,o="",l="",s=!1;for(46===e.charCodeAt(0)&&(t.push(""),i++);i<n;){const r=e[i];l?"\\"===r&&i+1<n?(i++,o+=e[i]):r===l?l="":o+=r:s?'"'===r||"'"===r?l=r:"]"===r?(s=!1,t.push(o),o=""):o+=r:"["===r?(s=!0,o&&(t.push(o),o="")):"."===r?o&&(t.push(o),o=""):o+=r,i++}return o&&t.push(o),t}},2086:(e,t,n)=>{"use strict";e.exports=n(5082)},2104:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(1203),a=n(9921);t.isArrayLikeObject=function(e){return a.isObjectLike(e)&&r.isArrayLike(e)}},2132:(e,t,n)=>{"use strict";var r=n(2897).default;Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const a=n(870),i=n(4803),o=n(9972);function l(e,t,n,u){if(t===e)return!0;switch(typeof t){case"object":return function(e,t,n,r){if(null==t)return!0;if(Array.isArray(t))return s(e,t,n,r);if(t instanceof Map)return function(e,t,n,r){if(0===t.size)return!0;if(!(e instanceof Map))return!1;for(const[a,i]of t.entries()){if(!1===n(e.get(a),i,a,e,t,r))return!1}return!0}(e,t,n,r);if(t instanceof Set)return c(e,t,n,r);const a=Object.keys(t);if(null==e)return 0===a.length;if(0===a.length)return!0;if(null!==r&&void 0!==r&&r.has(t))return r.get(t)===e;null===r||void 0===r||r.set(t,e);try{for(let o=0;o<a.length;o++){const l=a[o];if(!i.isPrimitive(e)&&!(l in e))return!1;if(void 0===t[l]&&void 0!==e[l])return!1;if(null===t[l]&&null!==e[l])return!1;if(!n(e[l],t[l],l,e,t,r))return!1}return!0}finally{null===r||void 0===r||r.delete(t)}}(e,t,n,u);case"function":return Object.keys(t).length>0?l(e,r({},t),n,u):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function s(e,t,n,r){if(0===t.length)return!0;if(!Array.isArray(e))return!1;const a=new Set;for(let i=0;i<t.length;i++){const o=t[i];let l=!1;for(let s=0;s<e.length;s++){if(a.has(s))continue;let c=!1;if(n(e[s],o,i,e,t,r)&&(c=!0),c){a.add(s),l=!0;break}}if(!l)return!1}return!0}function c(e,t,n,r){return 0===t.size||e instanceof Set&&s([...e],[...t],n,r)}t.isMatchWith=function e(t,n,r){return"function"!==typeof r?e(t,n,()=>{}):l(t,n,function e(t,n,a,i,o,s){const c=r(t,n,a,i,o,s);return void 0!==c?Boolean(c):l(t,n,e,s)},new Map)},t.isSetMatch=c},2204:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(5316),a=n(7312),i=n(3799);t.sortBy=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];const l=n.length;return l>1&&i.isIterateeCall(e,n[0],n[1])?n=[]:l>2&&i.isIterateeCall(n[0],n[1],n[2])&&(n=[n[0]]),r.orderBy(e,a.flatten(n),["asc"])}},2330:(e,t,n)=>{"use strict";var r=n(5043);var a="function"===typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e===1/t)||e!==e&&t!==t},i=r.useState,o=r.useEffect,l=r.useLayoutEffect,s=r.useDebugValue;function c(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var u="undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement?function(e,t){return t()}:function(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),a=r[0].inst,u=r[1];return l(function(){a.value=n,a.getSnapshot=t,c(a)&&u({inst:a})},[e,n,t]),o(function(){return c(a)&&u({iLine truncated
//# sourceMappingURL=main.b7e3c916.js.map
/*! For license information please see main.ee340b3d.js.LICENSE.txt */
(()=>{var e={356:(e,t,n)=>{e.exports=n(9645).last},418:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.toString=function e(t){if(null==t)return"";if("string"===typeof t)return t;if(Array.isArray(t))return t.map(e).join(",");const n=String(t);return"0"===n&&Object.is(Number(t),-0)?"-0":n}},579:(e,t,n)=>{"use strict";e.exports=n(2799)},870:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.isObject=function(e){return null!==e&&("object"===typeof e||"function"===typeof e)}},1203:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(8420);t.isArrayLike=function(e){return null!=e&&"function"!==typeof e&&r.isLength(e.length)}},1293:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(4830);t.debounce=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};"object"!==typeof n&&(n={});const{leading:a=!1,trailing:i=!0,maxWait:o}=n,l=Array(2);let s;a&&(l[0]="leading"),i&&(l[1]="trailing");let c=null;const u=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];s=e.apply(this,n),c=null},t,{edges:l}),d=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===c&&(c=Date.now()),Date.now()-c>=o)?(s=e.apply(this,n),c=Date.now(),u.cancel(),u.schedule(),s):(u.apply(this,n),s)};return d.cancel=u.cancel,d.flush=()=>(u.flush(),s),d}},1444:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(418),a=n(3272);t.toPath=function(e){if(Array.isArray(e))return e.map(a.toKey);if("symbol"===typeof e)return[e];const t=[],n=(e=r.toString(e)).length;if(0===n)return t;let i=0,o="",l="",s=!1;for(46===e.charCodeAt(0)&&(t.push(""),i++);i<n;){const r=e[i];l?"\\"===r&&i+1<n?(i++,o+=e[i]):r===l?l="":o+=r:s?'"'===r||"'"===r?l=r:"]"===r?(s=!1,t.push(o),o=""):o+=r:"["===r?(s=!0,o&&(t.push(o),o="")):"."===r?o&&(t.push(o),o=""):o+=r,i++}return o&&t.push(o),t}},2086:(e,t,n)=>{"use strict";e.exports=n(5082)},2104:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(1203),a=n(9921);t.isArrayLikeObject=function(e){return a.isObjectLike(e)&&r.isArrayLike(e)}},2132:(e,t,n)=>{"use strict";var r=n(2897).default;Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const a=n(870),i=n(4803),o=n(9972);function l(e,t,n,u){if(t===e)return!0;switch(typeof t){case"object":return function(e,t,n,r){if(null==t)return!0;if(Array.isArray(t))return s(e,t,n,r);if(t instanceof Map)return function(e,t,n,r){if(0===t.size)return!0;if(!(e instanceof Map))return!1;for(const[a,i]of t.entries()){if(!1===n(e.get(a),i,a,e,t,r))return!1}return!0}(e,t,n,r);if(t instanceof Set)return c(e,t,n,r);const a=Object.keys(t);if(null==e)return 0===a.length;if(0===a.length)return!0;if(null!==r&&void 0!==r&&r.has(t))return r.get(t)===e;null===r||void 0===r||r.set(t,e);try{for(let o=0;o<a.length;o++){const l=a[o];if(!i.isPrimitive(e)&&!(l in e))return!1;if(void 0===t[l]&&void 0!==e[l])return!1;if(null===t[l]&&null!==e[l])return!1;if(!n(e[l],t[l],l,e,t,r))return!1}return!0}finally{null===r||void 0===r||r.delete(t)}}(e,t,n,u);case"function":return Object.keys(t).length>0?l(e,r({},t),n,u):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function s(e,t,n,r){if(0===t.length)return!0;if(!Array.isArray(e))return!1;const a=new Set;for(let i=0;i<t.length;i++){const o=t[i];let l=!1;for(let s=0;s<e.length;s++){if(a.has(s))continue;let c=!1;if(n(e[s],o,i,e,t,r)&&(c=!0),c){a.add(s),l=!0;break}}if(!l)return!1}return!0}function c(e,t,n,r){return 0===t.size||e instanceof Set&&s([...e],[...t],n,r)}t.isMatchWith=function e(t,n,r){return"function"!==typeof r?e(t,n,()=>{}):l(t,n,function e(t,n,a,i,o,s){const c=r(t,n,a,i,o,s);return void 0!==c?Boolean(c):l(t,n,e,s)},new Map)},t.isSetMatch=c},2204:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(5316),a=n(7312),i=n(3799);t.sortBy=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];const l=n.length;return l>1&&i.isIterateeCall(e,n[0],n[1])?n=[]:l>2&&i.isIterateeCall(n[0],n[1],n[2])&&(n=[n[0]]),r.orderBy(e,a.flatten(n),["asc"])}},2330:(e,t,n)=>{"use strict";var r=n(5043);var a="function"===typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e===1/t)||e!==e&&t!==t},i=r.useState,o=r.useEffect,l=r.useLayoutEffect,s=r.useDebugValue;function c(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var u="undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement?function(e,t){return t()}:function(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),a=r[0].inst,u=r[1];return l(function(){a.value=n,a.getSnapshot=t,c(a)&&u({inst:a})},[e,n,t]),o(function(){return c(a)&&u({iLine truncated
//# sourceMappingURL=main.ee340b3d.js.map
@@ -0,0 +1 @@
{"version":3,"file":"static/js/main.ee340b3d.js","mappings":"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 truncated
-517
View File
@@ -1,517 +0,0 @@
/* ── Page layout ─────────────────────────────────────── */
.agents-page { padding-top: 2rem; }
.ag-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
}
.ag-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.ag-page-title {
font-size: 1.4rem;
font-weight: 700;
color: var(--text);
margin: 0;
}
.ag-count-pill {
padding: 0.2rem 0.65rem;
border-radius: 99px;
background: #0d3d35;
color: #34d399;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
}
/* ── Add-agent button ────────────────────────────────── */
.ag-install-toggle {
padding: 0.5rem 1.1rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
transition: background 0.15s, border-color 0.15s;
}
.ag-install-toggle:hover { background: var(--border); }
.ag-install-toggle-active { border-color: var(--accent); color: var(--accent); }
/* ── Platform tabs ───────────────────────────────────── */
.ag-platform-tabs {
display: flex;
gap: 0.4rem;
margin: 0.75rem 0 1rem;
flex-wrap: wrap;
}
.ag-platform-tab {
padding: 0.35rem 0.85rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text-muted);
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.ag-platform-tab:hover { background: var(--border); color: var(--text); }
.ag-platform-tab-active {
background: rgba(20,184,166,0.12);
border-color: var(--accent);
color: var(--accent);
}
/* ── Install panel ───────────────────────────────────── */
.ag-install-panel {
margin-bottom: 2rem;
padding: 1.5rem;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
}
.ag-install-title {
margin: 0 0 0.4rem;
font-size: 1rem;
font-weight: 700;
color: var(--text);
}
.ag-install-note {
color: var(--text-muted);
font-size: 0.87rem;
margin: 0 0 1.25rem;
line-height: 1.5;
}
.ag-install-note code {
background: var(--surface-2);
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.82rem;
color: var(--accent);
font-family: 'SF Mono', 'Cascadia Code', monospace;
}
.ag-install-note-sm { margin-top: 1.25rem; margin-bottom: 0; }
.ag-install-steps { display: flex; flex-direction: column; gap: 1rem; }
.ag-step { display: flex; align-items: flex-start; gap: 1rem; }
.ag-step-num {
flex-shrink: 0;
width: 26px; height: 26px;
border-radius: 50%;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--accent);
font-weight: 700;
font-size: 0.82rem;
display: flex;
align-items: center;
justify-content: center;
}
.ag-step-body { flex: 1; display: flex; flex-direction: column; gap: 0.4rem; min-width: 0; }
.ag-step-label { font-size: 0.82rem; color: var(--text-muted); font-weight: 600; }
.ag-cmd-row {
display: flex;
align-items: center;
gap: 0.5rem;
background: #050e1f;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.75rem;
overflow: hidden;
}
.ag-cmd {
flex: 1;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
font-size: 0.8rem;
color: #a5f3fc;
overflow-x: auto;
white-space: nowrap;
}
.ag-copy-btn {
flex-shrink: 0;
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
padding: 0.15rem 0.45rem;
font-size: 0.88rem;
transition: color 0.15s, border-color 0.15s;
}
.ag-copy-btn:hover { color: var(--accent); border-color: var(--accent); }
.ag-copy-btn.copied { color: #34d399; border-color: #34d399; }
/* ── Agent grid ──────────────────────────────────────── */
.ag-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1.5rem;
}
/* ── Agent card ──────────────────────────────────────── */
.ag-card {
padding: 1.25rem;
border: 1px solid var(--border);
border-left-width: 3px;
border-radius: 14px;
background: var(--surface);
display: flex;
flex-direction: column;
gap: 0;
}
.ag-online { border-left-color: #14b8a6; }
.ag-offline { border-left-color: #475569; opacity: 0.8; }
.ag-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.55rem;
}
.ag-card-identity {
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
}
.ag-dot {
flex-shrink: 0;
width: 10px; height: 10px;
border-radius: 50%;
}
.ag-dot-on { background: #14b8a6; box-shadow: 0 0 7px #14b8a680; }
.ag-dot-off { background: #475569; }
.ag-name {
font-weight: 700;
font-size: 0.98rem;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ag-sub {
font-size: 0.77rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
.ag-card-actions { display: flex; gap: 0.35rem; flex-shrink: 0; margin-top: 2px; }
.ag-btn {
padding: 0.18rem 0.55rem;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s, color 0.15s;
line-height: 1.4;
}
.ag-btn-ghost {
background: transparent;
border-color: var(--border);
color: var(--text-muted);
}
.ag-btn-ghost:hover { border-color: #ef4444; color: #ef4444; }
.ag-btn-danger { background: #450a0a; border-color: #b91c1c; color: #fca5a5; }
.ag-btn-danger:hover { background: #7f1d1d; }
.ag-btn-cancel { background: transparent; border-color: var(--border); color: var(--text-muted); }
.ag-btn-cancel:hover { background: var(--surface-2); }
.ag-btn-terminal {
background: transparent;
border-color: var(--border);
color: var(--text-muted);
font-family: 'SF Mono', 'Cascadia Code', monospace;
letter-spacing: -0.02em;
}
.ag-btn-terminal:hover { border-color: var(--accent); color: var(--accent); }
.ag-btn-terminal-active { border-color: var(--accent); color: var(--accent); background: rgba(20,184,166,0.1); }
/* ── OS + status row ─────────────────────────────────── */
.ag-meta {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 1rem;
}
.ag-os-tag {
font-size: 0.73rem;
padding: 0.15rem 0.55rem;
border-radius: 4px;
background: var(--surface-2);
color: var(--text-muted);
border: 1px solid var(--border);
max-width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ag-status-text { font-size: 0.77rem; font-weight: 600; }
.ag-status-on { color: #34d399; }
.ag-status-off { color: var(--text-dim); font-weight: 400; }
/* ── Gauge bars ──────────────────────────────────────── */
.ag-gauges { display: flex; flex-direction: column; gap: 0.65rem; margin-bottom: 1rem; }
.ag-gauge-row {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.2rem;
}
.ag-gauge-label {
font-size: 0.76rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 72%;
}
.ag-gauge-val {
font-size: 0.76rem;
font-weight: 700;
color: var(--text);
}
.ag-gauge-track {
height: 5px;
background: var(--surface-2);
border-radius: 3px;
overflow: hidden;
}
.ag-gauge-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
/* ── Stats grid ──────────────────────────────────────── */
.ag-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.6rem 0.4rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.ag-stat-label {
font-size: 0.68rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ag-stat-val {
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
margin-top: 0.15rem;
word-break: break-all;
}
.ag-stat-val-sm { font-size: 0.75rem; }
/* ── Empty / loading states ──────────────────────────── */
.ag-no-data {
text-align: center;
padding: 1.25rem 0 0.5rem;
color: var(--text-dim);
font-size: 0.85rem;
}
.ag-loading {
color: var(--text-muted);
padding: 4rem 0;
text-align: center;
font-size: 0.95rem;
}
.ag-empty {
text-align: center;
padding: 5rem 1rem;
}
.ag-empty-icon { font-size: 3rem; margin-bottom: 0.75rem; line-height: 1; }
.ag-empty-title { font-size: 1.1rem; font-weight: 700; color: var(--text); margin-bottom: 0.4rem; }
.ag-empty-sub { color: var(--text-muted); font-size: 0.9rem; line-height: 1.6; }
/* ── Command terminal ────────────────────────────────── */
.ag-terminal {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.ag-terminal-title {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
margin-bottom: 0.6rem;
}
.ag-terminal-input-row {
display: flex;
align-items: center;
gap: 0.4rem;
background: #050e1f;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.4rem 0.65rem;
margin-bottom: 0.75rem;
}
.ag-terminal-prompt {
color: var(--accent);
font-family: 'SF Mono', 'Cascadia Code', monospace;
font-size: 0.85rem;
flex-shrink: 0;
}
.ag-terminal-input {
flex: 1;
background: none;
border: none;
outline: none;
color: #e2e8f0;
font-family: 'SF Mono', 'Cascadia Code', monospace;
font-size: 0.82rem;
min-width: 0;
}
.ag-terminal-input::placeholder { color: #334155; }
.ag-terminal-run {
flex-shrink: 0;
padding: 0.15rem 0.6rem;
background: rgba(20,184,166,0.15);
border: 1px solid var(--accent);
border-radius: 5px;
color: var(--accent);
cursor: pointer;
font-size: 0.78rem;
font-weight: 700;
transition: background 0.15s;
}
.ag-terminal-run:hover:not(:disabled) { background: rgba(20,184,166,0.3); }
.ag-terminal-run:disabled { opacity: 0.4; cursor: default; }
.ag-terminal-history {
display: flex;
flex-direction: column;
gap: 0.3rem;
max-height: 240px;
overflow-y: auto;
}
.ag-terminal-empty {
font-size: 0.8rem;
color: var(--text-dim);
text-align: center;
padding: 0.75rem 0;
}
.ag-terminal-entry {
border-radius: 6px;
border: 1px solid var(--border);
overflow: hidden;
}
.ag-terminal-entry-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.55rem;
cursor: default;
background: var(--surface-2);
}
.ag-terminal-entry-head:has(.ag-terminal-expand) { cursor: pointer; }
.ag-terminal-entry-head:hover { background: #1e293b; }
.ag-cmd-status {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 700;
width: 14px;
text-align: center;
}
.ag-cmd-status-completed { color: #34d399; }
.ag-cmd-status-failed { color: #f87171; }
.ag-cmd-status-running { color: #fbbf24; }
.ag-cmd-status-pending { color: #64748b; }
.ag-terminal-entry-cmd {
flex: 1;
font-family: 'SF Mono', 'Cascadia Code', monospace;
font-size: 0.78rem;
color: #a5f3fc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ag-terminal-entry-time {
flex-shrink: 0;
font-size: 0.7rem;
color: var(--text-dim);
}
.ag-terminal-expand {
flex-shrink: 0;
font-size: 0.65rem;
color: var(--text-dim);
}
.ag-terminal-output {
margin: 0;
padding: 0.55rem 0.65rem;
background: #050e1f;
font-family: 'SF Mono', 'Cascadia Code', monospace;
font-size: 0.75rem;
color: #94a3b8;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
/* ── Responsive ──────────────────────────────────────── */
@media (max-width: 600px) {
.ag-grid { grid-template-columns: 1fr; gap: 1rem; }
.ag-stats { grid-template-columns: repeat(2, 1fr); }
}
-504
View File
@@ -1,504 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';
import './Agents.css';
// ── Formatters ────────────────────────────────────────────────────────────────
function fmtBytes(bytes) {
if (bytes == null) return '—';
const gb = bytes / 1073741824;
if (gb >= 1) return `${gb.toFixed(1)} GB`;
const mb = bytes / 1048576;
if (mb >= 1) return `${mb.toFixed(0)} MB`;
return `${(bytes / 1024).toFixed(0)} KB`;
}
function fmtUptime(secs) {
if (!secs) return '—';
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function fmtLoad(v) {
return v != null ? Number(v).toFixed(2) : '—';
}
function isOnline(lastSeenAt) {
if (!lastSeenAt) return false;
return Date.now() - new Date(lastSeenAt).getTime() < 3 * 60 * 1000;
}
function timeAgo(ts) {
if (!ts) return 'Never';
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return new Date(ts).toLocaleDateString();
}
// ── Gauge bar ─────────────────────────────────────────────────────────────────
function Gauge({ value, label }) {
const pct = Math.min(100, Math.max(0, value ?? 0));
const color = pct > 90 ? '#ef4444' : pct > 75 ? '#f59e0b' : '#14b8a6';
return (
<div className="ag-gauge">
<div className="ag-gauge-row">
<span className="ag-gauge-label" title={label}>{label}</span>
<span className="ag-gauge-val">{pct.toFixed(1)}%</span>
</div>
<div className="ag-gauge-track">
<div className="ag-gauge-fill" style={{ width: `${pct}%`, background: color }} />
</div>
</div>
);
}
// ── Copy-to-clipboard command line ────────────────────────────────────────────
function CopyCmd({ cmd }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(cmd).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch(() => {
// fallback for older browsers
const el = document.createElement('textarea');
el.value = cmd;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="ag-cmd-row">
<code className="ag-cmd">{cmd}</code>
<button
className={`ag-copy-btn${copied ? ' copied' : ''}`}
onClick={copy}
title="Copy to clipboard"
>
{copied ? '✓' : '⎘'}
</button>
</div>
);
}
// ── Platform-specific install steps ──────────────────────────────────────────
function LinuxSteps({ serverUrl }) {
return (
<>
<p className="ag-install-note">
Ubuntu 16.04+ / Debian 9+ / RHEL 7+. Runs as a systemd service.
</p>
<div className="ag-install-steps">
<div className="ag-step">
<span className="ag-step-num">1</span>
<div className="ag-step-body">
<div className="ag-step-label">Download the scripts</div>
<CopyCmd cmd={`curl -fsSL ${serverUrl}/api/agents/script -o skywatch-agent.sh`} />
<CopyCmd cmd={`curl -fsSL ${serverUrl}/api/agents/installer -o install-agent.sh`} />
</div>
</div>
<div className="ag-step">
<span className="ag-step-num">2</span>
<div className="ag-step-body">
<div className="ag-step-label">Run the installer as root</div>
<CopyCmd cmd={`sudo bash install-agent.sh --server-url ${serverUrl}`} />
</div>
</div>
</div>
<p className="ag-install-note ag-install-note-sm">
Optional: <code>--interval&nbsp;&lt;secs&gt;</code>, <code>--name&nbsp;&lt;label&gt;</code>,{' '}
<code>--registration-key&nbsp;&lt;key&gt;</code>.
Remove: <code>sudo bash install-agent.sh --uninstall</code>
</p>
</>
);
}
function MacosSteps({ serverUrl }) {
return (
<>
<p className="ag-install-note">
macOS 10.15+ (Intel &amp; Apple Silicon). Runs as a launchd system daemon.
</p>
<div className="ag-install-steps">
<div className="ag-step">
<span className="ag-step-num">1</span>
<div className="ag-step-body">
<div className="ag-step-label">Download the scripts</div>
<CopyCmd cmd={`curl -fsSL ${serverUrl}/api/agents/script-macos -o skywatch-agent-macos.sh`} />
<CopyCmd cmd={`curl -fsSL ${serverUrl}/api/agents/installer-macos -o install-agent-macos.sh`} />
</div>
</div>
<div className="ag-step">
<span className="ag-step-num">2</span>
<div className="ag-step-body">
<div className="ag-step-label">Run the installer as root</div>
<CopyCmd cmd={`sudo bash install-agent-macos.sh --server-url ${serverUrl}`} />
</div>
</div>
</div>
<p className="ag-install-note ag-install-note-sm">
Optional: <code>--interval&nbsp;&lt;secs&gt;</code>, <code>--name&nbsp;&lt;label&gt;</code>,{' '}
<code>--registration-key&nbsp;&lt;key&gt;</code>.
Remove: <code>sudo bash install-agent-macos.sh --uninstall</code>
</p>
</>
);
}
function WindowsSteps({ serverUrl }) {
return (
<>
<p className="ag-install-note">
Windows 10 / 11 / Server 2019+. Runs via Task Scheduler as SYSTEM.
Open <strong>PowerShell as Administrator</strong> and run:
</p>
<div className="ag-install-steps">
<div className="ag-step">
<span className="ag-step-num">1</span>
<div className="ag-step-body">
<div className="ag-step-label">Download the installer</div>
<CopyCmd cmd={`Invoke-WebRequest "${serverUrl}/api/agents/installer-windows" -OutFile install-agent-windows.ps1`} />
</div>
</div>
<div className="ag-step">
<span className="ag-step-num">2</span>
<div className="ag-step-body">
<div className="ag-step-label">Run the installer</div>
<CopyCmd cmd={`.\\install-agent-windows.ps1 -ServerUrl "${serverUrl}"`} />
</div>
</div>
</div>
<p className="ag-install-note ag-install-note-sm">
Optional: <code>-Interval&nbsp;&lt;secs&gt;</code>, <code>-AgentName&nbsp;&lt;label&gt;</code>,{' '}
<code>-RegistrationKey&nbsp;&lt;key&gt;</code>.
Remove: <code>.\\install-agent-windows.ps1&nbsp;-Uninstall</code>
</p>
</>
);
}
// ── Install instructions panel ────────────────────────────────────────────────
const PLATFORMS = [
{ id: 'linux', label: '🐧 Linux' },
{ id: 'macos', label: '🍎 macOS' },
{ id: 'windows', label: '🪟 Windows' },
];
function InstallPanel({ serverUrl }) {
const [platform, setPlatform] = useState('linux');
return (
<div className="ag-install-panel">
<h3 className="ag-install-title">Install agent on a host</h3>
<div className="ag-platform-tabs">
{PLATFORMS.map(p => (
<button
key={p.id}
className={`ag-platform-tab${platform === p.id ? ' ag-platform-tab-active' : ''}`}
onClick={() => setPlatform(p.id)}
>
{p.label}
</button>
))}
</div>
{platform === 'linux' && <LinuxSteps serverUrl={serverUrl} />}
{platform === 'macos' && <MacosSteps serverUrl={serverUrl} />}
{platform === 'windows' && <WindowsSteps serverUrl={serverUrl} />}
</div>
);
}
// ── Command terminal panel ────────────────────────────────────────────────────
function CommandTerminal({ agentId, agentName }) {
const [commands, setCommands] = useState([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [expanded, setExpanded] = useState({});
const pollRef = useRef(null);
const fetchCommands = useCallback(async () => {
try {
const { data } = await axios.get(`/api/agents/${agentId}/commands`);
setCommands(data.commands || []);
} catch (_) {}
}, [agentId]);
useEffect(() => {
fetchCommands();
pollRef.current = setInterval(fetchCommands, 4000);
return () => clearInterval(pollRef.current);
}, [fetchCommands]);
const send = async () => {
const cmd = input.trim();
if (!cmd || sending) return;
setSending(true);
try {
await axios.post(`/api/agents/${agentId}/commands`, { command: cmd });
setInput('');
fetchCommands();
} catch (_) {}
setSending(false);
};
const onKey = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
};
const toggleExpand = (id) => setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
const statusIcon = (s) => s === 'completed' ? '✓' : s === 'failed' ? '✗' : s === 'running' ? '↻' : '…';
const statusClass = (s) => `ag-cmd-status ag-cmd-status-${s}`;
return (
<div className="ag-terminal">
<div className="ag-terminal-title">Terminal — {agentName}</div>
<div className="ag-terminal-input-row">
<span className="ag-terminal-prompt">$</span>
<input
className="ag-terminal-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={onKey}
placeholder="Enter command…"
spellCheck={false}
autoComplete="off"
/>
<button
className="ag-terminal-run"
onClick={send}
disabled={!input.trim() || sending}
>
{sending ? '…' : 'Run'}
</button>
</div>
<div className="ag-terminal-history">
{commands.length === 0 && (
<div className="ag-terminal-empty">No commands yet.</div>
)}
{commands.map(cmd => (
<div key={cmd.id} className="ag-terminal-entry">
<div className="ag-terminal-entry-head" onClick={() => cmd.output && toggleExpand(cmd.id)}>
<span className={statusClass(cmd.status)}>{statusIcon(cmd.status)}</span>
<code className="ag-terminal-entry-cmd">{cmd.command}</code>
<span className="ag-terminal-entry-time">{timeAgo(cmd.created_at)}</span>
{cmd.output && (
<span className="ag-terminal-expand">{expanded[cmd.id] ? '▲' : '▼'}</span>
)}
</div>
{expanded[cmd.id] && cmd.output && (
<pre className="ag-terminal-output">{cmd.output}</pre>
)}
</div>
))}
</div>
</div>
);
}
// ── Single agent card ─────────────────────────────────────────────────────────
function AgentCard({ agent, onDelete }) {
const [confirmDel, setConfirmDel] = useState(false);
const [showTerminal, setShowTerminal] = useState(false);
const online = isOnline(agent.last_seen_at);
const topDisks = (agent.disk || []).slice(0, 3);
return (
<div className={`ag-card ${online ? 'ag-online' : 'ag-offline'}`}>
{/* ── Header row ── */}
<div className="ag-card-head">
<div className="ag-card-identity">
<span className={`ag-dot ${online ? 'ag-dot-on' : 'ag-dot-off'}`} />
<div style={{ minWidth: 0 }}>
<div className="ag-name" title={agent.name}>{agent.name}</div>
<div className="ag-sub" title={`${agent.hostname} · ${agent.ip_address || '?'}`}>
{agent.hostname} · {agent.ip_address || '?'}
</div>
</div>
</div>
<div className="ag-card-actions">
<button
className={`ag-btn ag-btn-terminal${showTerminal ? ' ag-btn-terminal-active' : ''}`}
onClick={() => setShowTerminal(v => !v)}
title="Open terminal"
>&gt;_</button>
{confirmDel ? (
<>
<button className="ag-btn ag-btn-danger" onClick={() => onDelete(agent.id)}>Delete</button>
<button className="ag-btn ag-btn-cancel" onClick={() => setConfirmDel(false)}>Cancel</button>
</>
) : (
<button
className="ag-btn ag-btn-ghost"
onClick={() => setConfirmDel(true)}
title="Remove agent"
>✕</button>
)}
</div>
</div>
{/* ── OS + status ── */}
<div className="ag-meta">
<span className="ag-os-tag" title={agent.os_info}>{agent.os_info || 'Linux'}</span>
<span className={`ag-status-text ${online ? 'ag-status-on' : 'ag-status-off'}`}>
{online ? '● Online' : `Last seen ${timeAgo(agent.last_seen_at)}`}
</span>
</div>
{/* ── Metrics ── */}
{agent.cpu_percent != null ? (
<>
<div className="ag-gauges">
<Gauge value={agent.cpu_percent} label="CPU" />
<Gauge
value={agent.mem_percent}
label={`Memory ${(agent.mem_used ?? 0).toLocaleString()} / ${(agent.mem_total ?? 0).toLocaleString()} MB`}
/>
{topDisks.map((d, i) => (
<Gauge key={i} value={d.percent} label={`Disk ${d.path}`} />
))}
</div>
<div className="ag-stats">
<div>
<div className="ag-stat-label">Load avg</div>
<div className="ag-stat-val" style={{ fontSize: '0.8rem' }}>
{fmtLoad(agent.load_1)} / {fmtLoad(agent.load_5)} / {fmtLoad(agent.load_15)}
</div>
</div>
<div>
<div className="ag-stat-label">Processes</div>
<div className="ag-stat-val">{agent.process_count ?? '—'}</div>
</div>
<div>
<div className="ag-stat-label">Uptime</div>
<div className="ag-stat-val">{fmtUptime(agent.uptime_seconds)}</div>
</div>
<div>
<div className="ag-stat-label">Net sent</div>
<div className="ag-stat-val ag-stat-val-sm">{fmtBytes(agent.net_bytes_sent)}</div>
</div>
<div>
<div className="ag-stat-label">Net recv</div>
<div className="ag-stat-val ag-stat-val-sm">{fmtBytes(agent.net_bytes_recv)}</div>
</div>
<div>
<div className="ag-stat-label">Reported</div>
<div className="ag-stat-val ag-stat-val-sm">{timeAgo(agent.last_metric_at)}</div>
</div>
</div>
</>
) : (
<div className="ag-no-data">Waiting for first report…</div>
)}
{showTerminal && (
<CommandTerminal agentId={agent.id} agentName={agent.name} />
)}
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function Agents() {
const [agents, setAgents] = useState([]);
const [loading, setLoading] = useState(true);
const [showInstall, setShowInstall] = useState(false);
// Use the actual origin so install commands work for both http and https
const serverUrl = window.location.origin;
const load = useCallback(async () => {
try {
const { data } = await axios.get('/api/agents');
setAgents(data.agents || []);
} catch (_) {}
setLoading(false);
}, []);
useEffect(() => {
load();
const t = setInterval(load, 30000);
return () => clearInterval(t);
}, [load]);
const deleteAgent = async (id) => {
try {
await axios.delete(`/api/agents/${id}`);
setAgents(prev => prev.filter(a => a.id !== id));
} catch (_) {}
};
const onlineCount = agents.filter(a => isOnline(a.last_seen_at)).length;
return (
<div className="container agents-page">
{/* ── Header ── */}
<div className="ag-header">
<div className="ag-header-left">
<h2 className="ag-page-title">Agents</h2>
{agents.length > 0 && (
<span className="ag-count-pill">
{onlineCount} / {agents.length} online
</span>
)}
</div>
<button
className={`ag-install-toggle${showInstall ? ' ag-install-toggle-active' : ''}`}
onClick={() => setShowInstall(v => !v)}
>
{showInstall ? '✕ Close' : '+ Add Agent'}
</button>
</div>
{/* ── Install panel ── */}
{showInstall && <InstallPanel serverUrl={serverUrl} />}
{/* ── Content ── */}
{loading ? (
<div className="ag-loading">Loading agents…</div>
) : agents.length === 0 ? (
<div className="ag-empty">
<div className="ag-empty-icon">🖥</div>
<div className="ag-empty-title">No agents registered yet</div>
<div className="ag-empty-sub">
Click <strong>+ Add Agent</strong> above to get the install commands.
</div>
</div>
) : (
<div className="ag-grid">
{agents.map(agent => (
<AgentCard key={agent.id} agent={agent} onDelete={deleteAgent} />
))}
</div>
)}
</div>
);
}
-3
View File
@@ -10,7 +10,6 @@ import SLA from './SLA';
import Status from './Status';
import Observability from './Observability';
import Notifications from './Notifications';
import Agents from './Agents';
function Navbar() {
const [notificationsConfigured, setNotificationsConfigured] = useState(false);
@@ -44,7 +43,6 @@ function Navbar() {
<Link to="/history" className="nav-link">History</Link>
<Link to="/sla" className="nav-link">SLA</Link>
<Link to="/observability" className="nav-link">Observability</Link>
<Link to="/agents" className="nav-link">Agents</Link>
<Link to="/notifications" className="nav-link">Notifications</Link>
<Link to="/status" className="nav-link" target="_blank" rel="noreferrer">Status</Link>
<Link to="/settings" className="nav-link settings-link">
@@ -77,7 +75,6 @@ function App() {
<Route path="/settings" element={<div className="container"><SettingsWizard /></div>} />
<Route path="/status" element={<Status />} />
<Route path="/observability" element={<Observability />} />
<Route path="/agents" element={<Agents />} />
<Route path="/notifications" element={<Notifications />} />
</Routes>
</div>
+1
View File
@@ -122,6 +122,7 @@
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0"
-38
View File
@@ -330,44 +330,6 @@ 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);
+20 -363
View File
@@ -6,6 +6,26 @@ const http = require('http');
const { randomUUID } = require('crypto');
require('dotenv').config();
// Prevent HTTP parser errors (e.g. "Data after Connection: close") and other
// transient async errors from crashing the process.
process.on('uncaughtException', (err) => {
if (err.code === 'HPE_INVALID_CONSTANT' || (err.message && err.message.includes('Parse Error'))) {
console.error('HTTP parse error (swallowed):', err.message);
return;
}
console.error('Uncaught exception:', err);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
const msg = reason instanceof Error ? reason.message : String(reason);
if (msg.includes('Parse Error')) {
console.error('HTTP parse error (swallowed):', msg);
return;
}
console.error('Unhandled rejection:', reason);
});
const db = require('./database');
const scheduler = require('./scheduler');
const monitorService = require('./monitorService');
@@ -16,27 +36,6 @@ const metrics = require('./metrics');
const { discoverNetwork, getLocalSubnets } = require('./networkDiscovery');
const auditLog = require('./auditLog');
// Initialize agent_commands table
try {
db.exec(`
CREATE TABLE IF NOT EXISTS agent_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
command TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
output TEXT,
exit_code INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME,
completed_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_agent_commands_agent ON agent_commands(agent_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_agent_commands_status ON agent_commands(agent_id, status);
`);
} catch (error) {
console.error('Error initializing agent_commands table:', error);
}
// Initialize notifications table if it doesn't exist
try {
db.exec(`
@@ -2189,348 +2188,6 @@ app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res)
}
});
// ===== Agent API =====
// Serve the agent daemon script
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' });
}
});
// Serve the installer script
app.get('/api/agents/installer', (req, res) => {
const fs = require('fs');
const installerPath = path.join(__dirname, '../agent/install-agent.sh');
if (fs.existsSync(installerPath)) {
res.setHeader('Content-Type', 'text/x-sh');
res.setHeader('Content-Disposition', 'attachment; filename="install-agent.sh"');
res.sendFile(installerPath);
} else {
res.status(404).json({ error: 'Installer script not found on this server' });
}
});
// macOS agent scripts
app.get('/api/agents/script-macos', (req, res) => {
const fs = require('fs');
const p = path.join(__dirname, '../agent/skywatch-agent-macos.sh');
if (fs.existsSync(p)) {
res.setHeader('Content-Type', 'text/x-sh');
res.setHeader('Content-Disposition', 'attachment; filename="skywatch-agent-macos.sh"');
res.sendFile(p);
} else { res.status(404).json({ error: 'macOS agent script not found' }); }
});
app.get('/api/agents/installer-macos', (req, res) => {
const fs = require('fs');
const p = path.join(__dirname, '../agent/install-agent-macos.sh');
if (fs.existsSync(p)) {
res.setHeader('Content-Type', 'text/x-sh');
res.setHeader('Content-Disposition', 'attachment; filename="install-agent-macos.sh"');
res.sendFile(p);
} else { res.status(404).json({ error: 'macOS installer not found' }); }
});
// Windows agent scripts
app.get('/api/agents/script-windows', (req, res) => {
const fs = require('fs');
const p = path.join(__dirname, '../agent/skywatch-agent-windows.ps1');
if (fs.existsSync(p)) {
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Disposition', 'attachment; filename="skywatch-agent-windows.ps1"');
res.sendFile(p);
} else { res.status(404).json({ error: 'Windows agent script not found' }); }
});
app.get('/api/agents/installer-windows', (req, res) => {
const fs = require('fs');
const p = path.join(__dirname, '../agent/install-agent-windows.ps1');
if (fs.existsSync(p)) {
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Disposition', 'attachment; filename="install-agent-windows.ps1"');
res.sendFile(p);
} else { res.status(404).json({ error: 'Windows installer not found' }); }
});
// 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' });
}
});
// ── Agent command queue ────────────────────────────────────────────────────────
// Dashboard → queue a command for a specific agent
app.post('/api/agents/:id/commands', (req, res) => {
const agentId = parseInt(req.params.id, 10);
const { command } = req.body;
if (!command || typeof command !== 'string' || !command.trim()) {
return res.status(400).json({ error: 'command is required' });
}
const agent = db.prepare('SELECT id FROM agents WHERE id = ?').get(agentId);
if (!agent) return res.status(404).json({ error: 'Agent not found' });
try {
const result = db.prepare(
`INSERT INTO agent_commands (agent_id, command) VALUES (?, ?)`
).run(agentId, command.trim());
res.json({ id: result.lastInsertRowid, status: 'pending' });
} catch (err) {
res.status(500).json({ error: 'Failed to queue command' });
}
});
// Dashboard → get command history for an agent (last 50)
app.get('/api/agents/:id/commands', (req, res) => {
const agentId = parseInt(req.params.id, 10);
try {
const cmds = db.prepare(`
SELECT id, command, status, output, exit_code,
REPLACE(created_at, ' ', 'T') || 'Z' AS created_at,
REPLACE(started_at, ' ', 'T') || 'Z' AS started_at,
REPLACE(completed_at, ' ', 'T') || 'Z' AS completed_at
FROM agent_commands
WHERE agent_id = ?
ORDER BY created_at DESC
LIMIT 50
`).all(agentId);
res.json({ commands: cmds });
} catch (err) {
res.status(500).json({ error: 'Failed to fetch commands' });
}
});
// Agent → dequeue one pending command (marks it running; returns 204 if queue empty)
app.get('/api/agents/commands/pending', (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 required' });
const agent = db.prepare('SELECT id FROM agents WHERE token = ?').get(token);
if (!agent) return res.status(401).json({ error: 'Invalid token' });
try {
const cmd = db.prepare(`
SELECT id, command FROM agent_commands
WHERE agent_id = ? AND status = 'pending'
ORDER BY created_at ASC
LIMIT 1
`).get(agent.id);
if (!cmd) return res.status(204).end();
db.prepare(
`UPDATE agent_commands SET status = 'running', started_at = CURRENT_TIMESTAMP WHERE id = ?`
).run(cmd.id);
res.json({ id: cmd.id, command: cmd.command });
} catch (err) {
res.status(500).json({ error: 'Failed to dequeue command' });
}
});
// Agent → post result for a completed command
app.post('/api/agents/commands/:cmdId/result', (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 required' });
const agent = db.prepare('SELECT id FROM agents WHERE token = ?').get(token);
if (!agent) return res.status(401).json({ error: 'Invalid token' });
const cmdId = parseInt(req.params.cmdId, 10);
const cmd = db.prepare('SELECT id, agent_id FROM agent_commands WHERE id = ?').get(cmdId);
if (!cmd || cmd.agent_id !== agent.id) return res.status(404).json({ error: 'Command not found' });
const { output, exit_code } = req.body;
const status = (exit_code === 0 || exit_code === '0') ? 'completed' : 'failed';
// Cap output at 128 KB
const safeOutput = typeof output === 'string' ? output.slice(0, 131072) : '';
try {
db.prepare(`
UPDATE agent_commands
SET status = ?, output = ?, exit_code = ?, completed_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(status, safeOutput, parseInt(exit_code) ?? -1, cmdId);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Failed to store result' });
}
});
// Serve React app with proper MIME types
const mimeTypes = {
'.js': 'application/javascript',
+8 -3
View File
@@ -31,9 +31,14 @@ function parseHostPort(urlString, fallbackPort) {
class MonitorService {
constructor() {
// Keep-alive agents for pooled HTTP/S connections
this.httpAgent = new http.Agent({ keepAlive: true, maxSockets: 50 });
this.httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 50 });
// Disable keep-alive to avoid "Parse Error: Data after Connection: close"
// crashes. Keep-alive connection reuse causes the HTTP parser to throw
// when a monitored server sends extra data after Connection: close — the
// socket-level error becomes an uncaught exception that kills the process.
// Monitoring checks are infrequent enough that connection pooling provides
// no meaningful benefit.
this.httpAgent = new http.Agent({ keepAlive: false });
this.httpsAgent = new https.Agent({ keepAlive: false });
// Circuit breaker for HTTP checks (5 failures before opening, 60s reset)
this.httpCircuitBreaker = new CircuitBreaker(5, 60000, this.isTransientError);
-71
View File
@@ -446,77 +446,6 @@ class NotificationService {
}
}
async sendAgentAlert(agent, type, message) {
const isOffline = type === 'offline';
const isOnline = type === 'online';
const emoji = isOffline ? '🔴' : isOnline ? '🟢' : '⚠️';
const title = isOffline ? `Agent Offline: ${agent.name}`
: isOnline ? `Agent Online: ${agent.name}`
: `Agent Alert: ${agent.name}`;
const notifType = isOffline ? 'down' : isOnline ? 'up' : 'warning';
// In-app notification
try {
const db = require('./database');
const result = db.prepare(`
INSERT INTO notifications (resource_id, incident_id, type, title, message)
VALUES (NULL, NULL, ?, ?, ?)
`).run(notifType, title, message);
if (global.broadcastNotification) {
global.broadcastNotification({
id: result.lastInsertRowid, resource_id: null, resource_name: agent.name,
incident_id: null, type: notifType, title, message, read: 0,
created_at: new Date().toISOString(),
});
}
} catch (_) {}
// Email
if (this.emailEnabled && this.transporter && this.config.email_to) {
try {
await this.transporter.sendMail({
from: this.config.email_from,
to: this.config.email_to,
subject: `${emoji} SkyWatch Agent: ${title}`,
text: [
message,
'',
`Agent: ${agent.name}`,
`Hostname: ${agent.hostname || '—'}`,
`IP: ${agent.ip_address || '—'}`,
`OS: ${agent.os_info || '—'}`,
`Time: ${new Date().toLocaleString()}`,
].join('\n'),
});
} catch (_) {}
}
// Webhook
if (this.webhookEnabled && this.config.webhook_url) {
try {
await axios.post(this.config.webhook_url, {
type: 'agent', event: type,
agent: { id: agent.id, name: agent.name, hostname: agent.hostname, ip_address: agent.ip_address },
title, message, timestamp: new Date().toISOString(),
});
} catch (_) {}
}
// Ntfy
if (this.ntfyEnabled && this.config.ntfy_topic) {
const ntfyBase = (this.config.ntfy_url || 'https://ntfy.sh').replace(/\/$/, '');
try {
await axios.post(`${ntfyBase}/`, {
topic: this.config.ntfy_topic,
title: `${emoji} ${title}`,
message,
priority: isOffline ? 4 : isOnline ? 2 : 3,
tags: isOffline ? ['red_circle'] : isOnline ? ['green_circle'] : ['warning'],
});
} catch (_) {}
}
}
async sendNtfy(resource, type) {
const ntfyUrl = this.config.ntfy_url || 'https://ntfy.sh';
const ntfyTopic = this.config.ntfy_topic;
+1 -119
View File
@@ -3,21 +3,13 @@ const db = require('./database');
const monitorService = require('./monitorService');
const notificationService = require('./notificationService');
const AGENT_CPU_THRESHOLD = 90;
const AGENT_MEM_THRESHOLD = 90;
const AGENT_DISK_THRESHOLD = 90;
const AGENT_OFFLINE_MS = 3 * 60 * 1000; // matches UI isOnline() check
class Scheduler {
constructor() {
this.jobs = new Map();
this.isRunning = false;
this.lastCheckedAt = new Map();
this.slowAlertCooldown = new Map();
this.slowConsecutiveCount = new Map();
// Agent health tracking — keyed by agent id
this.agentOnlineState = new Map(); // id -> boolean
this.agentAlertState = new Map(); // id -> { cpu, mem, disk }
this.slowConsecutiveCount = new Map(); // tracks consecutive slow checks per resource
}
start() {
@@ -33,11 +25,6 @@ class Scheduler {
}
});
// Agent health check every 2 minutes
cron.schedule('*/2 * * * *', async () => {
await this.checkAgents();
});
// Archive old checks + update baselines + check escalations daily at 2 AM
cron.schedule('0 2 * * *', () => {
this.archiveOldChecks();
@@ -56,111 +43,6 @@ class Scheduler {
}, 5000);
}
async checkAgents() {
try {
const rows = db.prepare(`
SELECT
a.id, a.name, a.hostname, a.ip_address, a.os_info, a.last_seen_at,
m.cpu_percent, m.mem_percent, m.disk_data
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.id
`).all();
const liveIds = new Set();
for (const agent of rows) {
liveIds.add(agent.id);
const isOnline = agent.last_seen_at
? Date.now() - new Date(agent.last_seen_at).getTime() < AGENT_OFFLINE_MS
: false;
const wasOnline = this.agentOnlineState.get(agent.id);
if (wasOnline === undefined) {
// First poll — seed state without alerting
this.agentOnlineState.set(agent.id, isOnline);
this.agentAlertState.set(agent.id, { cpu: false, mem: false, disk: false });
continue;
}
// Online/offline transitions
if (wasOnline && !isOnline) {
await notificationService.sendAgentAlert(
agent, 'offline',
`Agent "${agent.name}" (${agent.hostname || agent.ip_address || 'unknown'}) has gone offline.`
);
} else if (!wasOnline && isOnline) {
await notificationService.sendAgentAlert(
agent, 'online',
`Agent "${agent.name}" (${agent.hostname || agent.ip_address || 'unknown'}) is back online.`
);
this.agentAlertState.set(agent.id, { cpu: false, mem: false, disk: false });
}
this.agentOnlineState.set(agent.id, isOnline);
if (!isOnline || agent.cpu_percent == null) continue;
const as = this.agentAlertState.get(agent.id) || { cpu: false, mem: false, disk: false };
// CPU
if (agent.cpu_percent > AGENT_CPU_THRESHOLD && !as.cpu) {
await notificationService.sendAgentAlert(
agent, 'cpu',
`Agent "${agent.name}" CPU is at ${Number(agent.cpu_percent).toFixed(1)}% (threshold: ${AGENT_CPU_THRESHOLD}%).`
);
as.cpu = true;
} else if (agent.cpu_percent <= AGENT_CPU_THRESHOLD) {
as.cpu = false;
}
// Memory
if (agent.mem_percent > AGENT_MEM_THRESHOLD && !as.mem) {
await notificationService.sendAgentAlert(
agent, 'memory',
`Agent "${agent.name}" memory is at ${Number(agent.mem_percent).toFixed(1)}% (threshold: ${AGENT_MEM_THRESHOLD}%).`
);
as.mem = true;
} else if (agent.mem_percent <= AGENT_MEM_THRESHOLD) {
as.mem = false;
}
// Disk — check all partitions
if (agent.disk_data) {
try {
const disks = JSON.parse(agent.disk_data);
const worst = disks.reduce((m, d) => (d.percent > (m?.percent ?? 0) ? d : m), null);
if (worst && worst.percent > AGENT_DISK_THRESHOLD && !as.disk) {
await notificationService.sendAgentAlert(
agent, 'disk',
`Agent "${agent.name}" disk ${worst.path} is at ${worst.percent}% (threshold: ${AGENT_DISK_THRESHOLD}%).`
);
as.disk = true;
} else if (!worst || worst.percent <= AGENT_DISK_THRESHOLD) {
as.disk = false;
}
} catch (_) {}
}
this.agentAlertState.set(agent.id, as);
}
// Remove state for deleted agents
for (const id of this.agentOnlineState.keys()) {
if (!liveIds.has(id)) {
this.agentOnlineState.delete(id);
this.agentAlertState.delete(id);
}
}
} catch (err) {
console.error('Agent health check error:', err.message);
}
}
archiveOldChecks() {
try {
const retentionSetting = db.prepare(`SELECT value FROM settings WHERE key = 'retention_days'`).get();
+35 -15
View File
@@ -1,32 +1,52 @@
#!/bin/bash
# SkyWatch update script — run on the server to pull and deploy the latest version.
# The React client is built locally and committed to git before running this.
# Usage: bash update-server.sh
# Quick update script for SkyWatch on remote server
# Run this on the server: bash update-server.sh
set -e
APP_DIR="/opt/resource-monitor"
echo "🔄 Updating SkyWatch..."
echo "==> Updating SkyWatch at ${APP_DIR} ..."
cd "$APP_DIR"
cd /opt/resource-monitor
echo "==> Pulling latest code (includes pre-built client) ..."
echo "📥 Pulling latest code..."
git pull origin main
echo "==> Installing/updating server dependencies ..."
npm install --omit=dev 2>&1 | tail -20
echo "🔧 Ensuring build tools are present (needed for native modules)..."
if command -v apt-get &>/dev/null; then
apt-get install -y --no-install-recommends make gcc g++ python3 2>&1 | tail -5
elif command -v dnf &>/dev/null; then
dnf install -y make gcc gcc-c++ python3 2>&1 | tail -5
elif command -v yum &>/dev/null; then
yum install -y make gcc gcc-c++ python3 2>&1 | tail -5
fi
echo "==> Restarting application via PM2 ..."
echo "📦 Installing dependencies..."
rm -rf node_modules
# Prefer deterministic installs with npm ci when lockfile is present.
if [ -f package-lock.json ]; then
if ! npm ci --omit=dev 2>&1 | tail -20; then
echo "npm ci failed, falling back to npm install..."
npm install --omit=dev 2>&1 | tail -20
fi
else
echo "No package-lock.json found, using npm install..."
npm install --omit=dev 2>&1 | tail -20
fi
echo "🔄 Restarting application..."
pm2 restart resource-monitor
echo "==> Waiting for startup ..."
sleep 4
echo "✅ Waiting for startup..."
sleep 3
echo "==> Status:"
echo "📋 Checking status..."
pm2 status resource-monitor
echo ""
echo "==> Health check:"
curl -s http://localhost:3001/api/health | grep -o '"status":"[^"]*"' || echo "(no response yet)"
echo "📊 Recent logs (checking for database migration):"
pm2 logs resource-monitor --lines 30 --nostream
echo ""
echo "✅ Update complete!"
echo "🌐 Check your dashboard at http://$(hostname -I | awk '{print $1}'):3001"