Compare commits
32
Commits
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "SkyWatch",
|
||||||
|
"runtimeExecutable": "node",
|
||||||
|
"runtimeArgs": ["server/index.js"],
|
||||||
|
"port": 3001,
|
||||||
|
"url": "http://localhost:3001"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.69110889.css",
|
"main.css": "/static/css/main.b251a0fb.css",
|
||||||
"main.js": "/static/js/main.820bab7f.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.69110889.css.map": "/static/css/main.69110889.css.map",
|
"main.b251a0fb.css.map": "/static/css/main.b251a0fb.css.map",
|
||||||
"main.820bab7f.js.map": "/static/js/main.820bab7f.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.69110889.css",
|
"static/css/main.b251a0fb.css",
|
||||||
"static/js/main.820bab7f.js"
|
"static/js/main.b7e3c916.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
<!doctype html><html lang="en"><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.820bab7f.js"></script><link href="/static/css/main.69110889.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>
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#f8fafc;color:#0f172a;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}.App{background:radial-gradient(circle at 15% 0,#dff7f2 0,#0000 40%),radial-gradient(circle at 95% 10%,#e8eefc 0,#0000 38%),#f8fafc;min-height:100vh}.navbar{backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);background:#ffffffd9;border-bottom:1px solid #dbe2ea;box-shadow:0 4px 18px #0f172a0f;color:#0f172a;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:#475569;font-size:.92rem;font-weight:600;padding:.4rem .7rem;text-decoration:none;transition:background .2s,color .2s,box-shadow .2s}.nav-link:hover{background:#fff;box-shadow:inset 0 0 0 1px #dbe2ea;color:#0f172a}.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:#fff;border:1px solid #dbe2ea;border-radius:999px;color:#334155;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{font-size:1.85rem;letter-spacing:-.02em;margin:0}.dashboard-subtitle{color:#64748b;font-size:.9rem;margin:.2rem 0 0}.dashboard-actions{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.tag-filter-input{background:#ffffffe6;border:1px solid #cbd5e1;border-radius:10px;min-width:180px;padding:.5rem .75rem}.actions-menu-wrapper{position:relative}.actions-menu{background:#fff;border:1px solid #dbe2ea;border-radius:8px;box-shadow:0 8px 20px #0f172a24;min-width:220px;padding:.35rem 0;position:absolute;right:0;top:110%;z-index:20}.actions-menu-divider{border:0;border-top:1px solid #eef2f6;margin:.25rem 0}.hidden-file-input{display:none}.toast-stack{position:fixed;right:20px;top:20px;z-index:9999}.toast-item{border:1px solid #0000;border-radius:8px;box-shadow:0 8px 20px #0f172a29;margin-bottom:10px;min-width:280px;padding:12px 16px}.toast-error{background:#fee2e2;border-color:#fecaca;color:#7f1d1d}.toast-success{background:#dcfce7;border-color:#bbf7d0;color:#14532d}.toast-info{background:#dbeafe;border-color:#bfdbfe;color:#1e3a8a}.cc-metrics-grid{grid-gap:.65rem;display:grid;gap:.65rem;grid-template-columns:repeat(6,minmax(0,1fr));margin-bottom:1rem}.cc-metric-card{background:linear-gradient(180deg,#0f172af5,#1e293bf5);border:1px solid #334155;border-radius:10px;color:#e2e8f0;padding:.75rem}.cc-metric-label{color:#94a3b8;font-size:.68rem;letter-spacing:.08em;margin:0;text-transform:uppercase}.cc-metric-value{font-size:1.2rem;font-weight:700;margin:.35rem 0 0}.metric-ok{border-color:#14532d}.metric-down{border-color:#7f1d1d}.metric-maint{border-color:#78350f}.cc-layout{grid-gap:.8rem;align-items:start;display:grid;gap:.8rem;grid-template-columns:minmax(0,1fr) 300px}.cc-main{min-width:0}.cc-rail{display:flex;flex-direction:column;gap:.75rem;position:-webkit-sticky;position:sticky;top:82px}.cc-panel{background:#0f172af0;border:1px solid #334155;border-radius:12px;color:#e2e8f0;padding:.75rem}.cc-panel h3{font-size:.9rem;letter-spacing:.03em;margin:0 0 .65rem}.cc-empty{color:#94a3b8;font-size:.85rem;margin:0}.cc-group-list,.cc-incident-list{display:flex;flex-direction:column;gap:.4rem}.cc-group-item,.cc-incident-item{align-items:center;background:#0f172a;border:1px solid #334155;border-radius:8px;color:#e2e8f0;cursor:pointer;display:flex;justify-content:space-between;padding:.45rem .55rem;width:100%}.cc-group-item:hover,.cc-incident-item:hover{background:#111f34;border-color:#475569}.cc-incident-name{font-size:.85rem;font-weight:600;margin-right:.5rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dashboard-grid{grid-gap:1rem;display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-bottom:1.5rem}.resource-list{display:flex;flex-direction:column;gap:.45rem}.resource-list-shell{background:#ffffffd1;border:1px solid #cbd5e1;border-radius:12px;padding:.65rem}.resource-list-header{grid-gap:1rem;border-bottom:1px solid #e2e8f0;color:#64748b;display:grid;font-size:.72rem;gap:1rem;grid-template-columns:minmax(260px,2fr) minmax(220px,1.25fr) minmax(170px,.8fr);letter-spacing:.08em;margin-bottom:.45rem;padding:.35rem .6rem .6rem;text-transform:uppercase}.resource-list-count{color:#64748b;font-size:.75rem;margin:0 .6rem .55rem}.resource-row{grid-gap:1rem;align-items:cLine truncated
|
|
||||||
/*# sourceMappingURL=main.69110889.css.map*/
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"static/css/main.69110889.css","mappings":"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 truncated
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#020617;color:#e2e8f0;font-family:Manrope,IBM Plex Sans,Segoe UI,sans-serif;margin:0}*{box-sizing:border-box}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}@keyframes skeleton-pulse{0%,to{opacity:.4}50%{opacity:.8}}.skeleton{animation:skeleton-pulse 1.4s ease-in-out infinite;background:#1e293b;background:var(--surface-2);border-radius:4px}.skeleton-row{min-height:72px;pointer-events:none}.skeleton-name{height:14px;margin-bottom:8px;width:40%}.skeleton-url{height:11px;width:60%}.skeleton-badge{border-radius:12px;height:22px;width:56px}.action-delete-confirm{color:#ef4444!important}.discovery-scanning{align-items:center;color:#94a3b8;color:var(--text-muted);display:flex;gap:.75rem;padding:1rem 0}@keyframes spin{to{transform:rotate(1turn)}}.discovery-spinner{animation:spin .8s linear infinite;border:2px solid #334155;border-top-color:#14b8a6;border:2px solid var(--border);border-radius:50%;border-top-color:var(--accent);flex-shrink:0;height:18px;width:18px}.discovery-list{display:flex;flex-direction:column;gap:.5rem;max-height:360px;overflow-y:auto}.discovery-row{align-items:center;background:#1e293b;background:var(--surface-2);border:1px solid #334155;border:1px solid var(--border);border-radius:6px;display:flex;gap:1rem;justify-content:space-between;padding:.6rem .75rem}.discovery-info{display:flex;flex-direction:column;gap:2px;min-width:0}.discovery-ip{color:#e2e8f0;color:var(--text);font-size:.9rem;font-weight:600}.discovery-hostname{color:#94a3b8;color:var(--text-muted);font-size:.8rem}.discovery-url{color:#14b8a6;color:var(--accent);font-family:monospace;font-size:.8rem;word-break:break-all}.discovery-ports{color:#64748b;color:var(--text-dim);font-size:.75rem}.btn-sm{font-size:.82rem;padding:.3rem .7rem;white-space:nowrap}:root{--app-bg:#020617;--surface:#0f172a;--surface-2:#1e293b;--border:#334155;--text:#e2e8f0;--text-muted:#94a3b8;--text-dim:#64748b;--accent:#14b8a6}[data-theme=light]{--app-bg:#f8fafc;--surface:#fff;--surface-2:#f1f5f9;--border:#e2e8f0;--text:#1e293b;--text-muted:#475569;--text-dim:#94a3b8;--accent:#0d9488}[data-theme=light] .App{background:#020617;background:var(--app-bg)}[data-theme=light] .navbar{background:#ffffffeb;border-bottom:1px solid #334155;border-bottom:1px solid var(--border);color:#e2e8f0;color:var(--text)}[data-theme=light] .nav-link{color:#475569}[data-theme=light] .nav-link:hover{background:#f1f5f9;box-shadow:inset 0 0 0 1px #e2e8f0;color:#1e293b}[data-theme=light] .container,[data-theme=light] .detail-section,[data-theme=light] .resource-row{background:#0f172a;background:var(--surface);color:#e2e8f0;color:var(--text)}.App{background:radial-gradient(circle at 15% 0,#1e40af33 0,#0000 42%),radial-gradient(circle at 95% 10%,#0f766e24 0,#0000 36%),#020617;min-height:100vh}.navbar{backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);background:#0f172ad1;border-bottom:1px solid #334155;box-shadow:0 4px 18px #0f172a0f;color:#e2e8f0;padding:.85rem 2rem;position:-webkit-sticky;position:sticky;top:0;z-index:30}.navbar h1{font-size:1.5rem;letter-spacing:-.02em;margin:0}.navbar-inner{gap:1rem;justify-content:space-between;width:100%}.navbar-inner,.navbar-left{align-items:center;display:flex}.navbar-left{gap:1.5rem;min-width:0}.navbar-links{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.nav-link{border-radius:7px;color:#cbd5e1;font-size:.92rem;font-weight:600;padding:.4rem .7rem;text-decoration:none;transition:background .2s,color .2s,box-shadow .2s}.nav-link:hover{background:#111827;box-shadow:inset 0 0 0 1px #334155;color:#f8fafc}.settings-link{align-items:center;display:inline-flex;gap:.4rem}.setup-badge{background:#facc15;border-radius:999px;color:#111827;font-size:.68rem;font-weight:700;padding:.15rem .5rem}.notifications-pill{align-items:center;background:#111827;border:1px solid #334155;border-radius:999px;color:#e2e8f0;display:flex;font-size:.82rem;gap:.45rem;padding:.3rem .7rem}.container{margin:0 auto;max-width:1400px;padding:2rem}.dashboard-toolbar{align-items:center;display:flex;gap:1rem;justify-content:space-between;margin-bottom:2rem}.dashboard-title{color:#f8fafc;font-size:1.85rem;letter-spacing:-.02em;margin:0}.dashboard-subtitle{color:#94a3b8;font-size:.9rem;margin:.2rem 0 0}.dashboard-actions{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.quick-filter-row{display:flex;flex-wrap:wrap;gap:.45rem;margin:.2rem 0 .8rem}.quick-chip{background:#0f172a;border:1px solid #334155;border-radius:999px;color:#cbd5e1;cursor:pointer;font-size:.74rem;font-weight:700;letter-spacing:.02em;padding:.28rem .7rem}.quick-chip:hover{background:#111f34}.quick-chip.active{background:#14b8a6;border-color:#14b8a6;color:#052e2b}.tag-filter-input{background:#111827;border:1px solid #334155;border-radius:10px;color:#e2e8f0;min-width:180px;padding:.5rem .75rem}.actions-menu-wrapper{position:relative}.actions-menu{background:#fff;border:1px solid #Line truncated
|
||||||
|
/*# sourceMappingURL=main.b251a0fb.css.map*/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"static/css/main.b251a0fb.css","mappings":"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 truncated
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
/*! For license information please see main.820bab7f.js.LICENSE.txt */
|
|
||||||
(()=>{var e={356:(e,t,n)=>{e.exports=n(9645).last},418:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.toString=function e(t){if(null==t)return"";if("string"===typeof t)return t;if(Array.isArray(t))return t.map(e).join(",");const n=String(t);return"0"===n&&Object.is(Number(t),-0)?"-0":n}},579:(e,t,n)=>{"use strict";e.exports=n(2799)},870:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.isObject=function(e){return null!==e&&("object"===typeof e||"function"===typeof e)}},1203:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(8420);t.isArrayLike=function(e){return null!=e&&"function"!==typeof e&&r.isLength(e.length)}},1293:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(4830);t.debounce=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};"object"!==typeof n&&(n={});const{leading:a=!1,trailing:i=!0,maxWait:o}=n,l=Array(2);let s;a&&(l[0]="leading"),i&&(l[1]="trailing");let c=null;const u=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];s=e.apply(this,n),c=null},t,{edges:l}),d=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===c&&(c=Date.now()),Date.now()-c>=o)?(s=e.apply(this,n),c=Date.now(),u.cancel(),u.schedule(),s):(u.apply(this,n),s)};return d.cancel=u.cancel,d.flush=()=>(u.flush(),s),d}},1444:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(418),a=n(3272);t.toPath=function(e){if(Array.isArray(e))return e.map(a.toKey);if("symbol"===typeof e)return[e];const t=[],n=(e=r.toString(e)).length;if(0===n)return t;let i=0,o="",l="",s=!1;for(46===e.charCodeAt(0)&&(t.push(""),i++);i<n;){const r=e[i];l?"\\"===r&&i+1<n?(i++,o+=e[i]):r===l?l="":o+=r:s?'"'===r||"'"===r?l=r:"]"===r?(s=!1,t.push(o),o=""):o+=r:"["===r?(s=!0,o&&(t.push(o),o="")):"."===r?o&&(t.push(o),o=""):o+=r,i++}return o&&t.push(o),t}},2086:(e,t,n)=>{"use strict";e.exports=n(5082)},2104:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(1203),a=n(9921);t.isArrayLikeObject=function(e){return a.isObjectLike(e)&&r.isArrayLike(e)}},2132:(e,t,n)=>{"use strict";var r=n(2897).default;Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const a=n(870),i=n(4803),o=n(9972);function l(e,t,n,u){if(t===e)return!0;switch(typeof t){case"object":return function(e,t,n,r){if(null==t)return!0;if(Array.isArray(t))return s(e,t,n,r);if(t instanceof Map)return function(e,t,n,r){if(0===t.size)return!0;if(!(e instanceof Map))return!1;for(const[a,i]of t.entries()){if(!1===n(e.get(a),i,a,e,t,r))return!1}return!0}(e,t,n,r);if(t instanceof Set)return c(e,t,n,r);const a=Object.keys(t);if(null==e)return 0===a.length;if(0===a.length)return!0;if(null!==r&&void 0!==r&&r.has(t))return r.get(t)===e;null===r||void 0===r||r.set(t,e);try{for(let o=0;o<a.length;o++){const l=a[o];if(!i.isPrimitive(e)&&!(l in e))return!1;if(void 0===t[l]&&void 0!==e[l])return!1;if(null===t[l]&&null!==e[l])return!1;if(!n(e[l],t[l],l,e,t,r))return!1}return!0}finally{null===r||void 0===r||r.delete(t)}}(e,t,n,u);case"function":return Object.keys(t).length>0?l(e,r({},t),n,u):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function s(e,t,n,r){if(0===t.length)return!0;if(!Array.isArray(e))return!1;const a=new Set;for(let i=0;i<t.length;i++){const o=t[i];let l=!1;for(let s=0;s<e.length;s++){if(a.has(s))continue;let c=!1;if(n(e[s],o,i,e,t,r)&&(c=!0),c){a.add(s),l=!0;break}}if(!l)return!1}return!0}function c(e,t,n,r){return 0===t.size||e instanceof Set&&s([...e],[...t],n,r)}t.isMatchWith=function e(t,n,r){return"function"!==typeof r?e(t,n,()=>{}):l(t,n,function e(t,n,a,i,o,s){const c=r(t,n,a,i,o,s);return void 0!==c?Boolean(c):l(t,n,e,s)},new Map)},t.isSetMatch=c},2204:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(5316),a=n(7312),i=n(3799);t.sortBy=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];const l=n.length;return l>1&&i.isIterateeCall(e,n[0],n[1])?n=[]:l>2&&i.isIterateeCall(n[0],n[1],n[2])&&(n=[n[0]]),r.orderBy(e,a.flatten(n),["asc"])}},2330:(e,t,n)=>{"use strict";var r=n(5043);var a="function"===typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e===1/t)||e!==e&&t!==t},i=r.useState,o=r.useEffect,l=r.useLayoutEffect,s=r.useDebugValue;function c(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var u="undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement?function(e,t){return t()}:function(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),a=r[0].inst,u=r[1];return l(function(){a.value=n,a.getSnapshot=t,c(a)&&u({inst:a})},[e,n,t]),o(function(){return c(a)&&u({iLine truncated
|
|
||||||
//# sourceMappingURL=main.820bab7f.js.map
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"static/js/main.820bab7f.js","mappings":"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 truncated
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/*! For license information please see main.b7e3c916.js.LICENSE.txt */
|
||||||
|
(()=>{var e={356:(e,t,n)=>{e.exports=n(9645).last},418:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.toString=function e(t){if(null==t)return"";if("string"===typeof t)return t;if(Array.isArray(t))return t.map(e).join(",");const n=String(t);return"0"===n&&Object.is(Number(t),-0)?"-0":n}},579:(e,t,n)=>{"use strict";e.exports=n(2799)},870:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.isObject=function(e){return null!==e&&("object"===typeof e||"function"===typeof e)}},1203:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(8420);t.isArrayLike=function(e){return null!=e&&"function"!==typeof e&&r.isLength(e.length)}},1293:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(4830);t.debounce=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};"object"!==typeof n&&(n={});const{leading:a=!1,trailing:i=!0,maxWait:o}=n,l=Array(2);let s;a&&(l[0]="leading"),i&&(l[1]="trailing");let c=null;const u=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];s=e.apply(this,n),c=null},t,{edges:l}),d=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===c&&(c=Date.now()),Date.now()-c>=o)?(s=e.apply(this,n),c=Date.now(),u.cancel(),u.schedule(),s):(u.apply(this,n),s)};return d.cancel=u.cancel,d.flush=()=>(u.flush(),s),d}},1444:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(418),a=n(3272);t.toPath=function(e){if(Array.isArray(e))return e.map(a.toKey);if("symbol"===typeof e)return[e];const t=[],n=(e=r.toString(e)).length;if(0===n)return t;let i=0,o="",l="",s=!1;for(46===e.charCodeAt(0)&&(t.push(""),i++);i<n;){const r=e[i];l?"\\"===r&&i+1<n?(i++,o+=e[i]):r===l?l="":o+=r:s?'"'===r||"'"===r?l=r:"]"===r?(s=!1,t.push(o),o=""):o+=r:"["===r?(s=!0,o&&(t.push(o),o="")):"."===r?o&&(t.push(o),o=""):o+=r,i++}return o&&t.push(o),t}},2086:(e,t,n)=>{"use strict";e.exports=n(5082)},2104:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(1203),a=n(9921);t.isArrayLikeObject=function(e){return a.isObjectLike(e)&&r.isArrayLike(e)}},2132:(e,t,n)=>{"use strict";var r=n(2897).default;Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const a=n(870),i=n(4803),o=n(9972);function l(e,t,n,u){if(t===e)return!0;switch(typeof t){case"object":return function(e,t,n,r){if(null==t)return!0;if(Array.isArray(t))return s(e,t,n,r);if(t instanceof Map)return function(e,t,n,r){if(0===t.size)return!0;if(!(e instanceof Map))return!1;for(const[a,i]of t.entries()){if(!1===n(e.get(a),i,a,e,t,r))return!1}return!0}(e,t,n,r);if(t instanceof Set)return c(e,t,n,r);const a=Object.keys(t);if(null==e)return 0===a.length;if(0===a.length)return!0;if(null!==r&&void 0!==r&&r.has(t))return r.get(t)===e;null===r||void 0===r||r.set(t,e);try{for(let o=0;o<a.length;o++){const l=a[o];if(!i.isPrimitive(e)&&!(l in e))return!1;if(void 0===t[l]&&void 0!==e[l])return!1;if(null===t[l]&&null!==e[l])return!1;if(!n(e[l],t[l],l,e,t,r))return!1}return!0}finally{null===r||void 0===r||r.delete(t)}}(e,t,n,u);case"function":return Object.keys(t).length>0?l(e,r({},t),n,u):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function s(e,t,n,r){if(0===t.length)return!0;if(!Array.isArray(e))return!1;const a=new Set;for(let i=0;i<t.length;i++){const o=t[i];let l=!1;for(let s=0;s<e.length;s++){if(a.has(s))continue;let c=!1;if(n(e[s],o,i,e,t,r)&&(c=!0),c){a.add(s),l=!0;break}}if(!l)return!1}return!0}function c(e,t,n,r){return 0===t.size||e instanceof Set&&s([...e],[...t],n,r)}t.isMatchWith=function e(t,n,r){return"function"!==typeof r?e(t,n,()=>{}):l(t,n,function e(t,n,a,i,o,s){const c=r(t,n,a,i,o,s);return void 0!==c?Boolean(c):l(t,n,e,s)},new Map)},t.isSetMatch=c},2204:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(5316),a=n(7312),i=n(3799);t.sortBy=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];const l=n.length;return l>1&&i.isIterateeCall(e,n[0],n[1])?n=[]:l>2&&i.isIterateeCall(n[0],n[1],n[2])&&(n=[n[0]]),r.orderBy(e,a.flatten(n),["asc"])}},2330:(e,t,n)=>{"use strict";var r=n(5043);var a="function"===typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e===1/t)||e!==e&&t!==t},i=r.useState,o=r.useEffect,l=r.useLayoutEffect,s=r.useDebugValue;function c(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var u="undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement?function(e,t){return t()}:function(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),a=r[0].inst,u=r[1];return l(function(){a.value=n,a.getSnapshot=t,c(a)&&u({inst:a})},[e,n,t]),o(function(){return c(a)&&u({iLine truncated
|
||||||
|
//# sourceMappingURL=main.b7e3c916.js.map
|
||||||
File renamed without changes.
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"static/js/main.b7e3c916.js","mappings":"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 truncated
|
||||||
Generated
-95
@@ -3390,36 +3390,6 @@
|
|||||||
"url": "https://github.com/sponsors/gregberge"
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
|
||||||
"version": "10.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
|
||||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/code-frame": "^7.10.4",
|
|
||||||
"@babel/runtime": "^7.12.5",
|
|
||||||
"@types/aria-query": "^5.0.1",
|
|
||||||
"aria-query": "5.3.0",
|
|
||||||
"dom-accessibility-api": "^0.5.9",
|
|
||||||
"lz-string": "^1.5.0",
|
|
||||||
"picocolors": "1.1.1",
|
|
||||||
"pretty-format": "^27.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"dequal": "^2.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testing-library/jest-dom": {
|
"node_modules/@testing-library/jest-dom": {
|
||||||
"version": "6.9.1",
|
"version": "6.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||||
@@ -3490,13 +3460,6 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/aria-query": {
|
|
||||||
"version": "5.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -6755,16 +6718,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dequal": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/destroy": {
|
"node_modules/destroy": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||||
@@ -6879,13 +6832,6 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
|
||||||
"version": "0.5.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/dom-converter": {
|
"node_modules/dom-converter": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
|
||||||
@@ -11406,16 +11352,6 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lz-string": {
|
|
||||||
"version": "1.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"lz-string": "bin/bin.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.25.9",
|
"version": "0.25.9",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
|
||||||
@@ -16198,23 +16134,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss/node_modules/yaml": {
|
|
||||||
"version": "2.8.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
|
||||||
"license": "ISC",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||||
@@ -16709,20 +16628,6 @@
|
|||||||
"is-typedarray": "^1.0.0"
|
"is-typedarray": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "4.9.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
|
||||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" type="image/png" href="%PUBLIC_URL%/app-icon.png" />
|
<link rel="icon" type="image/png" href="%PUBLIC_URL%/app-icon.png" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+470
-60
@@ -1,18 +1,121 @@
|
|||||||
|
/* ── Skeleton loading ── */
|
||||||
|
@keyframes skeleton-pulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
.skeleton {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
animation: skeleton-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.skeleton-row { pointer-events: none; min-height: 72px; }
|
||||||
|
.skeleton-name { width: 40%; height: 14px; margin-bottom: 8px; }
|
||||||
|
.skeleton-url { width: 60%; height: 11px; }
|
||||||
|
.skeleton-badge { width: 56px; height: 22px; border-radius: 12px; }
|
||||||
|
|
||||||
|
/* ── Inline delete confirm ── */
|
||||||
|
.action-delete-confirm { color: #ef4444 !important; }
|
||||||
|
|
||||||
|
/* ── Network discovery modal ── */
|
||||||
|
.discovery-scanning {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.discovery-spinner {
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.discovery-list { display: flex; flex-direction: column; gap: 0.5rem; max-height: 360px; overflow-y: auto; }
|
||||||
|
.discovery-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.discovery-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.discovery-ip { font-weight: 600; font-size: 0.9rem; color: var(--text); }
|
||||||
|
.discovery-hostname { font-size: 0.8rem; color: var(--text-muted); }
|
||||||
|
.discovery-url { font-size: 0.8rem; color: var(--accent); font-family: monospace; word-break: break-all; }
|
||||||
|
.discovery-ports { font-size: 0.75rem; color: var(--text-dim); }
|
||||||
|
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.82rem; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Theme variables ── */
|
||||||
|
: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: #ffffff;
|
||||||
|
--surface-2: #f1f5f9;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--text: #1e293b;
|
||||||
|
--text-muted: #475569;
|
||||||
|
--text-dim: #94a3b8;
|
||||||
|
--accent: #0d9488;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .App {
|
||||||
|
background: var(--app-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .navbar {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .nav-link {
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .nav-link:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #1e293b;
|
||||||
|
box-shadow: inset 0 0 0 1px #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .container,
|
||||||
|
[data-theme="light"] .detail-section,
|
||||||
|
[data-theme="light"] .resource-row {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.App {
|
.App {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 15% 0%, #dff7f2 0%, transparent 40%),
|
radial-gradient(circle at 15% 0%, rgba(30, 64, 175, 0.2) 0%, transparent 42%),
|
||||||
radial-gradient(circle at 95% 10%, #e8eefc 0%, transparent 38%),
|
radial-gradient(circle at 95% 10%, rgba(15, 118, 110, 0.14) 0%, transparent 36%),
|
||||||
#f8fafc;
|
#020617;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar {
|
.navbar {
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: rgba(15, 23, 42, 0.82);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
-webkit-backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
||||||
padding: 0.85rem 2rem;
|
padding: 0.85rem 2rem;
|
||||||
color: #0f172a;
|
color: #e2e8f0;
|
||||||
border-bottom: 1px solid #dbe2ea;
|
border-bottom: 1px solid #334155;
|
||||||
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.06);
|
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.06);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -48,7 +151,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
color: #475569;
|
color: #cbd5e1;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
@@ -58,9 +161,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
background: #ffffff;
|
background: #111827;
|
||||||
color: #0f172a;
|
color: #f8fafc;
|
||||||
box-shadow: inset 0 0 0 1px #dbe2ea;
|
box-shadow: inset 0 0 0 1px #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-link {
|
.settings-link {
|
||||||
@@ -83,9 +186,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: #334155;
|
color: #e2e8f0;
|
||||||
background: #ffffff;
|
background: #111827;
|
||||||
border: 1px solid #dbe2ea;
|
border: 1px solid #334155;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.3rem 0.7rem;
|
padding: 0.3rem 0.7rem;
|
||||||
}
|
}
|
||||||
@@ -108,11 +211,12 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.85rem;
|
font-size: 1.85rem;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-subtitle {
|
.dashboard-subtitle {
|
||||||
margin: 0.2rem 0 0;
|
margin: 0.2rem 0 0;
|
||||||
color: #64748b;
|
color: #94a3b8;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,12 +227,42 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0.2rem 0 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-chip {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.28rem 0.7rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-chip:hover {
|
||||||
|
background: #111f34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-chip.active {
|
||||||
|
background: #14b8a6;
|
||||||
|
border-color: #14b8a6;
|
||||||
|
color: #052e2b;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-filter-input {
|
.tag-filter-input {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid #cbd5e1;
|
border: 1px solid #334155;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: #111827;
|
||||||
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-menu-wrapper {
|
.actions-menu-wrapper {
|
||||||
@@ -279,7 +413,44 @@
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-incident-item,
|
.cc-incident-item {
|
||||||
|
width: 100%;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.2rem 0.35rem 0.2rem 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-incident-item:hover {
|
||||||
|
background: #111f34;
|
||||||
|
border-color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-incident-nav {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-ack-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #4ade80;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.cc-group-item {
|
.cc-group-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #0f172a;
|
background: #0f172a;
|
||||||
@@ -293,7 +464,6 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-incident-item:hover,
|
|
||||||
.cc-group-item:hover {
|
.cc-group-item:hover {
|
||||||
background: #111f34;
|
background: #111f34;
|
||||||
border-color: #475569;
|
border-color: #475569;
|
||||||
@@ -322,49 +492,60 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resource-list-shell {
|
.resource-list-shell {
|
||||||
background: rgba(255, 255, 255, 0.82);
|
background: rgba(15, 23, 42, 0.9);
|
||||||
border: 1px solid #cbd5e1;
|
border: 1px solid #334155;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.65rem;
|
padding: 0.65rem;
|
||||||
|
max-height: calc(100vh - 250px);
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-list-header {
|
.resource-list-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(260px, 2fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr);
|
grid-template-columns: 28px minmax(260px, 2fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.35rem 0.6rem 0.6rem;
|
padding: 0.35rem 0.6rem 0.6rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
color: #64748b;
|
color: #94a3b8;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #334155;
|
||||||
margin-bottom: 0.45rem;
|
margin-bottom: 0.45rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
background: rgba(15, 23, 42, 0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-list-count {
|
.resource-list-count {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #64748b;
|
color: #94a3b8;
|
||||||
margin: 0 0.6rem 0.55rem;
|
margin: 0 0.6rem 0.55rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 31px;
|
||||||
|
z-index: 4;
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-row {
|
.resource-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(260px, 2fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr);
|
grid-template-columns: 28px minmax(260px, 2fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.65rem 0.8rem;
|
padding: 0.65rem 0.8rem;
|
||||||
background: rgba(248, 250, 252, 0.96);
|
background: rgba(15, 23, 42, 0.96);
|
||||||
border: 1px solid #dbe2ea;
|
border: 1px solid #334155;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 3px 10px rgba(15, 23, 42, 0.05);
|
box-shadow: 0 4px 12px rgba(2, 6, 23, 0.55);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-row:hover {
|
.resource-row:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
border-color: #94a3b8;
|
border-color: #60a5fa;
|
||||||
box-shadow: 0 9px 18px rgba(15, 23, 42, 0.1);
|
box-shadow: 0 12px 22px rgba(2, 6, 23, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-main {
|
.row-main {
|
||||||
@@ -382,13 +563,14 @@
|
|||||||
.row-name {
|
.row-name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.98rem;
|
font-size: 0.98rem;
|
||||||
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-group-pill {
|
.resource-group-pill {
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #e2e8f0;
|
background: #1e293b;
|
||||||
color: #334155;
|
color: #cbd5e1;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -414,6 +596,7 @@
|
|||||||
.row-url,
|
.row-url,
|
||||||
.row-type {
|
.row-type {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-url {
|
.row-url {
|
||||||
@@ -436,6 +619,12 @@
|
|||||||
|
|
||||||
.row-metrics .stat-label {
|
.row-metrics .stat-label {
|
||||||
font-size: 0.64rem;
|
font-size: 0.64rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-metrics .stat-value {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-sparkline {
|
.row-sparkline {
|
||||||
@@ -488,11 +677,40 @@
|
|||||||
margin: 0 0 0.2rem 0;
|
margin: 0 0 0.2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trend-chip {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.trend-better {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
.trend-worse {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
.trend-flat {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-url {
|
.resource-url {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
.load-more-btn {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px dashed #94a3b8;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #334155;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.load-more-btn:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-type {
|
.resource-type {
|
||||||
margin: 0.15rem 0 0;
|
margin: 0.15rem 0 0;
|
||||||
@@ -537,7 +755,7 @@
|
|||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #333;
|
color: #e2e8f0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,7 +765,7 @@
|
|||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #666;
|
color: #94a3b8;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -567,7 +785,7 @@
|
|||||||
.last-check {
|
.last-check {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #999;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-section {
|
.group-section {
|
||||||
@@ -671,17 +889,48 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
background: none;
|
background: #0b1325;
|
||||||
border: none;
|
border: 1px solid #334155;
|
||||||
|
color: #e2e8f0;
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
opacity: 0.6;
|
opacity: 1;
|
||||||
transition: opacity 0.2s;
|
transition: transform 0.15s, background 0.2s, border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon:hover {
|
.btn-icon:hover {
|
||||||
opacity: 1;
|
transform: translateY(-1px);
|
||||||
|
background: #111f34;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:focus-visible {
|
||||||
|
outline: 2px solid #60a5fa;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.action-edit {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.action-maint {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.action-delete {
|
||||||
|
color: #fca5a5;
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
background: #3f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.action-delete:hover {
|
||||||
|
background: #7f1d1d;
|
||||||
|
border-color: #ef4444;
|
||||||
|
color: #fee2e2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
@@ -813,17 +1062,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-section {
|
.detail-section {
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba(15, 23, 42, 0.94);
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #334155;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
box-shadow: 0 10px 24px rgba(2, 6, 23, 0.55);
|
||||||
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-section h2 {
|
.detail-section h2 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #333;
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-header-row {
|
.detail-header-row {
|
||||||
@@ -841,7 +1091,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resource-url-detail {
|
.resource-url-detail {
|
||||||
color: #64748b;
|
color: #94a3b8;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,7 +1125,15 @@
|
|||||||
|
|
||||||
.filter-control label {
|
.filter-control label {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #475569;
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-control select {
|
||||||
|
background: #111827;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-pagination {
|
.controls-pagination {
|
||||||
@@ -896,9 +1154,9 @@
|
|||||||
.data-table th {
|
.data-table th {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 2px solid #dbe2ea;
|
border-bottom: 2px solid #334155;
|
||||||
color: #1e293b;
|
color: #e2e8f0;
|
||||||
background: #f1f5f9;
|
background: #111827;
|
||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
@@ -906,11 +1164,12 @@
|
|||||||
|
|
||||||
.data-table td {
|
.data-table td {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #1f2937;
|
||||||
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table tbody tr:hover {
|
.data-table tbody tr:hover {
|
||||||
background: #f0f9ff;
|
background: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-right {
|
.text-right {
|
||||||
@@ -918,7 +1177,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.muted-cell {
|
.muted-cell {
|
||||||
color: #64748b;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-25 {
|
.w-25 {
|
||||||
@@ -942,14 +1201,14 @@
|
|||||||
|
|
||||||
.incident-reason-toggle {
|
.incident-reason-toggle {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #0f766e;
|
color: #93c5fd;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expand-indicator {
|
.expand-indicator {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #64748b;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-compact {
|
.btn-compact {
|
||||||
@@ -958,7 +1217,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.expanded-row {
|
.expanded-row {
|
||||||
background: #f8fafc;
|
background: #0b1325;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expanded-cell {
|
.expanded-cell {
|
||||||
@@ -966,7 +1225,7 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #334155;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title-row {
|
.section-title-row {
|
||||||
@@ -978,7 +1237,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-copy {
|
.empty-copy {
|
||||||
color: #64748b;
|
color: #94a3b8;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
@@ -986,6 +1245,10 @@
|
|||||||
.chart-container {
|
.chart-container {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
|
background: #0b1325;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.incident-badge {
|
.incident-badge {
|
||||||
@@ -1002,15 +1265,16 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: #6c757d;
|
background: #1e293b;
|
||||||
color: white;
|
color: #f8fafc;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
border: 1px solid #334155;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button:hover {
|
.back-button:hover {
|
||||||
background: #5a6268;
|
background: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@@ -1111,6 +1375,16 @@
|
|||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-list-shell {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-list-count,
|
||||||
|
.resource-list-header {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-row {
|
.resource-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
@@ -1120,6 +1394,10 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-checkbox {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.row-metrics {
|
.row-metrics {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
@@ -1259,3 +1537,135 @@
|
|||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Bulk action bar ── */
|
||||||
|
.bulk-action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-count {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Row checkbox ── */
|
||||||
|
.row-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-checkbox input[type="checkbox"],
|
||||||
|
.header-select-all input[type="checkbox"] {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
accent-color: #14b8a6;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-select-all {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-row-selected {
|
||||||
|
border-color: #14b8a6 !important;
|
||||||
|
background: rgba(20, 184, 166, 0.06) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status sparkline bars ── */
|
||||||
|
.status-spark-bars {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 40px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark-bar {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 3px;
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark-bar:hover { opacity: 0.65; }
|
||||||
|
.spark-bar-up { background: #14b8a6; }
|
||||||
|
.spark-bar-down { background: #ef4444; }
|
||||||
|
.spark-bar-unknown { background: #475569; }
|
||||||
|
|
||||||
|
/* ── Chart time-range buttons ── */
|
||||||
|
.chart-range-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-range-btn {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
color: #94a3b8;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-range-btn:hover {
|
||||||
|
border-color: #14b8a6;
|
||||||
|
color: #14b8a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-range-btn.active {
|
||||||
|
background: #14b8a6;
|
||||||
|
border-color: #14b8a6;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Check row expansion ── */
|
||||||
|
.check-row-expandable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-row-expandable:hover {
|
||||||
|
background: rgba(20, 184, 166, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-json {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
background: #0a0f1a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section title row ── */
|
||||||
|
.section-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-row h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
+11
-1675
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,290 @@
|
|||||||
|
.cc-page {
|
||||||
|
padding-top: 1.4rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-alert {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #854d0e;
|
||||||
|
background: #2b1907;
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.65rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-page h2 {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pill-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #111827;
|
||||||
|
color: #f1f5f9;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pill-toggle input {
|
||||||
|
accent-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pill-toggle:focus-within,
|
||||||
|
.cc-input-sm:focus,
|
||||||
|
.cc-select-sm:focus {
|
||||||
|
outline: 2px solid #60a5fa;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-surface {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-kpi {
|
||||||
|
background: #0b1325;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-kpi-label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-kpi-value {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #0b1325;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table thead tr {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid #1f2937;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table tbody tr:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-input-sm {
|
||||||
|
width: 56px;
|
||||||
|
padding: 0.45rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
background: #111827;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-select-sm {
|
||||||
|
padding: 0.45rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #111827;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: 2.2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-resource-card {
|
||||||
|
margin-bottom: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-resource-title {
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-resource-meta {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-resource-meta strong {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-chart-box {
|
||||||
|
height: 400px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: #111827;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-trend-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
background: #0b1325;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row.is-unread {
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.65rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-chip {
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-actions .btn:focus-visible {
|
||||||
|
outline: 2px solid #60a5fa;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cc-controls {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-controls .btn,
|
||||||
|
.cc-controls .tag-filter-input,
|
||||||
|
.cc-controls .cc-pill-toggle {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pagination {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
+55
-59
@@ -12,6 +12,7 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
import './CommandCenterPages.css';
|
||||||
import { formatChartTime } from './utils/timeUtils';
|
import { formatChartTime } from './utils/timeUtils';
|
||||||
|
|
||||||
const asNumberOrZero = (value) => {
|
const asNumberOrZero = (value) => {
|
||||||
@@ -107,23 +108,16 @@ function History() {
|
|||||||
if (error) return <div className="container">Error loading history: {error}</div>;
|
if (error) return <div className="container">Error loading history: {error}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container cc-page">
|
||||||
{limitedByRetention && (
|
{limitedByRetention && (
|
||||||
<div style={{
|
<div className="cc-alert">
|
||||||
padding: '1rem',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
backgroundColor: '#fff3cd',
|
|
||||||
border: '1px solid #ffc107',
|
|
||||||
borderRadius: '4px',
|
|
||||||
color: '#856404'
|
|
||||||
}}>
|
|
||||||
⚠️ Data limited to {effectiveDays} days (server retention setting). Requested {days} days.
|
⚠️ Data limited to {effectiveDays} days (server retention setting). Requested {days} days.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
|
<div className="cc-page-header">
|
||||||
<h2>Monitoring History</h2>
|
<h2 className="cc-page-title" style={{ color: '#f8fafc', fontWeight: 800 }}>Monitoring History</h2>
|
||||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
<div className="cc-controls">
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<div className="cc-controls">
|
||||||
{[7, 14, 30].map((d) => (
|
{[7, 14, 30].map((d) => (
|
||||||
<button
|
<button
|
||||||
key={d}
|
key={d}
|
||||||
@@ -136,21 +130,19 @@ function History() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none', background: '#f5f5f5', padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ddd' }}>
|
<label className="cc-pill-toggle">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showAveraged}
|
checked={showAveraged}
|
||||||
onChange={(e) => setShowAveraged(e.target.checked)}
|
onChange={(e) => setShowAveraged(e.target.checked)}
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
/>
|
/>
|
||||||
<span>Show Averages</span>
|
<span>Show Averages</span>
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none', background: '#f5f5f5', padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ddd' }}>
|
<label className="cc-pill-toggle">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showTrends}
|
checked={showTrends}
|
||||||
onChange={(e) => setShowTrends(e.target.checked)}
|
onChange={(e) => setShowTrends(e.target.checked)}
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
/>
|
/>
|
||||||
<span>Show Week-over-Week Trends</span>
|
<span>Show Week-over-Week Trends</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -169,41 +161,43 @@ function History() {
|
|||||||
const chartData = prepareChartData(resource.checks || []);
|
const chartData = prepareChartData(resource.checks || []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={resource.id} className="detail-section" style={{ marginBottom: '3rem', background: '#fff', padding: '1.5rem', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
|
<div key={resource.id} className="detail-section cc-surface history-resource-card">
|
||||||
<h3 style={{ marginBottom: '0.5rem', color: '#667eea', fontSize: '1.5rem' }}>{resource.name}</h3>
|
<h3 className="history-resource-title">{resource.name}</h3>
|
||||||
<p style={{ color: '#666', marginBottom: '1.5rem', fontSize: '0.95rem' }}>
|
<p className="history-resource-meta">
|
||||||
<span style={{ marginRight: '1.5rem' }}>Type: <strong style={{ color: '#333' }}>{resource.type.toUpperCase()}</strong></span>
|
<span>Type: <strong>{resource.type.toUpperCase()}</strong></span>
|
||||||
<span style={{ marginRight: '1.5rem' }}>Uptime: <strong style={{ color: resource.uptime >= 95 ? '#4caf50' : resource.uptime >= 80 ? '#ff9800' : '#f44336' }}>{resource.uptime}%</strong></span>
|
<span>Uptime: <strong style={{ color: resource.uptime >= 95 ? '#22c55e' : resource.uptime >= 80 ? '#f59e0b' : '#ef4444' }}>{resource.uptime}%</strong></span>
|
||||||
<span>Avg Response: <strong style={{ color: '#333' }}>{resource.avgResponseTime}ms</strong></span>
|
<span>Avg Response: <strong>{resource.avgResponseTime}ms</strong></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{chartData.length > 0 ? (
|
{chartData.length > 0 ? (
|
||||||
<div className="chart-container" style={{ height: '400px', marginBottom: '1.5rem', background: '#fafafa', padding: '1rem', borderRadius: '6px' }}>
|
<div className="chart-container history-chart-box">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
|
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fontSize: 12, fill: '#666' }}
|
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||||
angle={-45}
|
angle={-45}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
height={80}
|
height={80}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="left"
|
yAxisId="left"
|
||||||
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#666' } }}
|
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#94a3b8' } }}
|
||||||
tick={{ fontSize: 12, fill: '#666' }}
|
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="right"
|
yAxisId="right"
|
||||||
orientation="right"
|
orientation="right"
|
||||||
domain={[0, 1]}
|
domain={[0, 1]}
|
||||||
ticks={[0, 1]}
|
ticks={[0, 1]}
|
||||||
tick={{ fontSize: 12, fill: '#666' }}
|
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||||
label={{ value: 'Status', angle: 90, position: 'insideRight', style: { fill: '#666' } }}
|
label={{ value: 'Status', angle: 90, position: 'insideRight', style: { fill: '#94a3b8' } }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '10px' }}
|
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px', padding: '10px' }}
|
||||||
|
labelStyle={{ color: '#cbd5e1' }}
|
||||||
|
itemStyle={{ color: '#e2e8f0' }}
|
||||||
formatter={(value, name) => {
|
formatter={(value, name) => {
|
||||||
if (name === 'Response Time') return `${value}ms`;
|
if (name === 'Response Time') return `${value}ms`;
|
||||||
if (name === 'Status') return value === 1 ? '✓ UP' : '✗ DOWN';
|
if (name === 'Status') return value === 1 ? '✓ UP' : '✗ DOWN';
|
||||||
@@ -249,18 +243,18 @@ function History() {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ color: '#999', padding: '2rem', textAlign: 'center', background: '#fafafa', borderRadius: '6px' }}>No check data available</div>
|
<div className="cc-empty">No check data available</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showTrends && resource.trendsData && (
|
{showTrends && resource.trendsData && (
|
||||||
<div className="chart-container" style={{ height: '400px', marginBottom: '1.5rem', background: '#fafafa', padding: '1rem', borderRadius: '6px' }}>
|
<div className="chart-container history-chart-box">
|
||||||
<h4 style={{ marginBottom: '1rem', color: '#667eea' }}>Week-over-Week Comparison</h4>
|
<h4 className="history-resource-title">Week-over-Week Comparison</h4>
|
||||||
{(() => {
|
{(() => {
|
||||||
const comparison = resource.trendsData?.comparison || {};
|
const comparison = resource.trendsData?.comparison || {};
|
||||||
const responseTimeChange = asNumberOrZero(comparison.response_time_change);
|
const responseTimeChange = asNumberOrZero(comparison.response_time_change);
|
||||||
const uptimeChange = asNumberOrZero(comparison.uptime_change);
|
const uptimeChange = asNumberOrZero(comparison.uptime_change);
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1rem', fontSize: '0.9rem' }}>
|
<div className="history-trend-row">
|
||||||
<div>
|
<div>
|
||||||
<strong>Response Time Change:</strong>{' '}
|
<strong>Response Time Change:</strong>{' '}
|
||||||
<span style={{ color: responseTimeChange >= 0 ? '#f44336' : '#4caf50' }}>
|
<span style={{ color: responseTimeChange >= 0 ? '#f44336' : '#4caf50' }}>
|
||||||
@@ -280,20 +274,22 @@ function History() {
|
|||||||
})()}
|
})()}
|
||||||
<ResponsiveContainer width="100%" height="90%">
|
<ResponsiveContainer width="100%" height="90%">
|
||||||
<ComposedChart data={prepareTrendsChartData(resource.trendsData)} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
|
<ComposedChart data={prepareTrendsChartData(resource.trendsData)} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="day"
|
dataKey="day"
|
||||||
tick={{ fontSize: 12, fill: '#666' }}
|
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||||
angle={-45}
|
angle={-45}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
height={80}
|
height={80}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#666' } }}
|
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#94a3b8' } }}
|
||||||
tick={{ fontSize: 12, fill: '#666' }}
|
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '10px' }}
|
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px', padding: '10px' }}
|
||||||
|
labelStyle={{ color: '#cbd5e1' }}
|
||||||
|
itemStyle={{ color: '#e2e8f0' }}
|
||||||
formatter={(value, name) => {
|
formatter={(value, name) => {
|
||||||
if (name === 'Current Period') return `${value}ms`;
|
if (name === 'Current Period') return `${value}ms`;
|
||||||
if (name === 'Previous Period') return `${value}ms`;
|
if (name === 'Previous Period') return `${value}ms`;
|
||||||
@@ -327,22 +323,22 @@ function History() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem' }}>
|
<div className="cc-kpi-grid">
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2rem', fontWeight: 'bold' }}>{resource.checks?.length || 0}</p>
|
<p className="cc-kpi-label">Total Checks</p>
|
||||||
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Total Checks</p>
|
<p className="cc-kpi-value" style={{ color: '#60a5fa' }}>{resource.checks?.length || 0}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: resource.uptime >= 95 ? '#4caf50' : resource.uptime >= 80 ? '#ff9800' : '#f44336', fontSize: '2rem', fontWeight: 'bold' }}>{resource.uptime}%</p>
|
<p className="cc-kpi-label">Uptime</p>
|
||||||
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Uptime</p>
|
<p className="cc-kpi-value" style={{ color: resource.uptime >= 95 ? '#22c55e' : resource.uptime >= 80 ? '#f59e0b' : '#ef4444' }}>{resource.uptime}%</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2rem', fontWeight: 'bold' }}>{resource.avgResponseTime}ms</p>
|
<p className="cc-kpi-label">Avg Response</p>
|
||||||
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Avg Response</p>
|
<p className="cc-kpi-value" style={{ color: '#a78bfa' }}>{resource.avgResponseTime}ms</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#4caf50', fontSize: '2rem', fontWeight: 'bold' }}>{(resource.checks || []).filter((c) => c.status === 'up').length}/{resource.checks?.length || 0}</p>
|
<p className="cc-kpi-label">Successful Checks</p>
|
||||||
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Successful Checks</p>
|
<p className="cc-kpi-value" style={{ color: '#34d399' }}>{(resource.checks || []).filter((c) => c.status === 'up').length}/{resource.checks?.length || 0}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +346,7 @@ function History() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{totalItems > pageSize && (
|
{totalItems > pageSize && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
|
<div className="cc-pagination">
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||||
@@ -358,8 +354,8 @@ function History() {
|
|||||||
>
|
>
|
||||||
← Previous
|
← Previous
|
||||||
</button>
|
</button>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div className="cc-controls">
|
||||||
<span style={{ color: '#666', fontSize: '0.9rem' }}>Page</span>
|
<span>Page</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={currentPage}
|
value={currentPage}
|
||||||
@@ -367,14 +363,14 @@ function History() {
|
|||||||
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
|
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
}}
|
}}
|
||||||
style={{ width: '50px', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', textAlign: 'center' }}
|
className="cc-input-sm"
|
||||||
/>
|
/>
|
||||||
<span style={{ color: '#666', fontSize: '0.9rem' }}>of {Math.ceil(totalItems / pageSize)}</span>
|
<span>of {Math.ceil(totalItems / pageSize)}</span>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
|
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
|
||||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff' }}
|
className="cc-select-sm"
|
||||||
>
|
>
|
||||||
<option value={5}>5 per page</option>
|
<option value={5}>5 per page</option>
|
||||||
<option value={10}>10 per page</option>
|
<option value={10}>10 per page</option>
|
||||||
|
|||||||
@@ -25,134 +25,157 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: #dc3545;
|
background: #ef4444;
|
||||||
color: white;
|
color: #fff;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 1.25rem;
|
width: 1.2rem;
|
||||||
height: 1.25rem;
|
height: 1.2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.65rem;
|
font-size: 0.6rem;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Panel ── */
|
||||||
.notification-popup {
|
.notification-popup {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 60px;
|
top: 60px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
width: 400px;
|
width: 420px;
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
background: white;
|
background: #1e293b;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #334155;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
.notification-header {
|
.notification-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem;
|
padding: 0.875rem 1rem;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #334155;
|
||||||
background: #f9f9f9;
|
background: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-header h3 {
|
.notification-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1rem;
|
||||||
color: #333;
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 1.25rem;
|
font-size: 1.1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #999;
|
color: #64748b;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 30px;
|
width: 28px;
|
||||||
height: 30px;
|
height: 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
color: #333;
|
color: #e2e8f0;
|
||||||
|
background: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Filters ── */
|
||||||
.notification-filters {
|
.notification-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.6rem 0.875rem;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #334155;
|
||||||
background: #fafafa;
|
background: #1e293b;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.3rem 0.65rem;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #334155;
|
||||||
background: white;
|
background: transparent;
|
||||||
border-radius: 4px;
|
color: #94a3b8;
|
||||||
|
border-radius: 5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
transition: all 0.2s;
|
font-weight: 500;
|
||||||
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn:hover {
|
.filter-btn:hover {
|
||||||
border-color: #007bff;
|
border-color: #14b8a6;
|
||||||
color: #007bff;
|
color: #14b8a6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn.active {
|
.filter-btn.active {
|
||||||
background: #007bff;
|
background: #14b8a6;
|
||||||
color: white;
|
color: #0f172a;
|
||||||
border-color: #007bff;
|
border-color: #14b8a6;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-btn {
|
.clear-btn {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
background: #dc3545;
|
background: transparent;
|
||||||
color: white;
|
color: #f87171;
|
||||||
border-color: #dc3545;
|
border-color: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-btn:hover {
|
.clear-btn:hover {
|
||||||
background: #c82333;
|
background: #f87171;
|
||||||
border-color: #c82333;
|
color: #0f172a;
|
||||||
|
border-color: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── List ── */
|
||||||
.notification-list {
|
.notification-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: 450px;
|
max-height: 460px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-item {
|
.notification-item {
|
||||||
padding: 1rem;
|
padding: 0.875rem 1rem;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #1e293b;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
background: white;
|
background: #1e293b;
|
||||||
transition: background 0.2s;
|
transition: background 0.15s;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-item:hover {
|
.notification-item:hover {
|
||||||
background: #f9f9f9;
|
background: #263347;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-item.unread {
|
.notification-item.unread {
|
||||||
background: #f0f8ff;
|
background: #162032;
|
||||||
font-weight: 500;
|
border-left: 3px solid #14b8a6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-item.unread:hover {
|
||||||
|
background: #1c2a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Content ── */
|
||||||
.notification-content {
|
.notification-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -160,84 +183,106 @@
|
|||||||
|
|
||||||
.notification-title {
|
.notification-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.3rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.875rem;
|
||||||
color: #333;
|
color: #f1f5f9;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-message {
|
.notification-message {
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
color: #666;
|
color: #94a3b8;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-meta {
|
.notification-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
color: #999;
|
color: #64748b;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-resource {
|
.notification-resource {
|
||||||
background: #f0f0f0;
|
background: #0f172a;
|
||||||
padding: 0.2rem 0.5rem;
|
color: #94a3b8;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-time {
|
.notification-time {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Actions ── */
|
||||||
.notification-actions {
|
.notification-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 0.9rem;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.4rem;
|
||||||
color: #999;
|
color: #475569;
|
||||||
transition: color 0.2s;
|
border-radius: 4px;
|
||||||
flex-shrink: 0;
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:hover {
|
.action-btn:hover {
|
||||||
color: #333;
|
background: #334155;
|
||||||
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.mark-read:hover {
|
.action-btn.mark-read:hover {
|
||||||
color: #28a745;
|
color: #4ade80;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn.delete:hover {
|
.action-btn.delete:hover {
|
||||||
color: #dc3545;
|
color: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* ── Empty state ── */
|
||||||
|
.notification-empty {
|
||||||
|
padding: 2.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ── */
|
||||||
.notification-list::-webkit-scrollbar {
|
.notification-list::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-list::-webkit-scrollbar-track {
|
.notification-list::-webkit-scrollbar-track {
|
||||||
background: #f1f1f1;
|
background: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-list::-webkit-scrollbar-thumb {
|
.notification-list::-webkit-scrollbar-thumb {
|
||||||
background: #888;
|
background: #334155;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-list::-webkit-scrollbar-thumb:hover {
|
.notification-list::-webkit-scrollbar-thumb:hover {
|
||||||
background: #555;
|
background: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile responsive */
|
/* ── Push prompt ── */
|
||||||
|
.push-prompt {
|
||||||
|
padding: 0.4rem 0.875rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.notification-popup {
|
.notification-popup {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ function NotificationCenter() {
|
|||||||
const [filter, setFilter] = useState('all'); // all, unread, read
|
const [filter, setFilter] = useState('all'); // all, unread, read
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [wsConnected, setWsConnected] = useState(false);
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
|
const [pushPermission, setPushPermission] = useState(
|
||||||
|
'Notification' in window ? Notification.permission : 'unsupported'
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadNotifications();
|
loadNotifications();
|
||||||
@@ -41,8 +44,11 @@ function NotificationCenter() {
|
|||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
if (message.type === 'notification') {
|
if (message.type === 'notification') {
|
||||||
// New notification received - reload notifications
|
|
||||||
loadNotifications();
|
loadNotifications();
|
||||||
|
if ('Notification' in window && Notification.permission === 'granted' && !document.hasFocus()) {
|
||||||
|
const n = message.data || {};
|
||||||
|
new Notification(n.title || 'SkyWatch Alert', { body: n.message || '', icon: '/favicon.ico' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse WebSocket message:', error);
|
console.error('Failed to parse WebSocket message:', error);
|
||||||
@@ -103,6 +109,12 @@ function NotificationCenter() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRequestPush = async () => {
|
||||||
|
if (!('Notification' in window)) return;
|
||||||
|
const perm = await Notification.requestPermission();
|
||||||
|
setPushPermission(perm);
|
||||||
|
};
|
||||||
|
|
||||||
const handleClearAll = async () => {
|
const handleClearAll = async () => {
|
||||||
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
|
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
|
||||||
|
|
||||||
@@ -173,6 +185,14 @@ function NotificationCenter() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{pushPermission !== 'granted' && pushPermission !== 'unsupported' && (
|
||||||
|
<div className="push-prompt">
|
||||||
|
<button className="filter-btn" onClick={handleRequestPush}>
|
||||||
|
{pushPermission === 'denied' ? '🔕 Push blocked' : '🔔 Enable push alerts'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="notification-list">
|
<div className="notification-list">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p style={{ textAlign: 'center', color: '#999', padding: '1rem' }}>Loading...</p>
|
<p style={{ textAlign: 'center', color: '#999', padding: '1rem' }}>Loading...</p>
|
||||||
|
|||||||
+81
-83
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { formatLocalTime } from './utils/timeUtils';
|
import { formatLocalTime } from './utils/timeUtils';
|
||||||
|
import './CommandCenterPages.css';
|
||||||
|
|
||||||
function Notifications() {
|
function Notifications() {
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
@@ -9,50 +10,13 @@ function Notifications() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
const [pendingClear, setPendingClear] = useState(false);
|
||||||
const itemsPerPage = 50;
|
const itemsPerPage = 50;
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const wsReconnectTimer = useRef(null);
|
||||||
|
const wsReconnectDelay = useRef(1000);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadNotifications = useCallback(async () => {
|
||||||
loadNotifications();
|
|
||||||
const interval = setInterval(loadNotifications, 10000);
|
|
||||||
|
|
||||||
// Connect to WebSocket for real-time updates
|
|
||||||
connectWebSocket();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
if (window.notificationWebSocket) {
|
|
||||||
window.notificationWebSocket.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [filter, currentPage]);
|
|
||||||
|
|
||||||
const connectWebSocket = () => {
|
|
||||||
try {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(event.data);
|
|
||||||
if (message.type === 'notification') {
|
|
||||||
loadNotifications();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse WebSocket message:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
setTimeout(connectWebSocket, 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.notificationWebSocket = ws;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to connect WebSocket:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadNotifications = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
@@ -67,11 +31,51 @@ function Notifications() {
|
|||||||
setUnreadCount(response.data.unreadCount);
|
setUnreadCount(response.data.unreadCount);
|
||||||
setTotal(response.data.total);
|
setTotal(response.data.total);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load notifications:', error);
|
// Notifications load error handled
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [filter, currentPage]);
|
||||||
|
|
||||||
|
const connectWebSocket = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
if (message.type === 'notification') {
|
||||||
|
loadNotifications();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
wsRef.current = null;
|
||||||
|
wsReconnectTimer.current = setTimeout(() => {
|
||||||
|
wsReconnectDelay.current = Math.min(wsReconnectDelay.current * 2, 30000);
|
||||||
|
connectWebSocket();
|
||||||
|
}, wsReconnectDelay.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onopen = () => { wsReconnectDelay.current = 1000; };
|
||||||
|
ws.onerror = () => {};
|
||||||
|
|
||||||
|
wsRef.current = ws;
|
||||||
|
} catch (_) {}
|
||||||
|
}, [loadNotifications]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNotifications();
|
||||||
|
const interval = setInterval(loadNotifications, 10000);
|
||||||
|
connectWebSocket();
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
clearTimeout(wsReconnectTimer.current);
|
||||||
|
if (wsRef.current) wsRef.current.close();
|
||||||
|
};
|
||||||
|
}, [filter, currentPage, loadNotifications, connectWebSocket]);
|
||||||
|
|
||||||
const handleMarkAsRead = async (notificationId) => {
|
const handleMarkAsRead = async (notificationId) => {
|
||||||
try {
|
try {
|
||||||
@@ -92,25 +96,26 @@ function Notifications() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAll = async () => {
|
const handleClearAll = async () => {
|
||||||
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/notifications/clear', { type: filter });
|
await axios.post('/api/notifications/clear', { type: filter });
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
setPendingClear(false);
|
||||||
loadNotifications();
|
loadNotifications();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to clear notifications:', error);
|
setPendingClear(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / itemsPerPage);
|
const totalPages = Math.ceil(total / itemsPerPage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: '2rem' }}>
|
<div className="container cc-page">
|
||||||
<h2>📬 Notifications</h2>
|
<div className="cc-page-header">
|
||||||
|
<h2 className="cc-page-title">Notifications</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '2rem', display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
<div className="cc-controls" style={{ marginBottom: '1rem' }}>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div className="cc-controls">
|
||||||
<button
|
<button
|
||||||
className={`btn ${filter === 'all' ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn ${filter === 'all' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
onClick={() => { setFilter('all'); setCurrentPage(1); }}
|
onClick={() => { setFilter('all'); setCurrentPage(1); }}
|
||||||
@@ -132,61 +137,54 @@ function Notifications() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<button
|
pendingClear ? (
|
||||||
className="btn btn-danger"
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
||||||
onClick={handleClearAll}
|
<button className="btn btn-danger" onClick={handleClearAll}>Confirm Clear</button>
|
||||||
style={{ marginLeft: 'auto' }}
|
<button className="btn btn-secondary" onClick={() => setPendingClear(false)}>Cancel</button>
|
||||||
>
|
</div>
|
||||||
Clear All
|
) : (
|
||||||
</button>
|
<button className="btn btn-danger" onClick={() => setPendingClear(true)} style={{ marginLeft: 'auto' }}>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p style={{ textAlign: 'center', color: '#999' }}>Loading...</p>
|
<p className="cc-empty">Loading...</p>
|
||||||
) : notifications.length === 0 ? (
|
) : notifications.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '3rem 1rem', color: '#999' }}>
|
<div className="cc-empty">
|
||||||
<p>No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications</p>
|
<p>No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1rem' }}>
|
<div className="notifications-list">
|
||||||
{notifications.map((notification) => (
|
{notifications.map((notification) => (
|
||||||
<div
|
<div
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
style={{
|
className={`notification-row ${notification.read ? 'is-read' : 'is-unread'}`}
|
||||||
padding: '1rem',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '6px',
|
|
||||||
backgroundColor: notification.read ? 'white' : '#f0f8ff',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: '1rem'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div style={{ flex: 1 }}>
|
<div className="notification-content">
|
||||||
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '1rem' }}>{notification.title}</h4>
|
<h4 className="notification-title">{notification.title}</h4>
|
||||||
<p style={{ margin: '0 0 0.5rem 0', color: '#666', fontSize: '0.95rem' }}>
|
<p className="notification-message">
|
||||||
{notification.message}
|
{notification.message}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.85rem', color: '#999', flexWrap: 'wrap' }}>
|
<div className="notification-meta">
|
||||||
{notification.resource_name && (
|
{notification.resource_name && (
|
||||||
<span style={{ backgroundColor: '#f0f0f0', padding: '0.25rem 0.5rem', borderRadius: '3px' }}>
|
<span className="notification-chip">
|
||||||
📌 {notification.resource_name}
|
{notification.resource_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span>⏰ {formatLocalTime(notification.created_at)}</span>
|
<span>{formatLocalTime(notification.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
<div className="notification-actions">
|
||||||
{!notification.read && (
|
{!notification.read && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
onClick={() => handleMarkAsRead(notification.id)}
|
onClick={() => handleMarkAsRead(notification.id)}
|
||||||
title="Mark as read"
|
title="Mark as read"
|
||||||
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem' }}
|
|
||||||
>
|
>
|
||||||
✓ Read
|
✓ Read
|
||||||
</button>
|
</button>
|
||||||
@@ -195,7 +193,7 @@ function Notifications() {
|
|||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
onClick={() => handleDelete(notification.id)}
|
onClick={() => handleDelete(notification.id)}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem', background: '#dc3545' }}
|
style={{ background: '#b91c1c' }}
|
||||||
>
|
>
|
||||||
✕ Delete
|
✕ Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -206,7 +204,7 @@ function Notifications() {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div style={{ marginTop: '2rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div className="cc-pagination">
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||||
<button
|
<button
|
||||||
key={page}
|
key={page}
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
padding-top: 2rem;
|
padding-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.observability-page h2 {
|
||||||
|
color: #f8fafc;
|
||||||
|
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.obs-tabs {
|
.obs-tabs {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
border-bottom: 1px solid #dbe2ea;
|
border-bottom: 1px solid #334155;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -19,15 +24,20 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #64748b;
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-tab:focus-visible {
|
||||||
|
outline: 2px solid #60a5fa;
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-tab.is-active {
|
.obs-tab.is-active {
|
||||||
border-bottom-color: #0ea5a2;
|
border-bottom-color: #60a5fa;
|
||||||
color: #0f172a;
|
color: #f8fafc;
|
||||||
background: #ffffff;
|
background: #0f172a;
|
||||||
border-color: #dbe2ea;
|
border-color: #334155;
|
||||||
border-bottom-color: #0ea5a2;
|
border-bottom-color: #60a5fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-summary-grid {
|
.obs-summary-grid {
|
||||||
@@ -39,30 +49,30 @@
|
|||||||
|
|
||||||
.obs-summary-card {
|
.obs-summary-card {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: 1px solid #dbe2ea;
|
border: 1px solid #334155;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
background: linear-gradient(180deg, #0f172a 0%, #0b1325 100%);
|
||||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-card-label {
|
.obs-card-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #64748b;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-card-value {
|
.obs-card-value {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-top: 0.45rem;
|
margin-top: 0.45rem;
|
||||||
color: #0f172a;
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-card-value.is-danger {
|
.obs-card-value.is-danger {
|
||||||
color: #b91c1c;
|
color: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-card-value.is-ok {
|
.obs-card-value.is-ok {
|
||||||
color: #0f766e;
|
color: #34d399;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-table-wrap {
|
.obs-table-wrap {
|
||||||
@@ -81,9 +91,9 @@
|
|||||||
.obs-table th {
|
.obs-table th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #f1f5f9;
|
background: #0f172a;
|
||||||
border-bottom: 2px solid #dbe2ea;
|
border-bottom: 2px solid #334155;
|
||||||
color: #1e293b;
|
color: #e2e8f0;
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
@@ -91,11 +101,12 @@
|
|||||||
|
|
||||||
.obs-table td {
|
.obs-table td {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #1f2937;
|
||||||
|
color: #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-table tbody tr:hover {
|
.obs-table tbody tr:hover {
|
||||||
background: #f0f9ff;
|
background: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-right {
|
.obs-right {
|
||||||
@@ -105,38 +116,38 @@
|
|||||||
.obs-rate-pill {
|
.obs-rate-pill {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-rate-pill.is-good {
|
.obs-rate-pill.is-good {
|
||||||
background: #dcfce7;
|
background: #052e16;
|
||||||
color: #166534;
|
color: #4ade80;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-rate-pill.is-warn {
|
.obs-rate-pill.is-warn {
|
||||||
background: #fef3c7;
|
background: #3f2a0f;
|
||||||
color: #92400e;
|
color: #fbbf24;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-empty-copy {
|
.obs-empty-copy {
|
||||||
color: #94a3b8;
|
color: #cbd5e1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-zebra {
|
.obs-zebra {
|
||||||
background: #f8fafc;
|
background: #0b1325;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-error-code {
|
.obs-error-code {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #b91c1c;
|
color: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-muted-sm {
|
.obs-muted-sm {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #64748b;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-small {
|
.obs-small {
|
||||||
@@ -146,15 +157,15 @@
|
|||||||
.obs-audit-summary {
|
.obs-audit-summary {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #f8fafc;
|
background: #0f172a;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid #dbe2ea;
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-audit-summary-line {
|
.obs-audit-summary-line {
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #64748b;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-filters-grid {
|
.obs-filters-grid {
|
||||||
@@ -166,15 +177,21 @@
|
|||||||
|
|
||||||
.obs-filter-input {
|
.obs-filter-input {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border: 1px solid #dbe2ea;
|
border: 1px solid #334155;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #fff;
|
background: #111827;
|
||||||
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-filter-input:focus {
|
.obs-filter-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #0ea5a2;
|
border-color: #2563eb;
|
||||||
box-shadow: 0 0 0 3px rgba(14, 165, 162, 0.18);
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-toggle-code:focus-visible {
|
||||||
|
outline: 2px solid #60a5fa;
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-click-row {
|
.obs-click-row {
|
||||||
@@ -183,11 +200,11 @@
|
|||||||
|
|
||||||
.obs-toggle-code {
|
.obs-toggle-code {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #0f766e;
|
color: #bfdbfe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-expanded-row {
|
.obs-expanded-row {
|
||||||
background: #f0f9ff;
|
background: #111b2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-expanded-cell {
|
.obs-expanded-cell {
|
||||||
@@ -196,11 +213,12 @@
|
|||||||
|
|
||||||
.obs-pre {
|
.obs-pre {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
background: #fff;
|
background: #0b1325;
|
||||||
|
color: #e2e8f0;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid #dbe2ea;
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { formatLocalTime } from './utils/timeUtils';
|
import { formatLocalTime } from './utils/timeUtils';
|
||||||
import './Observability.css';
|
import './Observability.css';
|
||||||
@@ -9,7 +9,6 @@ function Observability() {
|
|||||||
const [errors, setErrors] = useState([]);
|
const [errors, setErrors] = useState([]);
|
||||||
const [auditLogs, setAuditLogs] = useState([]);
|
const [auditLogs, setAuditLogs] = useState([]);
|
||||||
const [auditSummary, setAuditSummary] = useState(null);
|
const [auditSummary, setAuditSummary] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [expandedAuditRow, setExpandedAuditRow] = useState(null);
|
const [expandedAuditRow, setExpandedAuditRow] = useState(null);
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
entityType: '',
|
entityType: '',
|
||||||
@@ -17,16 +16,50 @@ function Observability() {
|
|||||||
userId: '',
|
userId: '',
|
||||||
action: ''
|
action: ''
|
||||||
});
|
});
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const wsReconnectTimer = useRef(null);
|
||||||
|
const wsReconnectDelay = useRef(1000);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
const interval = setInterval(loadData, 30000); // Refresh every 30 seconds
|
const interval = setInterval(loadData, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTab, filters]);
|
}, [activeTab, filters]);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
useEffect(() => {
|
||||||
|
const connect = () => {
|
||||||
|
try {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === 'metrics' && activeTab === 'metrics') {
|
||||||
|
setMetrics(msg.data);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
ws.onopen = () => { wsReconnectDelay.current = 1000; };
|
||||||
|
ws.onclose = () => {
|
||||||
|
wsRef.current = null;
|
||||||
|
wsReconnectTimer.current = setTimeout(() => {
|
||||||
|
wsReconnectDelay.current = Math.min(wsReconnectDelay.current * 2, 30000);
|
||||||
|
connect();
|
||||||
|
}, wsReconnectDelay.current);
|
||||||
|
};
|
||||||
|
ws.onerror = () => {};
|
||||||
|
wsRef.current = ws;
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
clearTimeout(wsReconnectTimer.current);
|
||||||
|
if (wsRef.current) wsRef.current.close();
|
||||||
|
};
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
if (activeTab === 'metrics') {
|
if (activeTab === 'metrics') {
|
||||||
const resp = await axios.get('/api/observability/metrics');
|
const resp = await axios.get('/api/observability/metrics');
|
||||||
@@ -42,7 +75,6 @@ function Observability() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error loading data
|
// Error loading data
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import axios from 'axios';
|
||||||
|
import {
|
||||||
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer
|
||||||
|
} from 'recharts';
|
||||||
|
import { formatLocalTime, formatChartTime, formatDuration } from './utils/timeUtils';
|
||||||
|
|
||||||
|
function ResourceDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [resource, setResource] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [checks, setChecks] = useState([]);
|
||||||
|
const [checksLoading, setChecksLoading] = useState(false);
|
||||||
|
const [checksStatus, setChecksStatus] = useState('');
|
||||||
|
const [checksPage, setChecksPage] = useState(0);
|
||||||
|
const checksLimit = 10;
|
||||||
|
const [checksSort, setChecksSort] = useState('desc');
|
||||||
|
|
||||||
|
const [incidents, setIncidents] = useState([]);
|
||||||
|
const [incidentsLoading, setIncidentsLoading] = useState(false);
|
||||||
|
const [incidentsStatus, setIncidentsStatus] = useState('all');
|
||||||
|
const [incidentsPage, setIncidentsPage] = useState(0);
|
||||||
|
const incidentsLimit = 10;
|
||||||
|
const [incidentsSort, setIncidentsSort] = useState('desc');
|
||||||
|
const [expandedIncidentId, setExpandedIncidentId] = useState(null);
|
||||||
|
const [showIncidentModal, setShowIncidentModal] = useState(false);
|
||||||
|
const [editingIncident, setEditingIncident] = useState(null);
|
||||||
|
const [incidentDescription, setIncidentDescription] = useState('');
|
||||||
|
const [updatingIncident, setUpdatingIncident] = useState(false);
|
||||||
|
const [expandedCheckId, setExpandedCheckId] = useState(null);
|
||||||
|
const [chartRange, setChartRange] = useState('24h');
|
||||||
|
const [chartChecks, setChartChecks] = useState([]);
|
||||||
|
|
||||||
|
const [sla, setSla] = useState(null);
|
||||||
|
const [slaLoading, setSlaLoading] = useState(false);
|
||||||
|
const slaWindow = 24;
|
||||||
|
|
||||||
|
const [notifications, setNotifications] = useState([]);
|
||||||
|
const showNotification = useCallback((title, message, type = 'info') => {
|
||||||
|
const id = Date.now();
|
||||||
|
setNotifications(prev => [...prev, { id, title, message, type }]);
|
||||||
|
setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), 5000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [maintenanceWindows, setMaintenanceWindows] = useState([]);
|
||||||
|
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
|
||||||
|
const [maintenanceForm, setMaintenanceForm] = useState({ start_time: '', end_time: '', reason: '' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
loadResource(),
|
||||||
|
loadChecks(),
|
||||||
|
loadIncidents(),
|
||||||
|
loadSla(),
|
||||||
|
loadMaintenanceWindows(),
|
||||||
|
loadChartChecks(),
|
||||||
|
]).catch(() => {});
|
||||||
|
const interval = setInterval(loadResource, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadChartChecks();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [chartRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadChecks();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [checksStatus, checksPage, checksSort]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadIncidents();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [incidentsStatus, incidentsPage, incidentsSort]);
|
||||||
|
|
||||||
|
const loadResource = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/resources/${id}`);
|
||||||
|
setResource(response.data);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadChecks = async () => {
|
||||||
|
try {
|
||||||
|
setChecksLoading(true);
|
||||||
|
const response = await axios.get(`/api/resources/${id}/checks`, {
|
||||||
|
params: { limit: checksLimit, offset: checksPage * checksLimit, status: checksStatus || undefined, sort: checksSort },
|
||||||
|
});
|
||||||
|
setChecks(response.data.checks || []);
|
||||||
|
} catch (error) {
|
||||||
|
// Checks load error handled
|
||||||
|
} finally {
|
||||||
|
setChecksLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadIncidents = async () => {
|
||||||
|
try {
|
||||||
|
setIncidentsLoading(true);
|
||||||
|
const response = await axios.get(`/api/resources/${id}/incidents`, {
|
||||||
|
params: { limit: incidentsLimit, offset: incidentsPage * incidentsLimit, status: incidentsStatus, sort: incidentsSort },
|
||||||
|
});
|
||||||
|
setIncidents(response.data.incidents || []);
|
||||||
|
} catch (error) {
|
||||||
|
// Incidents load error handled
|
||||||
|
} finally {
|
||||||
|
setIncidentsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rangeMs = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '7d': 604800000 };
|
||||||
|
|
||||||
|
const loadChartChecks = async () => {
|
||||||
|
try {
|
||||||
|
const from = new Date(Date.now() - rangeMs[chartRange]).toISOString();
|
||||||
|
const resp = await axios.get(`/api/resources/${id}/checks`, {
|
||||||
|
params: { from, limit: 200, sort: 'asc' },
|
||||||
|
});
|
||||||
|
setChartChecks(resp.data.checks || []);
|
||||||
|
} catch (error) {
|
||||||
|
// chart load error handled
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAcknowledge = async (incidentId) => {
|
||||||
|
try {
|
||||||
|
await axios.post(`/api/incidents/${incidentId}/acknowledge`);
|
||||||
|
loadIncidents();
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Error', 'Failed to acknowledge incident', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditIncident = (incident) => {
|
||||||
|
setEditingIncident(incident);
|
||||||
|
setIncidentDescription(incident.description || '');
|
||||||
|
setShowIncidentModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateIncident = async () => {
|
||||||
|
if (!editingIncident) return;
|
||||||
|
try {
|
||||||
|
setUpdatingIncident(true);
|
||||||
|
await axios.patch(`/api/incidents/${editingIncident.id}`, { description: incidentDescription });
|
||||||
|
setShowIncidentModal(false);
|
||||||
|
setEditingIncident(null);
|
||||||
|
setIncidentDescription('');
|
||||||
|
loadIncidents();
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Error', error.response?.data?.error || 'Failed to update incident', 'error');
|
||||||
|
} finally {
|
||||||
|
setUpdatingIncident(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSla = async () => {
|
||||||
|
try {
|
||||||
|
setSlaLoading(true);
|
||||||
|
const response = await axios.get(`/api/resources/${id}/sla`, { params: { hours: slaWindow } });
|
||||||
|
setSla(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
// SLA load error handled
|
||||||
|
} finally {
|
||||||
|
setSlaLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMaintenanceWindows = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/resources/${id}/maintenance-windows`);
|
||||||
|
setMaintenanceWindows(response.data.windows || []);
|
||||||
|
} catch (error) {
|
||||||
|
// Maintenance windows load error handled
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateMaintenanceWindow = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await axios.post(`/api/resources/${id}/maintenance-windows`, maintenanceForm);
|
||||||
|
setShowMaintenanceModal(false);
|
||||||
|
setMaintenanceForm({ start_time: '', end_time: '', reason: '' });
|
||||||
|
loadMaintenanceWindows();
|
||||||
|
showNotification('Success', 'Maintenance window created', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Error', 'Failed to create maintenance window', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteMaintenanceWindow = async (windowId) => {
|
||||||
|
if (window.confirm('Delete this maintenance window?')) {
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/maintenance-windows/${windowId}`);
|
||||||
|
loadMaintenanceWindows();
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Error', 'Failed to delete maintenance window', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (window.confirm('Are you sure you want to delete this resource?')) {
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/resources/${id}`);
|
||||||
|
navigate('/');
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Error', 'Failed to delete resource', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEnabled = async () => {
|
||||||
|
try {
|
||||||
|
await axios.put(`/api/resources/${id}`, { ...resource, enabled: !resource.enabled });
|
||||||
|
loadResource();
|
||||||
|
} catch (error) {
|
||||||
|
// Update resource error handled
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMaintenance = async () => {
|
||||||
|
try {
|
||||||
|
await axios.put(`/api/resources/${id}`, { ...resource, maintenance_mode: !resource.maintenance_mode });
|
||||||
|
loadResource();
|
||||||
|
} catch (error) {
|
||||||
|
// Update maintenance mode error handled
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="container">Loading...</div>;
|
||||||
|
if (!resource) return <div className="container">Resource not found</div>;
|
||||||
|
|
||||||
|
const toastStack = notifications.length > 0 && (
|
||||||
|
<div className="toast-stack">
|
||||||
|
{notifications.map(n => (
|
||||||
|
<div key={n.id} className={`toast-item toast-${n.type}`}>
|
||||||
|
<strong>{n.title}</strong>
|
||||||
|
<div>{n.message}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartData = chartChecks.map((check) => ({
|
||||||
|
time: formatChartTime(check.checked_at),
|
||||||
|
responseTime: check.response_time,
|
||||||
|
status: check.status === 'up' ? 1 : 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
{toastStack}
|
||||||
|
<Link to="/" className="back-button">← Back to Dashboard</Link>
|
||||||
|
|
||||||
|
<div className="detail-section">
|
||||||
|
<div className="detail-header-row">
|
||||||
|
<div>
|
||||||
|
<h2>{resource.name}</h2>
|
||||||
|
<p className="resource-url-detail">{resource.url}</p>
|
||||||
|
<p className="resource-type">Type: {resource.type}</p>
|
||||||
|
</div>
|
||||||
|
<div className="detail-actions">
|
||||||
|
<button className="btn" onClick={toggleEnabled}>{resource.enabled ? 'Disable' : 'Enable'}</button>
|
||||||
|
<button className="btn btn-secondary" onClick={toggleMaintenance}>
|
||||||
|
{resource.maintenance_mode ? 'End Maintenance' : 'Start Maintenance'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-danger" onClick={handleDelete}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{resource.hasActiveIncident && (
|
||||||
|
<div className="incident-badge">⚠️ Active Incident - Resource is currently DOWN</div>
|
||||||
|
)}
|
||||||
|
{resource.maintenance_mode && (
|
||||||
|
<div className="incident-badge maintenance-badge">🛠️ Maintenance mode active — alerts are suppressed</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="stats-grid stats-grid-4 detail-stats-grid">
|
||||||
|
<div className="stat"><p className="stat-value">{resource.stats.uptime}%</p><p className="stat-label">Uptime (24h)</p></div>
|
||||||
|
<div className="stat"><p className="stat-value">{resource.stats.avgResponseTime}ms</p><p className="stat-label">Avg Response</p></div>
|
||||||
|
<div className="stat"><p className="stat-value">{resource.stats.totalChecks}</p><p className="stat-label">Total Checks</p></div>
|
||||||
|
<div className="stat">
|
||||||
|
<p className={`stat-value status-badge status-${resource.lastCheck?.status || 'unknown'}`}>{resource.lastCheck?.status || 'unknown'}</p>
|
||||||
|
<p className="stat-label">Current Status</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-section">
|
||||||
|
<h2>SLA / SLO (last {slaWindow}h)</h2>
|
||||||
|
{slaLoading ? <p>Loading SLA...</p> : !sla ? <p>No SLA data yet</p> : (
|
||||||
|
<div className="stats-grid stats-grid-4">
|
||||||
|
<div className="stat"><p className="stat-value">{sla.uptimePct}%</p><p className="stat-label">Uptime</p></div>
|
||||||
|
<div className="stat"><p className="stat-value">{formatDuration(sla.downtimeMinutes * 60000 || 0)}</p><p className="stat-label">Downtime</p></div>
|
||||||
|
<div className="stat"><p className="stat-value">{sla.p95LatencyMs != null ? `${sla.p95LatencyMs}ms` : '—'}</p><p className="stat-label">p95 Latency</p></div>
|
||||||
|
<div className="stat"><p className="stat-value">{sla.totalChecks}</p><p className="stat-label">Checks in window</p></div>
|
||||||
|
<div className="stat"><p className="stat-value">{sla.mttrMinutes != null ? `${sla.mttrMinutes}m` : '—'}</p><p className="stat-label">MTTR</p></div>
|
||||||
|
<div className="stat"><p className="stat-value">{sla.mtbfMinutes != null ? `${sla.mtbfMinutes}m` : '—'}</p><p className="stat-label">MTBF</p></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-section">
|
||||||
|
<div className="section-title-row">
|
||||||
|
<h2>Response Time</h2>
|
||||||
|
<div className="chart-range-btns">
|
||||||
|
{['1h', '6h', '24h', '7d'].map(r => (
|
||||||
|
<button key={r} className={`chart-range-btn${chartRange === r ? ' active' : ''}`} onClick={() => setChartRange(r)}>{r}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chart-container">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis dataKey="time" tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||||
|
<YAxis tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px' }}
|
||||||
|
labelStyle={{ color: '#cbd5e1' }}
|
||||||
|
itemStyle={{ color: '#e2e8f0' }}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="responseTime" stroke="#60a5fa" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-section">
|
||||||
|
<h2>Recent Checks</h2>
|
||||||
|
<div className="section-controls">
|
||||||
|
<div className="filter-control">
|
||||||
|
<label>Status</label>
|
||||||
|
<select value={checksStatus} onChange={(e) => { setChecksPage(0); setChecksStatus(e.target.value); }}>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="up">Up</option>
|
||||||
|
<option value="down">Down</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="filter-control">
|
||||||
|
<label>Sort</label>
|
||||||
|
<select value={checksSort} onChange={(e) => { setChecksPage(0); setChecksSort(e.target.value); }}>
|
||||||
|
<option value="desc">Newest first</option>
|
||||||
|
<option value="asc">Oldest first</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="controls-pagination">
|
||||||
|
<button className="btn btn-secondary" disabled={checksPage === 0 || checksLoading} onClick={() => setChecksPage(Math.max(0, checksPage - 1))}>← Prev</button>
|
||||||
|
<button className="btn btn-secondary" disabled={checksLoading || checks.length < checksLimit} onClick={() => setChecksPage(checksPage + 1)}>Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Time</th><th>Status</th><th className="text-right">Response Time</th><th>Message</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{checksLoading ? (
|
||||||
|
<tr><td colSpan="4">Loading checks...</td></tr>
|
||||||
|
) : checks.length === 0 ? (
|
||||||
|
<tr><td colSpan="4">No checks found</td></tr>
|
||||||
|
) : checks.map((check) => {
|
||||||
|
const isExpanded = expandedCheckId === check.id;
|
||||||
|
let parsedDetails = null;
|
||||||
|
if (check.details) {
|
||||||
|
try { parsedDetails = JSON.parse(check.details); } catch {}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<React.Fragment key={check.id}>
|
||||||
|
<tr
|
||||||
|
className={`check-row${parsedDetails ? ' check-row-expandable' : ''}`}
|
||||||
|
onClick={() => parsedDetails && setExpandedCheckId(isExpanded ? null : check.id)}
|
||||||
|
title={parsedDetails ? 'Click to expand details' : undefined}
|
||||||
|
>
|
||||||
|
<td>{formatLocalTime(check.checked_at)}</td>
|
||||||
|
<td><span className={`status-badge status-${check.status}`}>{check.status}</span></td>
|
||||||
|
<td className="text-right">{check.response_time ? `${check.response_time}ms` : '-'}</td>
|
||||||
|
<td className="muted-cell">
|
||||||
|
{check.error_message || 'OK'}
|
||||||
|
{parsedDetails && <span className="expand-indicator">{isExpanded ? ' ▼' : ' ▶'}</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && parsedDetails && (
|
||||||
|
<tr className="expanded-row">
|
||||||
|
<td colSpan="4" className="expanded-cell">
|
||||||
|
<pre className="detail-json">{JSON.stringify(parsedDetails, null, 2)}</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-section">
|
||||||
|
<h2>Incidents Timeline</h2>
|
||||||
|
<div className="section-controls">
|
||||||
|
<div className="filter-control">
|
||||||
|
<label>Status</label>
|
||||||
|
<select value={incidentsStatus} onChange={(e) => { setIncidentsPage(0); setIncidentsStatus(e.target.value); }}>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="filter-control">
|
||||||
|
<label>Sort</label>
|
||||||
|
<select value={incidentsSort} onChange={(e) => { setIncidentsPage(0); setIncidentsSort(e.target.value); }}>
|
||||||
|
<option value="desc">Newest first</option>
|
||||||
|
<option value="asc">Oldest first</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="controls-pagination">
|
||||||
|
<button className="btn btn-secondary" disabled={incidentsPage === 0 || incidentsLoading} onClick={() => setIncidentsPage(Math.max(0, incidentsPage - 1))}>← Prev</button>
|
||||||
|
<button className="btn btn-secondary" disabled={incidentsLoading || incidents.length < incidentsLimit} onClick={() => setIncidentsPage(incidentsPage + 1)}>Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th className="w-20">Started</th><th className="w-20">Resolved</th><th className="w-15">Duration</th><th className="w-35">Reason</th><th className="w-10"></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{incidentsLoading ? (
|
||||||
|
<tr><td colSpan="4">Loading incidents...</td></tr>
|
||||||
|
) : incidents.length === 0 ? (
|
||||||
|
<tr><td colSpan="4">No incidents found</td></tr>
|
||||||
|
) : incidents.map((incident) => {
|
||||||
|
const start = incident.started_at || incident.created_at;
|
||||||
|
const end = incident.resolved_at;
|
||||||
|
const durationMs = start && end ? (new Date(end).getTime() - new Date(start).getTime()) : null;
|
||||||
|
const durationText = durationMs != null ? formatDuration(durationMs) : 'Ongoing';
|
||||||
|
const isExpanded = expandedIncidentId === incident.id;
|
||||||
|
const description = incident.description || 'No description';
|
||||||
|
const isTruncated = description.length > 60;
|
||||||
|
const displayText = isExpanded ? description : (isTruncated ? description.substring(0, 60) + '...' : description);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={incident.id}>
|
||||||
|
<tr>
|
||||||
|
<td>{start ? formatLocalTime(start) : '-'}</td>
|
||||||
|
<td>{end ? formatLocalTime(end) : 'Open'}</td>
|
||||||
|
<td>{durationText}</td>
|
||||||
|
<td className="incident-reason-cell">
|
||||||
|
<span className="incident-reason-toggle" onClick={() => setExpandedIncidentId(isExpanded ? null : incident.id)}>
|
||||||
|
{displayText}
|
||||||
|
{isTruncated && <span className="expand-indicator">{isExpanded ? '▼' : '▶'}</span>}
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-secondary btn-compact" onClick={() => handleEditIncident(incident)}>Edit</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{!incident.resolved_at && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-compact"
|
||||||
|
title="Acknowledge — mark as seen"
|
||||||
|
onClick={() => handleAcknowledge(incident.id)}
|
||||||
|
>Ack</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="expanded-row">
|
||||||
|
<td colSpan="5" className="expanded-cell">{description}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-section">
|
||||||
|
<div className="section-title-row">
|
||||||
|
<h2>Maintenance Windows</h2>
|
||||||
|
<button className="btn btn-primary" onClick={() => setShowMaintenanceModal(true)}>+ Schedule Maintenance</button>
|
||||||
|
</div>
|
||||||
|
{maintenanceWindows.length === 0 ? (
|
||||||
|
<p className="empty-copy">No maintenance windows scheduled</p>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Start Time</th><th>End Time</th><th>Reason</th><th className="text-right">Actions</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{maintenanceWindows.map(window => (
|
||||||
|
<tr key={window.id}>
|
||||||
|
<td>{formatLocalTime(window.start_time)}</td>
|
||||||
|
<td>{formatLocalTime(window.end_time)}</td>
|
||||||
|
<td className="muted-cell">{window.reason || '-'}</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<button className="btn btn-danger btn-compact" onClick={() => handleDeleteMaintenanceWindow(window.id)}>Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMaintenanceModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowMaintenanceModal(false)}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2>Schedule Maintenance Window</h2>
|
||||||
|
<form onSubmit={handleCreateMaintenanceWindow}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Start Time *</label>
|
||||||
|
<input type="datetime-local" required value={maintenanceForm.start_time} onChange={(e) => setMaintenanceForm({ ...maintenanceForm, start_time: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>End Time *</label>
|
||||||
|
<input type="datetime-local" required value={maintenanceForm.end_time} onChange={(e) => setMaintenanceForm({ ...maintenanceForm, end_time: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Reason (Optional)</label>
|
||||||
|
<textarea rows="3" placeholder="Planned maintenance, server upgrade, etc." value={maintenanceForm.reason} onChange={(e) => setMaintenanceForm({ ...maintenanceForm, reason: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn" onClick={() => setShowMaintenanceModal(false)}>Cancel</button>
|
||||||
|
<button type="submit" className="btn btn-primary">Schedule</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showIncidentModal && editingIncident && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowIncidentModal(false)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2>Edit Incident</h2>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{ fontSize: '0.9rem', color: '#666' }}>Incident Started</label>
|
||||||
|
<div style={{ padding: '0.5rem', backgroundColor: '#f5f5f5', borderRadius: '4px', marginBottom: '1rem' }}>
|
||||||
|
{formatLocalTime(editingIncident.started_at || editingIncident.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Description *</label>
|
||||||
|
<textarea rows="6" placeholder="Describe what happened and why..." value={incidentDescription} onChange={(e) => setIncidentDescription(e.target.value)} style={{ fontFamily: 'monospace', fontSize: '0.9rem' }} />
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn" onClick={() => { setShowIncidentModal(false); setEditingIncident(null); setIncidentDescription(''); }}>Cancel</button>
|
||||||
|
<button type="submit" className="btn btn-primary" onClick={handleUpdateIncident} disabled={updatingIncident || !incidentDescription.trim()}>
|
||||||
|
{updatingIncident ? 'Updating...' : 'Update'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResourceDetail;
|
||||||
+54
-60
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
import './CommandCenterPages.css';
|
||||||
|
|
||||||
function SLA() {
|
function SLA() {
|
||||||
const [slaData, setSlaData] = useState([]);
|
const [slaData, setSlaData] = useState([]);
|
||||||
@@ -66,22 +67,15 @@ function SLA() {
|
|||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container cc-page">
|
||||||
{limitedByRetention && (
|
{limitedByRetention && (
|
||||||
<div style={{
|
<div className="cc-alert">
|
||||||
padding: '1rem',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
backgroundColor: '#fff3cd',
|
|
||||||
border: '1px solid #ffc107',
|
|
||||||
borderRadius: '4px',
|
|
||||||
color: '#856404'
|
|
||||||
}}>
|
|
||||||
⚠️ SLA data limited to {effectiveDays} days (server retention setting). Requested {days} days.
|
⚠️ SLA data limited to {effectiveDays} days (server retention setting). Requested {days} days.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
|
<div className="cc-page-header">
|
||||||
<h2>SLA Dashboard</h2>
|
<h2 className="cc-page-title">SLA Dashboard</h2>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<div className="cc-controls">
|
||||||
{[7, 14, 30, 90].map((d) => (
|
{[7, 14, 30, 90].map((d) => (
|
||||||
<button
|
<button
|
||||||
key={d}
|
key={d}
|
||||||
@@ -95,33 +89,33 @@ function SLA() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{limitedByRetention && (
|
{limitedByRetention && (
|
||||||
<div style={{ background: '#FFF8E1', color: '#8D6E63', padding: '0.5rem 0.75rem', borderRadius: '6px', border: '1px solid #FFECB3' }}>
|
<div className="cc-alert" style={{ marginBottom: 0 }}>
|
||||||
Limited by data retention ({effectiveDays} days). Increase Retention in Settings to view longer periods.
|
Limited by data retention ({effectiveDays} days). Increase Retention in Settings to view longer periods.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{overallStats && (
|
{overallStats && (
|
||||||
<div className="stats-grid" style={{ marginBottom: '2rem', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
|
<div className="cc-kpi-grid" style={{ marginBottom: '1rem' }}>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.totalResources}</p>
|
<p className="cc-kpi-label">Total Resources</p>
|
||||||
<p className="stat-label">Total Resources</p>
|
<p className="cc-kpi-value" style={{ color: '#60a5fa' }}>{overallStats.totalResources}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#4caf50', fontSize: '2.5rem' }}>{overallStats.meetingTarget}/{overallStats.totalResources}</p>
|
<p className="cc-kpi-label">Meeting SLA Target</p>
|
||||||
<p className="stat-label">Meeting SLA Target</p>
|
<p className="cc-kpi-value" style={{ color: '#22c55e' }}>{overallStats.meetingTarget}/{overallStats.totalResources}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.avgUptime}%</p>
|
<p className="cc-kpi-label">Average Uptime</p>
|
||||||
<p className="stat-label">Average Uptime</p>
|
<p className="cc-kpi-value" style={{ color: '#a78bfa' }}>{overallStats.avgUptime}%</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#ff9800', fontSize: '2.5rem' }}>{overallStats.totalIncidents}</p>
|
<p className="cc-kpi-label">Total Incidents</p>
|
||||||
<p className="stat-label">Total Incidents</p>
|
<p className="cc-kpi-value" style={{ color: '#f59e0b' }}>{overallStats.totalIncidents}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#f44336', fontSize: '2.5rem' }}>{Math.round(overallStats.totalDowntime)}m</p>
|
<p className="cc-kpi-label">Total Downtime</p>
|
||||||
<p className="stat-label">Total Downtime</p>
|
<p className="cc-kpi-value" style={{ color: '#ef4444' }}>{Math.round(overallStats.totalDowntime)}m</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -132,46 +126,46 @@ function SLA() {
|
|||||||
<p>Add resources to start tracking SLA metrics</p>
|
<p>Add resources to start tracking SLA metrics</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div className="cc-table-wrap">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
|
<table className="cc-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: '#667eea', color: 'white' }}>
|
<tr>
|
||||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Resource</th>
|
<th>Resource</th>
|
||||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Target</th>
|
<th style={{ textAlign: 'center' }}>Target</th>
|
||||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Actual</th>
|
<th style={{ textAlign: 'center' }}>Actual</th>
|
||||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Status</th>
|
<th style={{ textAlign: 'center' }}>Status</th>
|
||||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Checks</th>
|
<th style={{ textAlign: 'center' }}>Checks</th>
|
||||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Incidents</th>
|
<th style={{ textAlign: 'center' }}>Incidents</th>
|
||||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Downtime</th>
|
<th style={{ textAlign: 'center' }}>Downtime</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{slaData.map((item, idx) => (
|
{slaData.map((item, idx) => (
|
||||||
<tr key={item.resource_id} style={{ borderBottom: '1px solid #eee', background: idx % 2 === 0 ? '#fafafa' : '#fff' }}>
|
<tr key={item.resource_id} style={{ background: idx % 2 === 0 ? '#0b1325' : '#0f172a' }}>
|
||||||
<td style={{ padding: '1rem', fontWeight: 'bold' }}>{item.resource_name}</td>
|
<td style={{ fontWeight: 'bold' }}>{item.resource_name}</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'center' }}>{item.sla_target}%</td>
|
<td style={{ textAlign: 'center' }}>{item.sla_target}%</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'center', fontWeight: 'bold', color: item.meets_target ? '#4caf50' : '#f44336' }}>
|
<td style={{ textAlign: 'center', fontWeight: 'bold', color: item.meets_target ? '#22c55e' : '#ef4444' }}>
|
||||||
{item.actual_uptime}%
|
{item.actual_uptime}%
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
<td style={{ textAlign: 'center' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '0.25rem 0.75rem',
|
padding: '0.25rem 0.75rem',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
background: item.meets_target ? '#e8f5e9' : '#ffebee',
|
background: item.meets_target ? '#052e16' : '#3f1d1d',
|
||||||
color: item.meets_target ? '#2e7d32' : '#c62828',
|
color: item.meets_target ? '#4ade80' : '#fda4af',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
fontWeight: 'bold'
|
fontWeight: 'bold'
|
||||||
}}>
|
}}>
|
||||||
{item.meets_target ? '✓ PASS' : '✗ FAIL'}
|
{item.meets_target ? '✓ PASS' : '✗ FAIL'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
<td style={{ textAlign: 'center' }}>
|
||||||
{item.successful_checks}/{item.total_checks}
|
{item.successful_checks}/{item.total_checks}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'center', color: item.incidents > 0 ? '#ff9800' : '#666' }}>
|
<td style={{ textAlign: 'center', color: item.incidents > 0 ? '#f59e0b' : '#94a3b8' }}>
|
||||||
{item.incidents}
|
{item.incidents}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
<td style={{ textAlign: 'center' }}>
|
||||||
{item.downtime_minutes > 0 ? `${item.downtime_minutes}m` : '-'}
|
{item.downtime_minutes > 0 ? `${item.downtime_minutes}m` : '-'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -180,7 +174,7 @@ function SLA() {
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
{totalItems > pageSize && (
|
{totalItems > pageSize && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
|
<div className="cc-pagination" style={{ marginTop: '1rem' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||||
@@ -188,8 +182,8 @@ function SLA() {
|
|||||||
>
|
>
|
||||||
← Previous
|
← Previous
|
||||||
</button>
|
</button>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div className="cc-controls">
|
||||||
<span style={{ color: '#666', fontSize: '0.9rem' }}>Page</span>
|
<span>Page</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={currentPage}
|
value={currentPage}
|
||||||
@@ -197,14 +191,14 @@ function SLA() {
|
|||||||
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
|
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
}}
|
}}
|
||||||
style={{ width: '50px', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', textAlign: 'center' }}
|
className="cc-input-sm"
|
||||||
/>
|
/>
|
||||||
<span style={{ color: '#666', fontSize: '0.9rem' }}>of {Math.ceil(totalItems / pageSize)}</span>
|
<span>of {Math.ceil(totalItems / pageSize)}</span>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
|
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
|
||||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff' }}
|
className="cc-select-sm"
|
||||||
>
|
>
|
||||||
<option value={5}>5 per page</option>
|
<option value={5}>5 per page</option>
|
||||||
<option value={10}>10 per page</option>
|
<option value={10}>10 per page</option>
|
||||||
@@ -223,13 +217,13 @@ function SLA() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
|
<div className="cc-surface" style={{ marginTop: '1rem' }}>
|
||||||
<h3 style={{ marginTop: 0, color: '#667eea' }}>SLA Report Summary</h3>
|
<h3 className="history-resource-title">SLA Report Summary</h3>
|
||||||
<p style={{ color: '#666', lineHeight: '1.6' }}>
|
<p style={{ color: '#cbd5e1', lineHeight: '1.6' }}>
|
||||||
This report shows the Service Level Agreement (SLA) performance for all monitored resources over the last <strong>{effectiveDays} days</strong>.
|
This report shows the Service Level Agreement (SLA) performance for all monitored resources over the last <strong>{effectiveDays} days</strong>.
|
||||||
Resources meeting their target uptime are marked as <strong style={{ color: '#4caf50' }}>PASS</strong>, while those below target are marked as <strong style={{ color: '#f44336' }}>FAIL</strong>.
|
Resources meeting their target uptime are marked as <strong style={{ color: '#22c55e' }}>PASS</strong>, while those below target are marked as <strong style={{ color: '#ef4444' }}>FAIL</strong>.
|
||||||
</p>
|
</p>
|
||||||
<p style={{ color: '#666', lineHeight: '1.6', marginBottom: 0 }}>
|
<p style={{ color: '#cbd5e1', lineHeight: '1.6', marginBottom: 0 }}>
|
||||||
Overall performance: <strong>{overallStats?.meetingTarget || 0}</strong> out of <strong>{overallStats?.totalResources || 0}</strong> resources ({overallStats ? Math.round((overallStats.meetingTarget / overallStats.totalResources) * 100) : 0}%) are meeting their SLA targets.
|
Overall performance: <strong>{overallStats?.meetingTarget || 0}</strong> out of <strong>{overallStats?.totalResources || 0}</strong> resources ({overallStats ? Math.round((overallStats.meetingTarget / overallStats.totalResources) * 100) : 0}%) are meeting their SLA targets.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,39 @@
|
|||||||
.settings-wizard {
|
.settings-wizard {
|
||||||
max-width: 900px;
|
max-width: 980px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-wizard > h2 {
|
||||||
|
color: #f8fafc;
|
||||||
|
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-description {
|
.settings-description {
|
||||||
color: #666;
|
color: #cbd5e1;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section {
|
.settings-section {
|
||||||
background: white;
|
background: rgba(15, 23, 42, 0.85);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
padding: 2rem;
|
padding: 1.35rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1rem;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header {
|
.settings-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1rem;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 0.8rem;
|
||||||
border-bottom: 2px solid #f0f0f0;
|
border-bottom: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header h3 {
|
.settings-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #333;
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-form {
|
.settings-form {
|
||||||
@@ -43,23 +49,43 @@
|
|||||||
|
|
||||||
.form-group small {
|
.form-group small {
|
||||||
display: block;
|
display: block;
|
||||||
color: #999;
|
color: #cbd5e1;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-wizard .form-group label {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-wizard .form-group input,
|
||||||
|
.settings-wizard .form-group select,
|
||||||
|
.settings-wizard .form-group textarea {
|
||||||
|
background: #0b1325;
|
||||||
|
color: #f8fafc;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-wizard .form-group input:focus,
|
||||||
|
.settings-wizard .form-group select:focus,
|
||||||
|
.settings-wizard .form-group textarea:focus {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
.info-box {
|
.info-box {
|
||||||
background: #f8f9fa;
|
background: #0b1325;
|
||||||
border-left: 4px solid #667eea;
|
border-left: 4px solid #60a5fa;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box strong {
|
.info-box strong {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: #333;
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box ol {
|
.info-box ol {
|
||||||
@@ -69,11 +95,11 @@
|
|||||||
|
|
||||||
.info-box li {
|
.info-box li {
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
color: #666;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box a {
|
.info-box a {
|
||||||
color: #667eea;
|
color: #bfdbfe;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,12 +108,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-box pre {
|
.info-box pre {
|
||||||
background: white;
|
background: #111827;
|
||||||
|
color: #f8fafc;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch {
|
.toggle-switch {
|
||||||
@@ -110,7 +138,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: #ccc;
|
background-color: #334155;
|
||||||
transition: .4s;
|
transition: .4s;
|
||||||
border-radius: 34px;
|
border-radius: 34px;
|
||||||
}
|
}
|
||||||
@@ -122,13 +150,17 @@
|
|||||||
width: 26px;
|
width: 26px;
|
||||||
left: 4px;
|
left: 4px;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
background-color: white;
|
background-color: #e2e8f0;
|
||||||
transition: .4s;
|
transition: .4s;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-slider {
|
.toggle-switch input:checked + .toggle-slider {
|
||||||
background-color: #667eea;
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:focus + .toggle-slider {
|
||||||
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-slider:before {
|
.toggle-switch input:checked + .toggle-slider:before {
|
||||||
@@ -136,13 +168,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: #6c757d;
|
background: #1e293b;
|
||||||
color: white;
|
color: #f8fafc;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: #5a6268;
|
background: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:focus-visible {
|
||||||
|
outline: 2px solid #60a5fa;
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:disabled {
|
.btn-secondary:disabled {
|
||||||
@@ -157,15 +195,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.test-result.success {
|
.test-result.success {
|
||||||
background: #d4edda;
|
background: #052e16;
|
||||||
color: #155724;
|
color: #86efac;
|
||||||
border: 1px solid #c3e6cb;
|
border: 1px solid #166534;
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-result.error {
|
.test-result.error {
|
||||||
background: #f8d7da;
|
background: #3f1d1d;
|
||||||
color: #721c24;
|
color: #fda4af;
|
||||||
border: 1px solid #f5c6cb;
|
border: 1px solid #7f1d1d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-result strong {
|
.test-result strong {
|
||||||
@@ -201,15 +239,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.save-status.success {
|
.save-status.success {
|
||||||
background: #4caf50;
|
background: #166534;
|
||||||
color: white;
|
color: #dcfce7;
|
||||||
border-left: 4px solid #388e3c;
|
border-left: 4px solid #22c55e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-status.error {
|
.save-status.error {
|
||||||
background: #f44336;
|
background: #7f1d1d;
|
||||||
color: white;
|
color: #fee2e2;
|
||||||
border-left: 4px solid #d32f2f;
|
border-left: 4px solid #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-status strong {
|
.save-status strong {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ function SettingsWizard() {
|
|||||||
email_to: '',
|
email_to: '',
|
||||||
webhook_enabled: false,
|
webhook_enabled: false,
|
||||||
webhook_url: '',
|
webhook_url: '',
|
||||||
|
webhook_template: '',
|
||||||
|
ntfy_enabled: false,
|
||||||
|
ntfy_url: 'https://ntfy.sh',
|
||||||
|
ntfy_topic: '',
|
||||||
check_interval: 60000,
|
check_interval: 60000,
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
timezone: 'UTC',
|
timezone: 'UTC',
|
||||||
@@ -31,6 +35,7 @@ function SettingsWizard() {
|
|||||||
items_per_page: 20,
|
items_per_page: 20,
|
||||||
refresh_interval: 5000,
|
refresh_interval: 5000,
|
||||||
incident_failure_threshold: 10,
|
incident_failure_threshold: 10,
|
||||||
|
slow_alert_consecutive: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [testResult, setTestResult] = useState(null);
|
const [testResult, setTestResult] = useState(null);
|
||||||
@@ -57,6 +62,10 @@ function SettingsWizard() {
|
|||||||
setSavedMessage(null);
|
setSavedMessage(null);
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/settings', sectionData);
|
await axios.post('/api/settings', sectionData);
|
||||||
|
if (sectionData.timezone) {
|
||||||
|
localStorage.setItem('serverTimezone', sectionData.timezone);
|
||||||
|
localStorage.setItem('serverTimezoneTime', Date.now().toString());
|
||||||
|
}
|
||||||
setSavedMessage({ section: sectionName, success: true });
|
setSavedMessage({ section: sectionName, success: true });
|
||||||
// Clear message after 3 seconds
|
// Clear message after 3 seconds
|
||||||
setTimeout(() => setSavedMessage(null), 3000);
|
setTimeout(() => setSavedMessage(null), 3000);
|
||||||
@@ -100,9 +109,28 @@ function SettingsWizard() {
|
|||||||
});
|
});
|
||||||
setTestResult({ success: true, message: response.data.message });
|
setTestResult({ success: true, message: response.data.message });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTestResult({
|
setTestResult({
|
||||||
success: false,
|
success: false,
|
||||||
message: error.response?.data?.error || 'Failed to send test webhook'
|
message: error.response?.data?.error || 'Failed to send test webhook'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestNtfy = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/test-ntfy', {
|
||||||
|
ntfy_url: settings.ntfy_url,
|
||||||
|
ntfy_topic: settings.ntfy_topic,
|
||||||
|
});
|
||||||
|
setTestResult({ success: true, message: response.data.message });
|
||||||
|
} catch (error) {
|
||||||
|
setTestResult({
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.error || 'Failed to send ntfy notification',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
@@ -290,10 +318,22 @@ function SettingsWizard() {
|
|||||||
<small>Receives POST requests with incident data when resources go offline/online</small>
|
<small>Receives POST requests with incident data when resources go offline/online</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Payload Template (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={settings.webhook_template}
|
||||||
|
onChange={(e) => setSettings({ ...settings, webhook_template: e.target.value })}
|
||||||
|
placeholder={'{"text": "{{name}} is {{status}} — {{url}}"}'}
|
||||||
|
rows="4"
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||||
|
/>
|
||||||
|
<small>Variables: <code>{'{{name}}'}</code> <code>{'{{status}}'}</code> <code>{'{{url}}'}</code> <code>{'{{type}}'}</code> <code>{'{{message}}'}</code> <code>{'{{timestamp}}'}</code>. Leave blank for the default JSON payload.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="info-box">
|
<div className="info-box">
|
||||||
<strong>💡 Webhook Payload Example:</strong>
|
<strong>Default payload (when no template is set):</strong>
|
||||||
<pre>{JSON.stringify({
|
<pre>{JSON.stringify({
|
||||||
resource: "ZimaOS Server",
|
resource: "My Server",
|
||||||
url: "https://example.com",
|
url: "https://example.com",
|
||||||
status: "down",
|
status: "down",
|
||||||
message: "Resource is DOWN",
|
message: "Resource is DOWN",
|
||||||
@@ -314,6 +354,7 @@ function SettingsWizard() {
|
|||||||
onClick={() => handleSaveSection('Webhook', {
|
onClick={() => handleSaveSection('Webhook', {
|
||||||
webhook_enabled: settings.webhook_enabled,
|
webhook_enabled: settings.webhook_enabled,
|
||||||
webhook_url: settings.webhook_url,
|
webhook_url: settings.webhook_url,
|
||||||
|
webhook_template: settings.webhook_template,
|
||||||
})}
|
})}
|
||||||
disabled={savingSection === 'Webhook'}
|
disabled={savingSection === 'Webhook'}
|
||||||
>
|
>
|
||||||
@@ -324,6 +365,79 @@ function SettingsWizard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ntfy.sh Settings */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<div className="settings-header">
|
||||||
|
<h3>📲 ntfy.sh Push Notifications</h3>
|
||||||
|
<label className="toggle-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.ntfy_enabled}
|
||||||
|
onChange={(e) => setSettings({ ...settings, ntfy_enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.ntfy_enabled && (
|
||||||
|
<div className="settings-form">
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>ntfy Server URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={settings.ntfy_url}
|
||||||
|
onChange={(e) => setSettings({ ...settings, ntfy_url: e.target.value })}
|
||||||
|
placeholder="https://ntfy.sh"
|
||||||
|
/>
|
||||||
|
<small>Use <code>https://ntfy.sh</code> for the public server, or your self-hosted URL</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Topic</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.ntfy_topic}
|
||||||
|
onChange={(e) => setSettings({ ...settings, ntfy_topic: e.target.value })}
|
||||||
|
placeholder="my-skywatch-alerts"
|
||||||
|
/>
|
||||||
|
<small>Subscribe to this topic in the ntfy app to receive push notifications</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-box">
|
||||||
|
<strong>💡 Setup:</strong>
|
||||||
|
<ol>
|
||||||
|
<li>Install the <strong>ntfy</strong> app on your phone (iOS or Android)</li>
|
||||||
|
<li>Subscribe to your topic name above</li>
|
||||||
|
<li>Send a test notification to confirm it works</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleTestNtfy}
|
||||||
|
disabled={testing || !settings.ntfy_topic}
|
||||||
|
>
|
||||||
|
{testing ? 'Sending...' : '📲 Send Test Notification'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => handleSaveSection('ntfy', {
|
||||||
|
ntfy_enabled: settings.ntfy_enabled,
|
||||||
|
ntfy_url: settings.ntfy_url,
|
||||||
|
ntfy_topic: settings.ntfy_topic,
|
||||||
|
})}
|
||||||
|
disabled={savingSection === 'ntfy'}
|
||||||
|
>
|
||||||
|
{savingSection === 'ntfy' ? 'Saving...' : '💾 Save ntfy Settings'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Test Result */}
|
{/* Test Result */}
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div className={`test-result ${testResult.success ? 'success' : 'error'}`}>
|
<div className={`test-result ${testResult.success ? 'success' : 'error'}`}>
|
||||||
@@ -416,6 +530,18 @@ function SettingsWizard() {
|
|||||||
<small>Number of consecutive failures before triggering an alert (default: 3)</small>
|
<small>Number of consecutive failures before triggering an alert (default: 3)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Slow Alert Sensitivity</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={settings.slow_alert_consecutive || 3}
|
||||||
|
onChange={(e) => setSettings({ ...settings, slow_alert_consecutive: parseInt(e.target.value) })}
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
/>
|
||||||
|
<small>Consecutive slow checks required before alerting — 1 = any spike, 3 = default, 5+ = only sustained slowness</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Incident Failure Threshold</label>
|
<label>Incident Failure Threshold</label>
|
||||||
<input
|
<input
|
||||||
@@ -466,6 +592,7 @@ function SettingsWizard() {
|
|||||||
grace_period: settings.grace_period,
|
grace_period: settings.grace_period,
|
||||||
downtime_threshold: settings.downtime_threshold,
|
downtime_threshold: settings.downtime_threshold,
|
||||||
incident_failure_threshold: settings.incident_failure_threshold,
|
incident_failure_threshold: settings.incident_failure_threshold,
|
||||||
|
slow_alert_consecutive: settings.slow_alert_consecutive,
|
||||||
})}
|
})}
|
||||||
disabled={savingSection === 'Monitoring'}
|
disabled={savingSection === 'Monitoring'}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -319,3 +319,54 @@
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 90-day uptime bars ── */
|
||||||
|
.uptime-history {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
height: 28px;
|
||||||
|
margin: 0.75rem 0 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uptime-day-bar {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 1px;
|
||||||
|
border-radius: 1px;
|
||||||
|
cursor: default;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uptime-day-bar:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uptime-history-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Spark bars in dashboard ── */
|
||||||
|
.status-spark-bars {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 44px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark-bar {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 3px;
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark-bar:hover { opacity: 0.65; }
|
||||||
|
.spark-bar-up { background: #14b8a6; }
|
||||||
|
.spark-bar-down { background: #ef4444; }
|
||||||
|
.spark-bar-unknown { background: #475569; }
|
||||||
+43
-18
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { formatLocalTime } from './utils/timeUtils';
|
||||||
import './Status.css';
|
import './Status.css';
|
||||||
|
|
||||||
function Status() {
|
function Status() {
|
||||||
@@ -12,20 +13,17 @@ function Status() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStatusData();
|
loadStatusData();
|
||||||
const interval = setInterval(loadStatusData, 15000); // Refresh every 15 seconds for critical status
|
if (!autoRefresh) return;
|
||||||
|
const interval = setInterval(loadStatusData, 15000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, [autoRefresh]);
|
||||||
|
|
||||||
const loadStatusData = async () => {
|
const loadStatusData = async () => {
|
||||||
try {
|
try {
|
||||||
const [resourcesRes, incidentsRes, maintenanceRes] = await Promise.all([
|
const statusRes = await axios.get('/api/status-page');
|
||||||
axios.get('/api/resources'),
|
setResources(statusRes.data.resources || []);
|
||||||
axios.get('/api/incidents'),
|
setIncidents(statusRes.data.incidents || []);
|
||||||
axios.get('/api/maintenance-windows'),
|
setMaintenanceWindows(statusRes.data.maintenanceWindows || []);
|
||||||
]);
|
|
||||||
setResources(resourcesRes.data.resources || []);
|
|
||||||
setIncidents(incidentsRes.data.incidents || []);
|
|
||||||
setMaintenanceWindows(maintenanceRes.data.maintenanceWindows || []);
|
|
||||||
setLastUpdated(new Date());
|
setLastUpdated(new Date());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Status load error handled
|
// Status load error handled
|
||||||
@@ -34,14 +32,6 @@ function Status() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatLocalTime = (timestamp) => {
|
|
||||||
try {
|
|
||||||
return new Date(timestamp).toLocaleString();
|
|
||||||
} catch {
|
|
||||||
return timestamp;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDuration = (ms) => {
|
const formatDuration = (ms) => {
|
||||||
const seconds = Math.floor(ms / 1000);
|
const seconds = Math.floor(ms / 1000);
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
@@ -129,6 +119,19 @@ function Status() {
|
|||||||
return m.resource_id === resource.id && now >= start && now <= end;
|
return m.resource_id === resource.id && now >= start && now <= end;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const daily = resource.dailyUptime || [];
|
||||||
|
const last90Days = (() => {
|
||||||
|
const days = [];
|
||||||
|
for (let i = 89; i >= 0; i--) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const key = d.toISOString().slice(0, 10);
|
||||||
|
const entry = daily.find(e => e.day === key);
|
||||||
|
days.push({ day: key, uptime: entry ? entry.uptime : null });
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={resource.id}
|
key={resource.id}
|
||||||
@@ -149,6 +152,28 @@ function Status() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="uptime-history" title="90-day uptime — hover for details">
|
||||||
|
{last90Days.map((d) => {
|
||||||
|
const color = d.uptime === null ? '#334155'
|
||||||
|
: d.uptime >= 99 ? '#14b8a6'
|
||||||
|
: d.uptime >= 95 ? '#f59e0b'
|
||||||
|
: '#ef4444';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={d.day}
|
||||||
|
className="uptime-day-bar"
|
||||||
|
style={{ background: color }}
|
||||||
|
title={d.uptime !== null ? `${d.day}: ${d.uptime}%` : `${d.day}: no data`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="uptime-history-labels">
|
||||||
|
<span>90 days ago</span>
|
||||||
|
<span>{daily.length > 0 ? `${daily[daily.length - 1]?.uptime ?? '—'}% today` : ''}</span>
|
||||||
|
<span>Today</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{activeIncident && (
|
{activeIncident && (
|
||||||
<div className="incident-info">
|
<div className="incident-info">
|
||||||
<p className="incident-title">Active Incident</p>
|
<p className="incident-title">Active Incident</p>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ body {
|
|||||||
font-family: 'Manrope', 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
font-family: 'Manrope', 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
color: #0f172a;
|
color: #e2e8f0;
|
||||||
background-color: #f8fafc;
|
background-color: #020617;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ export const getCurrentTimezone = () => {
|
|||||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getServerTimezone = () => {
|
||||||
|
try {
|
||||||
|
const tz = localStorage.getItem('serverTimezone');
|
||||||
|
const ts = localStorage.getItem('serverTimezoneTime');
|
||||||
|
if (tz && ts && (Date.now() - parseInt(ts)) < 3600000 && tz !== 'null' && tz !== 'undefined' && tz.trim() !== '') {
|
||||||
|
return tz.replace(/^["']|["']$/g, '').trim();
|
||||||
|
}
|
||||||
|
if (tz) {
|
||||||
|
localStorage.removeItem('serverTimezone');
|
||||||
|
localStorage.removeItem('serverTimezoneTime');
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a timestamp to local time string with timezone info
|
* Format a timestamp to local time string with timezone info
|
||||||
* Uses server timezone from localStorage if available, otherwise browser timezone
|
* Uses server timezone from localStorage if available, otherwise browser timezone
|
||||||
@@ -23,40 +38,19 @@ export const formatLocalTime = (timestamp, options = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date(ts);
|
const date = new Date(ts);
|
||||||
|
const tz = getServerTimezone();
|
||||||
// Try to get server timezone from settings API response (cached in localStorage)
|
|
||||||
let timeZone;
|
|
||||||
try {
|
|
||||||
const cachedSettings = localStorage.getItem('serverTimezone');
|
|
||||||
const cacheTime = localStorage.getItem('serverTimezoneTime');
|
|
||||||
const now = Date.now();
|
|
||||||
// Invalidate cache every 1 hour
|
|
||||||
if (cachedSettings && cacheTime && (now - parseInt(cacheTime)) < 3600000 && cachedSettings !== 'null' && cachedSettings !== 'undefined' && cachedSettings.trim() !== '') {
|
|
||||||
// Remove any quotes that might be in the string
|
|
||||||
timeZone = cachedSettings.replace(/^["']|["']$/g, '').trim();
|
|
||||||
} else if (cachedSettings) {
|
|
||||||
// Clear stale cache
|
|
||||||
localStorage.removeItem('serverTimezone');
|
|
||||||
localStorage.removeItem('serverTimezoneTime');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Timezone load error handled silently
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
second: '2-digit',
|
second: '2-digit',
|
||||||
timeZoneName: 'short'
|
timeZoneName: 'short',
|
||||||
|
...(tz ? { timeZone: tz } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
if (timeZone) {
|
|
||||||
defaultOptions.timeZone = timeZone;
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toLocaleString('en-US', { ...defaultOptions, ...options });
|
return date.toLocaleString('en-US', { ...defaultOptions, ...options });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,15 +59,17 @@ export const formatLocalTime = (timestamp, options = {}) => {
|
|||||||
*/
|
*/
|
||||||
export const formatLocalTimeOnly = (timestamp, options = {}) => {
|
export const formatLocalTimeOnly = (timestamp, options = {}) => {
|
||||||
if (!timestamp) return 'Never';
|
if (!timestamp) return 'Never';
|
||||||
|
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
|
const tz = getServerTimezone();
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
second: '2-digit',
|
second: '2-digit',
|
||||||
timeZoneName: 'short'
|
timeZoneName: 'short',
|
||||||
|
...(tz ? { timeZone: tz } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
return date.toLocaleTimeString('en-US', { ...defaultOptions, ...options });
|
return date.toLocaleTimeString('en-US', { ...defaultOptions, ...options });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,26 +79,18 @@ export const formatLocalTimeOnly = (timestamp, options = {}) => {
|
|||||||
*/
|
*/
|
||||||
export const formatChartTime = (timestamp) => {
|
export const formatChartTime = (timestamp) => {
|
||||||
if (!timestamp) return '';
|
if (!timestamp) return '';
|
||||||
|
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
|
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
|
||||||
|
const tz = getServerTimezone();
|
||||||
// If within last 24 hours, show time only
|
const tzOpt = tz ? { timeZone: tz } : {};
|
||||||
|
|
||||||
if (diffDays === 0) {
|
if (diffDays === 0) {
|
||||||
return date.toLocaleTimeString('en-US', {
|
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', ...tzOpt });
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise show date and time
|
return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', ...tzOpt });
|
||||||
return date.toLocaleString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,10 +100,19 @@ export const getTimezoneInfo = () => {
|
|||||||
const tz = getCurrentTimezone();
|
const tz = getCurrentTimezone();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const tzDisplay = now.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop();
|
const tzDisplay = now.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timezone: tz,
|
timezone: tz,
|
||||||
abbreviation: tzDisplay,
|
abbreviation: tzDisplay,
|
||||||
offset: now.getTimezoneOffset()
|
offset: now.getTimezoneOffset()
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function formatDuration(ms) {
|
||||||
|
if (!ms || ms < 0) return '0m';
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours > 0) return `${hours}h ${mins}m`;
|
||||||
|
return `${mins}m`;
|
||||||
|
}
|
||||||
Generated
+13
-402
@@ -10,7 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"better-sqlite3": "^9.2.2",
|
"better-sqlite3": "^13.0.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
@@ -118,35 +118,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/base64-js": {
|
|
||||||
"version": "1.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
|
||||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "9.6.0",
|
"version": "13.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
||||||
"integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==",
|
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bindings": "^1.5.0",
|
"node-addon-api": "^8.0.0"
|
||||||
"prebuild-install": "^7.1.1"
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
@@ -162,26 +143,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bindings": {
|
|
||||||
"version": "1.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"file-uri-to-path": "1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bl": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"buffer": "^5.5.0",
|
|
||||||
"inherits": "^2.0.4",
|
|
||||||
"readable-stream": "^3.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.4",
|
"version": "1.20.4",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||||
@@ -230,30 +191,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/buffer": {
|
|
||||||
"version": "5.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
|
||||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "^1.3.1",
|
|
||||||
"ieee754": "^1.1.13"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/buffer-equal-constant-time": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
@@ -353,12 +290,6 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chownr": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
|
||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/cliui": {
|
"node_modules/cliui": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
@@ -516,30 +447,6 @@
|
|||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/decompress-response": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mimic-response": "^3.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/deep-extend": {
|
|
||||||
"version": "0.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
|
||||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -568,15 +475,6 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -634,15 +532,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/end-of-stream": {
|
|
||||||
"version": "1.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
|
||||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"once": "^1.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -713,15 +602,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expand-template": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
|
||||||
"license": "(MIT OR WTFPL)",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.1",
|
"version": "4.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
@@ -768,12 +648,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/file-uri-to-path": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -859,12 +733,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fs-constants": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -936,12 +804,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/github-from-package": {
|
|
||||||
"version": "0.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
|
||||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
@@ -1048,26 +910,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ieee754": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/ignore-by-default": {
|
"node_modules/ignore-by-default": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
||||||
@@ -1081,12 +923,6 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ini": {
|
|
||||||
"version": "1.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
|
||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -1319,18 +1155,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mimic-response": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@@ -1344,33 +1168,12 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimist": {
|
|
||||||
"version": "1.2.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mkdirp-classic": {
|
|
||||||
"version": "0.5.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
|
||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/napi-build-utils": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -1380,16 +1183,13 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-abi": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "3.85.0",
|
"version": "8.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz",
|
||||||
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
|
"integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"semver": "^7.3.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": "^18 || ^20 || >= 21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-cron": {
|
"node_modules/node-cron": {
|
||||||
@@ -1533,15 +1333,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/once": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"wrappy": "1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -1579,32 +1370,6 @@
|
|||||||
"node": ">=4.0.0"
|
"node": ">=4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prebuild-install": {
|
|
||||||
"version": "7.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
|
||||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.0",
|
|
||||||
"expand-template": "^2.0.3",
|
|
||||||
"github-from-package": "0.0.0",
|
|
||||||
"minimist": "^1.2.3",
|
|
||||||
"mkdirp-classic": "^0.5.3",
|
|
||||||
"napi-build-utils": "^2.0.0",
|
|
||||||
"node-abi": "^3.3.0",
|
|
||||||
"pump": "^3.0.0",
|
|
||||||
"rc": "^1.2.7",
|
|
||||||
"simple-get": "^4.0.0",
|
|
||||||
"tar-fs": "^2.0.0",
|
|
||||||
"tunnel-agent": "^0.6.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"prebuild-install": "bin.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -1631,16 +1396,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pump": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"end-of-stream": "^1.1.0",
|
|
||||||
"once": "^1.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||||
@@ -1680,35 +1435,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rc": {
|
|
||||||
"version": "1.2.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
|
||||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
|
||||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
|
||||||
"dependencies": {
|
|
||||||
"deep-extend": "^0.6.0",
|
|
||||||
"ini": "~1.3.0",
|
|
||||||
"minimist": "^1.2.0",
|
|
||||||
"strip-json-comments": "~2.0.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"rc": "cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readable-stream": {
|
|
||||||
"version": "3.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
|
||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"string_decoder": "^1.1.1",
|
|
||||||
"util-deprecate": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@@ -2005,51 +1731,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/simple-concat": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/simple-get": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"decompress-response": "^6.0.0",
|
|
||||||
"once": "^1.3.1",
|
|
||||||
"simple-concat": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/simple-update-notifier": {
|
"node_modules/simple-update-notifier": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||||
@@ -2078,15 +1759,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/string_decoder": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "~5.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -2115,15 +1787,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strip-json-comments": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "8.1.1",
|
"version": "8.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||||
@@ -2140,34 +1803,6 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar-fs": {
|
|
||||||
"version": "2.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
|
||||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chownr": "^1.1.1",
|
|
||||||
"mkdirp-classic": "^0.5.2",
|
|
||||||
"pump": "^3.0.0",
|
|
||||||
"tar-stream": "^2.1.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tar-stream": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bl": "^4.0.3",
|
|
||||||
"end-of-stream": "^1.4.1",
|
|
||||||
"fs-constants": "^1.0.0",
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"readable-stream": "^3.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -2217,18 +1852,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/tunnel-agent": {
|
|
||||||
"version": "0.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
|
||||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
@@ -2258,12 +1881,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/util-deprecate": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
@@ -2309,12 +1926,6 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wrappy": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.18.3",
|
"version": "8.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@
|
|||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||||
"server": "nodemon server/index.js",
|
"server": "nodemon server/index.js",
|
||||||
"client": "cd client && npm start",
|
"client": "cd client && PORT=3456 npm start",
|
||||||
"build": "cd client && npm run build",
|
"build": "cd client && npm run build",
|
||||||
"build:commit": "npm run build && git add -f client/build/ && git commit -m \"Auto-commit built React app\" && git push",
|
"build:commit": "npm run build && git add -f client/build/ && git commit -m \"Auto-commit built React app\" && git push",
|
||||||
"install-all": "npm install && cd client && npm install"
|
"install-all": "npm install && cd client && npm install"
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"better-sqlite3": "^9.2.2",
|
"better-sqlite3": "^13.0.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
|||||||
+69
-1
@@ -1,7 +1,11 @@
|
|||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
const db = new Database(path.join(__dirname, '../data/monitor.db'));
|
const dataDir = path.join(__dirname, '../data');
|
||||||
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
|
||||||
|
const db = new Database(path.join(dataDir, 'monitor.db'));
|
||||||
|
|
||||||
// Create tables
|
// Create tables
|
||||||
db.exec(`
|
db.exec(`
|
||||||
@@ -311,10 +315,74 @@ try {
|
|||||||
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('incident_failure_threshold', '10')").run();
|
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('incident_failure_threshold', '10')").run();
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
|
|
||||||
|
// Feature: HTTP method + request body support
|
||||||
|
try { db.prepare("ALTER TABLE resources ADD COLUMN http_method TEXT DEFAULT 'GET'").run(); } catch(e) {}
|
||||||
|
try { db.prepare("ALTER TABLE resources ADD COLUMN http_body TEXT").run(); } catch(e) {}
|
||||||
|
|
||||||
|
// Feature: alert escalation tracking
|
||||||
|
try { db.prepare("ALTER TABLE incidents ADD COLUMN escalated INTEGER DEFAULT 0").run(); } catch(e) {}
|
||||||
|
|
||||||
|
// Feature: heartbeat / cron-job monitoring
|
||||||
|
try { db.prepare("ALTER TABLE resources ADD COLUMN heartbeat_token TEXT").run(); } catch(e) {}
|
||||||
|
try { db.prepare("ALTER TABLE resources ADD COLUMN heartbeat_timeout INTEGER DEFAULT 300000").run(); } catch(e) {}
|
||||||
|
try { db.prepare("ALTER TABLE resources ADD COLUMN last_heartbeat_at DATETIME").run(); } catch(e) {}
|
||||||
|
|
||||||
|
// Feature: status page per-resource visibility
|
||||||
|
try { db.prepare("ALTER TABLE resources ADD COLUMN is_public INTEGER DEFAULT 1").run(); } catch(e) {}
|
||||||
|
|
||||||
|
// Feature: Linux agent monitoring
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
hostname TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
os_info TEXT,
|
||||||
|
last_seen_at DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_metrics (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id INTEGER NOT NULL,
|
||||||
|
cpu_percent REAL,
|
||||||
|
mem_total INTEGER,
|
||||||
|
mem_used INTEGER,
|
||||||
|
mem_percent REAL,
|
||||||
|
disk_data TEXT,
|
||||||
|
load_1 REAL,
|
||||||
|
load_5 REAL,
|
||||||
|
load_15 REAL,
|
||||||
|
uptime_seconds INTEGER,
|
||||||
|
process_count INTEGER,
|
||||||
|
net_bytes_sent INTEGER,
|
||||||
|
net_bytes_recv INTEGER,
|
||||||
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agents_token ON agents(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_metrics_agent_time ON agent_metrics(agent_id, recorded_at DESC);
|
||||||
|
`);
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
// Helpful indexes for time-range queries
|
// 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);
|
||||||
CREATE INDEX IF NOT EXISTS idx_incidents_resource_time ON incidents(resource_id, started_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_incidents_resource_time ON incidents(resource_id, started_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_resources_heartbeat_token ON resources(heartbeat_token);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// One-time migration: null out per-resource check_interval and timeout so all resources
|
||||||
|
// inherit from the global monitoring settings. Runs once, tracked by a settings flag.
|
||||||
|
try {
|
||||||
|
const alreadyMigrated = db.prepare("SELECT value FROM settings WHERE key = 'migrated_global_defaults_v1'").get();
|
||||||
|
if (!alreadyMigrated) {
|
||||||
|
db.prepare("UPDATE resources SET check_interval = NULL, timeout = NULL").run();
|
||||||
|
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('migrated_global_defaults_v1', 'true')").run();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
module.exports = db;
|
module.exports = db;
|
||||||
+822
-329
File diff suppressed because it is too large.
Load diff
+49
-10
@@ -98,12 +98,36 @@ class MonitorService {
|
|||||||
case 'icmp':
|
case 'icmp':
|
||||||
case 'ping':
|
case 'ping':
|
||||||
return this.checkIcmp(resource, startTime, base);
|
return this.checkIcmp(resource, startTime, base);
|
||||||
|
case 'heartbeat':
|
||||||
|
return this.checkHeartbeat(resource, startTime, base);
|
||||||
default:
|
default:
|
||||||
// Fallback to HTTP if unknown
|
// Fallback to HTTP if unknown
|
||||||
return this.checkHttp(resource, startTime, base);
|
return this.checkHttp(resource, startTime, base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkHeartbeat(resource, startTime, base) {
|
||||||
|
const now = Date.now();
|
||||||
|
const lastPing = resource.last_heartbeat_at
|
||||||
|
? new Date(resource.last_heartbeat_at).getTime()
|
||||||
|
: null;
|
||||||
|
const timeout = resource.heartbeat_timeout || 300000;
|
||||||
|
|
||||||
|
if (!lastPing) {
|
||||||
|
return { ...base, status: 'down', response_time: null, error_message: 'No heartbeat received yet' };
|
||||||
|
}
|
||||||
|
const elapsed = now - lastPing;
|
||||||
|
if (elapsed > timeout) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: 'down',
|
||||||
|
response_time: elapsed,
|
||||||
|
error_message: `Missed heartbeat — ${Math.round(elapsed / 1000)}s since last ping (timeout: ${timeout / 1000}s)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...base, status: 'up', response_time: elapsed };
|
||||||
|
}
|
||||||
|
|
||||||
async checkHttp(resource, startTime, base) {
|
async checkHttp(resource, startTime, base) {
|
||||||
let result = { ...base };
|
let result = { ...base };
|
||||||
const circuitBreaker = this.getResourceCircuitBreaker(resource.id);
|
const circuitBreaker = this.getResourceCircuitBreaker(resource.id);
|
||||||
@@ -121,15 +145,26 @@ class MonitorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isHttps = resource.url.startsWith('https');
|
const method = (resource.http_method || 'GET').toUpperCase();
|
||||||
const response = await axios.get(resource.url, {
|
const requestConfig = {
|
||||||
|
method,
|
||||||
|
url: resource.url,
|
||||||
timeout: resource.timeout,
|
timeout: resource.timeout,
|
||||||
validateStatus: () => true,
|
validateStatus: () => true,
|
||||||
maxRedirects: 5,
|
maxRedirects: 5,
|
||||||
headers,
|
headers,
|
||||||
httpAgent: this.httpAgent,
|
httpAgent: this.httpAgent,
|
||||||
httpsAgent: this.httpsAgent,
|
httpsAgent: this.httpsAgent,
|
||||||
});
|
};
|
||||||
|
if (resource.http_body && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||||
|
try {
|
||||||
|
requestConfig.data = JSON.parse(resource.http_body);
|
||||||
|
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
||||||
|
} catch {
|
||||||
|
requestConfig.data = resource.http_body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await axios(requestConfig);
|
||||||
const responseTime = Date.now() - startTime;
|
const responseTime = Date.now() - startTime;
|
||||||
const isUp = response.status >= 200 && response.status < 400;
|
const isUp = response.status >= 200 && response.status < 400;
|
||||||
|
|
||||||
@@ -353,9 +388,12 @@ class MonitorService {
|
|||||||
handleIncident(resourceId, isDown) {
|
handleIncident(resourceId, isDown) {
|
||||||
const resource = db.prepare('SELECT * FROM resources WHERE id = ?').get(resourceId);
|
const resource = db.prepare('SELECT * FROM resources WHERE id = ?').get(resourceId);
|
||||||
|
|
||||||
// Get incident failure threshold from settings (default 10)
|
// Per-resource threshold overrides global setting
|
||||||
const setting = db.prepare("SELECT value FROM settings WHERE key = 'incident_failure_threshold'").get();
|
const setting = db.prepare("SELECT value FROM settings WHERE key = 'incident_failure_threshold'").get();
|
||||||
const threshold = parseInt(setting?.value || '10');
|
const globalThreshold = parseInt(setting?.value || '10');
|
||||||
|
const threshold = (resource?.consecutive_failures_threshold > 0)
|
||||||
|
? resource.consecutive_failures_threshold
|
||||||
|
: globalThreshold;
|
||||||
|
|
||||||
const activeIncident = db.prepare(`
|
const activeIncident = db.prepare(`
|
||||||
SELECT * FROM incidents
|
SELECT * FROM incidents
|
||||||
@@ -371,8 +409,8 @@ class MonitorService {
|
|||||||
LIMIT ?
|
LIMIT ?
|
||||||
`).all(resourceId, threshold);
|
`).all(resourceId, threshold);
|
||||||
|
|
||||||
const consecutiveFailures = recentChecks.every(c => c.status === 'down') ? recentChecks.length :
|
const firstUpIdx = recentChecks.findIndex(c => c.status === 'up');
|
||||||
recentChecks.findIndex(c => c.status === 'up') + 1;
|
const consecutiveFailures = firstUpIdx === -1 ? recentChecks.length : firstUpIdx;
|
||||||
|
|
||||||
// Only trigger incident if we've hit the threshold
|
// Only trigger incident if we've hit the threshold
|
||||||
if (consecutiveFailures >= threshold && !activeIncident) {
|
if (consecutiveFailures >= threshold && !activeIncident) {
|
||||||
@@ -727,9 +765,10 @@ class MonitorService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return average of response times
|
// Use P95 so the baseline reflects realistic worst-case, not the average.
|
||||||
const sum = checks.reduce((acc, c) => acc + (c.response_time || 0), 0);
|
// Threshold is 2× P95, making it very unlikely to trigger on normal variance.
|
||||||
return Math.round(sum / checks.length);
|
const p95Index = Math.min(Math.floor(checks.length * 0.95), checks.length - 1);
|
||||||
|
return Math.round(checks[p95Index].response_time);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if response time is anomalous
|
// Check if response time is anomalous
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
const { exec } = require('child_process');
|
||||||
|
const net = require('net');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const COMMON_HTTP_PORTS = [80, 443, 8080, 8443, 3000, 3001, 5000, 8000, 8888, 9000, 9443];
|
||||||
|
const PROBE_TIMEOUT_MS = 800;
|
||||||
|
const PING_TIMEOUT_MS = 500;
|
||||||
|
const MAX_HOSTS_TO_SCAN = 50;
|
||||||
|
|
||||||
|
function getLocalSubnets() {
|
||||||
|
const ifaces = os.networkInterfaces();
|
||||||
|
const subnets = [];
|
||||||
|
for (const iface of Object.values(ifaces)) {
|
||||||
|
for (const addr of iface) {
|
||||||
|
if (addr.family === 'IPv4' && !addr.internal) {
|
||||||
|
subnets.push({ ip: addr.address, prefix: addr.cidr });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subnets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subnetToRange(cidr) {
|
||||||
|
try {
|
||||||
|
const [base, bits] = cidr.split('/');
|
||||||
|
const prefixLen = parseInt(bits);
|
||||||
|
if (prefixLen < 16 || prefixLen > 30) return [];
|
||||||
|
const parts = base.split('.').map(Number);
|
||||||
|
const hostBits = 32 - prefixLen;
|
||||||
|
const totalHosts = Math.min(Math.pow(2, hostBits) - 2, MAX_HOSTS_TO_SCAN);
|
||||||
|
const baseNum = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
|
||||||
|
const networkNum = baseNum & (~0 << hostBits);
|
||||||
|
const ips = [];
|
||||||
|
for (let i = 1; i <= totalHosts; i++) {
|
||||||
|
const n = networkNum + i;
|
||||||
|
ips.push(`${(n >> 24) & 255}.${(n >> 16) & 255}.${(n >> 8) & 255}.${n & 255}`);
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pingHost(ip) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const cmd = process.platform === 'darwin'
|
||||||
|
? `ping -c 1 -W ${PING_TIMEOUT_MS} ${ip}`
|
||||||
|
: `ping -c 1 -W 1 ${ip}`;
|
||||||
|
exec(cmd, { timeout: PING_TIMEOUT_MS + 500 }, (err) => resolve(!err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function probePort(ip, port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sock = new net.Socket();
|
||||||
|
let done = false;
|
||||||
|
sock.setTimeout(PROBE_TIMEOUT_MS);
|
||||||
|
sock.on('connect', () => { done = true; sock.destroy(); resolve(true); });
|
||||||
|
sock.on('timeout', () => { if (!done) { done = true; sock.destroy(); resolve(false); } });
|
||||||
|
sock.on('error', () => { if (!done) { done = true; resolve(false); } });
|
||||||
|
try { sock.connect(port, ip); } catch { resolve(false); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeHost(ip) {
|
||||||
|
const openPorts = [];
|
||||||
|
await Promise.all(
|
||||||
|
COMMON_HTTP_PORTS.map(async (port) => {
|
||||||
|
if (await probePort(ip, port)) openPorts.push(port);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return openPorts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferServiceType(ip, openPorts) {
|
||||||
|
if (openPorts.includes(443) || openPorts.includes(8443) || openPorts.includes(9443)) {
|
||||||
|
return { type: 'https', port: openPorts.find(p => [443, 8443, 9443].includes(p)), scheme: 'https' };
|
||||||
|
}
|
||||||
|
if (openPorts.length > 0) {
|
||||||
|
const p = openPorts[0];
|
||||||
|
return { type: 'http', port: p, scheme: 'http' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getArpHosts() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
exec('arp -a', { timeout: 3000 }, (err, stdout) => {
|
||||||
|
if (err) return resolve([]);
|
||||||
|
const hosts = [];
|
||||||
|
const lines = stdout.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/\((\d+\.\d+\.\d+\.\d+)\)/);
|
||||||
|
if (match && !line.includes('incomplete')) {
|
||||||
|
const name = line.match(/^(\S+)/)?.[1];
|
||||||
|
hosts.push({ ip: match[1], hostname: name && name !== '?' ? name : null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(hosts);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverNetwork(customSubnet = null) {
|
||||||
|
const arpHosts = await getArpHosts();
|
||||||
|
const arpIps = new Set(arpHosts.map(h => h.ip));
|
||||||
|
|
||||||
|
const allIps = new Map();
|
||||||
|
|
||||||
|
if (customSubnet) {
|
||||||
|
// Scan the user-specified subnet only (no ARP seeding, still probe all IPs in range)
|
||||||
|
const range = subnetToRange(customSubnet);
|
||||||
|
for (const ip of range) allIps.set(ip, { ip, hostname: null });
|
||||||
|
// Supplement with ARP hostnames for any IPs that happen to be in the ARP table
|
||||||
|
for (const h of arpHosts) {
|
||||||
|
if (allIps.has(h.ip) && h.hostname) allIps.get(h.ip).hostname = h.hostname;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Auto-detect: ARP table + all local subnets
|
||||||
|
for (const h of arpHosts) allIps.set(h.ip, { ip: h.ip, hostname: h.hostname });
|
||||||
|
for (const subnet of getLocalSubnets()) {
|
||||||
|
if (!subnet.prefix) continue;
|
||||||
|
const range = subnetToRange(subnet.prefix);
|
||||||
|
for (const ip of range) {
|
||||||
|
if (!allIps.has(ip)) allIps.set(ip, { ip, hostname: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping all IPs in parallel (skip ones already in ARP — they're reachable by definition)
|
||||||
|
const candidates = [...allIps.values()];
|
||||||
|
const pingResults = await Promise.all(
|
||||||
|
candidates.map(async (host) => {
|
||||||
|
const alive = arpIps.has(host.ip) || await pingHost(host.ip);
|
||||||
|
return { ...host, alive };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const alive = pingResults.filter(h => h.alive);
|
||||||
|
|
||||||
|
// Probe open HTTP ports on alive hosts
|
||||||
|
const results = await Promise.all(
|
||||||
|
alive.map(async (host) => {
|
||||||
|
const openPorts = await probeHost(host.ip);
|
||||||
|
const service = inferServiceType(host.ip, openPorts);
|
||||||
|
return {
|
||||||
|
ip: host.ip,
|
||||||
|
hostname: host.hostname,
|
||||||
|
openPorts,
|
||||||
|
service,
|
||||||
|
suggestedUrl: service ? `${service.scheme}://${host.ip}${service.port !== 80 && service.port !== 443 ? ':' + service.port : ''}` : null,
|
||||||
|
suggestedType: service ? service.type : 'icmp',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort: HTTP-accessible hosts first, then by IP
|
||||||
|
return results.sort((a, b) => {
|
||||||
|
const aHttp = a.openPorts.length > 0 ? 0 : 1;
|
||||||
|
const bHttp = b.openPorts.length > 0 ? 0 : 1;
|
||||||
|
if (aHttp !== bHttp) return aHttp - bHttp;
|
||||||
|
return a.ip.localeCompare(b.ip, undefined, { numeric: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { discoverNetwork, getLocalSubnets };
|
||||||
+266
-19
@@ -20,13 +20,18 @@ class NotificationService {
|
|||||||
email_to: process.env.EMAIL_TO,
|
email_to: process.env.EMAIL_TO,
|
||||||
webhook_enabled: process.env.WEBHOOK_ENABLED === 'true',
|
webhook_enabled: process.env.WEBHOOK_ENABLED === 'true',
|
||||||
webhook_url: process.env.WEBHOOK_URL,
|
webhook_url: process.env.WEBHOOK_URL,
|
||||||
|
webhook_template: process.env.WEBHOOK_TEMPLATE || '',
|
||||||
|
ntfy_enabled: process.env.NTFY_ENABLED === 'true',
|
||||||
|
ntfy_url: process.env.NTFY_URL || 'https://ntfy.sh',
|
||||||
|
ntfy_topic: process.env.NTFY_TOPIC || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setConfig(config) {
|
setConfig(config) {
|
||||||
this.emailEnabled = !!config.email_enabled;
|
this.emailEnabled = !!config.email_enabled;
|
||||||
this.webhookEnabled = !!config.webhook_enabled;
|
this.webhookEnabled = !!config.webhook_enabled;
|
||||||
this.config = config;
|
this.ntfyEnabled = !!config.ntfy_enabled;
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
|
||||||
// Recreate transporter when email config changes; skip if incomplete
|
// Recreate transporter when email config changes; skip if incomplete
|
||||||
if (
|
if (
|
||||||
@@ -69,10 +74,12 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isDown = incident.type === 'started';
|
const isDown = incident.type === 'started';
|
||||||
const statusEmoji = isDown ? '🔴' : '🟢';
|
const isSlow = incident.type === 'slow';
|
||||||
const statusText = isDown ? 'DOWN' : 'UP';
|
const statusEmoji = isDown ? '🔴' : isSlow ? '🟡' : '🟢';
|
||||||
|
const statusText = isDown ? 'DOWN' : isSlow ? 'SLOW' : 'UP';
|
||||||
const message = `${statusEmoji} ${resource.name} is ${statusText}!\n\nURL: ${resource.url}\nCheck Type: ${resource.type || 'http'}\nTime: ${new Date().toLocaleString()}`;
|
const slowDetail = isSlow ? `\nResponse Time: ${incident.responseTime}ms (threshold: ${incident.threshold}ms)` : '';
|
||||||
|
|
||||||
|
const message = `${statusEmoji} ${resource.name} is ${statusText}!\n\nURL: ${resource.url}\nCheck Type: ${resource.type || 'http'}\nTime: ${new Date().toLocaleString()}${slowDetail}`;
|
||||||
|
|
||||||
const promises = [];
|
const promises = [];
|
||||||
|
|
||||||
@@ -100,19 +107,25 @@ class NotificationService {
|
|||||||
promises.push(this.sendWebhook(resource, message, incident.type));
|
promises.push(this.sendWebhook(resource, message, incident.type));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.ntfyEnabled) {
|
||||||
|
promises.push(this.sendNtfy(resource, incident.type));
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.allSettled(promises);
|
await Promise.allSettled(promises);
|
||||||
|
|
||||||
// Save notification to in-app notification center
|
// Save notification to in-app notification center
|
||||||
const status = isDown ? 'down' : 'up';
|
const status = isDown ? 'down' : isSlow ? 'slow' : 'up';
|
||||||
this.saveNotification(resource, incident, status, message);
|
this.saveNotification(resource, incident, status, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveNotification(resource, incident, status, message) {
|
saveNotification(resource, incident, status, message) {
|
||||||
try {
|
try {
|
||||||
const db = require('./database');
|
const db = require('./database');
|
||||||
const title = status === 'down' ?
|
const title = status === 'down'
|
||||||
`🔴 ${resource.name} is DOWN` :
|
? `🔴 ${resource.name} is DOWN`
|
||||||
`🟢 ${resource.name} is UP`;
|
: status === 'slow'
|
||||||
|
? `🟡 ${resource.name} is SLOW`
|
||||||
|
: `🟢 ${resource.name} is UP`;
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO notifications (resource_id, incident_id, type, title, message, read)
|
INSERT INTO notifications (resource_id, incident_id, type, title, message, read)
|
||||||
@@ -144,6 +157,116 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async flushAlerts(pendingAlerts) {
|
||||||
|
if (pendingAlerts.length === 0) return;
|
||||||
|
if (pendingAlerts.length === 1) {
|
||||||
|
const { resource, incident, stats } = pendingAlerts[0];
|
||||||
|
await this.sendAlert(resource, incident, stats);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Multiple alerts in one check cycle — save individual in-app entries but send one grouped email/webhook
|
||||||
|
for (const { resource, incident } of pendingAlerts) {
|
||||||
|
const status = incident.type === 'started' ? 'down' : 'up';
|
||||||
|
const message = `${resource.name} is ${status.toUpperCase()}. URL: ${resource.url}`;
|
||||||
|
this.saveNotification(resource, incident, status, message);
|
||||||
|
}
|
||||||
|
await this.sendGroupedEmail(pendingAlerts);
|
||||||
|
if (this.webhookEnabled) {
|
||||||
|
await this.sendGroupedWebhook(pendingAlerts);
|
||||||
|
}
|
||||||
|
if (this.ntfyEnabled) {
|
||||||
|
const ntfyUrl = this.config.ntfy_url || 'https://ntfy.sh';
|
||||||
|
const ntfyTopic = this.config.ntfy_topic;
|
||||||
|
if (ntfyTopic) {
|
||||||
|
try {
|
||||||
|
await axios.post(`${ntfyUrl.replace(/\/$/, '')}/`, {
|
||||||
|
topic: ntfyTopic,
|
||||||
|
title: `⚠️ ${pendingAlerts.length} monitors changed status`,
|
||||||
|
message: pendingAlerts.map(({ resource, incident }) =>
|
||||||
|
`${incident.type === 'started' ? '🔴' : '🟢'} ${resource.name}`
|
||||||
|
).join('\n'),
|
||||||
|
priority: 4,
|
||||||
|
tags: ['warning'],
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendGroupedEmail(alerts) {
|
||||||
|
if (!this.transporter) return;
|
||||||
|
const targetEmail = this.config.email_to;
|
||||||
|
if (!targetEmail || !this.emailEnabled) return;
|
||||||
|
try {
|
||||||
|
const rows = alerts.map(({ resource, incident }) => {
|
||||||
|
const status = incident.type === 'started' ? 'DOWN' : 'UP';
|
||||||
|
const emoji = incident.type === 'started' ? '🔴' : '🟢';
|
||||||
|
return `<li>${emoji} <strong>${resource.name}</strong> is ${status} — ${resource.url}</li>`;
|
||||||
|
}).join('');
|
||||||
|
await this.transporter.sendMail({
|
||||||
|
from: this.config.email_from,
|
||||||
|
to: targetEmail,
|
||||||
|
subject: `⚠️ ${alerts.length} monitors changed status`,
|
||||||
|
text: alerts.map(({ resource, incident }) =>
|
||||||
|
`${resource.name} is ${incident.type === 'started' ? 'DOWN' : 'UP'} — ${resource.url}`
|
||||||
|
).join('\n'),
|
||||||
|
html: `<div style="font-family:Arial,sans-serif;max-width:600px">
|
||||||
|
<h2 style="color:#d32f2f">⚠️ ${alerts.length} Monitors Changed Status</h2>
|
||||||
|
<ul>${rows}</ul>
|
||||||
|
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
|
||||||
|
</div>`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Grouped email error handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendGroupedWebhook(alerts) {
|
||||||
|
const webhookUrl = this.config.webhook_url || process.env.WEBHOOK_URL;
|
||||||
|
if (!webhookUrl) return;
|
||||||
|
try {
|
||||||
|
await axios.post(webhookUrl, {
|
||||||
|
type: 'grouped_alert',
|
||||||
|
count: alerts.length,
|
||||||
|
resources: alerts.map(({ resource, incident }) => ({
|
||||||
|
name: resource.name,
|
||||||
|
url: resource.url,
|
||||||
|
status: incident.type === 'started' ? 'down' : 'up',
|
||||||
|
})),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Grouped webhook error handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendEscalationAlert(incident, fallbackWebhookUrl) {
|
||||||
|
try {
|
||||||
|
const durationMin = Math.round((Date.now() - new Date(incident.started_at).getTime()) / 60000);
|
||||||
|
const message = `⚠️ ESCALATION: ${incident.resource_name} has been DOWN for ${durationMin} minutes without resolution.`;
|
||||||
|
if (fallbackWebhookUrl) {
|
||||||
|
await axios.post(fallbackWebhookUrl, {
|
||||||
|
resource: incident.resource_name,
|
||||||
|
url: incident.resource_url,
|
||||||
|
status: 'escalated',
|
||||||
|
duration_minutes: durationMin,
|
||||||
|
incident_id: incident.id,
|
||||||
|
started_at: incident.started_at,
|
||||||
|
message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.saveNotification(
|
||||||
|
{ id: incident.resource_id, name: incident.resource_name, url: incident.resource_url },
|
||||||
|
{ id: incident.id },
|
||||||
|
'escalated',
|
||||||
|
message
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Escalation alert error handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isQuietHours(resource) {
|
isQuietHours(resource) {
|
||||||
if (!resource.quiet_hours_start || !resource.quiet_hours_end) {
|
if (!resource.quiet_hours_start || !resource.quiet_hours_end) {
|
||||||
return false;
|
return false;
|
||||||
@@ -179,12 +302,18 @@ class NotificationService {
|
|||||||
? `<div style="margin-bottom: 12px;"><img src="cid:${logoCid}" alt="SkyWatch" style="height: 40px; width: 40px; border-radius: 8px;" /></div>`
|
? `<div style="margin-bottom: 12px;"><img src="cid:${logoCid}" alt="SkyWatch" style="height: 40px; width: 40px; border-radius: 8px;" /></div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const isSlow = type === 'slow';
|
||||||
|
const isDown = type === 'started';
|
||||||
|
const headingColor = isDown ? '#d32f2f' : isSlow ? '#f57c00' : '#388e3c';
|
||||||
|
const headingText = isDown ? '🔴 Alert' : isSlow ? '🟡 Slow Response' : '🟢 Recovered';
|
||||||
|
const statusLabel = isDown ? 'DOWN' : isSlow ? 'SLOW' : 'UP';
|
||||||
|
|
||||||
let htmlContent = `
|
let htmlContent = `
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
${logoBlock}
|
${logoBlock}
|
||||||
<h2 style="color: ${type === 'started' ? '#d32f2f' : '#388e3c'};">${type === 'started' ? '🔴 Alert' : '🟢 Recovered'}</h2>
|
<h2 style="color: ${headingColor};">${headingText}</h2>
|
||||||
<p><strong>Resource:</strong> ${resource.name}</p>
|
<p><strong>Resource:</strong> ${resource.name}</p>
|
||||||
<p><strong>Status:</strong> ${type === 'started' ? 'DOWN' : 'UP'}</p>
|
<p><strong>Status:</strong> ${statusLabel}</p>
|
||||||
<p><strong>URL:</strong> <a href="${resource.url}">${resource.url}</a></p>
|
<p><strong>URL:</strong> <a href="${resource.url}">${resource.url}</a></p>
|
||||||
<p><strong>Check Type:</strong> ${resource.type || 'http'}</p>
|
<p><strong>Check Type:</strong> ${resource.type || 'http'}</p>
|
||||||
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
|
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
|
||||||
@@ -221,7 +350,7 @@ class NotificationService {
|
|||||||
await this.transporter.sendMail({
|
await this.transporter.sendMail({
|
||||||
from: this.config.email_from,
|
from: this.config.email_from,
|
||||||
to: emailTo,
|
to: emailTo,
|
||||||
subject: `Alert: ${resource.name} is ${type === 'started' ? 'DOWN' : 'UP'}`,
|
subject: `Alert: ${resource.name} is ${type === 'started' ? 'DOWN' : type === 'slow' ? 'SLOW' : 'UP'}`,
|
||||||
text: message,
|
text: message,
|
||||||
html: htmlContent,
|
html: htmlContent,
|
||||||
attachments: hasLogo
|
attachments: hasLogo
|
||||||
@@ -282,17 +411,135 @@ class NotificationService {
|
|||||||
|
|
||||||
async sendWebhook(resource, message, type) {
|
async sendWebhook(resource, message, type) {
|
||||||
try {
|
try {
|
||||||
await axios.post(this.config.webhook_url || process.env.WEBHOOK_URL, {
|
const webhookUrl = this.config.webhook_url || process.env.WEBHOOK_URL;
|
||||||
resource: resource.name,
|
const status = type === 'started' ? 'down' : type === 'slow' ? 'slow' : 'up';
|
||||||
url: resource.url,
|
const template = this.config.webhook_template;
|
||||||
status: type === 'started' ? 'down' : 'up',
|
|
||||||
message,
|
let payload;
|
||||||
timestamp: new Date().toISOString(),
|
if (template) {
|
||||||
});
|
const interpolated = template
|
||||||
|
.replace(/\{\{name\}\}/g, resource.name)
|
||||||
|
.replace(/\{\{status\}\}/g, status)
|
||||||
|
.replace(/\{\{url\}\}/g, resource.url)
|
||||||
|
.replace(/\{\{type\}\}/g, resource.type || 'http')
|
||||||
|
.replace(/\{\{message\}\}/g, message)
|
||||||
|
.replace(/\{\{timestamp\}\}/g, new Date().toISOString());
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(interpolated);
|
||||||
|
} catch {
|
||||||
|
// Template is plain text (e.g. Slack text field), wrap it
|
||||||
|
payload = { text: interpolated };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload = {
|
||||||
|
resource: resource.name,
|
||||||
|
url: resource.url,
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.post(webhookUrl, payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Webhook error handled silently
|
// Webhook error handled silently
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendAgentAlert(agent, type, message) {
|
||||||
|
const isOffline = type === 'offline';
|
||||||
|
const isOnline = type === 'online';
|
||||||
|
const emoji = isOffline ? '🔴' : isOnline ? '🟢' : '⚠️';
|
||||||
|
const title = isOffline ? `Agent Offline: ${agent.name}`
|
||||||
|
: isOnline ? `Agent Online: ${agent.name}`
|
||||||
|
: `Agent Alert: ${agent.name}`;
|
||||||
|
const notifType = isOffline ? 'down' : isOnline ? 'up' : 'warning';
|
||||||
|
|
||||||
|
// In-app notification
|
||||||
|
try {
|
||||||
|
const db = require('./database');
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO notifications (resource_id, incident_id, type, title, message)
|
||||||
|
VALUES (NULL, NULL, ?, ?, ?)
|
||||||
|
`).run(notifType, title, message);
|
||||||
|
if (global.broadcastNotification) {
|
||||||
|
global.broadcastNotification({
|
||||||
|
id: result.lastInsertRowid, resource_id: null, resource_name: agent.name,
|
||||||
|
incident_id: null, type: notifType, title, message, read: 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Email
|
||||||
|
if (this.emailEnabled && this.transporter && this.config.email_to) {
|
||||||
|
try {
|
||||||
|
await this.transporter.sendMail({
|
||||||
|
from: this.config.email_from,
|
||||||
|
to: this.config.email_to,
|
||||||
|
subject: `${emoji} SkyWatch Agent: ${title}`,
|
||||||
|
text: [
|
||||||
|
message,
|
||||||
|
'',
|
||||||
|
`Agent: ${agent.name}`,
|
||||||
|
`Hostname: ${agent.hostname || '—'}`,
|
||||||
|
`IP: ${agent.ip_address || '—'}`,
|
||||||
|
`OS: ${agent.os_info || '—'}`,
|
||||||
|
`Time: ${new Date().toLocaleString()}`,
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhook
|
||||||
|
if (this.webhookEnabled && this.config.webhook_url) {
|
||||||
|
try {
|
||||||
|
await axios.post(this.config.webhook_url, {
|
||||||
|
type: 'agent', event: type,
|
||||||
|
agent: { id: agent.id, name: agent.name, hostname: agent.hostname, ip_address: agent.ip_address },
|
||||||
|
title, message, timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ntfy
|
||||||
|
if (this.ntfyEnabled && this.config.ntfy_topic) {
|
||||||
|
const ntfyBase = (this.config.ntfy_url || 'https://ntfy.sh').replace(/\/$/, '');
|
||||||
|
try {
|
||||||
|
await axios.post(`${ntfyBase}/`, {
|
||||||
|
topic: this.config.ntfy_topic,
|
||||||
|
title: `${emoji} ${title}`,
|
||||||
|
message,
|
||||||
|
priority: isOffline ? 4 : isOnline ? 2 : 3,
|
||||||
|
tags: isOffline ? ['red_circle'] : isOnline ? ['green_circle'] : ['warning'],
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendNtfy(resource, type) {
|
||||||
|
const ntfyUrl = this.config.ntfy_url || 'https://ntfy.sh';
|
||||||
|
const ntfyTopic = this.config.ntfy_topic;
|
||||||
|
if (!ntfyTopic) return;
|
||||||
|
|
||||||
|
const isDown = type === 'started';
|
||||||
|
const isSlow = type === 'slow';
|
||||||
|
const title = isDown ? `🔴 ${resource.name} is DOWN` : isSlow ? `🟡 ${resource.name} is SLOW` : `🟢 ${resource.name} is UP`;
|
||||||
|
const priority = isDown ? 4 : isSlow ? 3 : 2;
|
||||||
|
const tags = isDown ? ['red_circle', 'warning'] : isSlow ? ['yellow_circle'] : ['green_circle', 'white_check_mark'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post(`${ntfyUrl.replace(/\/$/, '')}/`, {
|
||||||
|
topic: ntfyTopic,
|
||||||
|
title,
|
||||||
|
message: resource.url || resource.name,
|
||||||
|
priority,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// ntfy error handled silently
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new NotificationService();
|
module.exports = new NotificationService();
|
||||||
+251
-65
@@ -3,10 +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.slowAlertCooldown = new Map();
|
||||||
|
this.slowConsecutiveCount = new Map();
|
||||||
|
// Agent health tracking — keyed by agent id
|
||||||
|
this.agentOnlineState = new Map(); // id -> boolean
|
||||||
|
this.agentAlertState = new Map(); // id -> { cpu, mem, disk }
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
@@ -22,65 +33,156 @@ class Scheduler {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Archive old checks daily at 2 AM
|
// Agent health check every 2 minutes
|
||||||
|
cron.schedule('*/2 * * * *', async () => {
|
||||||
|
await this.checkAgents();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Archive old checks + update baselines + check escalations daily at 2 AM
|
||||||
cron.schedule('0 2 * * *', () => {
|
cron.schedule('0 2 * * *', () => {
|
||||||
this.archiveOldChecks();
|
this.archiveOldChecks();
|
||||||
|
this.updateBaselines();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escalation check runs every hour
|
||||||
|
cron.schedule('0 * * * *', async () => {
|
||||||
|
await this.checkEscalations();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also run archive on startup (with delay to let DB init)
|
// Also run archive on startup (with delay to let DB init)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.archiveOldChecks();
|
this.archiveOldChecks();
|
||||||
|
this.updateBaselines();
|
||||||
}, 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 {
|
||||||
// Get global retention setting
|
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();
|
|
||||||
|
|
||||||
const globalRetentionDays = retentionSetting ? parseInt(retentionSetting.value) : 30;
|
const globalRetentionDays = retentionSetting ? parseInt(retentionSetting.value) : 30;
|
||||||
|
|
||||||
// Get all resources with their retention settings
|
const resources = db.prepare(`SELECT id, retention_days FROM resources`).all();
|
||||||
const resources = db.prepare(`
|
|
||||||
SELECT id, retention_days FROM resources
|
|
||||||
`).all();
|
|
||||||
|
|
||||||
const insertArchived = db.prepare(`
|
const bulkArchive = db.prepare(`
|
||||||
INSERT INTO archived_checks (resource_id, status, response_time, status_code, error_message, details, checked_at)
|
INSERT INTO archived_checks (resource_id, status, response_time, status_code, error_message, details, checked_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
SELECT resource_id, status, response_time, status_code, error_message, details, checked_at
|
||||||
`);
|
FROM checks WHERE resource_id = ? AND checked_at < ?
|
||||||
|
|
||||||
const deleteOld = db.prepare(`
|
|
||||||
DELETE FROM checks WHERE id = ?
|
|
||||||
`);
|
`);
|
||||||
|
const bulkDelete = db.prepare(`DELETE FROM checks WHERE resource_id = ? AND checked_at < ?`);
|
||||||
|
|
||||||
const transaction = db.transaction(() => {
|
const transaction = db.transaction(() => {
|
||||||
resources.forEach(resource => {
|
resources.forEach(resource => {
|
||||||
// Use per-resource retention if set, otherwise use global
|
|
||||||
const retentionDays = resource.retention_days || globalRetentionDays;
|
const retentionDays = resource.retention_days || globalRetentionDays;
|
||||||
const cutoffDate = new Date();
|
const cutoffDate = new Date();
|
||||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||||
const cutoffString = cutoffDate.toISOString().replace('T', ' ').split('.')[0];
|
const cutoffString = cutoffDate.toISOString().replace('T', ' ').split('.')[0];
|
||||||
|
bulkArchive.run(resource.id, cutoffString);
|
||||||
// Get old checks for this resource
|
bulkDelete.run(resource.id, cutoffString);
|
||||||
const oldChecks = db.prepare(`
|
|
||||||
SELECT * FROM checks WHERE resource_id = ? AND checked_at < ?
|
|
||||||
`).all(resource.id, cutoffString);
|
|
||||||
|
|
||||||
oldChecks.forEach(check => {
|
|
||||||
insertArchived.run(
|
|
||||||
check.resource_id,
|
|
||||||
check.status,
|
|
||||||
check.response_time,
|
|
||||||
check.status_code,
|
|
||||||
check.error_message,
|
|
||||||
check.details,
|
|
||||||
check.checked_at
|
|
||||||
);
|
|
||||||
deleteOld.run(check.id);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,20 +192,82 @@ class Scheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateBaselines() {
|
||||||
|
try {
|
||||||
|
const resources = db.prepare(`SELECT id, type FROM resources WHERE type != 'heartbeat'`).all();
|
||||||
|
for (const resource of resources) {
|
||||||
|
const baseline = monitorService.calculateResponseTimeBaseline(resource.id, 7);
|
||||||
|
if (baseline !== null) {
|
||||||
|
db.prepare(`UPDATE resources SET response_time_baseline = ? WHERE id = ?`).run(baseline, resource.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Baseline update error handled silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkEscalations() {
|
||||||
|
try {
|
||||||
|
const escalationSetting = db.prepare(`SELECT value FROM settings WHERE key = 'escalation_hours'`).get();
|
||||||
|
const escalationHours = parseInt(escalationSetting?.value || '4');
|
||||||
|
const fallbackSetting = db.prepare(`SELECT value FROM settings WHERE key = 'fallback_webhook'`).get();
|
||||||
|
const fallbackWebhook = fallbackSetting?.value || process.env.FALLBACK_WEBHOOK || '';
|
||||||
|
|
||||||
|
if (!fallbackWebhook) return;
|
||||||
|
|
||||||
|
const cutoff = new Date(Date.now() - escalationHours * 60 * 60 * 1000)
|
||||||
|
.toISOString().replace('T', ' ').split('.')[0];
|
||||||
|
|
||||||
|
const stalled = db.prepare(`
|
||||||
|
SELECT i.*, r.name AS resource_name, r.url AS resource_url, r.type AS resource_type,
|
||||||
|
r.maintenance_mode
|
||||||
|
FROM incidents i
|
||||||
|
JOIN resources r ON r.id = i.resource_id
|
||||||
|
WHERE i.resolved_at IS NULL AND i.escalated = 0 AND i.started_at < ?
|
||||||
|
AND r.maintenance_mode = 0
|
||||||
|
`).all(cutoff);
|
||||||
|
|
||||||
|
for (const incident of stalled) {
|
||||||
|
await notificationService.sendEscalationAlert(incident, fallbackWebhook);
|
||||||
|
db.prepare(`UPDATE incidents SET escalated = 1 WHERE id = ?`).run(incident.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Escalation check error handled silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async runChecks() {
|
async runChecks() {
|
||||||
const resources = db.prepare(`
|
const resources = db.prepare(`SELECT * FROM resources WHERE enabled = 1`).all();
|
||||||
SELECT * FROM resources WHERE enabled = 1
|
|
||||||
`).all();
|
// Read global defaults once per cycle so per-resource nulls inherit correctly
|
||||||
|
const globalIntervalRow = db.prepare(`SELECT value FROM settings WHERE key = 'check_interval'`).get();
|
||||||
|
const globalTimeoutRow = db.prepare(`SELECT value FROM settings WHERE key = 'timeout'`).get();
|
||||||
|
const globalCheckInterval = parseInt(globalIntervalRow?.value) || 60000;
|
||||||
|
const globalTimeout = parseInt(globalTimeoutRow?.value) || 5000;
|
||||||
|
|
||||||
// Stagger checks to avoid thundering herd
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
const jitter = (maxMs = 1500) => Math.floor(Math.random() * maxMs);
|
const jitter = (maxMs = 1500) => Math.floor(Math.random() * maxMs);
|
||||||
|
const baseSpacingMs = 250;
|
||||||
|
|
||||||
|
// Collect incident alerts to flush as a group after all checks complete
|
||||||
|
const pendingAlerts = [];
|
||||||
|
|
||||||
const baseSpacingMs = 250; // spread starts in 250ms increments
|
|
||||||
const tasks = resources.map((resource, index) => (async () => {
|
const tasks = resources.map((resource, index) => (async () => {
|
||||||
|
// Honour per-resource check_interval, falling back to global setting
|
||||||
|
const now = Date.now();
|
||||||
|
const lastChecked = this.lastCheckedAt.get(resource.id) || 0;
|
||||||
|
const interval = resource.check_interval || globalCheckInterval;
|
||||||
|
if (now - lastChecked < interval) return;
|
||||||
|
this.lastCheckedAt.set(resource.id, now);
|
||||||
|
|
||||||
|
// Resolve effective timeout: per-resource override or global default
|
||||||
|
const effectiveResource = resource.timeout
|
||||||
|
? resource
|
||||||
|
: { ...resource, timeout: globalTimeout };
|
||||||
|
|
||||||
await sleep(index * baseSpacingMs + jitter());
|
await sleep(index * baseSpacingMs + jitter());
|
||||||
try {
|
try {
|
||||||
const result = await monitorService.checkResource(resource);
|
const result = await monitorService.checkResource(effectiveResource);
|
||||||
monitorService.saveCheck(result);
|
monitorService.saveCheck(result);
|
||||||
|
|
||||||
// Rolling cap per resource (configurable via settings; default 10000)
|
// Rolling cap per resource (configurable via settings; default 10000)
|
||||||
@@ -111,41 +275,61 @@ class Scheduler {
|
|||||||
const capSetting = db.prepare(`SELECT value FROM settings WHERE key = 'checks_cap_per_resource'`).get();
|
const capSetting = db.prepare(`SELECT value FROM settings WHERE key = 'checks_cap_per_resource'`).get();
|
||||||
const cap = capSetting ? parseInt(capSetting.value) : 10000;
|
const cap = capSetting ? parseInt(capSetting.value) : 10000;
|
||||||
if (Number.isFinite(cap) && cap > 0) {
|
if (Number.isFinite(cap) && cap > 0) {
|
||||||
// Delete oldest rows beyond cap, using id ordering per resource
|
const rowCount = db.prepare('SELECT COUNT(*) as cnt FROM checks WHERE resource_id = ?').get(resource.id).cnt;
|
||||||
db.prepare(`
|
const excess = rowCount - cap;
|
||||||
DELETE FROM checks
|
if (excess > 0) {
|
||||||
WHERE id IN (
|
db.prepare(`
|
||||||
SELECT id FROM checks
|
DELETE FROM checks WHERE id IN (
|
||||||
WHERE resource_id = ?
|
SELECT id FROM checks WHERE resource_id = ? ORDER BY id ASC LIMIT ?
|
||||||
ORDER BY id ASC
|
|
||||||
LIMIT (
|
|
||||||
SELECT MAX(COUNT(*) - ?, 0)
|
|
||||||
FROM checks WHERE resource_id = ?
|
|
||||||
)
|
)
|
||||||
)
|
`).run(resource.id, excess);
|
||||||
`).run(resource.id, cap, resource.id);
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Non-fatal
|
// Non-fatal
|
||||||
}
|
}
|
||||||
|
|
||||||
const incident = monitorService.handleIncident(
|
const incident = monitorService.handleIncident(resource.id, result.status === 'down');
|
||||||
resource.id,
|
|
||||||
result.status === 'down'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (incident.type !== 'none') {
|
if (incident.type !== 'none' && !resource.maintenance_mode) {
|
||||||
const stats = monitorService.getResourceStats(resource.id, 24);
|
const stats = monitorService.getResourceStats(resource.id, 24);
|
||||||
const recentChecks = db.prepare(`
|
const recentChecks = db.prepare(`
|
||||||
SELECT response_time, status FROM checks
|
SELECT response_time, status FROM checks
|
||||||
WHERE resource_id = ?
|
WHERE resource_id = ?
|
||||||
ORDER BY checked_at DESC
|
ORDER BY checked_at DESC LIMIT 12
|
||||||
LIMIT 12
|
|
||||||
`).all(resource.id);
|
`).all(resource.id);
|
||||||
stats.recentChecks = recentChecks.reverse();
|
stats.recentChecks = recentChecks.reverse();
|
||||||
if (!resource.maintenance_mode) {
|
pendingAlerts.push({ resource, incident, stats });
|
||||||
await notificationService.sendAlert(resource, incident, stats);
|
}
|
||||||
|
|
||||||
|
// Slow alert: prefer explicit threshold, fall back to 2× P95 baseline
|
||||||
|
const effectiveThreshold = resource.response_time_threshold ||
|
||||||
|
(resource.response_time_baseline ? resource.response_time_baseline * 2 : null);
|
||||||
|
|
||||||
|
if (result.status === 'up' && effectiveThreshold && !resource.maintenance_mode) {
|
||||||
|
if (result.response_time > effectiveThreshold) {
|
||||||
|
const consecutive = (this.slowConsecutiveCount.get(resource.id) || 0) + 1;
|
||||||
|
this.slowConsecutiveCount.set(resource.id, consecutive);
|
||||||
|
const slowAlertConsecutiveSetting = db.prepare(`SELECT value FROM settings WHERE key = 'slow_alert_consecutive'`).get();
|
||||||
|
const slowAlertConsecutive = parseInt(slowAlertConsecutiveSetting?.value) || 3;
|
||||||
|
if (consecutive >= slowAlertConsecutive) {
|
||||||
|
const lastSlowAlert = this.slowAlertCooldown.get(resource.id) || 0;
|
||||||
|
if (now - lastSlowAlert > 60 * 60 * 1000) {
|
||||||
|
this.slowAlertCooldown.set(resource.id, now);
|
||||||
|
await notificationService.sendAlert(resource, {
|
||||||
|
type: 'slow',
|
||||||
|
responseTime: result.response_time,
|
||||||
|
threshold: effectiveThreshold,
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset consecutive counter when response time is back to normal
|
||||||
|
this.slowConsecutiveCount.set(resource.id, 0);
|
||||||
}
|
}
|
||||||
|
} else if (!effectiveThreshold) {
|
||||||
|
this.slowConsecutiveCount.set(resource.id, 0);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Check error handled silently
|
// Check error handled silently
|
||||||
@@ -154,6 +338,9 @@ class Scheduler {
|
|||||||
|
|
||||||
await Promise.allSettled(tasks);
|
await Promise.allSettled(tasks);
|
||||||
|
|
||||||
|
// Flush collected alerts — grouped if multiple fired in the same cycle
|
||||||
|
await notificationService.flushAlerts(pendingAlerts);
|
||||||
|
|
||||||
// Broadcast updated dashboard to all connected WebSocket clients
|
// Broadcast updated dashboard to all connected WebSocket clients
|
||||||
if (global.broadcastDashboardUpdate) {
|
if (global.broadcastDashboardUpdate) {
|
||||||
global.broadcastDashboardUpdate();
|
global.broadcastDashboardUpdate();
|
||||||
@@ -161,7 +348,6 @@ class Scheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
// Stop the scheduler (cron tasks will continue running but we flag isRunning)
|
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { test, describe } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// validateResourceBody (inline copy — avoids spinning up Express)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function validateResourceBody(body, requireUrl = true) {
|
||||||
|
const { name, url, type, check_interval, timeout, sla_target, retention_days } = body;
|
||||||
|
const isHeartbeat = (type || 'http') === 'heartbeat';
|
||||||
|
if (!name) return 'Name is required';
|
||||||
|
if (!isHeartbeat && requireUrl && !url) return 'URL is required';
|
||||||
|
if (!isHeartbeat && url) {
|
||||||
|
try { new URL(url); } catch (e) { return 'Invalid URL format'; }
|
||||||
|
}
|
||||||
|
if (check_interval && (isNaN(check_interval) || check_interval < 10000)) return 'Check interval must be at least 10000ms';
|
||||||
|
if (timeout && (isNaN(timeout) || timeout < 1000)) return 'Timeout must be at least 1000ms';
|
||||||
|
if (sla_target !== undefined && sla_target !== null && (isNaN(sla_target) || sla_target < 0 || sla_target > 100)) return 'SLA target must be between 0 and 100';
|
||||||
|
if (retention_days && (isNaN(retention_days) || retention_days < 1 || retention_days > 365)) return 'Retention days must be between 1 and 365';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// formatDuration (inline copy)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function formatDuration(ms) {
|
||||||
|
if (!ms || ms < 0) return '0m';
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours > 0) return `${hours}h ${mins}m`;
|
||||||
|
return `${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('validateResourceBody', () => {
|
||||||
|
test('rejects missing name', () => {
|
||||||
|
const result = validateResourceBody({ url: 'https://example.com' });
|
||||||
|
assert.equal(result, 'Name is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing URL for non-heartbeat', () => {
|
||||||
|
const result = validateResourceBody({ name: 'Test' });
|
||||||
|
assert.equal(result, 'URL is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid URL', () => {
|
||||||
|
const result = validateResourceBody({ name: 'Test', url: 'not-a-url' });
|
||||||
|
assert.equal(result, 'Invalid URL format');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts valid http resource', () => {
|
||||||
|
const result = validateResourceBody({ name: 'Test', url: 'https://example.com' });
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts heartbeat without URL', () => {
|
||||||
|
const result = validateResourceBody({ name: 'My Job', type: 'heartbeat' });
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects check_interval below 10000', () => {
|
||||||
|
const result = validateResourceBody({ name: 'Test', url: 'https://example.com', check_interval: 5000 });
|
||||||
|
assert.equal(result, 'Check interval must be at least 10000ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects timeout below 1000', () => {
|
||||||
|
const result = validateResourceBody({ name: 'Test', url: 'https://example.com', timeout: 500 });
|
||||||
|
assert.equal(result, 'Timeout must be at least 1000ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects sla_target above 100', () => {
|
||||||
|
const result = validateResourceBody({ name: 'Test', url: 'https://example.com', sla_target: 101 });
|
||||||
|
assert.equal(result, 'SLA target must be between 0 and 100');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects retention_days above 365', () => {
|
||||||
|
const result = validateResourceBody({ name: 'Test', url: 'https://example.com', retention_days: 400 });
|
||||||
|
assert.equal(result, 'Retention days must be between 1 and 365');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDuration', () => {
|
||||||
|
test('returns 0m for null input', () => {
|
||||||
|
assert.equal(formatDuration(null), '0m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 0m for negative input', () => {
|
||||||
|
assert.equal(formatDuration(-100), '0m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats minutes only', () => {
|
||||||
|
assert.equal(formatDuration(5 * 60000), '5m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats hours and minutes', () => {
|
||||||
|
assert.equal(formatDuration(90 * 60000), '1h 30m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats exactly 1 hour', () => {
|
||||||
|
assert.equal(formatDuration(60 * 60000), '1h 0m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats 0 minutes as 0m', () => {
|
||||||
|
assert.equal(formatDuration(0), '0m');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rate limiter logic', () => {
|
||||||
|
// Inline the rate-limit counter logic
|
||||||
|
function makeRateLimiter(maxReqs, windowMs = 60000) {
|
||||||
|
const map = new Map();
|
||||||
|
return (ip, path) => {
|
||||||
|
const key = `${path}:${ip}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const e = map.get(key);
|
||||||
|
if (!e || now - e.start > windowMs) { map.set(key, { start: now, count: 1 }); return true; }
|
||||||
|
e.count++;
|
||||||
|
return e.count <= maxReqs;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('allows requests within limit', () => {
|
||||||
|
const check = makeRateLimiter(3);
|
||||||
|
assert.ok(check('127.0.0.1', '/api/heartbeat/tok'));
|
||||||
|
assert.ok(check('127.0.0.1', '/api/heartbeat/tok'));
|
||||||
|
assert.ok(check('127.0.0.1', '/api/heartbeat/tok'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks requests exceeding limit', () => {
|
||||||
|
const check = makeRateLimiter(2);
|
||||||
|
check('127.0.0.1', '/test');
|
||||||
|
check('127.0.0.1', '/test');
|
||||||
|
const allowed = check('127.0.0.1', '/test');
|
||||||
|
assert.equal(allowed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different IPs are tracked separately', () => {
|
||||||
|
const check = makeRateLimiter(1);
|
||||||
|
assert.ok(check('1.1.1.1', '/test'));
|
||||||
|
assert.ok(check('2.2.2.2', '/test'));
|
||||||
|
});
|
||||||
|
});
|
||||||
+15
-16
@@ -1,33 +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 "📦 Installing dependencies..."
|
echo "==> Installing/updating server dependencies ..."
|
||||||
rm -rf node_modules package-lock.json
|
npm install --omit=dev 2>&1 | tail -20
|
||||||
npm ci --only=production 2>&1 | tail -20
|
|
||||||
|
|
||||||
echo "🔄 Restarting application..."
|
echo "==> Restarting application via PM2 ..."
|
||||||
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