From 1fd29e449f8609b216e2c83a5126ce3e1989a8f9 Mon Sep 17 00:00:00 2001 From: Debian Date: Tue, 6 Jan 2026 09:10:26 +0000 Subject: [PATCH] Add local SQLite database and sync with Uptime Kuma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - SQLite database to track monitors and hosts locally - Uses Uptime Kuma tags to mark monitors as managed by Kuma Strapper - Sync on startup, before each scan, and on-demand via API - Shows existing monitors when re-scanning a host New files: - backend/services/database.py - SQLite database service - backend/services/sync.py - Sync service for Uptime Kuma reconciliation API endpoints: - POST /api/sync - Full sync with Uptime Kuma - POST /api/sync/host/ - Sync specific host - GET /api/hosts - List tracked hosts - GET /api/hosts//monitors - Get monitors for host - GET /api/monitors/tracked - Get all tracked monitors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/app.py | 104 +++++ backend/config.py | 2 + backend/services/database.py | 377 +++++++++++++++++++ backend/services/kuma_client.py | 44 +++ backend/services/monitors.py | 151 +++++--- backend/services/sync.py | 325 ++++++++++++++++ docker-compose.yml | 4 + frontend/src/App.jsx | 10 + frontend/src/api/client.js | 9 + frontend/src/components/Dashboard.jsx | 3 +- frontend/src/components/DiscoveryResults.jsx | 62 ++- 11 files changed, 1044 insertions(+), 47 deletions(-) create mode 100644 backend/services/database.py create mode 100644 backend/services/sync.py diff --git a/backend/app.py b/backend/app.py index 08d0ce3..4feb24f 100644 --- a/backend/app.py +++ b/backend/app.py @@ -17,6 +17,8 @@ from services.monitors import ( parse_docker_containers_from_scan, ) from services.kuma_client import get_kuma_client +from services.database import get_database +from services.sync import get_sync_service from utils.approval import get_approval_queue, ApprovalStatus @@ -90,6 +92,20 @@ def start_scan(): # Start scan in background thread def run_scan(): + # Sync this host's monitors before scanning + try: + sync = get_sync_service() + sync_result = sync.sync_host(hostname) + db = get_database() + existing_monitors = db.get_monitors_for_hostname(hostname) + socketio.emit("host_sync_complete", { + "hostname": hostname, + "sync_result": sync_result, + "existing_monitors": [m.to_dict() for m in existing_monitors], + }) + except Exception as e: + print(f"Pre-scan sync failed (non-fatal): {e}") + discovery = get_discovery_service() def on_progress(cmd_name, status): @@ -576,6 +592,72 @@ def test_kuma_connection(): return jsonify({"connected": False, "error": str(e)}) +# Sync endpoints +@app.route("/api/sync", methods=["POST"]) +def trigger_sync(): + """Trigger a full sync with Uptime Kuma.""" + try: + sync = get_sync_service() + result = sync.full_sync() + return jsonify(result) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/sync/host/", methods=["POST"]) +def sync_host(hostname): + """Sync monitors for a specific host.""" + try: + sync = get_sync_service() + result = sync.sync_host(hostname) + return jsonify(result) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/hosts", methods=["GET"]) +def get_hosts(): + """Get all tracked hosts.""" + try: + db = get_database() + hosts = db.get_all_hosts() + return jsonify({ + "hosts": [h.to_dict() for h in hosts], + "count": len(hosts) + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/hosts//monitors", methods=["GET"]) +def get_host_monitors(hostname): + """Get all tracked monitors for a host.""" + try: + db = get_database() + monitors = db.get_monitors_for_hostname(hostname) + return jsonify({ + "hostname": hostname, + "monitors": [m.to_dict() for m in monitors], + "count": len(monitors) + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/monitors/tracked", methods=["GET"]) +def get_tracked_monitors(): + """Get all tracked monitors across all hosts.""" + try: + db = get_database() + monitors = db.get_all_monitors() + return jsonify({ + "monitors": [m.to_dict() for m in monitors], + "count": len(monitors) + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + # WebSocket events @socketio.on("connect") def handle_connect(): @@ -587,6 +669,19 @@ def handle_disconnect(): pass +def startup_sync(): + """Run initial sync with Uptime Kuma on startup.""" + try: + print("Running startup sync with Uptime Kuma...") + sync = get_sync_service() + result = sync.full_sync() + print(f"Startup sync complete: added={result['added']}, updated={result['updated']}, removed={result['removed']}") + if result['errors']: + print(f"Sync errors: {result['errors']}") + except Exception as e: + print(f"Startup sync failed (non-fatal): {e}") + + if __name__ == "__main__": # Validate config on startup config = get_config() @@ -600,4 +695,13 @@ if __name__ == "__main__": print("Configuration OK") print(f"Dev mode: {'enabled' if config.dev_mode else 'disabled'}") + # Run startup sync in background after short delay + def delayed_sync(): + import time + time.sleep(2) # Wait for app to fully start + startup_sync() + + sync_thread = threading.Thread(target=delayed_sync, daemon=True) + sync_thread.start() + socketio.run(app, host="0.0.0.0", port=5000, debug=os.environ.get("DEBUG", "false").lower() == "true") diff --git a/backend/config.py b/backend/config.py index a2e9719..cc35e47 100644 --- a/backend/config.py +++ b/backend/config.py @@ -73,6 +73,7 @@ class Config: uptime_kuma_api_key: str claude_api_key: str dev_mode: bool = False + database_path: str = "/app/data/kuma_strapper.db" @classmethod def from_env(cls) -> "Config": @@ -91,6 +92,7 @@ class Config: uptime_kuma_api_key=resolve_secret("UPTIME_KUMA_API_KEY", ""), claude_api_key=resolve_secret("CLAUDE_API_KEY", ""), dev_mode=os.environ.get("DEV_MODE", "false").lower() == "true", + database_path=os.environ.get("DATABASE_PATH", "/app/data/kuma_strapper.db"), ) def validate(self) -> list[str]: diff --git a/backend/services/database.py b/backend/services/database.py new file mode 100644 index 0000000..b3bda80 --- /dev/null +++ b/backend/services/database.py @@ -0,0 +1,377 @@ +"""SQLite database for tracking monitors and hosts.""" + +import os +import sqlite3 +import logging +from contextlib import contextmanager +from dataclasses import dataclass, asdict +from datetime import datetime +from typing import Optional + +from config import get_config + +logger = logging.getLogger(__name__) + +SCHEMA = """ +-- Hosts that have been scanned +CREATE TABLE IF NOT EXISTS hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname TEXT UNIQUE NOT NULL, + last_scan_at TIMESTAMP, + last_sync_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Monitors created by Kuma Strapper +CREATE TABLE IF NOT EXISTS monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kuma_monitor_id INTEGER UNIQUE NOT NULL, + host_id INTEGER NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + target TEXT, + port INTEGER, + interval_seconds INTEGER DEFAULT 60, + push_metric TEXT, + status TEXT DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + synced_at TIMESTAMP, + FOREIGN KEY (host_id) REFERENCES hosts(id) ON DELETE CASCADE +); + +-- Push script deployments +CREATE TABLE IF NOT EXISTS deployments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + monitor_id INTEGER NOT NULL, + host_id INTEGER NOT NULL, + script_path TEXT NOT NULL, + scheduling_method TEXT, + scheduling_info TEXT, + deployed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT 'deployed', + FOREIGN KEY (monitor_id) REFERENCES monitors(id) ON DELETE CASCADE, + FOREIGN KEY (host_id) REFERENCES hosts(id) ON DELETE CASCADE +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_monitors_host_id ON monitors(host_id); +CREATE INDEX IF NOT EXISTS idx_monitors_kuma_id ON monitors(kuma_monitor_id); +CREATE INDEX IF NOT EXISTS idx_hosts_hostname ON hosts(hostname); +""" + + +@dataclass +class Host: + id: Optional[int] + hostname: str + last_scan_at: Optional[datetime] = None + last_sync_at: Optional[datetime] = None + created_at: Optional[datetime] = None + + def to_dict(self) -> dict: + d = asdict(self) + for k, v in d.items(): + if isinstance(v, datetime): + d[k] = v.isoformat() + return d + + +@dataclass +class TrackedMonitor: + id: Optional[int] + kuma_monitor_id: int + host_id: int + name: str + type: str + target: Optional[str] = None + port: Optional[int] = None + interval_seconds: int = 60 + push_metric: Optional[str] = None + status: str = "active" + created_at: Optional[datetime] = None + synced_at: Optional[datetime] = None + + def to_dict(self) -> dict: + d = asdict(self) + for k, v in d.items(): + if isinstance(v, datetime): + d[k] = v.isoformat() + return d + + +@dataclass +class Deployment: + id: Optional[int] + monitor_id: int + host_id: int + script_path: str + scheduling_method: Optional[str] = None + scheduling_info: Optional[str] = None + deployed_at: Optional[datetime] = None + status: str = "deployed" + + def to_dict(self) -> dict: + d = asdict(self) + for k, v in d.items(): + if isinstance(v, datetime): + d[k] = v.isoformat() + return d + + +class Database: + def __init__(self, db_path: Optional[str] = None): + self.db_path = db_path or get_config().database_path + self._ensure_directory() + self._init_schema() + + def _ensure_directory(self): + dir_path = os.path.dirname(self.db_path) + if dir_path: + os.makedirs(dir_path, exist_ok=True) + + @contextmanager + def get_connection(self): + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def _init_schema(self): + with self.get_connection() as conn: + conn.executescript(SCHEMA) + logger.info(f"Database initialized at {self.db_path}") + + # Host operations + + def get_or_create_host(self, hostname: str) -> Host: + with self.get_connection() as conn: + row = conn.execute( + "SELECT * FROM hosts WHERE hostname = ?", (hostname,) + ).fetchone() + + if row: + return self._row_to_host(row) + + cursor = conn.execute( + "INSERT INTO hosts (hostname) VALUES (?)", (hostname,) + ) + return Host(id=cursor.lastrowid, hostname=hostname) + + def get_host_by_hostname(self, hostname: str) -> Optional[Host]: + with self.get_connection() as conn: + row = conn.execute( + "SELECT * FROM hosts WHERE hostname = ?", (hostname,) + ).fetchone() + return self._row_to_host(row) if row else None + + def get_all_hosts(self) -> list[Host]: + with self.get_connection() as conn: + rows = conn.execute("SELECT * FROM hosts ORDER BY hostname").fetchall() + return [self._row_to_host(row) for row in rows] + + def update_host_scan_time(self, host_id: int): + with self.get_connection() as conn: + conn.execute( + "UPDATE hosts SET last_scan_at = ? WHERE id = ?", + (datetime.utcnow(), host_id), + ) + + def update_host_sync_time(self, host_id: int): + with self.get_connection() as conn: + conn.execute( + "UPDATE hosts SET last_sync_at = ? WHERE id = ?", + (datetime.utcnow(), host_id), + ) + + def _row_to_host(self, row: sqlite3.Row) -> Host: + return Host( + id=row["id"], + hostname=row["hostname"], + last_scan_at=self._parse_timestamp(row["last_scan_at"]), + last_sync_at=self._parse_timestamp(row["last_sync_at"]), + created_at=self._parse_timestamp(row["created_at"]), + ) + + # Monitor operations + + def add_monitor(self, monitor: TrackedMonitor) -> int: + with self.get_connection() as conn: + cursor = conn.execute( + """INSERT INTO monitors + (kuma_monitor_id, host_id, name, type, target, port, + interval_seconds, push_metric, status, synced_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + monitor.kuma_monitor_id, + monitor.host_id, + monitor.name, + monitor.type, + monitor.target, + monitor.port, + monitor.interval_seconds, + monitor.push_metric, + monitor.status, + datetime.utcnow(), + ), + ) + return cursor.lastrowid + + def get_monitor_by_kuma_id(self, kuma_monitor_id: int) -> Optional[TrackedMonitor]: + with self.get_connection() as conn: + row = conn.execute( + "SELECT * FROM monitors WHERE kuma_monitor_id = ?", (kuma_monitor_id,) + ).fetchone() + return self._row_to_monitor(row) if row else None + + def get_monitors_for_host(self, host_id: int) -> list[TrackedMonitor]: + with self.get_connection() as conn: + rows = conn.execute( + "SELECT * FROM monitors WHERE host_id = ? ORDER BY name", + (host_id,), + ).fetchall() + return [self._row_to_monitor(row) for row in rows] + + def get_monitors_for_hostname(self, hostname: str) -> list[TrackedMonitor]: + with self.get_connection() as conn: + rows = conn.execute( + """SELECT m.* FROM monitors m + JOIN hosts h ON m.host_id = h.id + WHERE h.hostname = ? + ORDER BY m.name""", + (hostname,), + ).fetchall() + return [self._row_to_monitor(row) for row in rows] + + def get_all_monitors(self) -> list[TrackedMonitor]: + with self.get_connection() as conn: + rows = conn.execute("SELECT * FROM monitors ORDER BY name").fetchall() + return [self._row_to_monitor(row) for row in rows] + + def update_monitor(self, monitor: TrackedMonitor): + with self.get_connection() as conn: + conn.execute( + """UPDATE monitors SET + name = ?, type = ?, target = ?, port = ?, + interval_seconds = ?, push_metric = ?, status = ?, synced_at = ? + WHERE id = ?""", + ( + monitor.name, + monitor.type, + monitor.target, + monitor.port, + monitor.interval_seconds, + monitor.push_metric, + monitor.status, + datetime.utcnow(), + monitor.id, + ), + ) + + def update_monitor_status(self, monitor_id: int, status: str): + with self.get_connection() as conn: + conn.execute( + "UPDATE monitors SET status = ?, synced_at = ? WHERE id = ?", + (status, datetime.utcnow(), monitor_id), + ) + + def mark_monitor_synced(self, monitor_id: int): + with self.get_connection() as conn: + conn.execute( + "UPDATE monitors SET synced_at = ? WHERE id = ?", + (datetime.utcnow(), monitor_id), + ) + + def delete_monitor(self, monitor_id: int): + with self.get_connection() as conn: + conn.execute("DELETE FROM monitors WHERE id = ?", (monitor_id,)) + + def _row_to_monitor(self, row: sqlite3.Row) -> TrackedMonitor: + return TrackedMonitor( + id=row["id"], + kuma_monitor_id=row["kuma_monitor_id"], + host_id=row["host_id"], + name=row["name"], + type=row["type"], + target=row["target"], + port=row["port"], + interval_seconds=row["interval_seconds"], + push_metric=row["push_metric"], + status=row["status"], + created_at=self._parse_timestamp(row["created_at"]), + synced_at=self._parse_timestamp(row["synced_at"]), + ) + + # Deployment operations + + def add_deployment(self, deployment: Deployment) -> int: + with self.get_connection() as conn: + cursor = conn.execute( + """INSERT INTO deployments + (monitor_id, host_id, script_path, scheduling_method, + scheduling_info, status) + VALUES (?, ?, ?, ?, ?, ?)""", + ( + deployment.monitor_id, + deployment.host_id, + deployment.script_path, + deployment.scheduling_method, + deployment.scheduling_info, + deployment.status, + ), + ) + return cursor.lastrowid + + def get_deployment_for_monitor(self, monitor_id: int) -> Optional[Deployment]: + with self.get_connection() as conn: + row = conn.execute( + "SELECT * FROM deployments WHERE monitor_id = ?", (monitor_id,) + ).fetchone() + return self._row_to_deployment(row) if row else None + + def update_deployment_status(self, deployment_id: int, status: str): + with self.get_connection() as conn: + conn.execute( + "UPDATE deployments SET status = ? WHERE id = ?", + (status, deployment_id), + ) + + def _row_to_deployment(self, row: sqlite3.Row) -> Deployment: + return Deployment( + id=row["id"], + monitor_id=row["monitor_id"], + host_id=row["host_id"], + script_path=row["script_path"], + scheduling_method=row["scheduling_method"], + scheduling_info=row["scheduling_info"], + deployed_at=self._parse_timestamp(row["deployed_at"]), + status=row["status"], + ) + + def _parse_timestamp(self, value) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + return value + try: + return datetime.fromisoformat(value) + except (ValueError, TypeError): + return None + + +# Global database instance +_database: Optional[Database] = None + + +def get_database() -> Database: + """Get the global database instance.""" + global _database + if _database is None: + _database = Database() + return _database diff --git a/backend/services/kuma_client.py b/backend/services/kuma_client.py index 212222b..61e570e 100644 --- a/backend/services/kuma_client.py +++ b/backend/services/kuma_client.py @@ -283,6 +283,50 @@ class UptimeKumaClient: self._disconnect() return False + # Tag management methods + + def get_tags(self) -> list[dict]: + """Get all tags.""" + try: + api = self._get_api() + return api.get_tags() + except Exception as e: + self._disconnect() + raise Exception(f"Failed to get tags: {str(e)}") + + def add_tag(self, name: str, color: str) -> dict: + """Create a new tag.""" + try: + api = self._get_api() + return api.add_tag(name=name, color=color) + except Exception as e: + self._disconnect() + raise Exception(f"Failed to add tag: {str(e)}") + + def add_monitor_tag(self, tag_id: int, monitor_id: int, value: str = "") -> dict: + """Add a tag to a monitor. + + Args: + tag_id: The tag ID + monitor_id: The monitor ID + value: Optional value for the tag (e.g., hostname) + """ + try: + api = self._get_api() + return api.add_monitor_tag(tag_id=tag_id, monitor_id=monitor_id, value=value) + except Exception as e: + self._disconnect() + raise Exception(f"Failed to add tag to monitor: {str(e)}") + + def delete_monitor_tag(self, tag_id: int, monitor_id: int, value: str = "") -> dict: + """Remove a tag from a monitor.""" + try: + api = self._get_api() + return api.delete_monitor_tag(tag_id=tag_id, monitor_id=monitor_id, value=value) + except Exception as e: + self._disconnect() + raise Exception(f"Failed to remove tag from monitor: {str(e)}") + def get_push_url(self, push_token: str) -> str: """Build the full push URL for a push monitor. diff --git a/backend/services/monitors.py b/backend/services/monitors.py index 0535866..c037ebd 100644 --- a/backend/services/monitors.py +++ b/backend/services/monitors.py @@ -5,6 +5,8 @@ import logging from services.kuma_client import get_kuma_client, Monitor from services.claude_agent import MonitorSuggestion from services.ssh_manager import get_ssh_manager +from services.database import get_database, TrackedMonitor, Deployment +from services.sync import get_sync_service from services import push_scripts logger = logging.getLogger(__name__) @@ -74,6 +76,56 @@ class MonitorService: def __init__(self): self.created_monitors: list[dict] = [] + def _create_and_track_monitor( + self, + monitor: Monitor, + hostname: str, + kuma, + ) -> dict: + """Create a monitor in Uptime Kuma and track it in the database.""" + try: + result = kuma.create_monitor(monitor) + kuma_monitor_id = result.get("monitorID") + + # Tag and track in database + if kuma_monitor_id: + try: + sync = get_sync_service() + sync.add_tag_to_monitor(kuma_monitor_id, hostname) + + db = get_database() + host = db.get_or_create_host(hostname) + tracked = TrackedMonitor( + id=None, + kuma_monitor_id=kuma_monitor_id, + host_id=host.id, + name=monitor.name, + type=monitor.type, + target=monitor.url or monitor.hostname or monitor.docker_container, + port=monitor.port, + interval_seconds=monitor.interval, + push_metric=None, + status="active", + ) + db.add_monitor(tracked) + logger.info(f"Tracked monitor '{monitor.name}' in database") + except Exception as e: + logger.warning(f"Failed to tag/track monitor: {e}") + + return { + "monitor": monitor.name, + "type": monitor.type, + "status": "created", + "result": result, + } + except Exception as e: + return { + "monitor": monitor.name, + "type": monitor.type, + "status": "failed", + "error": str(e), + } + def create_default_monitors( self, hostname: str, @@ -92,21 +144,8 @@ class MonitorService: # Host health monitors health_monitors = create_host_health_monitors(hostname, ssh_port) for monitor in health_monitors: - try: - result = kuma.create_monitor(monitor) - created.append({ - "monitor": monitor.name, - "type": monitor.type, - "status": "created", - "result": result, - }) - except Exception as e: - created.append({ - "monitor": monitor.name, - "type": monitor.type, - "status": "failed", - "error": str(e), - }) + result = self._create_and_track_monitor(monitor, hostname, kuma) + created.append(result) # Web server monitors if web_ports: @@ -114,41 +153,15 @@ class MonitorService: https = port == 443 or port == 8443 web_monitors = create_web_server_monitors(hostname, port, https) for monitor in web_monitors: - try: - result = kuma.create_monitor(monitor) - created.append({ - "monitor": monitor.name, - "type": monitor.type, - "status": "created", - "result": result, - }) - except Exception as e: - created.append({ - "monitor": monitor.name, - "type": monitor.type, - "status": "failed", - "error": str(e), - }) + result = self._create_and_track_monitor(monitor, hostname, kuma) + created.append(result) # Docker container monitors if has_docker and containers: docker_monitors = create_docker_container_monitors(hostname, containers) for monitor in docker_monitors: - try: - result = kuma.create_monitor(monitor) - created.append({ - "monitor": monitor.name, - "type": monitor.type, - "status": "created", - "result": result, - }) - except Exception as e: - created.append({ - "monitor": monitor.name, - "type": monitor.type, - "status": "failed", - "error": str(e), - }) + result = self._create_and_track_monitor(monitor, hostname, kuma) + created.append(result) self.created_monitors.extend(created) return created @@ -199,6 +212,34 @@ class MonitorService: try: result = kuma.create_monitor(monitor) + kuma_monitor_id = result.get("monitorID") + + # Add kuma-strapper tag and track in database + if kuma_monitor_id: + try: + sync = get_sync_service() + sync.add_tag_to_monitor(kuma_monitor_id, hostname) + + # Track in local database + db = get_database() + host = db.get_or_create_host(hostname) + tracked = TrackedMonitor( + id=None, + kuma_monitor_id=kuma_monitor_id, + host_id=host.id, + name=monitor.name, + type=monitor.type, + target=suggestion.target, + port=suggestion.port, + interval_seconds=suggestion.interval, + push_metric=suggestion.push_metric, + status="active", + ) + db.add_monitor(tracked) + logger.info(f"Tracked monitor '{monitor.name}' in database") + except Exception as e: + logger.warning(f"Failed to tag/track monitor: {e}") + response = { "monitor": monitor.name, "type": monitor.type, @@ -224,6 +265,26 @@ class MonitorService: port=port, ) response["deployment"] = deploy_result + + # Track deployment in database + if deploy_result.get("status") == "deployed": + try: + db = get_database() + host = db.get_or_create_host(hostname) + tracked = db.get_monitor_by_kuma_id(monitor_id) + if tracked: + deployment = Deployment( + id=None, + monitor_id=tracked.id, + host_id=host.id, + script_path=deploy_result.get("script_path", ""), + scheduling_method=deploy_result.get("scheduling", {}).get("method"), + scheduling_info=deploy_result.get("scheduling", {}).get("info"), + status="deployed", + ) + db.add_deployment(deployment) + except Exception as e: + logger.warning(f"Failed to track deployment: {e}") else: response["deployment"] = { "status": "failed", diff --git a/backend/services/sync.py b/backend/services/sync.py new file mode 100644 index 0000000..d820b6a --- /dev/null +++ b/backend/services/sync.py @@ -0,0 +1,325 @@ +"""Sync service for reconciling local database with Uptime Kuma.""" + +import logging +from datetime import datetime +from typing import Optional + +from services.database import get_database, TrackedMonitor, Host +from services.kuma_client import get_kuma_client + +logger = logging.getLogger(__name__) + +TAG_NAME = "kuma-strapper" +TAG_COLOR = "#5865F2" # Discord blurple + + +class SyncService: + """Service for syncing local database with Uptime Kuma.""" + + def __init__(self): + self.db = get_database() + self.kuma = get_kuma_client() + self._tag_id: Optional[int] = None + + def ensure_tag_exists(self) -> int: + """Get or create the kuma-strapper tag in Uptime Kuma. + + Returns: + The tag ID + """ + if self._tag_id is not None: + return self._tag_id + + try: + tags = self.kuma.get_tags() + for tag in tags: + if tag.get("name") == TAG_NAME: + self._tag_id = tag["id"] + logger.info(f"Found existing tag '{TAG_NAME}' with id {self._tag_id}") + return self._tag_id + + # Create tag if it doesn't exist + result = self.kuma.add_tag(name=TAG_NAME, color=TAG_COLOR) + self._tag_id = result.get("id") + logger.info(f"Created tag '{TAG_NAME}' with id {self._tag_id}") + return self._tag_id + except Exception as e: + logger.error(f"Failed to ensure tag exists: {e}") + raise + + def get_tag_id(self) -> int: + """Get the kuma-strapper tag ID, creating it if needed.""" + if self._tag_id is None: + return self.ensure_tag_exists() + return self._tag_id + + def add_tag_to_monitor(self, monitor_id: int, hostname: str): + """Add the kuma-strapper tag to a monitor. + + Args: + monitor_id: The Uptime Kuma monitor ID + hostname: The source hostname (stored as tag value) + """ + tag_id = self.get_tag_id() + self.kuma.add_monitor_tag(tag_id=tag_id, monitor_id=monitor_id, value=hostname) + logger.info(f"Added tag to monitor {monitor_id} with hostname '{hostname}'") + + def full_sync(self) -> dict: + """Sync all monitors with kuma-strapper tag from Uptime Kuma. + + Returns: + Summary dict with added, updated, removed counts and any errors + """ + logger.info("Starting full sync with Uptime Kuma") + added, updated, removed = 0, 0, 0 + errors = [] + + try: + self.ensure_tag_exists() + monitors = self.kuma.get_monitors() + + # Track which Kuma IDs we've seen + seen_kuma_ids = set() + + for monitor in monitors: + tag_info = self._get_kuma_strapper_tag(monitor) + if not tag_info: + continue # Not managed by kuma-strapper + + kuma_id = monitor["id"] + seen_kuma_ids.add(kuma_id) + hostname = tag_info.get("value", "unknown") + + # Get or create host + host = self.db.get_or_create_host(hostname) + + # Check if we already track this monitor + existing = self.db.get_monitor_by_kuma_id(kuma_id) + + if existing: + # Update if changed + if self._monitor_changed(existing, monitor): + self._update_tracked_monitor(existing, monitor) + updated += 1 + self.db.mark_monitor_synced(existing.id) + else: + # New monitor - import it + self._import_monitor(monitor, host, hostname) + added += 1 + + # Mark monitors deleted in Kuma + all_local = self.db.get_all_monitors() + for local in all_local: + if local.kuma_monitor_id not in seen_kuma_ids: + if local.status != "deleted_in_kuma": + self.db.update_monitor_status(local.id, "deleted_in_kuma") + removed += 1 + logger.info( + f"Marked monitor '{local.name}' (kuma_id={local.kuma_monitor_id}) " + "as deleted_in_kuma" + ) + + except Exception as e: + logger.error(f"Full sync failed: {e}") + errors.append(str(e)) + + result = { + "added": added, + "updated": updated, + "removed": removed, + "errors": errors, + } + logger.info(f"Full sync complete: {result}") + return result + + def sync_host(self, hostname: str) -> dict: + """Sync monitors for a specific host. + + Args: + hostname: The hostname to sync + + Returns: + Summary dict with added, updated, removed counts and any errors + """ + logger.info(f"Starting host sync for '{hostname}'") + added, updated, removed = 0, 0, 0 + errors = [] + + try: + self.ensure_tag_exists() + monitors = self.kuma.get_monitors() + + host = self.db.get_or_create_host(hostname) + seen_kuma_ids = set() + + for monitor in monitors: + tag_info = self._get_kuma_strapper_tag(monitor) + if not tag_info: + continue + + tag_hostname = tag_info.get("value", "") + if tag_hostname != hostname: + continue + + kuma_id = monitor["id"] + seen_kuma_ids.add(kuma_id) + + existing = self.db.get_monitor_by_kuma_id(kuma_id) + + if existing: + if self._monitor_changed(existing, monitor): + self._update_tracked_monitor(existing, monitor) + updated += 1 + self.db.mark_monitor_synced(existing.id) + else: + self._import_monitor(monitor, host, hostname) + added += 1 + + # Mark host's monitors as deleted if not in Kuma + local_monitors = self.db.get_monitors_for_host(host.id) + for local in local_monitors: + if local.kuma_monitor_id not in seen_kuma_ids: + if local.status != "deleted_in_kuma": + self.db.update_monitor_status(local.id, "deleted_in_kuma") + removed += 1 + + self.db.update_host_sync_time(host.id) + + except Exception as e: + logger.error(f"Host sync failed for '{hostname}': {e}") + errors.append(str(e)) + + result = { + "hostname": hostname, + "added": added, + "updated": updated, + "removed": removed, + "errors": errors, + } + logger.info(f"Host sync complete for '{hostname}': {result}") + return result + + def _get_kuma_strapper_tag(self, monitor: dict) -> Optional[dict]: + """Extract kuma-strapper tag info from monitor if present. + + Args: + monitor: Monitor dict from Uptime Kuma + + Returns: + Tag dict with id, name, value, color or None if not found + """ + for tag in monitor.get("tags", []): + if tag.get("name") == TAG_NAME: + return tag + return None + + def _import_monitor(self, kuma_monitor: dict, host: Host, hostname: str): + """Import a monitor from Uptime Kuma into local DB. + + Args: + kuma_monitor: Monitor dict from Uptime Kuma + host: Local Host object + hostname: The hostname string + """ + monitor = TrackedMonitor( + id=None, + kuma_monitor_id=kuma_monitor["id"], + host_id=host.id, + name=kuma_monitor.get("name", "Unknown"), + type=self._kuma_type_to_string(kuma_monitor.get("type")), + target=self._extract_target(kuma_monitor), + port=kuma_monitor.get("port"), + interval_seconds=kuma_monitor.get("interval", 60), + push_metric=None, # Can't determine from Kuma API + status="active" if kuma_monitor.get("active", True) else "paused", + synced_at=datetime.utcnow(), + ) + self.db.add_monitor(monitor) + logger.info(f"Imported monitor '{monitor.name}' (kuma_id={monitor.kuma_monitor_id})") + + def _monitor_changed(self, local: TrackedMonitor, kuma: dict) -> bool: + """Check if a monitor has changed in Uptime Kuma. + + Args: + local: Local TrackedMonitor + kuma: Monitor dict from Uptime Kuma + + Returns: + True if the monitor has changed + """ + if local.name != kuma.get("name"): + return True + if local.interval_seconds != kuma.get("interval", 60): + return True + kuma_active = kuma.get("active", True) + local_active = local.status == "active" + if local_active != kuma_active: + return True + return False + + def _update_tracked_monitor(self, local: TrackedMonitor, kuma: dict): + """Update a tracked monitor with data from Uptime Kuma. + + Args: + local: Local TrackedMonitor to update + kuma: Monitor dict from Uptime Kuma + """ + local.name = kuma.get("name", local.name) + local.interval_seconds = kuma.get("interval", 60) + local.status = "active" if kuma.get("active", True) else "paused" + local.target = self._extract_target(kuma) + local.port = kuma.get("port") + self.db.update_monitor(local) + logger.info(f"Updated monitor '{local.name}' (kuma_id={local.kuma_monitor_id})") + + def _kuma_type_to_string(self, kuma_type) -> str: + """Convert Uptime Kuma monitor type to string. + + The uptime-kuma-api returns types as MonitorType enum values. + """ + if kuma_type is None: + return "unknown" + + # Handle MonitorType enum or string + type_str = str(kuma_type) + + # Map common types + type_map = { + "MonitorType.HTTP": "http", + "MonitorType.PORT": "tcp", + "MonitorType.PING": "ping", + "MonitorType.KEYWORD": "keyword", + "MonitorType.DOCKER": "docker", + "MonitorType.PUSH": "push", + } + + return type_map.get(type_str, type_str.lower().replace("monitortype.", "")) + + def _extract_target(self, monitor: dict) -> Optional[str]: + """Extract the target from a monitor based on its type. + + Args: + monitor: Monitor dict from Uptime Kuma + + Returns: + The target URL, hostname, or container name + """ + if monitor.get("url"): + return monitor["url"] + if monitor.get("hostname"): + return monitor["hostname"] + if monitor.get("docker_container"): + return monitor["docker_container"] + return None + + +# Global sync service instance +_sync_service: Optional[SyncService] = None + + +def get_sync_service() -> SyncService: + """Get the global sync service instance.""" + global _sync_service + if _sync_service is None: + _sync_service = SyncService() + return _sync_service diff --git a/docker-compose.yml b/docker-compose.yml index ac61e82..a562d64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,10 @@ services: - DEV_MODE=${DEV_MODE:-false} # Optional: Enable debug mode - DEBUG=${DEBUG:-false} + # Database path (inside container) + - DATABASE_PATH=/app/data/kuma_strapper.db + volumes: + - ./data:/app/data restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"] diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a9a7291..0bfd447 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,6 +17,7 @@ export default function App() { const [scanProgress, setScanProgress] = useState({}); const [scanResults, setScanResults] = useState({}); const [analysisResults, setAnalysisResults] = useState({}); + const [existingMonitors, setExistingMonitors] = useState({}); const [kumaAuth, setKumaAuth] = useState({ authenticated: false, url: '' }); const [showKumaLogin, setShowKumaLogin] = useState(false); @@ -94,6 +95,13 @@ export default function App() { setPendingApprovals(prev => prev.filter(r => r.id !== request.id)); }); + socket.on('host_sync_complete', (data) => { + setExistingMonitors(prev => ({ + ...prev, + [data.hostname]: data.existing_monitors || [], + })); + }); + return () => { socket.off('connect'); socket.off('disconnect'); @@ -105,6 +113,7 @@ export default function App() { socket.off('analysis_error'); socket.off('approval_request'); socket.off('approval_resolved'); + socket.off('host_sync_complete'); }; }, []); @@ -183,6 +192,7 @@ export default function App() { scanProgress={scanProgress} scanResults={scanResults} analysisResults={analysisResults} + existingMonitors={existingMonitors} devMode={settings.dev_mode} /> diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 463fc17..8f91a84 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -81,4 +81,13 @@ export const api = { body: JSON.stringify({ username, password, totp }), }), kumaLogout: () => fetchApi('/kuma/logout', { method: 'POST' }), + + // Sync + triggerSync: () => fetchApi('/sync', { method: 'POST' }), + syncHost: (hostname) => fetchApi(`/sync/host/${encodeURIComponent(hostname)}`, { method: 'POST' }), + + // Hosts and tracked monitors + getHosts: () => fetchApi('/hosts'), + getHostMonitors: (hostname) => fetchApi(`/hosts/${encodeURIComponent(hostname)}/monitors`), + getTrackedMonitors: () => fetchApi('/monitors/tracked'), }; diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 26ff0b1..037faca 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -3,7 +3,7 @@ import { api } from '../api/client'; import HostCard from './HostCard'; import DiscoveryResults from './DiscoveryResults'; -export default function Dashboard({ scanProgress, scanResults, analysisResults, devMode }) { +export default function Dashboard({ scanProgress, scanResults, analysisResults, existingMonitors, devMode }) { const [hostname, setHostname] = useState(''); const [username, setUsername] = useState('root'); const [port, setPort] = useState('22'); @@ -133,6 +133,7 @@ export default function Dashboard({ scanProgress, scanResults, analysisResults, scanId={currentScanId} scan={currentScan} analysis={currentAnalysis} + existingMonitors={existingMonitors?.[hostname] || []} devMode={devMode} onCommandApproved={async (command) => { await api.runCommand(currentScanId, command, 'User approved from UI'); diff --git a/frontend/src/components/DiscoveryResults.jsx b/frontend/src/components/DiscoveryResults.jsx index c287fe6..f869c90 100644 --- a/frontend/src/components/DiscoveryResults.jsx +++ b/frontend/src/components/DiscoveryResults.jsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { api } from '../api/client'; -export default function DiscoveryResults({ scanId, scan, analysis, devMode, onCommandApproved, onQuestionAnswered }) { +export default function DiscoveryResults({ scanId, scan, analysis, existingMonitors, devMode, onCommandApproved, onQuestionAnswered }) { const [selectedMonitors, setSelectedMonitors] = useState([]); const [creatingDefaults, setCreatingDefaults] = useState(false); const [creatingSuggested, setCreatingSuggested] = useState(false); @@ -194,6 +194,66 @@ export default function DiscoveryResults({ scanId, scan, analysis, devMode, onCo )} + {/* Existing Monitors */} + {existingMonitors && existingMonitors.length > 0 && ( +
+

+ + + + Existing Monitors ({existingMonitors.length}) + + Already tracked for this host + +

+
+ {existingMonitors.map((monitor, index) => ( +
+
+ + + {monitor.type.toUpperCase()} + {monitor.push_metric && ( + ({monitor.push_metric}) + )} + + {monitor.name} + {monitor.status === 'deleted_in_kuma' && ( + + Deleted in Uptime Kuma + + )} + {monitor.status === 'paused' && ( + + Paused + + )} +
+
+ ))} +
+
+ )} + {/* Suggested Monitors */} {analysis.monitors && analysis.monitors.length > 0 && (