Author SHA1 Message Date
nmemmertandClaude Sonnet 4.6 696daa22fa Fix package.json/lockfile mismatch for better-sqlite3
Docker Build and Push / build (push) Successful in 9m53s
package.json pinned 9.6.0 but package-lock.json had 13.0.3,
causing npm ci to fail immediately. Updated to ^13.0.3 to match
what is already resolved in the lockfile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 15:16:13 -04:00
nmemmertandClaude Sonnet 4.6 9c5468fe7e Switch final stage to node:20-slim for better-sqlite3 compatibility
Docker Build and Push / build (push) Failing after 6m41s
Alpine's musl libc causes repeated npm ci failures for better-sqlite3's
native module compilation. node:20-slim (Debian) uses glibc and compiles
reliably. Client builder stage stays Alpine since it has no native deps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 15:06:32 -04:00
nmemmertandClaude Sonnet 4.6 559a47c092 Use build-base + linux-headers for better-sqlite3 on Alpine
Docker Build and Push / build (push) Failing after 2m31s
g++ alone is missing musl-dev and binutils; build-base is Alpine's
equivalent of build-essential and covers the full GCC toolchain
needed by node-gyp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 15:02:32 -04:00
nmemmertandClaude Sonnet 4.6 d96d923054 Fix server npm ci: add native build tools for better-sqlite3
Docker Build and Push / build (push) Failing after 3m39s
better-sqlite3 requires python3/make/g++ to compile on Alpine.
Also switches deprecated --only=production to --omit=dev.
Build tools are removed after install to keep image size down.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 14:54:01 -04:00
nmemmertandClaude Sonnet 4.6 e7efe3c80d Fix npm ci failure for react-scripts 5 + React 19 peer deps
Docker Build and Push / build (push) Failing after 3m3s
react-scripts@5.0.1 declares peer deps against React 17/18; adding
--legacy-peer-deps lets npm ci succeed without changing the lockfile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 14:48:18 -04:00
nmemmertandClaude Sonnet 4.6 a0af4e7435 Add Gitea Actions workflow for Docker build and push
Docker Build and Push / build (push) Failing after 2m22s
Moves .github/workflows to .gitea/workflows, updates registry to
git.necloud.us, switches secrets to REGISTRY_USER/REGISTRY_TOKEN,
and replaces github. context refs with gitea. equivalents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 13:30:52 -04:00
nmemmertandClaude Sonnet 4.6 ce4bea5e29 Add agent command execution (terminal panel)
- DB: new agent_commands table (id, agent_id, command, status, output, exit_code, timestamps)
- Server: 4 new routes
    POST /api/agents/:id/commands        queue a command (dashboard → agent)
    GET  /api/agents/:id/commands        history (last 50) for dashboard
    GET  /api/agents/commands/pending    agent dequeues + marks running (Bearer auth)
    POST /api/agents/commands/:id/result agent posts output/exit_code (Bearer auth)
  Output capped at 128 KB; Bearer token enforced on agent-facing routes
- Agents (Linux/macOS/Windows): each polls /commands/pending each cycle,
  executes via bash/PowerShell with 60s timeout, posts result back
- UI: ">_" button on each AgentCard opens CommandTerminal panel
    Input line, Run button, history list (status icon + command + time)
    Click any entry with output to expand/collapse its output
    Auto-refreshes every 4 s while open

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-11 17:05:20 -04:00
nmemmertandClaude Sonnet 4.6 13b3e3612b Add macOS and Windows agents + multi-platform install UI
- agent/skywatch-agent-macos.sh: Full macOS bash agent (launchd)
  - CPU via top -l 2, memory via vm_stat/sysctl, disk via df -P
  - Network via netstat -ib, uptime via kern.boottime, OS via sw_vers
- agent/install-agent-macos.sh: Installs macOS agent as launchd daemon
- agent/skywatch-agent-windows.ps1: PowerShell agent (Task Scheduler)
  - CPU via Win32_PerfRawData two-sample delta, memory via Win32_OperatingSystem
  - Disk via Get-PSDrive, network via Get-NetAdapterStatistics
- agent/install-agent-windows.ps1: Installs Windows agent via Task Scheduler
- server/index.js: New routes for macOS and Windows agent scripts/installers
- client/src/Agents.js: Multi-platform install panel with Linux/macOS/Windows tabs
- client/src/Agents.css: Platform tab button styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-11 16:54:50 -04:00
nmemmertandClaude Sonnet 4.6 784e539af3 Fix update script: remove server-side client build
Server can't handle the React build. Client is built locally and
committed to git; update-server.sh just pulls and restarts PM2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-11 15:09:48 -04:00
nmemmertandClaude Sonnet 4.6 65e97f2dae Add agent notifications + fix update script
- scheduler.js: check agents every 2 min — alert on offline/online
  transitions and CPU/memory/disk threshold breaches (90%)
- notificationService.js: sendAgentAlert() sends via email, webhook,
  ntfy, and saves to in-app notifications table
- package.json: pin better-sqlite3 to 9.6.0 (compatible with Node 20)
- update-server.sh: add React client build step; use npm install
  instead of rm -rf + npm ci for faster incremental updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-11 14:42:24 -04:00
nmemmertandClaude Sonnet 4.6 020a8a8ea7 Add Agents UI page + installer script endpoint
- client/src/Agents.js: page showing registered Linux agents with live
  status, CPU/memory/disk gauge bars, load/uptime/network stats, and
  an install-instructions panel with copyable curl commands
- client/src/Agents.css: dark-theme card grid styles matching the app
- client/src/App.js: adds /agents route and Agents nav link
- server/index.js: adds GET /api/agents/installer to serve install-agent.sh
- client/build: production build updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-11 10:05:24 -04:00
nmemmertandClaude Sonnet 4.6 06e8e64a0b Add Linux agent: metrics collection + server API + installer
- agent/skywatch-agent.sh: bash daemon that collects CPU, memory, disk,
  network, load, and uptime, then POSTs to /api/agents/report every 60 s
- agent/install-agent.sh: systemd installer; registers agent on first run
- server/database.js: adds `agents` and `agent_metrics` tables with indexes
- server/index.js: adds agent API routes (register, report, list, history,
  delete, script/installer download); optional AGENT_REGISTRATION_KEY guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-11 10:01:45 -04:00
nmemmertandClaude Sonnet 4.6 0f93d16874 Add build-tool install step to update script
Ensures gcc/g++/python3 are present before npm install so
better-sqlite3 can compile its native binding on a fresh Ubuntu server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 18:19:20 -04:00
nmemmertandClaude Sonnet 4.6 f119168399 Full health check: fix startup crash, add subnet input to network discovery
Server fixes:
- database.js: auto-create data/ directory on startup — was crashing on
  fresh clone because the gitignored directory didn't exist
- better-sqlite3 upgraded to latest (13.x) for Node 26 compatibility

Network discovery:
- discoverNetwork() accepts optional subnet param; scans that range instead
  of auto-detecting when provided
- New GET /api/network-subnets endpoint returns detected local interfaces
  so UI can offer quick-select buttons
- Discovery modal: subnet text input + Scan button + auto-detect quick-select;
  local subnet buttons populate once the modal is open
- server/index.js: pass subnet from request body to discoverNetwork()

Infra:
- React client runs on PORT=3456 in dev to avoid port conflicts
- .claude/launch.json added for one-command preview via Express (serves
  the pre-built client, no port conflict with the preview tool)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 17:21:34 -04:00
nmemmertandClaude Sonnet 4.6 59078362da Fix per-resource settings: tags saving, is_public bug, global setting inheritance
Bug fixes:
- tags field was missing from INSERT/UPDATE SQL — tags never persisted
- is_public: `0 !== false` is true in JS, so private resources (is_public=0)
  were silently flipped to public on every edit; fixed with explicit 0/false check

Global setting inheritance:
- One-time DB migration nulls out check_interval and timeout on all existing
  resources so they inherit from global monitoring settings going forward
- Scheduler reads global check_interval and timeout from DB each cycle and
  uses them as the fallback when per-resource values are null
- Create and edit modals: empty field = use global; placeholder shows the
  current global value so it's clear what will be used

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 17:01:03 -04:00
nmemmertandClaude Sonnet 4.6 3a3d32ecac Add slow_alert_consecutive setting to control slow alert sensitivity
Exposes the consecutive-slow-checks requirement as a configurable setting
instead of hardcoding 3. Saved under key 'slow_alert_consecutive' in the
DB, readable via SLOW_ALERT_CONSECUTIVE env var. UI appears in Settings →
Monitoring alongside the existing Consecutive Failures Threshold field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 16:53:06 -04:00
nmemmertandClaude Sonnet 4.6 dd85a79d55 Fix false-positive slow alerts: require 3 consecutive checks + use P95 baseline
Previously a single check above 2× average response time fired an alert.
This caused false positives from normal network variance.

- scheduler.js: track consecutive slow checks per resource; only alert after
  3 in a row (resets to 0 when response time normalises)
- monitorService.js: calculateResponseTimeBaseline now returns P95 instead
  of mean, making 2× baseline a much more realistic slow threshold

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 16:50:57 -04:00
nmemmertandClaude Sonnet 4.6 98e0949d08 Fix timezone consistency: apply server timezone everywhere in the app
- Extract getServerTimezone() helper in timeUtils.js; apply it in
  formatChartTime and formatLocalTimeOnly (previously ignored TZ)
- Status.js: remove local formatLocalTime (was bare toLocaleString),
  import the shared util that reads server timezone
- SettingsWizard: immediately update localStorage when timezone is saved
  so new value takes effect without waiting for cache to expire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 16:48:12 -04:00
nmemmertandClaude Sonnet 4.6 5e98e9aa05 Fix settings: only persist keys present in the request body
The POST /api/settings handler previously wrote ALL settings on every
save — so saving just the Email section would overwrite timezone,
check interval, ntfy topic, etc. with defaults.

Now:
- DB: only UPSERT the keys that are in this specific request body
- process.env: only update env vars for the keys in this request
- .env file: read → patch touched keys → write (not a full rewrite)

Section-scoped saves from the UI no longer clobber unrelated settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 16:40:29 -04:00
nmemmertandClaude Sonnet 4.6 279cba5115 Fix ntfy: use JSON body instead of headers to support emoji titles
Sending emoji in HTTP headers is not valid ASCII and causes errors.
Switching to ntfy's JSON body API (POST to /topic with {topic, title,
message, priority, tags}) allows full UTF-8 including emoji.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 16:31:44 -04:00
nmemmertandClaude Sonnet 4.6 d46a37a219 Fix ntfy: URL-encode Title header to avoid non-ASCII rejection
HTTP headers must be ASCII-only. Emoji in the Title header caused
"Invalid character in header content" errors. ntfy.sh accepts
URL-encoded header values, so encodeURIComponent() fixes it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 16:26:31 -04:00
nmemmertandClaude Sonnet 4.6 8d50d3cb61 Fix bugs, UX rough edges, add ntfy.sh + network discovery
Bugs fixed:
- Status page: autoRefresh checkbox now actually pauses the interval
- Dashboard edit modal: hide URL field for heartbeat resources (was always shown as required)
- Dashboard: apply items_per_page, default_sort, and refresh_interval from settings on mount
- Dashboard: call loadResources() immediately on mount (was waiting 15s for fallback)
- Observability: listen for WS 'metrics' messages so tab updates live, not just on poll

UX improvements:
- Dashboard: replaced window.confirm() with inline Confirm/Cancel buttons for single and bulk delete
- Dashboard: skeleton rows shown while initial data loads (no more flash of empty state)
- Notifications: WebSocket moved from window global to useRef; exponential backoff on reconnect
- Notifications: replaced window.confirm() with inline Confirm/Cancel for Clear All
- ResourceDetail: initial API calls run in parallel via Promise.all

New features:
- ntfy.sh push notification channel: enable in Settings, enter topic, get mobile push alerts
- Network Discovery: Actions → Discover Network scans LAN for reachable hosts, probes HTTP ports, and lets you add any found host to monitoring in one click

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 13:24:47 -04:00
nmemmertandClaude Sonnet 4.6 de4619992e Fix dark mode: purge stale theme=light from DB on startup
The previous fix changed code defaults to dark but the DB still had
theme='light' stored explicitly — a truthy value that bypassed all
fallbacks. On every restart the server was returning theme:'light',
which the client applied via data-theme="light", making the app white.

- Delete the 'theme' key from DB on startup (one-time migration)
- Always return theme:'dark' from buildSettingsFromDb (not from DB)
- Stop writing theme to DB/env/process.env on settings save

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 13:04:52 -04:00
nmemmertandClaude Sonnet 4.6 50f4f73af1 Fix white screen caused by theme defaulting to 'light'
The app is a dark-mode design but all theme defaults were 'light'.
Once the theme setting was wired to data-theme, it applied light mode
overrides that turned the app background white while panels stayed dark.

- Changed all server-side theme defaults from 'light' to 'dark'
- Added data-theme="dark" to index.html so the correct theme applies
  before React loads (eliminates any white flash on first render)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 11:09:19 -04:00
nmemmertandClaude Sonnet 4.6 a8fca2d1a1 Fix: SQLite integer 0 rendering as text, NaN avg response display
- maintenance_mode and hasActiveIncident are SQLite integers (0/1), not JS booleans.
  {0 && <span>} evaluates to {0} which React renders as the text "0" in the DOM.
  Fixed with !! coercion on both conditions in renderResourceRow.

- avgResponseTime can be NaN for resources with no checks (e.g. icmp with no data).
  Added || 0 guard in the aggregate average and —/ms conditional in the row display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 11:06:23 -04:00
nmemmertandClaude Sonnet 4.6 b83a19e9cf Add 10 UX/feature improvements across dashboard, status page, and API
- Incident acknowledge: Ack button in dashboard rail and ResourceDetail incidents table
- Status page 90-day uptime history bars (teal ≥99%, amber ≥95%, red <95%, grey = no data)
- Theme setting applied: data-theme attribute wired to saved setting with CSS variables + light mode overrides
- Sparkline replaced with colored status bars (teal=up, red=down, grey=unknown) per check tick
- ResourceDetail chart time-range selector: 1h / 6h / 24h / 7d toggle fetches from checks endpoint
- Bulk actions: checkbox per row, select-all header, bulk delete and maintenance toggle bar
- CSV export adds 6 new fields (http_method, http_body, heartbeat_timeout, is_public, consecutive_failures_threshold, response_time_threshold)
- CSV import handles those 6 new fields on INSERT
- Check detail expansion: click expandable rows in Recent Checks to show full details JSON
- Browser push notifications: request permission opt-in, new Notification() on WS message when tab unfocused

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 11:01:24 -04:00
nmemmertandClaude Sonnet 4.6 5cfea703fb Fix notification panel readability — dark theme styling
Replace hard-coded white/light colors with dark theme palette matching the rest
of the app. Key changes: dark navy background (#1e293b/#0f172a), high-contrast
text (#f1f5f9 titles, #94a3b8 body), teal accent (#14b8a6) for active filter and
unread left-border, red (#f87171) clear button, dark scrollbar, subtle hover
states on items and action buttons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 10:26:09 -04:00
nmemmertandClaude Sonnet 4.6 4fea0a95f5 Add HTTP method/body, alert escalation, baseline alerts, notification grouping, rate limiting, App.js split, and API tests
- HTTP method + request body: GET/POST/PUT/PATCH/DELETE/HEAD selector and body textarea for http/health checks; stored in http_method/http_body columns; monitorService sends correct axios method+data
- Alert escalation: incidents open longer than escalation_hours get forwarded to fallback_webhook and marked escalated=1; hourly cron in scheduler
- Baseline-relative slow alerts: effectiveThreshold = response_time_threshold || (baseline * 2) — adaptive threshold without manual config
- Notification grouping: pendingAlerts collected per check cycle; ≥2 alerts flush as one grouped email+webhook; individual in-app entries still saved per resource
- Rate limiting: in-memory per-IP limiter (20/min heartbeat, 60/min status-page); no external package
- App.js split: Dashboard.js (monitors list + modals) and ResourceDetail.js extracted; App.js reduced from ~1987 to ~80 lines
- formatDuration moved to utils/timeUtils.js; imported by both new components
- DB migrations: http_method, http_body, incidents.escalated columns
- 18 passing API/unit tests (node:test, zero dependencies)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 09:37:31 -04:00
nmemmertandClaude Sonnet 4.6 a6cf6a9762 Add heartbeat monitoring, alert rules, cert badges, webhook templates, and status page visibility
- Heartbeat type: cron jobs POST to /api/heartbeat/:token to stay alive; scheduler marks
  them down if no ping is received within the configured timeout window
- Alert rules: per-resource consecutive_failures_threshold now respected in handleIncident,
  overriding the global setting; GET /api/resources/:id/alert-rules endpoint added
- SSL cert expiry badge: dashboard rows surface days_remaining for TLS monitors, turning
  orange at ≤30 days and red at ≤7 days
- Webhook payload templates: {{name}}/{{status}}/{{url}}/{{type}}/{{message}}/{{timestamp}}
  variables interpolated before POST; Slack-compatible text wrapping as fallback
- Status page visibility: is_public column per resource; GET /api/status-page returns only
  public resources; Status.js updated to use the new endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 09:08:15 -04:00
Nate Emmert 34986b5760 Harden update script dependency install flow 2026-03-10 15:00:45 -04:00
Nate Emmert 85368ba0dc Merge redesign command-center updates 2026-03-10 14:54:50 -04:00
Nate Emmert 0475bf5aa5 Refine dark theme readability and chart visibility across dashboard pages 2026-03-10 14:54:40 -04:00
52 changed files with 7638 additions and 3102 deletions

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "SkyWatch",
"runtimeExecutable": "node",
"runtimeArgs": ["server/index.js"],
"port": 3001,
"url": "http://localhost:3001"
}
]
}
+54
View File
@@ -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
View File
@@ -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
+130
View File
@@ -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."
+144
View File
@@ -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."
+195
View File
@@ -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
+333
View File
@@ -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 "$@"
+313
View File
@@ -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
}
+355
View File
@@ -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 "$@"
+6 -6
View File
@@ -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
View File
@@ -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":"AAAA,KAGE,kCAAmC,CACnC,iCAAkC,CAElC,wBAAyB,CADzB,aAAc,CAHd,qDAA+D,CAD/D,QAMF,CAEA,EACE,qBACF,CAEA,KACE,uEAEF,CChBA,KAEE,8HAGS,CAJT,gBAKF,CAEA,QAEE,yBAA0B,CAC1B,iCAAkC,CAFlC,oBAAqC,CAKrC,+BAAgC,CAChC,+BAA6C,CAF7C,aAAc,CADd,mBAAqB,CAIrB,uBAAgB,CAAhB,eAAgB,CAChB,KAAM,CACN,UACF,CAEA,WAEE,gBAAiB,CACjB,qBAAuB,CAFvB,QAGF,CAEA,cAIE,QAAS,CADT,6BAA8B,CAE9B,UACF,CAEA,2BANE,kBAAmB,CADnB,YAYF,CALA,aAGE,UAAW,CACX,WACF,CAEA,cAGE,kBAAmB,CAFnB,YAAa,CAGb,cAAe,CAFf,SAGF,CAEA,UAME,iBAAkB,CALlB,aAAc,CAGd,gBAAkB,CADlB,eAAgB,CAEhB,mBAAsB,CAHtB,oBAAqB,CAKrB,kDACF,CAEA,gBACE,eAAmB,CAEnB,kCAAmC,CADnC,aAEF,CAEA,eAEE,kBAAmB,CADnB,mBAAoB,CAEpB,SACF,CAEA,aACE,kBAAmB,CAGnB,mBAAoB,CAFpB,aAAc,CAGd,gBAAkB,CAClB,eAAgB,CAHhB,oBAIF,CAEA,oBAEE,kBAAmB,CAInB,eAAmB,CACnB,wBAAyB,CACzB,mBAAoB,CAHpB,aAAc,CAJd,YAAa,CAGb,gBAAkB,CADlB,UAAY,CAMZ,mBACF,CAEA,WAEE,aAAc,CADd,gBAAiB,CAEjB,YACF,CAEA,mBAGE,kBAAmB,CAFnB,YAAa,CAGb,QAAS,CAFT,6BAA8B,CAG9B,kBACF,CAEA,iBAEE,iBAAkB,CAClB,qBAAuB,CAFvB,QAGF,CAEA,oBAEE,aAAc,CACd,eAAiB,CAFjB,gBAGF,CAEA,mBAIE,kBAAmB,CAHnB,YAAa,CAEb,cAAe,CADf,SAGF,CAEA,kBAKE,oBAAoC,CAFpC,wBAAyB,CADzB,kBAAmB,CAEnB,eAAgB,CAHhB,oBAKF,CAEA,sBACE,iBACF,CAEA,cAIE,eAAmB,CACnB,wBAAyB,CACzB,iBAAkB,CAClB,+BAA6C,CAC7C,eAAgB,CAChB,gBAAkB,CARlB,iBAAkB,CAClB,OAAQ,CACR,QAAS,CAOT,UACF,CAEA,sBAEE,QAAS,CACT,4BAA6B,CAF7B,eAGF,CAEA,mBACE,YACF,CAEA,aACE,cAAe,CAEf,UAAW,CADX,QAAS,CAET,YACF,CAEA,YAME,sBAA6B,CAH7B,iBAAkB,CAElB,+BAA6C,CAJ7C,kBAAmB,CAGnB,eAAgB,CAFhB,iBAKF,CAEA,aACE,kBAAmB,CAEnB,oBAAqB,CADrB,aAEF,CAEA,eACE,kBAAmB,CAEnB,oBAAqB,CADrB,aAEF,CAEA,YACE,kBAAmB,CAEnB,oBAAqB,CADrB,aAEF,CAEA,iBAGE,eAAY,CAFZ,YAAa,CAEb,UAAY,CADZ,6CAAgD,CAEhD,kBACF,CAEA,gBACE,sDAA2F,CAC3F,wBAAyB,CACzB,kBAAmB,CAEnB,aAAc,CADd,cAEF,CAEA,iBAKE,aAAc,CAHd,gBAAkB,CAElB,oBAAsB,CAHtB,QAAS,CAET,wBAGF,CAEA,iBAEE,gBAAiB,CACjB,eAAgB,CAFhB,iBAGF,CAEA,WACE,oBACF,CAEA,aACE,oBACF,CAEA,cACE,oBACF,CAEA,WAGE,cAAW,CACX,iBAAkB,CAHlB,YAAa,CAEb,SAAW,CADX,yCAGF,CAEA,SACE,WACF,CAEA,SACE,YAAa,CACb,qBAAsB,CACtB,UAAY,CACZ,uBAAgB,CAAhB,eAAgB,CAChB,QACF,CAEA,UACE,oBAAkC,CAElC,wBAAyB,CACzB,kBAAmB,CAFnB,aAAc,CAGd,cACF,CAEA,aAEE,eAAiB,CACjB,oBAAsB,CAFtB,iBAGF,CAEA,UAEE,aAAc,CACd,gBAAkB,CAFlB,QAGF,CAEA,iCAEE,YAAa,CACb,qBAAsB,CACtB,SACF,CAEA,iCAUE,kBAAmB,CAPnB,kBAAmB,CACnB,wBAAyB,CAEzB,iBAAkB,CADlB,aAAc,CAMd,cAAe,CAHf,YAAa,CACb,6BAA8B,CAF9B,qBAAwB,CALxB,UAUF,CAEA,6CAEE,kBAAmB,CACnB,oBACF,CAEA,kBACE,gBAAkB,CAClB,eAAgB,CAIhB,kBAAoB,CAFpB,eAAgB,CAChB,sBAAuB,CAFvB,kBAIF,CAEA,gBAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,yDAA4D,CAE5D,oBACF,CAEA,eACE,YAAa,CACb,qBAAsB,CACtB,UACF,CAEA,qBACE,oBAAqC,CACrC,wBAAyB,CACzB,kBAAmB,CACnB,cACF,CAEA,sBAGE,aAAS,CAMT,+BAAgC,CADhC,aAAc,CAPd,YAAa,CAIb,gBAAkB,CAFlB,QAAS,CADT,+EAAoF,CAKpF,oBAAsB,CAGtB,oBAAsB,CANtB,0BAA8B,CAE9B,wBAKF,CAEA,qBAEE,aAAc,CADd,gBAAkB,CAElB,qBACF,CAEA,cAIE,aAAS,CADT,kBAAmB,CAGnB,oBAAqC,CACrC,wBAAyB,CACzB,kBAAmB,CACnB,+BAA6C,CAC7C,cAAe,CATf,YAAa,CAGb,QAAS,CAFT,+EAAoF,CAGpF,oBAAuB,CAMvB,wDACF,CAEA,oBAEE,oBAAqB,CACrB,+BAA4C,CAF5C,0BAGF,CAEA,UACE,WACF,CAEA,gBAEE,kBAAmB,CADnB,YAAa,CAGb,cAAe,CADf,SAAW,CAEX,oBACF,CAEA,UAEE,gBAAkB,CADlB,QAEF,CAEA,qBAGE,kBAAmB,CADnB,mBAAoB,CAEpB,aAAc,CACd,gBAAkB,CAClB,eAAgB,CALhB,oBAMF,CAEA,iBAGE,kBAAmB,CACnB,wBAAyB,CAEzB,mBAAoB,CAJpB,aAKF,CAEA,qCARE,gBAAkB,CAIlB,qBAWF,CAPA,oBAGE,kBAAmB,CACnB,wBAAyB,CAEzB,mBAAoB,CAJpB,aAKF,CAEA,mBAEE,QACF,CAEA,SAIE,gBAAkB,CAFlB,eAAgB,CAChB,sBAAuB,CAFvB,kBAIF,CAEA,UACE,gBACF,CAEA,aAGE,cAAW,CACX,kBAAmB,CAHnB,YAAa,CAEb,SAAW,CADX,6CAGF,CAEA,yBACE,gBACF,CAEA,eACE,cACF,CAEA,UAGE,oBAAqB,CAFrB,YAAa,CACb,qBAAsB,CAEtB,SACF,CAEA,aACE,YAAa,CACb,UACF,CAEA,eACE,+CAA6D,CAM7D,wBAAyB,CALzB,kBAAmB,CAEnB,+BAA6C,CAE7C,cAAe,CAHf,mBAAoB,CAEpB,uCAGF,CAEA,uBACE,YAAa,CACb,qBAAsB,CACtB,SACF,CAEA,qBAEE,gCAA8C,CAD9C,0BAEF,CAEA,iBAGE,sBAAuB,CAFvB,YAAa,CACb,6BAA8B,CAE9B,mBACF,CAEA,eACE,iBAAkB,CAClB,eAAgB,CAChB,gBACF,CAEA,cACE,aAAc,CACd,eAAiB,CACjB,oBACF,CAEA,eAEE,aAAc,CACd,gBAAkB,CAFlB,iBAGF,CAEA,cAEE,kBAAmB,CACnB,eAAiB,CACjB,eAAgB,CAChB,wBACF,CAEA,WACE,kBAAmB,CACnB,aACF,CAEA,aACE,kBAAmB,CACnB,aACF,CAEA,gBACE,kBAAmB,CACnB,aACF,CAEA,YAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,6BAA8B,CAE9B,eACF,CAEA,MACE,iBACF,CAEA,YAGE,UAAW,CAFX,gBAAiB,CACjB,eAAgB,CAEhB,QACF,CAEA,kBACE,iBACF,CAEA,YAEE,UAAW,CADX,gBAAkB,CAGlB,QAAS,CADT,wBAEF,CAEA,eAIE,eAAY,CADZ,kBAAmB,CAFnB,YAAa,CAGb,UAAY,CAFZ,mCAGF,CAEA,WAEE,WAAY,CADZ,UAEF,CAEA,YAGE,UAAW,CADX,gBAAkB,CADlB,QAGF,CAEA,eAEE,oBAAoC,CACpC,wBAAyB,CACzB,kBAAmB,CAHnB,kBAAmB,CAInB,YACF,CAEA,cAEE,kBAAmB,CAGnB,eAAiB,CAIjB,wBAAyB,CAHzB,kBAAmB,CACnB,cAAe,CANf,YAAa,CAEb,QAAS,CAOT,kBAAmB,CANnB,YAAa,CAIb,yBAGF,CAEA,oBACE,kBACF,CAEA,iBAGE,UAAW,CAFX,gBAAiB,CACjB,QAEF,CAEA,YAEE,QAAO,CADP,QAEF,CAEA,cAEE,aAAc,CADd,eAAiB,CAEjB,cACF,CAEA,aACE,YAAa,CAGb,gBAAkB,CAFlB,UAAW,CACX,gBAEF,CAEA,YACE,kBAAmB,CACnB,wBAAyB,CAEzB,mBAAoB,CACpB,aAAc,CACd,eAAgB,CAHhB,mBAIF,CAEA,YAEE,wBAAyB,CACzB,iBAAkB,CAClB,iBAAkB,CAElB,mBAAqB,CALrB,aAAe,CAIf,UAEF,CAEA,gBAGE,yBAA0B,CAF1B,iBAAmB,CACnB,kBAEF,CAEA,YACE,mBACF,CAEA,kBAGE,UAAW,CAFX,aAAc,CACd,gBAAkB,CAGlB,eAAgB,CADhB,oBAEF,CAEA,kBAGE,qBAAsB,CACtB,iBAAkB,CACLine 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
-3
View File
@@ -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":";2BAAAA,EAAOC,QAAU,EAAjBD,MAAAA,I,2BCEAE,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAmB5DL,EAAQM,SAjBR,SAASA,EAASD,GACd,GAAa,MAATA,EACA,MAAO,GAEX,GAAqB,kBAAVA,EACP,OAAOA,EAEX,GAAIE,MAAMC,QAAQH,GACd,OAAOA,EAAMI,IAAIH,GAAUI,KAAK,KAEpC,MAAMC,EAASC,OAAOP,GACtB,MAAe,MAAXM,GAAkBV,OAAOY,GAAGC,OAAOT,IAAS,GACrC,KAEJM,CACX,C,6BChBEZ,EAAOC,QAAU,EAAjBD,K,2BCDFE,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAM5DL,EAAQe,SAJR,SAAkBV,GACd,OAAiB,OAAVA,IAAoC,kBAAVA,GAAuC,oBAAVA,EAClE,C,8BCJAJ,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMW,EAAWC,EAAQ,MAMzBjB,EAAQkB,YAJR,SAAqBb,GACjB,OAAgB,MAATA,GAAkC,oBAAVA,GAAwBW,EAASA,SAASX,EAAMc,OACnF,C,8BCNAlB,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMe,EAAaH,EAAQ,MA6C3BjB,EAAQqB,SA3CR,SAAkBC,GAAoC,IAA9BC,EAAUC,UAAAL,OAAA,QAAAM,IAAAD,UAAA,GAAAA,UAAA,GAAG,EAAGE,EAAOF,UAAAL,OAAA,QAAAM,IAAAD,UAAA,GAAAA,UAAA,GAAG,CAAC,EACxB,kBAAZE,IACPA,EAAU,CAAC,GAEf,MAAM,QAAEC,GAAU,EAAK,SAAEC,GAAW,EAAI,QAAEC,GAAYH,EAChDI,EAAQvB,MAAM,GAOpB,IAAII,EANAgB,IACAG,EAAM,GAAK,WAEXF,IACAE,EAAM,GAAK,YAGf,IAAIC,EAAY,KAChB,MAAMC,EAAaZ,EAAWC,SAAS,WAAmB,QAAAY,EAAAT,UAAAL,OAANe,EAAI,IAAA3B,MAAA0B,GAAAE,EAAA,EAAAA,EAAAF,EAAAE,IAAJD,EAAIC,GAAAX,UAAAW,GACpDxB,EAASW,EAAKc,MAAMC,KAAMH,GAC1BH,EAAY,IAChB,EAAGR,EAAY,CAAEO,UACXQ,EAAY,WAAmB,QAAAC,EAAAf,UAAAL,OAANe,EAAI,IAAA3B,MAAAgC,GAAAC,EAAA,EAAAA,EAAAD,EAAAC,IAAJN,EAAIM,GAAAhB,UAAAgB,GAC/B,OAAe,MAAXX,IACkB,OAAdE,IACAA,EAAYU,KAAKC,OAEjBD,KAAKC,MAAQX,GAAaF,IAC1BlB,EAASW,EAAKc,MAAMC,KAAMH,GAC1BH,EAAYU,KAAKC,MACjBV,EAAWW,SACXX,EAAWY,WACJjC,IAGfqB,EAAWI,MAAMC,KAAMH,GAChBvB,EACX,EAOA,OAFA2B,EAAUK,OAASX,EAAWW,OAC9BL,EAAUO,MALIA,KACVb,EAAWa,QACJlC,GAIJ2B,CACX,C,8BC7CArC,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMC,EAAWW,EAAQ,KACnB6B,EAAQ7B,EAAQ,MA4EtBjB,EAAQ+C,OA1ER,SAAgBC,GACZ,GAAIzC,MAAMC,QAAQwC,GACd,OAAOA,EAAQvC,IAAIqC,EAAMA,OAE7B,GAAuB,kBAAZE,EACP,MAAO,CAACA,GAGZ,MAAMrC,EAAS,GACTQ,GAFN6B,EAAU1C,EAASA,SAAS0C,IAEL7B,OACvB,GAAe,IAAXA,EACA,OAAOR,EAEX,IAAIsC,EAAQ,EACRC,EAAM,GACNC,EAAY,GACZC,GAAU,EAKd,IAJ8B,KAA1BJ,EAAQK,WAAW,KACnB1C,EAAO2C,KAAK,IACZL,KAEGA,EAAQ9B,GAAQ,CACnB,MAAMoC,EAAOP,EAAQC,GACjBE,EACa,OAATI,GAAiBN,EAAQ,EAAI9B,GAC7B8B,IACAC,GAAOF,EAAQC,IAEVM,IAASJ,EACdA,EAAY,GAGZD,GAAOK,EAGNH,EACQ,MAATG,GAAyB,MAATA,EAChBJ,EAAYI,EAEE,MAATA,GACLH,GAAU,EACVzC,EAAO2C,KAAKJ,GACZA,EAAM,IAGNA,GAAOK,EAIE,MAATA,GACAH,GAAU,EACNF,IACAvC,EAAO2C,KAAKJ,GACZA,EAAM,KAGI,MAATK,EACDL,IACAvC,EAAO2C,KAAKJ,GACZA,EAAM,IAIVA,GAAOK,EAGfN,GACJ,CAIA,OAHIC,GACAvC,EAAO2C,KAAKJ,GAETvC,CACX,C,8BC5EEZ,EAAOC,QAAU,EAAjBD,K,8BCDFE,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMa,EAAcD,EAAQ,MACtBuC,EAAevC,EAAQ,MAM7BjB,EAAQyD,kBAJR,SAA2BpD,GACvB,OAAOmD,EAAaA,aAAanD,IAAUa,EAAYA,YAAYb,EACvE,C,8BCTa,IAAAqD,EAAAzC,EAAAA,MAAAA,QAEbhB,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMU,EAAWE,EAAQ,KACnB0C,EAAc1C,EAAQ,MACtB2C,EAAK3C,EAAQ,MAcnB,SAAS4C,EAAoBC,EAAQC,EAAQC,EAASC,GAClD,GAAIF,IAAWD,EACX,OAAO,EAEX,cAAeC,GACX,IAAK,SACD,OAoBZ,SAAuBD,EAAQC,EAAQC,EAASC,GAC5C,GAAc,MAAVF,EACA,OAAO,EAEX,GAAIxD,MAAMC,QAAQuD,GACd,OAAOG,EAAaJ,EAAQC,EAAQC,EAASC,GAEjD,GAAIF,aAAkBI,IAClB,OAuCR,SAAoBL,EAAQC,EAAQC,EAASC,GACzC,GAAoB,IAAhBF,EAAOK,KACP,OAAO,EAEX,KAAMN,aAAkBK,KACpB,OAAO,EAEX,IAAK,MAAOjB,EAAKmB,KAAgBN,EAAOO,UAAW,CAG/C,IAAgB,IADAN,EADIF,EAAOS,IAAIrB,GACMmB,EAAanB,EAAKY,EAAQC,EAAQE,GAEnE,OAAO,CAEf,CACA,OAAO,CACX,CAtDeO,CAAWV,EAAQC,EAAQC,EAASC,GAE/C,GAAIF,aAAkBU,IAClB,OAAOC,EAAWZ,EAAQC,EAAQC,EAASC,GAE/C,MAAMU,EAAO1E,OAAO0E,KAAKZ,GACzB,GAAc,MAAVD,EACA,OAAuB,IAAhBa,EAAKxD,OAEhB,GAAoB,IAAhBwD,EAAKxD,OACL,OAAO,EAEX,GAAS,OAAL8C,QAAK,IAALA,GAAAA,EAAOW,IAAIb,GACX,OAAOE,EAAMM,IAAIR,KAAYD,EAE5B,OAALG,QAAK,IAALA,GAAAA,EAAOY,IAAId,EAAQD,GACnB,IACI,IAAK,IAAIgB,EAAI,EAAGA,EAAIH,EAAKxD,OAAQ2D,IAAK,CAClC,MAAM5B,EAAMyB,EAAKG,GACjB,IAAKnB,EAAYA,YAAYG,MAAaZ,KAAOY,GAC7C,OAAO,EAEX,QAAoBrC,IAAhBsC,EAAOb,SAAsCzB,IAAhBqC,EAAOZ,GACpC,OAAO,EAEX,GAAoB,OAAhBa,EAAOb,IAAiC,OAAhBY,EAAOZ,GAC/B,OAAO,EAGX,IADgBc,EAAQF,EAAOZ,GAAMa,EAAOb,GAAMA,EAAKY,EAAQC,EAAQE,GAEnE,OAAO,CAEf,CACA,OAAO,CACX,CAAC,QAEQ,OAALA,QAAK,IAALA,GAAAA,EAAOc,OAAOhB,EAClB,CACJ,CAlEmBiB,CAAclB,EAAQC,EAAQC,EAASC,GAElD,IAAK,WAED,OADmBhE,OAAO0E,KAAKZ,GAChB5C,OAAS,EACb0C,EAAoBC,EAAMJ,EAAA,GAAOK,GAAUC,EAASC,GAExDL,EAAGA,GAAGE,EAAQC,GAEzB,QACI,OAAKhD,EAASA,SAAS+C,GAGD,kBAAXC,GACW,KAAXA,EAHAH,EAAGA,GAAGE,EAAQC,GAQrC,CAgEA,SAASG,EAAaJ,EAAQC,EAAQC,EAASC,GAC3C,GAAsB,IAAlBF,EAAO5C,OACP,OAAO,EAEX,IAAKZ,MAAMC,QAAQsD,GACf,OAAO,EAEX,MAAMmB,EAAe,IAAIR,IACzB,IAAK,IAAIK,EAAI,EAAGA,EAAIf,EAAO5C,OAAQ2D,IAAK,CACpC,MAAMI,EAAanB,EAAOe,GAC1B,IAAIK,GAAQ,EACZ,IAAK,IAAIC,EAAI,EAAGA,EAAItB,EAAO3C,OAAQiE,IAAK,CACpC,GAAIH,EAAaL,IAAIQ,GACjB,SAGJ,IAAIC,GAAU,EAKd,GAJgBrB,EAFGF,EAAOsB,GAEUF,EAAYJ,EAAGhB,EAAQC,EAAQE,KAE/DoB,GAAU,GAEVA,EAAS,CACTJ,EAAaK,IAAIF,GACjBD,GAAQ,EACR,KACJ,CACJ,CACA,IAAKA,EACD,OAAO,CAEf,CACA,OAAO,CACX,CACA,SAAST,EAAWZ,EAAQC,EAAQC,EAASC,GACzC,OAAoB,IAAhBF,EAAOK,MAGLN,aAAkBW,KAGjBP,EAAa,IAAIJ,GAAS,IAAIC,GAASC,EAASC,EAC3D,CAEAjE,EAAQuF,YAhJR,SAASA,EAAYzB,EAAQC,EAAQC,GACjC,MAAuB,oBAAZA,EACAuB,EAAYzB,EAAQC,EAAQ,QAEhCF,EAAoBC,EAAQC,EAAQ,SAASyB,EAAUCLine truncated
+3
View File
@@ -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
@@ -0,0 +1 @@
{"version":3,"file":"static/js/main.b7e3c916.js","mappings":"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 truncated
-95
View File
@@ -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 -1
View File
@@ -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" />
+517
View File
@@ -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); }
}
+504
View File
@@ -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&nbsp;&lt;secs&gt;</code>, <code>--name&nbsp;&lt;label&gt;</code>,{' '}
<code>--registration-key&nbsp;&lt;key&gt;</code>.
Remove: <code>sudo bash install-agent.sh --uninstall</code>
</p>
</>
);
}
function MacosSteps({ serverUrl }) {
return (
<>
<p className="ag-install-note">
macOS 10.15+ (Intel &amp; Apple Silicon). Runs as a launchd system daemon.
</p>
<div className="ag-install-steps">
<div className="ag-step">
<span className="ag-step-num">1</span>
<div className="ag-step-body">
<div className="ag-step-label">Download the scripts</div>
<CopyCmd cmd={`curl -fsSL ${serverUrl}/api/agents/script-macos -o skywatch-agent-macos.sh`} />
<CopyCmd cmd={`curl -fsSL ${serverUrl}/api/agents/installer-macos -o install-agent-macos.sh`} />
</div>
</div>
<div className="ag-step">
<span className="ag-step-num">2</span>
<div className="ag-step-body">
<div className="ag-step-label">Run the installer as root</div>
<CopyCmd cmd={`sudo bash install-agent-macos.sh --server-url ${serverUrl}`} />
</div>
</div>
</div>
<p className="ag-install-note ag-install-note-sm">
Optional: <code>--interval&nbsp;&lt;secs&gt;</code>, <code>--name&nbsp;&lt;label&gt;</code>,{' '}
<code>--registration-key&nbsp;&lt;key&gt;</code>.
Remove: <code>sudo bash install-agent-macos.sh --uninstall</code>
</p>
</>
);
}
function WindowsSteps({ serverUrl }) {
return (
<>
<p className="ag-install-note">
Windows 10 / 11 / Server 2019+. Runs via Task Scheduler as SYSTEM.
Open <strong>PowerShell as Administrator</strong> and run:
</p>
<div className="ag-install-steps">
<div className="ag-step">
<span className="ag-step-num">1</span>
<div className="ag-step-body">
<div className="ag-step-label">Download the installer</div>
<CopyCmd cmd={`Invoke-WebRequest "${serverUrl}/api/agents/installer-windows" -OutFile install-agent-windows.ps1`} />
</div>
</div>
<div className="ag-step">
<span className="ag-step-num">2</span>
<div className="ag-step-body">
<div className="ag-step-label">Run the installer</div>
<CopyCmd cmd={`.\\install-agent-windows.ps1 -ServerUrl "${serverUrl}"`} />
</div>
</div>
</div>
<p className="ag-install-note ag-install-note-sm">
Optional: <code>-Interval&nbsp;&lt;secs&gt;</code>, <code>-AgentName&nbsp;&lt;label&gt;</code>,{' '}
<code>-RegistrationKey&nbsp;&lt;key&gt;</code>.
Remove: <code>.\\install-agent-windows.ps1&nbsp;-Uninstall</code>
</p>
</>
);
}
// ── Install instructions panel ────────────────────────────────────────────────
const PLATFORMS = [
{ id: 'linux', label: '🐧 Linux' },
{ id: 'macos', label: '🍎 macOS' },
{ id: 'windows', label: '🪟 Windows' },
];
function InstallPanel({ serverUrl }) {
const [platform, setPlatform] = useState('linux');
return (
<div className="ag-install-panel">
<h3 className="ag-install-title">Install agent on a host</h3>
<div className="ag-platform-tabs">
{PLATFORMS.map(p => (
<button
key={p.id}
className={`ag-platform-tab${platform === p.id ? ' ag-platform-tab-active' : ''}`}
onClick={() => setPlatform(p.id)}
>
{p.label}
</button>
))}
</div>
{platform === 'linux' && <LinuxSteps serverUrl={serverUrl} />}
{platform === 'macos' && <MacosSteps serverUrl={serverUrl} />}
{platform === 'windows' && <WindowsSteps serverUrl={serverUrl} />}
</div>
);
}
// ── Command terminal panel ────────────────────────────────────────────────────
function CommandTerminal({ agentId, agentName }) {
const [commands, setCommands] = useState([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [expanded, setExpanded] = useState({});
const pollRef = useRef(null);
const fetchCommands = useCallback(async () => {
try {
const { data } = await axios.get(`/api/agents/${agentId}/commands`);
setCommands(data.commands || []);
} catch (_) {}
}, [agentId]);
useEffect(() => {
fetchCommands();
pollRef.current = setInterval(fetchCommands, 4000);
return () => clearInterval(pollRef.current);
}, [fetchCommands]);
const send = async () => {
const cmd = input.trim();
if (!cmd || sending) return;
setSending(true);
try {
await axios.post(`/api/agents/${agentId}/commands`, { command: cmd });
setInput('');
fetchCommands();
} catch (_) {}
setSending(false);
};
const onKey = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
};
const toggleExpand = (id) => setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
const statusIcon = (s) => s === 'completed' ? '✓' : s === 'failed' ? '✗' : s === 'running' ? '↻' : '…';
const statusClass = (s) => `ag-cmd-status ag-cmd-status-${s}`;
return (
<div className="ag-terminal">
<div className="ag-terminal-title">Terminal — {agentName}</div>
<div className="ag-terminal-input-row">
<span className="ag-terminal-prompt">$</span>
<input
className="ag-terminal-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={onKey}
placeholder="Enter command…"
spellCheck={false}
autoComplete="off"
/>
<button
className="ag-terminal-run"
onClick={send}
disabled={!input.trim() || sending}
>
{sending ? '…' : 'Run'}
</button>
</div>
<div className="ag-terminal-history">
{commands.length === 0 && (
<div className="ag-terminal-empty">No commands yet.</div>
)}
{commands.map(cmd => (
<div key={cmd.id} className="ag-terminal-entry">
<div className="ag-terminal-entry-head" onClick={() => cmd.output && toggleExpand(cmd.id)}>
<span className={statusClass(cmd.status)}>{statusIcon(cmd.status)}</span>
<code className="ag-terminal-entry-cmd">{cmd.command}</code>
<span className="ag-terminal-entry-time">{timeAgo(cmd.created_at)}</span>
{cmd.output && (
<span className="ag-terminal-expand">{expanded[cmd.id] ? '▲' : '▼'}</span>
)}
</div>
{expanded[cmd.id] && cmd.output && (
<pre className="ag-terminal-output">{cmd.output}</pre>
)}
</div>
))}
</div>
</div>
);
}
// ── Single agent card ─────────────────────────────────────────────────────────
function AgentCard({ agent, onDelete }) {
const [confirmDel, setConfirmDel] = useState(false);
const [showTerminal, setShowTerminal] = useState(false);
const online = isOnline(agent.last_seen_at);
const topDisks = (agent.disk || []).slice(0, 3);
return (
<div className={`ag-card ${online ? 'ag-online' : 'ag-offline'}`}>
{/* ── Header row ── */}
<div className="ag-card-head">
<div className="ag-card-identity">
<span className={`ag-dot ${online ? 'ag-dot-on' : 'ag-dot-off'}`} />
<div style={{ minWidth: 0 }}>
<div className="ag-name" title={agent.name}>{agent.name}</div>
<div className="ag-sub" title={`${agent.hostname} · ${agent.ip_address || '?'}`}>
{agent.hostname} · {agent.ip_address || '?'}
</div>
</div>
</div>
<div className="ag-card-actions">
<button
className={`ag-btn ag-btn-terminal${showTerminal ? ' ag-btn-terminal-active' : ''}`}
onClick={() => setShowTerminal(v => !v)}
title="Open terminal"
>&gt;_</button>
{confirmDel ? (
<>
<button className="ag-btn ag-btn-danger" onClick={() => onDelete(agent.id)}>Delete</button>
<button className="ag-btn ag-btn-cancel" onClick={() => setConfirmDel(false)}>Cancel</button>
</>
) : (
<button
className="ag-btn ag-btn-ghost"
onClick={() => setConfirmDel(true)}
title="Remove agent"
>✕</button>
)}
</div>
</div>
{/* ── OS + status ── */}
<div className="ag-meta">
<span className="ag-os-tag" title={agent.os_info}>{agent.os_info || 'Linux'}</span>
<span className={`ag-status-text ${online ? 'ag-status-on' : 'ag-status-off'}`}>
{online ? '● Online' : `Last seen ${timeAgo(agent.last_seen_at)}`}
</span>
</div>
{/* ── Metrics ── */}
{agent.cpu_percent != null ? (
<>
<div className="ag-gauges">
<Gauge value={agent.cpu_percent} label="CPU" />
<Gauge
value={agent.mem_percent}
label={`Memory ${(agent.mem_used ?? 0).toLocaleString()} / ${(agent.mem_total ?? 0).toLocaleString()} MB`}
/>
{topDisks.map((d, i) => (
<Gauge key={i} value={d.percent} label={`Disk ${d.path}`} />
))}
</div>
<div className="ag-stats">
<div>
<div className="ag-stat-label">Load avg</div>
<div className="ag-stat-val" style={{ fontSize: '0.8rem' }}>
{fmtLoad(agent.load_1)} / {fmtLoad(agent.load_5)} / {fmtLoad(agent.load_15)}
</div>
</div>
<div>
<div className="ag-stat-label">Processes</div>
<div className="ag-stat-val">{agent.process_count ?? '—'}</div>
</div>
<div>
<div className="ag-stat-label">Uptime</div>
<div className="ag-stat-val">{fmtUptime(agent.uptime_seconds)}</div>
</div>
<div>
<div className="ag-stat-label">Net sent</div>
<div className="ag-stat-val ag-stat-val-sm">{fmtBytes(agent.net_bytes_sent)}</div>
</div>
<div>
<div className="ag-stat-label">Net recv</div>
<div className="ag-stat-val ag-stat-val-sm">{fmtBytes(agent.net_bytes_recv)}</div>
</div>
<div>
<div className="ag-stat-label">Reported</div>
<div className="ag-stat-val ag-stat-val-sm">{timeAgo(agent.last_metric_at)}</div>
</div>
</div>
</>
) : (
<div className="ag-no-data">Waiting for first report…</div>
)}
{showTerminal && (
<CommandTerminal agentId={agent.id} agentName={agent.name} />
)}
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function Agents() {
const [agents, setAgents] = useState([]);
const [loading, setLoading] = useState(true);
const [showInstall, setShowInstall] = useState(false);
// Use the actual origin so install commands work for both http and https
const serverUrl = window.location.origin;
const load = useCallback(async () => {
try {
const { data } = await axios.get('/api/agents');
setAgents(data.agents || []);
} catch (_) {}
setLoading(false);
}, []);
useEffect(() => {
load();
const t = setInterval(load, 30000);
return () => clearInterval(t);
}, [load]);
const deleteAgent = async (id) => {
try {
await axios.delete(`/api/agents/${id}`);
setAgents(prev => prev.filter(a => a.id !== id));
} catch (_) {}
};
const onlineCount = agents.filter(a => isOnline(a.last_seen_at)).length;
return (
<div className="container agents-page">
{/* ── Header ── */}
<div className="ag-header">
<div className="ag-header-left">
<h2 className="ag-page-title">Agents</h2>
{agents.length > 0 && (
<span className="ag-count-pill">
{onlineCount} / {agents.length} online
</span>
)}
</div>
<button
className={`ag-install-toggle${showInstall ? ' ag-install-toggle-active' : ''}`}
onClick={() => setShowInstall(v => !v)}
>
{showInstall ? '✕ Close' : '+ Add Agent'}
</button>
</div>
{/* ── Install panel ── */}
{showInstall && <InstallPanel serverUrl={serverUrl} />}
{/* ── Content ── */}
{loading ? (
<div className="ag-loading">Loading agents…</div>
) : agents.length === 0 ? (
<div className="ag-empty">
<div className="ag-empty-icon">🖥</div>
<div className="ag-empty-title">No agents registered yet</div>
<div className="ag-empty-sub">
Click <strong>+ Add Agent</strong> above to get the install commands.
</div>
</div>
) : (
<div className="ag-grid">
{agents.map(agent => (
<AgentCard key={agent.id} agent={agent} onDelete={deleteAgent} />
))}
</div>
)}
</div>
);
}
+470 -60
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+290
View File
@@ -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
View File
@@ -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>
+119 -74
View File
@@ -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;
+21 -1
View File
@@ -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>
+78 -80
View File
@@ -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,12 +31,52 @@ 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 {
await axios.put(`/api/notifications/${notificationId}/read`); await axios.put(`/api/notifications/${notificationId}/read`);
@@ -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>
) : (
<button className="btn btn-danger" onClick={() => setPendingClear(true)} style={{ marginLeft: 'auto' }}>
Clear All Clear All
</button> </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}
+57 -39
View File
@@ -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) {
+38 -6
View File
@@ -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 (
+566
View File
@@ -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
View File
@@ -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>
+77 -39
View File
@@ -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 {
+129 -2
View File
@@ -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);
@@ -109,6 +118,25 @@ function SettingsWizard() {
} }
}; };
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 {
setTesting(false);
}
};
const emailProviders = [ const emailProviders = [
{ name: 'Gmail', host: 'smtp.gmail.com', port: 587 }, { name: 'Gmail', host: 'smtp.gmail.com', port: 587 },
{ name: 'Outlook', host: 'smtp-mail.outlook.com', port: 587 }, { name: 'Outlook', host: 'smtp-mail.outlook.com', port: 587 },
@@ -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'}
> >
+51
View File
@@ -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
View File
@@ -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>
+2 -2
View File
@@ -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;
} }
* { * {
+34 -37
View File
@@ -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,25 +38,7 @@ 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',
@@ -50,13 +47,10 @@ export const formatLocalTime = (timestamp, options = {}) => {
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 });
}; };
@@ -67,11 +61,13 @@ 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 });
@@ -87,22 +83,14 @@ export const formatChartTime = (timestamp) => {
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();
const tzOpt = tz ? { timeZone: tz } : {};
// If within last 24 hours, show time only
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'
});
}; };
/** /**
@@ -119,3 +107,12 @@ export const getTimezoneInfo = () => {
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`;
}
+13 -402
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+813 -320
View File
File diff suppressed because it is too large. Load diff
+49 -10
View File
@@ -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
+166
View File
@@ -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 };
+261 -14
View File
@@ -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 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()}`; 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;
const status = type === 'started' ? 'down' : type === 'slow' ? 'slow' : 'up';
const template = this.config.webhook_template;
let payload;
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, resource: resource.name,
url: resource.url, url: resource.url,
status: type === 'started' ? 'down' : 'up', status,
message, message,
timestamp: new Date().toISOString(), 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();
+248 -62
View File
@@ -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;
const excess = rowCount - cap;
if (excess > 0) {
db.prepare(` db.prepare(`
DELETE FROM checks DELETE FROM checks WHERE id IN (
WHERE id IN ( SELECT id FROM checks WHERE resource_id = ? ORDER BY id ASC LIMIT ?
SELECT id FROM checks
WHERE resource_id = ?
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;
} }
} }
+144
View File
@@ -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
View File
@@ -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"