Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
696daa22fa | ||
|
|
9c5468fe7e | ||
|
|
559a47c092 | ||
|
|
d96d923054 | ||
|
|
e7efe3c80d | ||
|
|
a0af4e7435 | ||
|
|
ce4bea5e29 | ||
|
|
13b3e3612b | ||
|
|
784e539af3 | ||
|
|
65e97f2dae | ||
|
|
020a8a8ea7 | ||
|
|
06e8e64a0b |
No files matched your search
@@ -0,0 +1,54 @@
|
|||||||
|
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 }}
|
||||||
+14
-13
@@ -6,8 +6,8 @@ WORKDIR /app/client
|
|||||||
# Copy client package files
|
# Copy client package files
|
||||||
COPY client/package*.json ./
|
COPY client/package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies (--legacy-peer-deps needed for react-scripts 5 + React 19)
|
||||||
RUN npm ci
|
RUN npm ci --legacy-peer-deps
|
||||||
|
|
||||||
# Copy client source
|
# Copy client source
|
||||||
COPY client/src ./src
|
COPY client/src ./src
|
||||||
@@ -16,19 +16,21 @@ COPY client/public ./public
|
|||||||
# Build React app
|
# Build React app
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Final stage - Node.js server
|
# Final stage - Node.js server (slim Debian for glibc native module compatibility)
|
||||||
FROM node:20-alpine
|
FROM node:20-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dumb-init for proper signal handling
|
# Install dumb-init and build tools needed by better-sqlite3
|
||||||
RUN apk add --no-cache dumb-init
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
dumb-init python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy server package files
|
# Copy server package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install production dependencies only
|
# Install production dependencies only
|
||||||
RUN npm ci --only=production
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Copy server code
|
# Copy server code
|
||||||
COPY server ./server
|
COPY server ./server
|
||||||
@@ -36,12 +38,11 @@ COPY server ./server
|
|||||||
# Copy built React client from builder stage
|
# Copy built React client from builder stage
|
||||||
COPY --from=client-builder /app/client/build ./client/build
|
COPY --from=client-builder /app/client/build ./client/build
|
||||||
|
|
||||||
# Create data directory and set permissions
|
# Create non-root user and data directory
|
||||||
RUN mkdir -p /app/data && chown -R 1001:1001 /app
|
RUN groupadd -g 1001 nodejs && \
|
||||||
|
useradd -u 1001 -g nodejs nodejs && \
|
||||||
# Create non-root user
|
mkdir -p /app/data && \
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
chown -R nodejs:nodejs /app
|
||||||
adduser -S nodejs -u 1001
|
|
||||||
|
|
||||||
USER nodejs
|
USER nodejs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#!/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."
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#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."
|
||||||
Executable
+195
@@ -0,0 +1,195 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# SkyWatch Linux Agent Installer
|
||||||
|
# Installs the agent as a systemd service on Ubuntu/Debian/RHEL/CentOS/Fedora
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sudo bash install-agent.sh --server-url http://<host>:3001 [options]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --server-url <URL> SkyWatch server URL (required)
|
||||||
|
# --interval <seconds> Metrics report interval (default: 60)
|
||||||
|
# --name <name> Agent display name (default: hostname)
|
||||||
|
# --uninstall Remove the agent and all its files
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
AGENT_DIR="/opt/skywatch-agent"
|
||||||
|
CONFIG_FILE="/etc/skywatch-agent.conf"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/skywatch-agent.service"
|
||||||
|
SCRIPT_NAME="skywatch-agent.sh"
|
||||||
|
SERVICE_NAME="skywatch-agent"
|
||||||
|
|
||||||
|
# ── Colours ──────────────────────────────────────────────────────────────────
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'; NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||||
|
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||||
|
die() { error "$*"; exit 1; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat << EOF
|
||||||
|
Usage: sudo bash install-agent.sh --server-url <URL> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--server-url <URL> SkyWatch server URL (required)
|
||||||
|
--interval <secs> How often to report metrics in seconds (default: 60)
|
||||||
|
--name <name> Agent name shown in dashboard (default: hostname)
|
||||||
|
--registration-key <key> Secret key required by the server to allow registration
|
||||||
|
--uninstall Stop, disable, and remove the agent
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
sudo bash install-agent.sh --server-url https://skywatch.necloud.us
|
||||||
|
sudo bash install-agent.sh --server-url https://skywatch.necloud.us --interval 30 --name web-01
|
||||||
|
sudo bash install-agent.sh --server-url https://skywatch.necloud.us --registration-key mySecret
|
||||||
|
sudo bash install-agent.sh --uninstall
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||||
|
SKYWATCH_URL=""
|
||||||
|
INTERVAL=60
|
||||||
|
AGENT_NAME=""
|
||||||
|
REGISTRATION_KEY=""
|
||||||
|
UNINSTALL=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--server-url) SKYWATCH_URL="${2:?'--server-url requires a value'}"; shift 2 ;;
|
||||||
|
--interval) INTERVAL="${2:?'--interval requires a value'}"; shift 2 ;;
|
||||||
|
--name) AGENT_NAME="${2:?'--name requires a value'}"; shift 2 ;;
|
||||||
|
--registration-key) REGISTRATION_KEY="${2:?'--registration-key requires a value'}"; shift 2 ;;
|
||||||
|
--uninstall) UNINSTALL=true; shift ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) error "Unknown option: $1"; usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Root check ────────────────────────────────────────────────────────────────
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
die "This installer must be run as root. Try: sudo bash $0 $*"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Uninstall path ────────────────────────────────────────────────────────────
|
||||||
|
if [ "$UNINSTALL" = true ]; then
|
||||||
|
info "Uninstalling SkyWatch Agent..."
|
||||||
|
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||||
|
systemctl disable "$SERVICE_NAME" 2>/dev/null || true
|
||||||
|
rm -f "$SERVICE_FILE"
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
rm -rf "$AGENT_DIR"
|
||||||
|
rm -f "$CONFIG_FILE"
|
||||||
|
rm -f /var/log/skywatch-agent.log
|
||||||
|
ok "SkyWatch Agent removed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Validate required args ────────────────────────────────────────────────────
|
||||||
|
[ -z "$SKYWATCH_URL" ] && usage
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════╗"
|
||||||
|
echo "║ SkyWatch Linux Agent Installer ║"
|
||||||
|
echo "╚══════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
info "Server URL : $SKYWATCH_URL"
|
||||||
|
info "Interval : ${INTERVAL}s"
|
||||||
|
info "Agent name : ${AGENT_NAME:-$(hostname -f 2>/dev/null || hostname)}"
|
||||||
|
[ -n "$REGISTRATION_KEY" ] && info "Reg. key : (set)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Dependency check ──────────────────────────────────────────────────────────
|
||||||
|
for cmd in curl awk df; do
|
||||||
|
command -v "$cmd" &>/dev/null || die "Required command '$cmd' not found. Install it and retry."
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! command -v systemctl &>/dev/null; then
|
||||||
|
die "systemd not found. This installer requires a systemd-based Linux distro (Ubuntu 16.04+, Debian 9+, RHEL/CentOS 7+)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Locate the agent script ───────────────────────────────────────────────────
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SCRIPT_SRC="$SCRIPT_DIR/$SCRIPT_NAME"
|
||||||
|
|
||||||
|
mkdir -p "$AGENT_DIR"
|
||||||
|
|
||||||
|
if [ -f "$SCRIPT_SRC" ]; then
|
||||||
|
cp "$SCRIPT_SRC" "$AGENT_DIR/$SCRIPT_NAME"
|
||||||
|
ok "Agent script copied from $SCRIPT_SRC"
|
||||||
|
else
|
||||||
|
info "Downloading agent script from server..."
|
||||||
|
if ! curl -fsSL "${SKYWATCH_URL}/api/agents/script" -o "$AGENT_DIR/$SCRIPT_NAME"; then
|
||||||
|
die "Could not find $SCRIPT_NAME locally or download it from the server.\nPlace install-agent.sh and skywatch-agent.sh in the same directory."
|
||||||
|
fi
|
||||||
|
ok "Agent script downloaded."
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "$AGENT_DIR/$SCRIPT_NAME"
|
||||||
|
|
||||||
|
# ── Write config ──────────────────────────────────────────────────────────────
|
||||||
|
cat > "$CONFIG_FILE" << CONF
|
||||||
|
# SkyWatch Agent configuration
|
||||||
|
SKYWATCH_URL="${SKYWATCH_URL}"
|
||||||
|
AGENT_TOKEN=""
|
||||||
|
REPORT_INTERVAL="${INTERVAL}"
|
||||||
|
AGENT_NAME="${AGENT_NAME:-$(hostname -f 2>/dev/null || hostname)}"
|
||||||
|
REGISTRATION_KEY="${REGISTRATION_KEY}"
|
||||||
|
CONF
|
||||||
|
chmod 600 "$CONFIG_FILE"
|
||||||
|
ok "Config written to $CONFIG_FILE"
|
||||||
|
|
||||||
|
# ── Create systemd unit ───────────────────────────────────────────────────────
|
||||||
|
cat > "$SERVICE_FILE" << UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=SkyWatch System Metrics Agent
|
||||||
|
Documentation=https://github.com/nmemmert/monitor
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/bin/bash ${AGENT_DIR}/${SCRIPT_NAME}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=30
|
||||||
|
Environment=CONFIG_FILE=${CONFIG_FILE}
|
||||||
|
Environment=LOG_FILE=/var/log/skywatch-agent.log
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=skywatch-agent
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
ok "Systemd unit written to $SERVICE_FILE"
|
||||||
|
|
||||||
|
# ── Enable and start ──────────────────────────────────────────────────────────
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$SERVICE_NAME"
|
||||||
|
systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
|
sleep 2 # give it a moment to register
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════╗"
|
||||||
|
echo "║ Agent installed and running! ║"
|
||||||
|
echo "╚══════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
ok "Service: $SERVICE_NAME"
|
||||||
|
ok "Config: $CONFIG_FILE"
|
||||||
|
ok "Logs: /var/log/skywatch-agent.log"
|
||||||
|
echo ""
|
||||||
|
echo "Useful commands:"
|
||||||
|
echo " sudo systemctl status $SERVICE_NAME"
|
||||||
|
echo " sudo journalctl -u $SERVICE_NAME -f"
|
||||||
|
echo " sudo systemctl stop $SERVICE_NAME"
|
||||||
|
echo " sudo bash $0 --uninstall"
|
||||||
|
echo ""
|
||||||
|
echo "The agent will appear in your SkyWatch dashboard under the Agents section."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Show live status ──────────────────────────────────────────────────────────
|
||||||
|
systemctl --no-pager status "$SERVICE_NAME" || true
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
#!/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 "$@"
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
#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
|
||||||
|
}
|
||||||
Executable
+355
@@ -0,0 +1,355 @@
|
|||||||
|
#!/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 "$@"
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"files": {
|
"files": {
|
||||||
"main.css": "/static/css/main.d4111563.css",
|
"main.css": "/static/css/main.b251a0fb.css",
|
||||||
"main.js": "/static/js/main.ee340b3d.js",
|
"main.js": "/static/js/main.b7e3c916.js",
|
||||||
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
|
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
|
||||||
"index.html": "/index.html",
|
"index.html": "/index.html",
|
||||||
"main.d4111563.css.map": "/static/css/main.d4111563.css.map",
|
"main.b251a0fb.css.map": "/static/css/main.b251a0fb.css.map",
|
||||||
"main.ee340b3d.js.map": "/static/js/main.ee340b3d.js.map",
|
"main.b7e3c916.js.map": "/static/js/main.b7e3c916.js.map",
|
||||||
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
|
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
|
||||||
},
|
},
|
||||||
"entrypoints": [
|
"entrypoints": [
|
||||||
"static/css/main.d4111563.css",
|
"static/css/main.b251a0fb.css",
|
||||||
"static/js/main.ee340b3d.js"
|
"static/js/main.b7e3c916.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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.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>
|
<!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>
|
||||||
+2
-2
@@ -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
|
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*/
|
/*# sourceMappingURL=main.b251a0fb.css.map*/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"static/css/main.b251a0fb.css","mappings":"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 truncated
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"static/css/main.d4111563.css","mappings":"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 truncated
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
/*! For license information please see main.ee340b3d.js.LICENSE.txt */
|
/*! 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
|
(()=>{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
|
//# sourceMappingURL=main.b7e3c916.js.map
|
||||||
File renamed without changes.
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"static/js/main.b7e3c916.js","mappings":"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 truncated
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"static/js/main.ee340b3d.js","mappings":"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 truncated
|
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
/* ── 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); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
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 <secs></code>, <code>--name <label></code>,{' '}
|
||||||
|
<code>--registration-key <key></code>.
|
||||||
|
Remove: <code>sudo bash install-agent.sh --uninstall</code>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MacosSteps({ serverUrl }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="ag-install-note">
|
||||||
|
macOS 10.15+ (Intel & 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 <secs></code>, <code>--name <label></code>,{' '}
|
||||||
|
<code>--registration-key <key></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 <secs></code>, <code>-AgentName <label></code>,{' '}
|
||||||
|
<code>-RegistrationKey <key></code>.
|
||||||
|
Remove: <code>.\\install-agent-windows.ps1 -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"
|
||||||
|
>>_</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import SLA from './SLA';
|
|||||||
import Status from './Status';
|
import Status from './Status';
|
||||||
import Observability from './Observability';
|
import Observability from './Observability';
|
||||||
import Notifications from './Notifications';
|
import Notifications from './Notifications';
|
||||||
|
import Agents from './Agents';
|
||||||
|
|
||||||
function Navbar() {
|
function Navbar() {
|
||||||
const [notificationsConfigured, setNotificationsConfigured] = useState(false);
|
const [notificationsConfigured, setNotificationsConfigured] = useState(false);
|
||||||
@@ -43,6 +44,7 @@ function Navbar() {
|
|||||||
<Link to="/history" className="nav-link">History</Link>
|
<Link to="/history" className="nav-link">History</Link>
|
||||||
<Link to="/sla" className="nav-link">SLA</Link>
|
<Link to="/sla" className="nav-link">SLA</Link>
|
||||||
<Link to="/observability" className="nav-link">Observability</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="/notifications" className="nav-link">Notifications</Link>
|
||||||
<Link to="/status" className="nav-link" target="_blank" rel="noreferrer">Status</Link>
|
<Link to="/status" className="nav-link" target="_blank" rel="noreferrer">Status</Link>
|
||||||
<Link to="/settings" className="nav-link settings-link">
|
<Link to="/settings" className="nav-link settings-link">
|
||||||
@@ -75,6 +77,7 @@ function App() {
|
|||||||
<Route path="/settings" element={<div className="container"><SettingsWizard /></div>} />
|
<Route path="/settings" element={<div className="container"><SettingsWizard /></div>} />
|
||||||
<Route path="/status" element={<Status />} />
|
<Route path="/status" element={<Status />} />
|
||||||
<Route path="/observability" element={<Observability />} />
|
<Route path="/observability" element={<Observability />} />
|
||||||
|
<Route path="/agents" element={<Agents />} />
|
||||||
<Route path="/notifications" element={<Notifications />} />
|
<Route path="/notifications" element={<Notifications />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -330,6 +330,44 @@ try { db.prepare("ALTER TABLE resources ADD COLUMN last_heartbeat_at DATETIME").
|
|||||||
// Feature: status page per-resource visibility
|
// Feature: status page per-resource visibility
|
||||||
try { db.prepare("ALTER TABLE resources ADD COLUMN is_public INTEGER DEFAULT 1").run(); } catch(e) {}
|
try { db.prepare("ALTER TABLE resources ADD COLUMN is_public INTEGER DEFAULT 1").run(); } catch(e) {}
|
||||||
|
|
||||||
|
// Feature: Linux agent monitoring
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
hostname TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
os_info TEXT,
|
||||||
|
last_seen_at DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_metrics (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id INTEGER NOT NULL,
|
||||||
|
cpu_percent REAL,
|
||||||
|
mem_total INTEGER,
|
||||||
|
mem_used INTEGER,
|
||||||
|
mem_percent REAL,
|
||||||
|
disk_data TEXT,
|
||||||
|
load_1 REAL,
|
||||||
|
load_5 REAL,
|
||||||
|
load_15 REAL,
|
||||||
|
uptime_seconds INTEGER,
|
||||||
|
process_count INTEGER,
|
||||||
|
net_bytes_sent INTEGER,
|
||||||
|
net_bytes_recv INTEGER,
|
||||||
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agents_token ON agents(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_metrics_agent_time ON agent_metrics(agent_id, recorded_at DESC);
|
||||||
|
`);
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
// Helpful indexes for time-range queries
|
// Helpful indexes for time-range queries
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_checks_resource_time ON checks(resource_id, checked_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_checks_resource_time ON checks(resource_id, checked_at DESC);
|
||||||
|
|||||||
+363
@@ -16,6 +16,27 @@ const metrics = require('./metrics');
|
|||||||
const { discoverNetwork, getLocalSubnets } = require('./networkDiscovery');
|
const { discoverNetwork, getLocalSubnets } = require('./networkDiscovery');
|
||||||
const auditLog = require('./auditLog');
|
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
|
// Initialize notifications table if it doesn't exist
|
||||||
try {
|
try {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
@@ -2168,6 +2189,348 @@ 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
|
// Serve React app with proper MIME types
|
||||||
const mimeTypes = {
|
const mimeTypes = {
|
||||||
'.js': 'application/javascript',
|
'.js': 'application/javascript',
|
||||||
|
|||||||
@@ -446,6 +446,77 @@ 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) {
|
async sendNtfy(resource, type) {
|
||||||
const ntfyUrl = this.config.ntfy_url || 'https://ntfy.sh';
|
const ntfyUrl = this.config.ntfy_url || 'https://ntfy.sh';
|
||||||
const ntfyTopic = this.config.ntfy_topic;
|
const ntfyTopic = this.config.ntfy_topic;
|
||||||
|
|||||||
+119
-1
@@ -3,13 +3,21 @@ const db = require('./database');
|
|||||||
const monitorService = require('./monitorService');
|
const monitorService = require('./monitorService');
|
||||||
const notificationService = require('./notificationService');
|
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 {
|
class Scheduler {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.jobs = new Map();
|
this.jobs = new Map();
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
this.lastCheckedAt = new Map();
|
this.lastCheckedAt = new Map();
|
||||||
this.slowAlertCooldown = new Map();
|
this.slowAlertCooldown = new Map();
|
||||||
this.slowConsecutiveCount = new Map(); // tracks consecutive slow checks per resource
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
@@ -25,6 +33,11 @@ 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
|
// Archive old checks + update baselines + check escalations daily at 2 AM
|
||||||
cron.schedule('0 2 * * *', () => {
|
cron.schedule('0 2 * * *', () => {
|
||||||
this.archiveOldChecks();
|
this.archiveOldChecks();
|
||||||
@@ -43,6 +56,111 @@ class Scheduler {
|
|||||||
}, 5000);
|
}, 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() {
|
archiveOldChecks() {
|
||||||
try {
|
try {
|
||||||
const retentionSetting = db.prepare(`SELECT value FROM settings WHERE key = 'retention_days'`).get();
|
const retentionSetting = db.prepare(`SELECT value FROM settings WHERE key = 'retention_days'`).get();
|
||||||
|
|||||||
+15
-35
@@ -1,52 +1,32 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Quick update script for SkyWatch on remote server
|
# SkyWatch update script — run on the server to pull and deploy the latest version.
|
||||||
# Run this on the server: bash update-server.sh
|
# The React client is built locally and committed to git before running this.
|
||||||
|
# Usage: bash update-server.sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "🔄 Updating SkyWatch..."
|
APP_DIR="/opt/resource-monitor"
|
||||||
|
|
||||||
cd /opt/resource-monitor
|
echo "==> Updating SkyWatch at ${APP_DIR} ..."
|
||||||
|
cd "$APP_DIR"
|
||||||
|
|
||||||
echo "📥 Pulling latest code..."
|
echo "==> Pulling latest code (includes pre-built client) ..."
|
||||||
git pull origin main
|
git pull origin main
|
||||||
|
|
||||||
echo "🔧 Ensuring build tools are present (needed for native modules)..."
|
echo "==> Installing/updating server dependencies ..."
|
||||||
if command -v apt-get &>/dev/null; then
|
npm install --omit=dev 2>&1 | tail -20
|
||||||
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 "📦 Installing dependencies..."
|
echo "==> Restarting application via PM2 ..."
|
||||||
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
|
pm2 restart resource-monitor
|
||||||
|
|
||||||
echo "✅ Waiting for startup..."
|
echo "==> Waiting for startup ..."
|
||||||
sleep 3
|
sleep 4
|
||||||
|
|
||||||
echo "📋 Checking status..."
|
echo "==> Status:"
|
||||||
pm2 status resource-monitor
|
pm2 status resource-monitor
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "📊 Recent logs (checking for database migration):"
|
echo "==> Health check:"
|
||||||
pm2 logs resource-monitor --lines 30 --nostream
|
curl -s http://localhost:3001/api/health | grep -o '"status":"[^"]*"' || echo "(no response yet)"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Update complete!"
|
echo "✅ Update complete!"
|
||||||
echo "🌐 Check your dashboard at http://$(hostname -I | awk '{print $1}'):3001"
|
|
||||||
Reference in new issue
Block a user