Files
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

389 lines
13 KiB
JavaScript

const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
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
db.exec(`
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS resources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'http',
check_interval INTEGER DEFAULT 60000,
timeout INTEGER DEFAULT 5000,
enabled INTEGER DEFAULT 1,
maintenance_mode INTEGER DEFAULT 0,
group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
retention_days INTEGER DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_id INTEGER NOT NULL,
status TEXT NOT NULL,
response_time INTEGER,
status_code INTEGER,
error_message TEXT,
details TEXT,
checked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS incidents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_id INTEGER NOT NULL,
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
resolved_at DATETIME,
notified INTEGER DEFAULT 0,
acknowledged INTEGER DEFAULT 0,
acknowledged_at DATETIME,
acknowledged_by TEXT,
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS maintenance_windows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_id INTEGER NOT NULL,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
reason TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS alert_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_id INTEGER NOT NULL,
consecutive_failures_threshold INTEGER DEFAULT 1,
response_time_threshold INTEGER,
response_time_baseline INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS transaction_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_id INTEGER NOT NULL,
step_order INTEGER NOT NULL,
url TEXT NOT NULL,
method TEXT DEFAULT 'GET',
headers TEXT,
body TEXT,
expected_status INTEGER DEFAULT 200,
keyword TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS archived_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_id INTEGER NOT NULL,
status TEXT NOT NULL,
response_time INTEGER,
status_code INTEGER,
error_message TEXT,
details TEXT,
checked_at DATETIME NOT NULL,
archived_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS resource_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_id INTEGER NOT NULL,
tag TEXT NOT NULL,
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE,
UNIQUE(resource_id, tag)
);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_id INTEGER,
incident_id INTEGER,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
status TEXT DEFAULT 'unread',
read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE,
FOREIGN KEY (incident_id) REFERENCES incidents(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_checks_resource_id ON checks(resource_id);
CREATE INDEX IF NOT EXISTS idx_checks_checked_at ON checks(checked_at);
CREATE INDEX IF NOT EXISTS idx_checks_resource_checked ON checks(resource_id, checked_at);
CREATE INDEX IF NOT EXISTS idx_incidents_resource_id ON incidents(resource_id);
CREATE INDEX IF NOT EXISTS idx_incidents_resource_started ON incidents(resource_id, started_at);
CREATE INDEX IF NOT EXISTS idx_incidents_resolved ON incidents(resource_id, resolved_at);
CREATE INDEX IF NOT EXISTS idx_resources_group_id ON resources(group_id);
CREATE INDEX IF NOT EXISTS idx_maintenance_windows_resource ON maintenance_windows(resource_id);
CREATE INDEX IF NOT EXISTS idx_alert_rules_resource ON alert_rules(resource_id);
CREATE INDEX IF NOT EXISTS idx_transaction_checks_resource ON transaction_checks(resource_id);
CREATE INDEX IF NOT EXISTS idx_archived_checks_resource_time ON archived_checks(resource_id, checked_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_read_status ON notifications(read, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_resource_tags_resource ON resource_tags(resource_id);
CREATE INDEX IF NOT EXISTS idx_resource_tags_tag ON resource_tags(tag);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
user_id TEXT DEFAULT 'system',
changes TEXT,
ip_address TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS error_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
error_message TEXT NOT NULL,
stack_trace TEXT,
context TEXT,
occurred_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_error_logs_occurred ON error_logs(occurred_at DESC);
`);
// Lightweight migrations - for existing databases
try {
db.prepare("ALTER TABLE checks ADD COLUMN details TEXT").run();
} catch (err) {
// Column already exists
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN group_id INTEGER").run();
} catch (err) {
// Column already exists
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN maintenance_mode INTEGER DEFAULT 0").run();
} catch (err) {
if (err.message.includes('duplicate')) {
// Column already exists
}
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN http_keyword TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN http_headers TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN quiet_hours_start TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN quiet_hours_end TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN cert_expiry_days INTEGER DEFAULT 30").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN sla_target REAL DEFAULT 99.9").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN email_to TEXT").run();
} catch (err) {
if (err.message.includes('duplicate')) {
// email_to column already exists
} else {
// Error adding email_to column
}
}
try {
db.prepare("ALTER TABLE incidents ADD COLUMN acknowledged INTEGER DEFAULT 0").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE incidents ADD COLUMN acknowledged_at DATETIME").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE incidents ADD COLUMN acknowledged_by TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE checks ADD COLUMN cert_expiry_date TEXT").run();
} catch (err) {}
// New columns for features
try {
db.prepare("ALTER TABLE resources ADD COLUMN tags TEXT").run();
} catch (err) {
if (err.message.includes('duplicate')) {
// tags column already exists
}
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN consecutive_failures_threshold INTEGER DEFAULT 1").run();
} catch (err) {
if (err.message.includes('duplicate')) {
// consecutive_failures_threshold column already exists
}
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN response_time_threshold INTEGER").run();
} catch (err) {
if (err.message.includes('duplicate')) {
// response_time_threshold column already exists
}
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN response_time_baseline INTEGER").run();
} catch (err) {
if (err.message.includes('duplicate')) {
// response_time_baseline column already exists
}
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN is_transaction INTEGER DEFAULT 0").run();
} catch (err) {
if (err.message.includes('duplicate')) {
// is_transaction column already exists
}
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN retention_days INTEGER DEFAULT NULL").run();
} catch (err) {
if (err.message.includes('duplicate')) {
// retention_days column already exists
}
}
// Add description column to incidents for incident summaries
try {
db.prepare("ALTER TABLE incidents ADD COLUMN description TEXT").run();
} catch (err) {
if (err.message.includes('duplicate')) {
// description column already exists
}
}
// Add failed_check_count to incidents to track consecutive failures at creation time
try {
db.prepare("ALTER TABLE incidents ADD COLUMN failed_check_count INTEGER DEFAULT 0").run();
} catch (err) {
if (err.message.includes('duplicate')) {
// failed_check_count column already exists
}
}
// Settings for data retention and incident thresholds
try {
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('retention_days', '30')").run();
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('incident_failure_threshold', '10')").run();
} 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
db.exec(`
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_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;