commit ea49143a13249e476130b7ee1d1b34ed4ebf0938 Author: Debian Date: Sun Jan 4 21:38:50 2026 +0000 Initial commit with CI workflow - Flask backend with SSH discovery and Claude AI integration - React/Vite frontend with Tailwind CSS - Docker multi-stage build - Gitea Actions workflow for container builds 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dcf6db0 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Kuma Strapper Environment Variables + +# Required: Base64-encoded SSH private key +# To encode your key: base64 -w 0 ~/.ssh/id_rsa +SSH_PRIVATE_KEY= + +# Required: Uptime Kuma instance URL +UPTIME_KUMA_URL=http://localhost:3001 + +# Required: Uptime Kuma API token +# Get this from Uptime Kuma Settings > API Keys +UPTIME_KUMA_API_KEY= + +# Required: Claude/Anthropic API key +# Get this from https://console.anthropic.com/ +CLAUDE_API_KEY= + +# Optional: Enable dev mode on startup +# When enabled, Claude-suggested SSH commands require approval +DEV_MODE=false + +# Optional: Enable debug mode for development +DEBUG=false diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..703aedf --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,30 @@ +name: Build Container + +on: + push: + branches: + - main + - master + pull_request: + branches: + - main + - master + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: false + tags: kuma-strapper:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19ab1d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Environment +.env +.env.local +.env.*.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +ENV/ +env/ +.venv/ + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +frontend/dist/ +*.egg-info/ +dist/ +build/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Docker +docker-compose.override.yml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e37aeca --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Run Commands + +### Docker (Production) +```bash +docker compose up --build # Build and run +docker compose up -d # Run detached +``` + +### Local Development +```bash +# Backend (Flask + SocketIO on port 5000) +cd backend +pip install -r requirements.txt +python app.py + +# Frontend (Vite dev server on port 5173, proxies to backend) +cd frontend +npm install +npm run dev + +# Frontend production build +npm run build +``` + +### Environment Setup +Copy `.env.example` to `.env` and set: +- `SSH_PRIVATE_KEY` - Base64-encoded SSH key (`base64 -w 0 ~/.ssh/id_rsa`) +- `UPTIME_KUMA_URL` - Uptime Kuma instance URL +- `UPTIME_KUMA_API_KEY` - Uptime Kuma API token +- `CLAUDE_API_KEY` - Anthropic API key +- `DEV_MODE` - When true, Claude-suggested SSH commands require user approval + +## Architecture + +**Stack**: Python Flask backend + React/Vite frontend + WebSocket (Socket.io) for real-time updates + +### Data Flow +1. User submits hostname → `POST /api/scan` starts background thread +2. SSH Manager connects and runs safe discovery commands (docker ps, systemctl, df, etc.) +3. WebSocket emits `scan_progress` events as commands complete +4. On completion, Claude Agent analyzes results and suggests monitors +5. User selects monitors → created in Uptime Kuma via REST API + +### Backend Services (`backend/services/`) +- **ssh_manager.py** - Paramiko-based SSH connection pool, supports RSA/Ed25519/ECDSA keys +- **discovery.py** - Runs safe commands on hosts, defines `SAFE_DISCOVERY_COMMANDS` whitelist +- **claude_agent.py** - Claude API integration with system prompt for monitor suggestions +- **kuma_client.py** - Uptime Kuma REST API wrapper +- **monitors.py** - Creates monitors from discovery data or Claude suggestions + +### Approval System (`backend/utils/approval.py`) +When `DEV_MODE=true`, non-whitelisted SSH commands from Claude are queued for user approval. Safe commands (read-only patterns like `cat`, `docker ps`, `systemctl status`) execute immediately. + +### Frontend Components (`frontend/src/components/`) +- **Dashboard.jsx** - Scan form and results display +- **DiscoveryResults.jsx** - Claude analysis, monitor selection checkboxes +- **ApprovalModal.jsx** - Dev mode command approval UI +- **DevModeToggle.jsx** - Toggle switch for dev mode + +### API Routes (defined in `backend/app.py`) +- `POST /api/scan` - Initiate host scan +- `GET /api/scan/` - Get scan results +- `POST /api/scan//command` - Run additional SSH command +- `GET/POST /api/approvals/*` - Approval queue management +- `POST /api/monitors/create-defaults` - Create ping/SSH monitors +- `POST /api/monitors/create-suggested` - Create Claude-suggested monitors + +### WebSocket Events +- `scan_progress`, `scan_complete` - Scan lifecycle +- `analysis_started`, `analysis_complete`, `analysis_update` - Claude analysis +- `approval_request`, `approval_resolved` - Dev mode approvals + +## Key Implementation Details + +- Flask serves built frontend from `frontend/dist/` in production (see `serve_frontend` route) +- Vite proxies `/api` and `/socket.io` to Flask in development +- SSH key is decoded from base64 at runtime, never written to disk +- Claude uses `claude-sonnet-4-20250514` model with structured JSON responses diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fb1d7a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# Stage 1: Build frontend +FROM node:20-alpine AS frontend-builder + +WORKDIR /app/frontend + +# Copy package files +COPY frontend/package.json frontend/package-lock.json* ./ + +# Install dependencies +RUN npm install + +# Copy frontend source +COPY frontend/ ./ + +# Build frontend +RUN npm run build + +# Stage 2: Production image +FROM python:3.12-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/backend + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy backend requirements and install +COPY backend/requirements.txt ./backend/ +RUN pip install --no-cache-dir -r backend/requirements.txt + +# Copy backend source +COPY backend/ ./backend/ + +# Copy built frontend from first stage +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist + +# Create non-root user +RUN useradd -m -u 1000 appuser && \ + chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 5000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:5000/api/health || exit 1 + +# Start the application +WORKDIR /app/backend +CMD ["python", "app.py"] diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..aebb7d4 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,444 @@ +import os +import threading +from flask import Flask, jsonify, request, send_from_directory +from flask_cors import CORS +from flask_socketio import SocketIO, emit + +from config import get_config, set_dev_mode + +# Path to frontend build +FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist") +from services.ssh_manager import get_ssh_manager +from services.discovery import get_discovery_service, DiscoveryResult +from services.claude_agent import create_agent, AgentResponse +from services.monitors import ( + get_monitor_service, + parse_web_ports_from_scan, + parse_docker_containers_from_scan, +) +from services.kuma_client import get_kuma_client +from utils.approval import get_approval_queue, ApprovalStatus + + +app = Flask(__name__) +CORS(app, origins=["http://localhost:5173", "http://localhost:3000"]) +socketio = SocketIO(app, cors_allowed_origins="*", async_mode="gevent") + +# Store for active scans and their Claude agents +active_scans: dict[str, dict] = {} + + +# Setup approval queue callbacks for WebSocket notifications +def on_approval_added(request): + socketio.emit("approval_request", request.to_dict()) + + +def on_approval_resolved(request): + socketio.emit("approval_resolved", request.to_dict()) + + +approval_queue = get_approval_queue() +approval_queue.set_callbacks(on_approval_added, on_approval_resolved) + + +# Serve frontend static files +@app.route("/", defaults={"path": ""}) +@app.route("/") +def serve_frontend(path): + if path and os.path.exists(os.path.join(FRONTEND_DIR, path)): + return send_from_directory(FRONTEND_DIR, path) + return send_from_directory(FRONTEND_DIR, "index.html") + + +# Health check +@app.route("/api/health") +def health(): + return jsonify({"status": "ok"}) + + +# Settings endpoints +@app.route("/api/settings", methods=["GET"]) +def get_settings(): + config = get_config() + return jsonify({ + "dev_mode": config.dev_mode, + "uptime_kuma_url": config.uptime_kuma_url, + "has_ssh_key": bool(config.ssh_private_key), + "has_claude_key": bool(config.claude_api_key), + "has_kuma_key": bool(config.uptime_kuma_api_key), + }) + + +@app.route("/api/settings", methods=["PUT"]) +def update_settings(): + data = request.json + if "dev_mode" in data: + set_dev_mode(data["dev_mode"]) + return jsonify({"status": "ok"}) + + +# Scan endpoints +@app.route("/api/scan", methods=["POST"]) +def start_scan(): + data = request.json + hostname = data.get("hostname") + username = data.get("username", "root") + port = data.get("port", 22) + + if not hostname: + return jsonify({"error": "hostname is required"}), 400 + + # Start scan in background thread + def run_scan(): + discovery = get_discovery_service() + + def on_progress(cmd_name, status): + socketio.emit("scan_progress", { + "hostname": hostname, + "command": cmd_name, + "status": status, + }) + + result = discovery.scan_host(hostname, username, port, on_progress) + active_scans[result.scan_id] = { + "result": result, + "agent": None, + "suggestions": None, + } + + socketio.emit("scan_complete", { + "scan_id": result.scan_id, + "hostname": hostname, + "connected": result.connected, + "error": result.error, + }) + + # If scan succeeded, analyze with Claude + if result.connected: + analyze_with_claude(result) + + thread = threading.Thread(target=run_scan) + thread.start() + + return jsonify({"status": "started", "hostname": hostname}) + + +def analyze_with_claude(result: DiscoveryResult): + """Analyze scan results with Claude agent.""" + socketio.emit("analysis_started", {"scan_id": result.scan_id}) + + try: + agent = create_agent() + response = agent.analyze_host(result.to_dict(), result.hostname) + + active_scans[result.scan_id]["agent"] = agent + active_scans[result.scan_id]["suggestions"] = response + + socketio.emit("analysis_complete", { + "scan_id": result.scan_id, + "analysis": response.analysis, + "monitors": [ + { + "type": m.type, + "name": m.name, + "target": m.target, + "port": m.port, + "interval": m.interval, + "reason": m.reason, + } + for m in response.monitors + ], + "additional_commands": [ + {"command": c.command, "reason": c.reason} + for c in response.additional_commands + ], + "questions": response.questions, + }) + + except Exception as e: + socketio.emit("analysis_error", { + "scan_id": result.scan_id, + "error": str(e), + }) + + +@app.route("/api/scan/", methods=["GET"]) +def get_scan(scan_id): + scan_data = active_scans.get(scan_id) + if not scan_data: + return jsonify({"error": "Scan not found"}), 404 + + result = scan_data["result"] + suggestions = scan_data.get("suggestions") + + response_data = { + "scan_id": result.scan_id, + "hostname": result.hostname, + "connected": result.connected, + "error": result.error, + "data": result.to_dict(), + } + + if suggestions: + response_data["suggestions"] = { + "analysis": suggestions.analysis, + "monitors": [ + { + "type": m.type, + "name": m.name, + "target": m.target, + "port": m.port, + "interval": m.interval, + "reason": m.reason, + } + for m in suggestions.monitors + ], + "additional_commands": [ + {"command": c.command, "reason": c.reason} + for c in suggestions.additional_commands + ], + "questions": suggestions.questions, + } + + return jsonify(response_data) + + +# Execute additional command (requires approval in dev mode) +@app.route("/api/scan//command", methods=["POST"]) +def run_additional_command(scan_id): + scan_data = active_scans.get(scan_id) + if not scan_data: + return jsonify({"error": "Scan not found"}), 404 + + data = request.json + command = data.get("command") + reason = data.get("reason", "User requested") + + if not command: + return jsonify({"error": "command is required"}), 400 + + result = scan_data["result"] + config = get_config() + discovery = get_discovery_service() + + # Check if command is safe (built-in) + if discovery.is_safe_command(command): + # Execute immediately + cmd_result = discovery.run_additional_command( + result.hostname, command, result.username, result.port + ) + return jsonify({ + "status": "completed", + "output": cmd_result.stdout, + "error": cmd_result.stderr, + "exit_code": cmd_result.exit_code, + }) + + # In dev mode, require approval + if config.dev_mode: + approval_request = approval_queue.add_ssh_command( + command, reason, result.hostname + ) + return jsonify({ + "status": "pending_approval", + "approval_id": approval_request.id, + "message": "Command requires approval in dev mode", + }) + + # In production mode, block non-safe commands from Claude + return jsonify({ + "status": "blocked", + "message": "This command is not in the safe list and dev mode is disabled", + }), 403 + + +# Approval endpoints +@app.route("/api/approvals", methods=["GET"]) +def get_pending_approvals(): + pending = approval_queue.get_pending() + return jsonify({ + "approvals": [r.to_dict() for r in pending], + }) + + +@app.route("/api/approvals//approve", methods=["POST"]) +def approve_request(approval_id): + request_obj = approval_queue.approve(approval_id) + if not request_obj: + return jsonify({"error": "Approval not found or already resolved"}), 404 + + # If it was an SSH command, execute it now + if request_obj.type.value == "ssh_command": + hostname = request_obj.details["hostname"] + command = request_obj.details["command"] + + # Find the scan for this hostname + for scan_id, scan_data in active_scans.items(): + if scan_data["result"].hostname == hostname: + discovery = get_discovery_service() + result = scan_data["result"] + cmd_result = discovery.run_additional_command( + hostname, command, result.username, result.port + ) + + # Send result via WebSocket + socketio.emit("command_result", { + "approval_id": approval_id, + "command": command, + "output": cmd_result.stdout, + "error": cmd_result.stderr, + "exit_code": cmd_result.exit_code, + }) + + # Feed result back to Claude if agent exists + agent = scan_data.get("agent") + if agent: + output = cmd_result.stdout if cmd_result.success else cmd_result.stderr + response = agent.process_command_results(command, output) + scan_data["suggestions"] = response + + socketio.emit("analysis_update", { + "scan_id": scan_id, + "analysis": response.analysis, + "monitors": [ + { + "type": m.type, + "name": m.name, + "target": m.target, + "port": m.port, + "interval": m.interval, + "reason": m.reason, + } + for m in response.monitors + ], + "additional_commands": [ + {"command": c.command, "reason": c.reason} + for c in response.additional_commands + ], + "questions": response.questions, + }) + + break + + return jsonify({"status": "approved", "request": request_obj.to_dict()}) + + +@app.route("/api/approvals//reject", methods=["POST"]) +def reject_request(approval_id): + request_obj = approval_queue.reject(approval_id) + if not request_obj: + return jsonify({"error": "Approval not found or already resolved"}), 404 + + return jsonify({"status": "rejected", "request": request_obj.to_dict()}) + + +# Monitor endpoints +@app.route("/api/monitors", methods=["GET"]) +def get_monitors(): + try: + kuma = get_kuma_client() + monitors = kuma.get_monitors() + return jsonify({"monitors": monitors}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/monitors/create-defaults", methods=["POST"]) +def create_default_monitors(): + data = request.json + scan_id = data.get("scan_id") + + if not scan_id: + return jsonify({"error": "scan_id is required"}), 400 + + scan_data = active_scans.get(scan_id) + if not scan_data: + return jsonify({"error": "Scan not found"}), 404 + + result = scan_data["result"] + monitor_service = get_monitor_service() + + # Parse discovered services + web_ports = parse_web_ports_from_scan(result.open_ports) + containers = parse_docker_containers_from_scan(result.docker_containers) + has_docker = "Docker not available" not in result.docker_containers + + created = monitor_service.create_default_monitors( + hostname=result.hostname, + ssh_port=result.port, + has_docker=has_docker, + containers=containers, + web_ports=web_ports, + ) + + return jsonify({"created": created}) + + +@app.route("/api/monitors/create-suggested", methods=["POST"]) +def create_suggested_monitors(): + data = request.json + scan_id = data.get("scan_id") + monitor_indices = data.get("monitors", []) # List of indices to create + + if not scan_id: + return jsonify({"error": "scan_id is required"}), 400 + + scan_data = active_scans.get(scan_id) + if not scan_data: + return jsonify({"error": "Scan not found"}), 404 + + suggestions = scan_data.get("suggestions") + if not suggestions: + return jsonify({"error": "No suggestions available"}), 400 + + result = scan_data["result"] + monitor_service = get_monitor_service() + created = [] + + for idx in monitor_indices: + if 0 <= idx < len(suggestions.monitors): + suggestion = suggestions.monitors[idx] + monitor_result = monitor_service.create_from_suggestion( + suggestion, result.hostname + ) + created.append(monitor_result) + + return jsonify({"created": created}) + + +# Test Uptime Kuma connection +@app.route("/api/kuma/test", methods=["GET"]) +def test_kuma_connection(): + try: + kuma = get_kuma_client() + connected = kuma.test_connection() + return jsonify({"connected": connected}) + except Exception as e: + return jsonify({"connected": False, "error": str(e)}) + + +# WebSocket events +@socketio.on("connect") +def handle_connect(): + emit("connected", {"status": "ok"}) + + +@socketio.on("disconnect") +def handle_disconnect(): + pass + + +if __name__ == "__main__": + # Validate config on startup + config = get_config() + errors = config.validate() + if errors: + print("Configuration errors:") + for error in errors: + print(f" - {error}") + print("\nSet the required environment variables and restart.") + else: + print("Configuration OK") + print(f"Dev mode: {'enabled' if config.dev_mode else 'disabled'}") + + 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 new file mode 100644 index 0000000..155d2c0 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,65 @@ +import os +import base64 +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class Config: + """Application configuration loaded from environment variables.""" + + ssh_private_key: str + uptime_kuma_url: str + uptime_kuma_api_key: str + claude_api_key: str + dev_mode: bool = False + + @classmethod + def from_env(cls) -> "Config": + """Load configuration from environment variables.""" + ssh_key_b64 = os.environ.get("SSH_PRIVATE_KEY", "") + + # Decode base64 SSH key + try: + ssh_private_key = base64.b64decode(ssh_key_b64).decode("utf-8") if ssh_key_b64 else "" + except Exception: + ssh_private_key = ssh_key_b64 # Allow plain text for development + + return cls( + ssh_private_key=ssh_private_key, + uptime_kuma_url=os.environ.get("UPTIME_KUMA_URL", "http://localhost:3001"), + uptime_kuma_api_key=os.environ.get("UPTIME_KUMA_API_KEY", ""), + claude_api_key=os.environ.get("CLAUDE_API_KEY", ""), + dev_mode=os.environ.get("DEV_MODE", "false").lower() == "true", + ) + + def validate(self) -> list[str]: + """Validate configuration and return list of errors.""" + errors = [] + if not self.ssh_private_key: + errors.append("SSH_PRIVATE_KEY is required") + if not self.uptime_kuma_url: + errors.append("UPTIME_KUMA_URL is required") + if not self.uptime_kuma_api_key: + errors.append("UPTIME_KUMA_API_KEY is required") + if not self.claude_api_key: + errors.append("CLAUDE_API_KEY is required") + return errors + + +# Global config instance +_config: Optional[Config] = None + + +def get_config() -> Config: + """Get the global configuration instance.""" + global _config + if _config is None: + _config = Config.from_env() + return _config + + +def set_dev_mode(enabled: bool) -> None: + """Update dev mode setting.""" + config = get_config() + config.dev_mode = enabled diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..afa7d15 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +flask==3.0.0 +flask-cors==4.0.0 +flask-socketio==5.3.6 +paramiko==3.4.0 +anthropic==0.39.0 +requests==2.31.0 +python-dotenv==1.0.0 +gevent==23.9.1 +gevent-websocket==0.10.1 diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..a70b302 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/backend/services/claude_agent.py b/backend/services/claude_agent.py new file mode 100644 index 0000000..0694215 --- /dev/null +++ b/backend/services/claude_agent.py @@ -0,0 +1,273 @@ +import json +from typing import Optional +from dataclasses import dataclass + +from anthropic import Anthropic + +from config import get_config + + +SYSTEM_PROMPT = """You are an intelligent monitoring configuration assistant for Uptime Kuma. Your role is to analyze system information from hosts and recommend what should be monitored. + +## Your Capabilities +1. Analyze host scan results (OS info, running services, Docker containers, open ports) +2. Suggest monitors to create in Uptime Kuma +3. Request additional SSH commands to gather more information when needed +4. Explain your monitoring recommendations + +## Rules for Suggestions +1. **Always explain WHY** you want to monitor something - what failure would it detect? +2. **Be specific** with monitor configurations (ports, paths, intervals) +3. **Prioritize critical services** - databases, web servers, auth services come first +4. **Suggest appropriate intervals** based on criticality: + - Critical services (databases, auth): 30-60 seconds + - Web services: 60-120 seconds + - Background jobs: 300 seconds +5. **Look for health endpoints** - prefer /health, /healthz, /status over root paths +6. **Consider dependencies** - if a service depends on another, both should be monitored + +## Rules for SSH Commands +When you need more information, you can request SSH commands. Follow these rules: +1. **Read-only only** - never suggest commands that modify the system +2. **Be specific** - explain exactly what information you need and why +3. **Safe commands only** - no sudo unless absolutely necessary for reading +4. **Examples of acceptable commands:** + - `curl -s localhost:8080/health` - check if a service responds + - `cat /etc/nginx/nginx.conf` - read configuration + - `docker inspect ` - get container details + - `systemctl status ` - check service status + +## Response Format +Always respond with valid JSON in this structure: +{ + "analysis": "Your analysis of what you found on the host", + "monitors": [ + { + "type": "http|tcp|ping|docker|keyword", + "name": "Human-readable monitor name", + "target": "URL, hostname, or container name", + "port": 80, + "interval": 60, + "reason": "Why this should be monitored" + } + ], + "additional_commands": [ + { + "command": "the SSH command to run", + "reason": "why you need this information" + } + ], + "questions": ["Any questions for the user about what to monitor"] +} + +## Monitor Types +- **http**: Web endpoints (provide full URL with protocol) +- **tcp**: Port connectivity (provide hostname and port) +- **ping**: Host availability (provide hostname) +- **docker**: Docker container status (provide container name) +- **keyword**: Check for specific text in response (provide URL and keyword) + +Be thorough but not excessive. Quality over quantity - suggest monitors that will actually catch real problems.""" + + +@dataclass +class MonitorSuggestion: + """A suggested monitor configuration.""" + + type: str + name: str + target: str + port: Optional[int] = None + interval: int = 60 + reason: str = "" + keyword: Optional[str] = None + + +@dataclass +class CommandRequest: + """A request to run an SSH command.""" + + command: str + reason: str + + +@dataclass +class AgentResponse: + """Response from the Claude agent.""" + + analysis: str + monitors: list[MonitorSuggestion] + additional_commands: list[CommandRequest] + questions: list[str] + raw_response: str + + +class ClaudeAgent: + """Claude AI agent for intelligent monitoring suggestions.""" + + def __init__(self): + config = get_config() + self.client = Anthropic(api_key=config.claude_api_key) + self.conversation_history: list[dict] = [] + + def analyze_host(self, scan_results: dict, hostname: str) -> AgentResponse: + """Analyze host scan results and suggest monitors.""" + user_message = f"""I've scanned the host '{hostname}' and gathered the following information: + +## System Information +``` +{scan_results.get('system_info', 'Not available')} +``` + +## OS Release +``` +{scan_results.get('os_release', 'Not available')} +``` + +## Running Docker Containers +``` +{scan_results.get('docker_containers', 'No Docker or no containers running')} +``` + +## Running Systemd Services +``` +{scan_results.get('systemd_services', 'Not available')} +``` + +## Disk Usage +``` +{scan_results.get('disk_usage', 'Not available')} +``` + +## Memory Usage +``` +{scan_results.get('memory_usage', 'Not available')} +``` + +## CPU Info +``` +{scan_results.get('cpu_count', 'Not available')} CPU cores +``` + +## Open Ports (Listening) +``` +{scan_results.get('open_ports', 'Not available')} +``` + +Please analyze this information and suggest what should be monitored in Uptime Kuma. +Respond with JSON as specified in your instructions.""" + + return self._send_message(user_message) + + def process_command_results(self, command: str, result: str) -> AgentResponse: + """Process the results of an additional SSH command.""" + user_message = f"""Here are the results of the command you requested: + +Command: `{command}` + +Output: +``` +{result} +``` + +Please update your analysis and suggestions based on this new information. +Respond with JSON as specified in your instructions.""" + + return self._send_message(user_message) + + def answer_question(self, question: str, answer: str) -> AgentResponse: + """Process user's answer to a question.""" + user_message = f"""You asked: "{question}" + +The user responded: "{answer}" + +Please update your recommendations based on this information. +Respond with JSON as specified in your instructions.""" + + return self._send_message(user_message) + + def _send_message(self, user_message: str) -> AgentResponse: + """Send a message to Claude and parse the response.""" + self.conversation_history.append({"role": "user", "content": user_message}) + + response = self.client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=4096, + system=SYSTEM_PROMPT, + messages=self.conversation_history, + ) + + assistant_message = response.content[0].text + self.conversation_history.append({"role": "assistant", "content": assistant_message}) + + return self._parse_response(assistant_message) + + def _parse_response(self, response_text: str) -> AgentResponse: + """Parse Claude's JSON response.""" + # Try to extract JSON from the response + try: + # Look for JSON block in the response + json_start = response_text.find("{") + json_end = response_text.rfind("}") + 1 + + if json_start != -1 and json_end > json_start: + json_str = response_text[json_start:json_end] + data = json.loads(json_str) + else: + # No JSON found, return empty response + return AgentResponse( + analysis=response_text, + monitors=[], + additional_commands=[], + questions=[], + raw_response=response_text, + ) + + monitors = [] + for m in data.get("monitors", []): + monitors.append( + MonitorSuggestion( + type=m.get("type", "http"), + name=m.get("name", "Unknown"), + target=m.get("target", ""), + port=m.get("port"), + interval=m.get("interval", 60), + reason=m.get("reason", ""), + keyword=m.get("keyword"), + ) + ) + + commands = [] + for c in data.get("additional_commands", []): + commands.append( + CommandRequest( + command=c.get("command", ""), + reason=c.get("reason", ""), + ) + ) + + return AgentResponse( + analysis=data.get("analysis", ""), + monitors=monitors, + additional_commands=commands, + questions=data.get("questions", []), + raw_response=response_text, + ) + + except json.JSONDecodeError: + return AgentResponse( + analysis=response_text, + monitors=[], + additional_commands=[], + questions=[], + raw_response=response_text, + ) + + def reset_conversation(self) -> None: + """Reset the conversation history.""" + self.conversation_history = [] + + +def create_agent() -> ClaudeAgent: + """Create a new Claude agent instance.""" + return ClaudeAgent() diff --git a/backend/services/discovery.py b/backend/services/discovery.py new file mode 100644 index 0000000..4c18f8e --- /dev/null +++ b/backend/services/discovery.py @@ -0,0 +1,205 @@ +from dataclasses import dataclass +from typing import Optional, Callable +import uuid + +from services.ssh_manager import get_ssh_manager, CommandResult + + +# Built-in safe commands that never require approval +SAFE_DISCOVERY_COMMANDS = { + "system_info": "uname -a", + "os_release": "cat /etc/os-release 2>/dev/null || cat /etc/*-release 2>/dev/null | head -20", + "docker_containers": "docker ps --format '{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.Status}}\\t{{.Ports}}' 2>/dev/null || echo 'Docker not available'", + "systemd_services": "systemctl list-units --type=service --state=running --no-pager 2>/dev/null | head -50 || echo 'Systemd not available'", + "disk_usage": "df -h 2>/dev/null | head -20", + "memory_usage": "free -h 2>/dev/null || cat /proc/meminfo 2>/dev/null | head -10", + "cpu_count": "nproc 2>/dev/null || grep -c processor /proc/cpuinfo 2>/dev/null || echo 'Unknown'", + "open_ports": "ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null | head -30 || echo 'Unable to list ports'", +} + + +@dataclass +class DiscoveryResult: + """Results from a host discovery scan.""" + + scan_id: str + hostname: str + username: str + port: int + connected: bool + error: Optional[str] = None + system_info: str = "" + os_release: str = "" + docker_containers: str = "" + systemd_services: str = "" + disk_usage: str = "" + memory_usage: str = "" + cpu_count: str = "" + open_ports: str = "" + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return { + "scan_id": self.scan_id, + "hostname": self.hostname, + "username": self.username, + "port": self.port, + "connected": self.connected, + "error": self.error, + "system_info": self.system_info, + "os_release": self.os_release, + "docker_containers": self.docker_containers, + "systemd_services": self.systemd_services, + "disk_usage": self.disk_usage, + "memory_usage": self.memory_usage, + "cpu_count": self.cpu_count, + "open_ports": self.open_ports, + } + + +class DiscoveryService: + """Service for discovering services on remote hosts.""" + + def __init__(self): + self.active_scans: dict[str, DiscoveryResult] = {} + + def is_safe_command(self, command: str) -> bool: + """Check if a command is in the safe built-in list.""" + # Check exact matches + if command in SAFE_DISCOVERY_COMMANDS.values(): + return True + + # Check if it's a safe read-only command pattern + safe_patterns = [ + "cat ", + "head ", + "tail ", + "grep ", + "ls ", + "ps ", + "df ", + "free ", + "uname ", + "uptime", + "hostname", + "whoami", + "id ", + "docker ps", + "docker inspect", + "docker logs", + "systemctl status", + "systemctl list-", + "journalctl", + "ss ", + "netstat ", + "curl -s", + "wget -q", + "nproc", + ] + + command_lower = command.lower().strip() + for pattern in safe_patterns: + if command_lower.startswith(pattern): + return True + + return False + + def scan_host( + self, + hostname: str, + username: str = "root", + port: int = 22, + on_progress: Optional[Callable[[str, str], None]] = None, + ) -> DiscoveryResult: + """ + Scan a host for services and system information. + + Args: + hostname: Target hostname or IP + username: SSH username + port: SSH port + on_progress: Callback for progress updates (command_name, status) + + Returns: + DiscoveryResult with all gathered information + """ + scan_id = str(uuid.uuid4()) + result = DiscoveryResult( + scan_id=scan_id, + hostname=hostname, + username=username, + port=port, + connected=False, + ) + self.active_scans[scan_id] = result + + ssh = get_ssh_manager() + + # Connect to host + if on_progress: + on_progress("connect", "connecting") + + try: + ssh.connect(hostname, username, port) + result.connected = True + if on_progress: + on_progress("connect", "connected") + except Exception as e: + result.error = str(e) + if on_progress: + on_progress("connect", f"failed: {str(e)}") + return result + + # Run discovery commands + for cmd_name, command in SAFE_DISCOVERY_COMMANDS.items(): + if on_progress: + on_progress(cmd_name, "running") + + try: + cmd_result = ssh.execute(hostname, command, username, port, timeout=30) + output = cmd_result.stdout if cmd_result.success else cmd_result.stderr + setattr(result, cmd_name, output.strip()) + + if on_progress: + on_progress(cmd_name, "complete" if cmd_result.success else "failed") + except Exception as e: + setattr(result, cmd_name, f"Error: {str(e)}") + if on_progress: + on_progress(cmd_name, f"error: {str(e)}") + + return result + + def run_additional_command( + self, + hostname: str, + command: str, + username: str = "root", + port: int = 22, + ) -> CommandResult: + """ + Run an additional command on a host. + + This should only be called after approval if in dev mode. + """ + ssh = get_ssh_manager() + + if not ssh.is_connected(hostname, username, port): + ssh.connect(hostname, username, port) + + return ssh.execute(hostname, command, username, port, timeout=60) + + def get_scan(self, scan_id: str) -> Optional[DiscoveryResult]: + """Get a scan result by ID.""" + return self.active_scans.get(scan_id) + + +# Global discovery service instance +_discovery_service: Optional[DiscoveryService] = None + + +def get_discovery_service() -> DiscoveryService: + """Get the global discovery service instance.""" + global _discovery_service + if _discovery_service is None: + _discovery_service = DiscoveryService() + return _discovery_service diff --git a/backend/services/kuma_client.py b/backend/services/kuma_client.py new file mode 100644 index 0000000..35b2106 --- /dev/null +++ b/backend/services/kuma_client.py @@ -0,0 +1,174 @@ +from typing import Optional +from dataclasses import dataclass, asdict +import requests + +from config import get_config + + +@dataclass +class Monitor: + """Uptime Kuma monitor configuration.""" + + type: str # http, tcp, ping, docker, keyword + name: str + url: Optional[str] = None # For HTTP monitors + hostname: Optional[str] = None # For TCP/Ping monitors + port: Optional[int] = None # For TCP monitors + interval: int = 60 + keyword: Optional[str] = None # For keyword monitors + docker_container: Optional[str] = None # For Docker monitors + docker_host: Optional[str] = None # For Docker monitors + retries: int = 3 + retry_interval: int = 60 + max_redirects: int = 10 + accepted_statuscodes: list[str] = None + notification_id_list: Optional[list[int]] = None + + def __post_init__(self): + if self.accepted_statuscodes is None: + self.accepted_statuscodes = ["200-299"] + + def to_api_format(self) -> dict: + """Convert to Uptime Kuma API format.""" + # Map our types to Kuma's type values + type_map = { + "http": "http", + "tcp": "port", + "ping": "ping", + "docker": "docker", + "keyword": "keyword", + } + + data = { + "type": type_map.get(self.type, self.type), + "name": self.name, + "interval": self.interval, + "retries": self.retries, + "retryInterval": self.retry_interval, + "maxredirects": self.max_redirects, + "accepted_statuscodes": self.accepted_statuscodes, + } + + if self.url: + data["url"] = self.url + if self.hostname: + data["hostname"] = self.hostname + if self.port: + data["port"] = self.port + if self.keyword: + data["keyword"] = self.keyword + if self.docker_container: + data["docker_container"] = self.docker_container + if self.docker_host: + data["docker_host"] = self.docker_host + if self.notification_id_list: + data["notificationIDList"] = self.notification_id_list + + return data + + +class UptimeKumaClient: + """Client for Uptime Kuma REST API.""" + + def __init__(self): + config = get_config() + self.base_url = config.uptime_kuma_url.rstrip("/") + self.api_key = config.uptime_kuma_api_key + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }) + + def _request(self, method: str, endpoint: str, **kwargs) -> dict: + """Make an API request.""" + url = f"{self.base_url}/api{endpoint}" + response = self.session.request(method, url, **kwargs) + response.raise_for_status() + return response.json() if response.content else {} + + def get_monitors(self) -> list[dict]: + """Get all monitors.""" + try: + result = self._request("GET", "/monitors") + return result.get("monitors", []) + except Exception as e: + raise Exception(f"Failed to get monitors: {str(e)}") + + def get_monitor(self, monitor_id: int) -> dict: + """Get a specific monitor.""" + try: + result = self._request("GET", f"/monitors/{monitor_id}") + return result.get("monitor", {}) + except Exception as e: + raise Exception(f"Failed to get monitor {monitor_id}: {str(e)}") + + def create_monitor(self, monitor: Monitor) -> dict: + """Create a new monitor.""" + try: + data = monitor.to_api_format() + result = self._request("POST", "/monitors", json=data) + return result + except Exception as e: + raise Exception(f"Failed to create monitor: {str(e)}") + + def update_monitor(self, monitor_id: int, monitor: Monitor) -> dict: + """Update an existing monitor.""" + try: + data = monitor.to_api_format() + result = self._request("PUT", f"/monitors/{monitor_id}", json=data) + return result + except Exception as e: + raise Exception(f"Failed to update monitor {monitor_id}: {str(e)}") + + def delete_monitor(self, monitor_id: int) -> dict: + """Delete a monitor.""" + try: + result = self._request("DELETE", f"/monitors/{monitor_id}") + return result + except Exception as e: + raise Exception(f"Failed to delete monitor {monitor_id}: {str(e)}") + + def pause_monitor(self, monitor_id: int) -> dict: + """Pause a monitor.""" + try: + result = self._request("POST", f"/monitors/{monitor_id}/pause") + return result + except Exception as e: + raise Exception(f"Failed to pause monitor {monitor_id}: {str(e)}") + + def resume_monitor(self, monitor_id: int) -> dict: + """Resume a paused monitor.""" + try: + result = self._request("POST", f"/monitors/{monitor_id}/resume") + return result + except Exception as e: + raise Exception(f"Failed to resume monitor {monitor_id}: {str(e)}") + + def get_status(self) -> dict: + """Get Uptime Kuma status/info.""" + try: + result = self._request("GET", "/status-page") + return result + except Exception as e: + raise Exception(f"Failed to get status: {str(e)}") + + def test_connection(self) -> bool: + """Test connection to Uptime Kuma.""" + try: + self._request("GET", "/monitors") + return True + except Exception: + return False + + +# Global client instance +_kuma_client: Optional[UptimeKumaClient] = None + + +def get_kuma_client() -> UptimeKumaClient: + """Get the global Uptime Kuma client instance.""" + global _kuma_client + if _kuma_client is None: + _kuma_client = UptimeKumaClient() + return _kuma_client diff --git a/backend/services/monitors.py b/backend/services/monitors.py new file mode 100644 index 0000000..1ffd0a2 --- /dev/null +++ b/backend/services/monitors.py @@ -0,0 +1,249 @@ +from dataclasses import dataclass +from typing import Optional + +from services.kuma_client import get_kuma_client, Monitor +from services.claude_agent import MonitorSuggestion + + +@dataclass +class DefaultMonitorProfile: + """A default monitoring profile that doesn't require approval.""" + + name: str + description: str + monitors: list[Monitor] + + +def create_host_health_monitors(hostname: str, ssh_port: int = 22) -> list[Monitor]: + """Create default host health monitors.""" + return [ + Monitor( + type="ping", + name=f"{hostname} - Ping", + hostname=hostname, + interval=60, + ), + Monitor( + type="tcp", + name=f"{hostname} - SSH", + hostname=hostname, + port=ssh_port, + interval=120, + ), + ] + + +def create_web_server_monitors(hostname: str, port: int = 80, https: bool = False) -> list[Monitor]: + """Create monitors for a detected web server.""" + protocol = "https" if https else "http" + return [ + Monitor( + type="http", + name=f"{hostname} - Web ({port})", + url=f"{protocol}://{hostname}:{port}/", + interval=60, + ), + ] + + +def create_docker_container_monitors(hostname: str, containers: list[dict]) -> list[Monitor]: + """Create monitors for detected Docker containers.""" + monitors = [] + for container in containers: + name = container.get("name", container.get("id", "unknown")) + monitors.append( + Monitor( + type="docker", + name=f"{hostname} - Container: {name}", + docker_container=name, + docker_host=hostname, + interval=60, + ) + ) + return monitors + + +class MonitorService: + """Service for managing monitors in Uptime Kuma.""" + + def __init__(self): + self.created_monitors: list[dict] = [] + + def create_default_monitors( + self, + hostname: str, + ssh_port: int = 22, + has_docker: bool = False, + containers: Optional[list[dict]] = None, + web_ports: Optional[list[int]] = None, + ) -> list[dict]: + """ + Create default monitors for a host. + These are built-in and never require approval. + """ + kuma = get_kuma_client() + created = [] + + # 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), + }) + + # Web server monitors + if web_ports: + for port in web_ports: + 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), + }) + + # 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), + }) + + self.created_monitors.extend(created) + return created + + def create_from_suggestion(self, suggestion: MonitorSuggestion, hostname: str) -> dict: + """ + Create a monitor from a Claude suggestion. + In production mode, this executes automatically. + In dev mode, this should only be called after approval. + """ + kuma = get_kuma_client() + + # Build monitor from suggestion + monitor = Monitor( + type=suggestion.type, + name=suggestion.name, + interval=suggestion.interval, + ) + + # Set type-specific fields + if suggestion.type == "http" or suggestion.type == "keyword": + monitor.url = suggestion.target + if suggestion.keyword: + monitor.keyword = suggestion.keyword + elif suggestion.type == "tcp": + monitor.hostname = suggestion.target + monitor.port = suggestion.port + elif suggestion.type == "ping": + monitor.hostname = suggestion.target + elif suggestion.type == "docker": + monitor.docker_container = suggestion.target + monitor.docker_host = hostname + + try: + result = kuma.create_monitor(monitor) + return { + "monitor": monitor.name, + "type": monitor.type, + "status": "created", + "result": result, + "reason": suggestion.reason, + } + except Exception as e: + return { + "monitor": monitor.name, + "type": monitor.type, + "status": "failed", + "error": str(e), + "reason": suggestion.reason, + } + + def get_existing_monitors(self) -> list[dict]: + """Get all existing monitors from Uptime Kuma.""" + kuma = get_kuma_client() + return kuma.get_monitors() + + +def parse_web_ports_from_scan(open_ports: str) -> list[int]: + """Extract web server ports from port scan output.""" + common_web_ports = [80, 443, 8080, 8443, 3000, 5000, 8000] + found_ports = [] + + for port in common_web_ports: + if f":{port}" in open_ports or f" {port} " in open_ports: + found_ports.append(port) + + return found_ports + + +def parse_docker_containers_from_scan(docker_output: str) -> list[dict]: + """Parse Docker container info from scan output.""" + containers = [] + + if "Docker not available" in docker_output or not docker_output.strip(): + return containers + + for line in docker_output.strip().split("\n"): + if not line.strip(): + continue + + parts = line.split("\t") + if len(parts) >= 2: + containers.append({ + "id": parts[0] if len(parts) > 0 else "", + "name": parts[1] if len(parts) > 1 else "", + "image": parts[2] if len(parts) > 2 else "", + "status": parts[3] if len(parts) > 3 else "", + "ports": parts[4] if len(parts) > 4 else "", + }) + + return containers + + +# Global monitor service instance +_monitor_service: Optional[MonitorService] = None + + +def get_monitor_service() -> MonitorService: + """Get the global monitor service instance.""" + global _monitor_service + if _monitor_service is None: + _monitor_service = MonitorService() + return _monitor_service diff --git a/backend/services/ssh_manager.py b/backend/services/ssh_manager.py new file mode 100644 index 0000000..386a6cd --- /dev/null +++ b/backend/services/ssh_manager.py @@ -0,0 +1,175 @@ +import io +import threading +from typing import Optional, Callable +from dataclasses import dataclass + +import paramiko + +from config import get_config + + +@dataclass +class CommandResult: + """Result of an SSH command execution.""" + + stdout: str + stderr: str + exit_code: int + success: bool + + +class SSHManager: + """Manages SSH connections to target hosts.""" + + def __init__(self): + self._connections: dict[str, paramiko.SSHClient] = {} + self._lock = threading.Lock() + + def _get_private_key(self) -> paramiko.PKey: + """Parse the private key from config.""" + config = get_config() + key_data = config.ssh_private_key + + # Try different key formats + key_file = io.StringIO(key_data) + + for key_class in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]: + try: + key_file.seek(0) + return key_class.from_private_key(key_file) + except Exception: + continue + + raise ValueError("Unable to parse SSH private key. Supported formats: RSA, Ed25519, ECDSA") + + def connect(self, hostname: str, username: str = "root", port: int = 22) -> bool: + """Establish SSH connection to a host.""" + connection_key = f"{username}@{hostname}:{port}" + + with self._lock: + if connection_key in self._connections: + # Test if connection is still alive + try: + transport = self._connections[connection_key].get_transport() + if transport and transport.is_active(): + return True + except Exception: + pass + # Remove dead connection + self._connections.pop(connection_key, None) + + try: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + private_key = self._get_private_key() + client.connect( + hostname=hostname, + port=port, + username=username, + pkey=private_key, + timeout=30, + allow_agent=False, + look_for_keys=False, + ) + self._connections[connection_key] = client + return True + except Exception as e: + raise ConnectionError(f"Failed to connect to {connection_key}: {str(e)}") + + def execute( + self, + hostname: str, + command: str, + username: str = "root", + port: int = 22, + timeout: int = 60, + on_output: Optional[Callable[[str], None]] = None, + ) -> CommandResult: + """Execute a command on a remote host.""" + connection_key = f"{username}@{hostname}:{port}" + + with self._lock: + client = self._connections.get(connection_key) + if not client: + raise ConnectionError(f"Not connected to {connection_key}. Call connect() first.") + + try: + stdin, stdout, stderr = client.exec_command(command, timeout=timeout) + + # Read output + stdout_data = "" + stderr_data = "" + + # Stream stdout if callback provided + if on_output: + for line in stdout: + stdout_data += line + on_output(line.rstrip("\n")) + else: + stdout_data = stdout.read().decode("utf-8", errors="replace") + + stderr_data = stderr.read().decode("utf-8", errors="replace") + exit_code = stdout.channel.recv_exit_status() + + return CommandResult( + stdout=stdout_data, + stderr=stderr_data, + exit_code=exit_code, + success=exit_code == 0, + ) + except Exception as e: + return CommandResult( + stdout="", + stderr=str(e), + exit_code=-1, + success=False, + ) + + def disconnect(self, hostname: str, username: str = "root", port: int = 22) -> None: + """Close SSH connection to a host.""" + connection_key = f"{username}@{hostname}:{port}" + + with self._lock: + client = self._connections.pop(connection_key, None) + if client: + try: + client.close() + except Exception: + pass + + def disconnect_all(self) -> None: + """Close all SSH connections.""" + with self._lock: + for client in self._connections.values(): + try: + client.close() + except Exception: + pass + self._connections.clear() + + def is_connected(self, hostname: str, username: str = "root", port: int = 22) -> bool: + """Check if connected to a host.""" + connection_key = f"{username}@{hostname}:{port}" + + with self._lock: + client = self._connections.get(connection_key) + if not client: + return False + try: + transport = client.get_transport() + return transport is not None and transport.is_active() + except Exception: + return False + + +# Global SSH manager instance +_ssh_manager: Optional[SSHManager] = None + + +def get_ssh_manager() -> SSHManager: + """Get the global SSH manager instance.""" + global _ssh_manager + if _ssh_manager is None: + _ssh_manager = SSHManager() + return _ssh_manager diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100644 index 0000000..dd7ee44 --- /dev/null +++ b/backend/utils/__init__.py @@ -0,0 +1 @@ +# Utils package diff --git a/backend/utils/approval.py b/backend/utils/approval.py new file mode 100644 index 0000000..ae28daf --- /dev/null +++ b/backend/utils/approval.py @@ -0,0 +1,213 @@ +import uuid +import threading +from datetime import datetime +from enum import Enum +from dataclasses import dataclass, field +from typing import Optional, Callable + + +class ApprovalStatus(Enum): + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + EXPIRED = "expired" + + +class ApprovalType(Enum): + SSH_COMMAND = "ssh_command" + CREATE_MONITOR = "create_monitor" + + +@dataclass +class ApprovalRequest: + """A request waiting for user approval.""" + + id: str + type: ApprovalType + description: str + details: dict + status: ApprovalStatus = ApprovalStatus.PENDING + created_at: datetime = field(default_factory=datetime.now) + resolved_at: Optional[datetime] = None + reason: str = "" # Why Claude wants to do this + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return { + "id": self.id, + "type": self.type.value, + "description": self.description, + "details": self.details, + "status": self.status.value, + "created_at": self.created_at.isoformat(), + "resolved_at": self.resolved_at.isoformat() if self.resolved_at else None, + "reason": self.reason, + } + + +class ApprovalQueue: + """Queue for managing approval requests in dev mode.""" + + def __init__(self): + self._requests: dict[str, ApprovalRequest] = {} + self._lock = threading.Lock() + self._on_request_added: Optional[Callable[[ApprovalRequest], None]] = None + self._on_request_resolved: Optional[Callable[[ApprovalRequest], None]] = None + + def set_callbacks( + self, + on_added: Optional[Callable[[ApprovalRequest], None]] = None, + on_resolved: Optional[Callable[[ApprovalRequest], None]] = None, + ) -> None: + """Set callbacks for queue events.""" + self._on_request_added = on_added + self._on_request_resolved = on_resolved + + def add_ssh_command(self, command: str, reason: str, hostname: str) -> ApprovalRequest: + """Add an SSH command approval request.""" + request = ApprovalRequest( + id=str(uuid.uuid4()), + type=ApprovalType.SSH_COMMAND, + description=f"Execute SSH command on {hostname}", + details={ + "command": command, + "hostname": hostname, + }, + reason=reason, + ) + + with self._lock: + self._requests[request.id] = request + + if self._on_request_added: + self._on_request_added(request) + + return request + + def add_monitor_creation( + self, + monitor_name: str, + monitor_type: str, + target: str, + reason: str, + ) -> ApprovalRequest: + """Add a monitor creation approval request.""" + request = ApprovalRequest( + id=str(uuid.uuid4()), + type=ApprovalType.CREATE_MONITOR, + description=f"Create {monitor_type} monitor: {monitor_name}", + details={ + "name": monitor_name, + "type": monitor_type, + "target": target, + }, + reason=reason, + ) + + with self._lock: + self._requests[request.id] = request + + if self._on_request_added: + self._on_request_added(request) + + return request + + def approve(self, request_id: str) -> Optional[ApprovalRequest]: + """Approve a request.""" + with self._lock: + request = self._requests.get(request_id) + if not request or request.status != ApprovalStatus.PENDING: + return None + + request.status = ApprovalStatus.APPROVED + request.resolved_at = datetime.now() + + if self._on_request_resolved: + self._on_request_resolved(request) + + return request + + def reject(self, request_id: str) -> Optional[ApprovalRequest]: + """Reject a request.""" + with self._lock: + request = self._requests.get(request_id) + if not request or request.status != ApprovalStatus.PENDING: + return None + + request.status = ApprovalStatus.REJECTED + request.resolved_at = datetime.now() + + if self._on_request_resolved: + self._on_request_resolved(request) + + return request + + def get_pending(self) -> list[ApprovalRequest]: + """Get all pending requests.""" + with self._lock: + return [r for r in self._requests.values() if r.status == ApprovalStatus.PENDING] + + def get_request(self, request_id: str) -> Optional[ApprovalRequest]: + """Get a specific request.""" + with self._lock: + return self._requests.get(request_id) + + def clear_resolved(self) -> int: + """Clear all resolved requests. Returns count of cleared requests.""" + with self._lock: + to_remove = [ + rid for rid, req in self._requests.items() + if req.status != ApprovalStatus.PENDING + ] + for rid in to_remove: + del self._requests[rid] + return len(to_remove) + + def wait_for_approval( + self, + request_id: str, + timeout: float = 300.0, + check_interval: float = 0.5, + ) -> Optional[ApprovalRequest]: + """ + Wait for a request to be approved or rejected. + + Args: + request_id: The request ID to wait for + timeout: Maximum time to wait in seconds + check_interval: How often to check status + + Returns: + The resolved request, or None if timeout + """ + import time + start = time.time() + + while time.time() - start < timeout: + request = self.get_request(request_id) + if not request: + return None + if request.status != ApprovalStatus.PENDING: + return request + time.sleep(check_interval) + + # Timeout - mark as expired + with self._lock: + request = self._requests.get(request_id) + if request and request.status == ApprovalStatus.PENDING: + request.status = ApprovalStatus.EXPIRED + request.resolved_at = datetime.now() + + return request + + +# Global approval queue instance +_approval_queue: Optional[ApprovalQueue] = None + + +def get_approval_queue() -> ApprovalQueue: + """Get the global approval queue instance.""" + global _approval_queue + if _approval_queue is None: + _approval_queue = ApprovalQueue() + return _approval_queue diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ac61e82 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + kuma-strapper: + build: + context: . + dockerfile: Dockerfile + container_name: kuma-strapper + ports: + - "5000:5000" + environment: + # Required: Base64-encoded SSH private key + - SSH_PRIVATE_KEY=${SSH_PRIVATE_KEY} + # Required: Uptime Kuma instance URL + - UPTIME_KUMA_URL=${UPTIME_KUMA_URL:-http://localhost:3001} + # Required: Uptime Kuma API token + - UPTIME_KUMA_API_KEY=${UPTIME_KUMA_API_KEY} + # Required: Claude/Anthropic API key + - CLAUDE_API_KEY=${CLAUDE_API_KEY} + # Optional: Enable dev mode on startup (default: false) + - DEV_MODE=${DEV_MODE:-false} + # Optional: Enable debug mode + - DEBUG=${DEBUG:-false} + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4465c30 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Kuma Strapper + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..cb67327 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "kuma-strapper-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "socket.io-client": "^4.7.2" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "vite": "^5.0.10" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..6bf6c33 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,170 @@ +import { useState, useEffect, useCallback } from 'react'; +import { io } from 'socket.io-client'; +import Dashboard from './components/Dashboard'; +import DevModeToggle from './components/DevModeToggle'; +import ApprovalModal from './components/ApprovalModal'; +import { api } from './api/client'; + +const socket = io(window.location.origin, { + transports: ['websocket', 'polling'], +}); + +export default function App() { + const [settings, setSettings] = useState(null); + const [connected, setConnected] = useState(false); + const [pendingApprovals, setPendingApprovals] = useState([]); + const [scanProgress, setScanProgress] = useState({}); + const [scanResults, setScanResults] = useState({}); + const [analysisResults, setAnalysisResults] = useState({}); + + // Load initial settings + useEffect(() => { + api.getSettings().then(setSettings).catch(console.error); + api.getPendingApprovals().then(data => setPendingApprovals(data.approvals)).catch(console.error); + }, []); + + // Socket connection + useEffect(() => { + socket.on('connect', () => setConnected(true)); + socket.on('disconnect', () => setConnected(false)); + + socket.on('scan_progress', (data) => { + setScanProgress(prev => ({ + ...prev, + [data.hostname]: { + ...prev[data.hostname], + [data.command]: data.status, + }, + })); + }); + + socket.on('scan_complete', (data) => { + setScanResults(prev => ({ + ...prev, + [data.scan_id]: data, + })); + }); + + socket.on('analysis_started', (data) => { + setAnalysisResults(prev => ({ + ...prev, + [data.scan_id]: { loading: true }, + })); + }); + + socket.on('analysis_complete', (data) => { + setAnalysisResults(prev => ({ + ...prev, + [data.scan_id]: { ...data, loading: false }, + })); + }); + + socket.on('analysis_update', (data) => { + setAnalysisResults(prev => ({ + ...prev, + [data.scan_id]: { ...data, loading: false }, + })); + }); + + socket.on('analysis_error', (data) => { + setAnalysisResults(prev => ({ + ...prev, + [data.scan_id]: { error: data.error, loading: false }, + })); + }); + + socket.on('approval_request', (request) => { + setPendingApprovals(prev => [...prev, request]); + }); + + socket.on('approval_resolved', (request) => { + setPendingApprovals(prev => prev.filter(r => r.id !== request.id)); + }); + + return () => { + socket.off('connect'); + socket.off('disconnect'); + socket.off('scan_progress'); + socket.off('scan_complete'); + socket.off('analysis_started'); + socket.off('analysis_complete'); + socket.off('analysis_update'); + socket.off('analysis_error'); + socket.off('approval_request'); + socket.off('approval_resolved'); + }; + }, []); + + const toggleDevMode = useCallback(async () => { + const newDevMode = !settings?.dev_mode; + await api.updateSettings({ dev_mode: newDevMode }); + setSettings(prev => ({ ...prev, dev_mode: newDevMode })); + }, [settings]); + + const handleApprove = useCallback(async (approvalId) => { + await api.approveRequest(approvalId); + }, []); + + const handleReject = useCallback(async (approvalId) => { + await api.rejectRequest(approvalId); + }, []); + + if (!settings) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

+ Kuma Strapper +

+
+ + {connected ? 'Connected' : 'Disconnected'} +
+
+ +
+
+ + {/* Main Content */} +
+ {/* Config Status */} + {(!settings.has_ssh_key || !settings.has_claude_key || !settings.has_kuma_key) && ( +
+

Configuration Required

+
    + {!settings.has_ssh_key &&
  • • SSH_PRIVATE_KEY environment variable is not set
  • } + {!settings.has_claude_key &&
  • • CLAUDE_API_KEY environment variable is not set
  • } + {!settings.has_kuma_key &&
  • • UPTIME_KUMA_API_KEY environment variable is not set
  • } +
+
+ )} + + +
+ + {/* Approval Modal */} + {pendingApprovals.length > 0 && settings.dev_mode && ( + + )} +
+ ); +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..6d5eab3 --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,64 @@ +const API_BASE = '/api'; + +export async function fetchApi(endpoint, options = {}) { + const url = `${API_BASE}${endpoint}`; + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + ...options, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(error.error || `HTTP ${response.status}`); + } + + return response.json(); +} + +export const api = { + // Settings + getSettings: () => fetchApi('/settings'), + updateSettings: (settings) => fetchApi('/settings', { + method: 'PUT', + body: JSON.stringify(settings), + }), + + // Scanning + startScan: (hostname, username = 'root', port = 22) => fetchApi('/scan', { + method: 'POST', + body: JSON.stringify({ hostname, username, port }), + }), + getScan: (scanId) => fetchApi(`/scan/${scanId}`), + + // Commands + runCommand: (scanId, command, reason) => fetchApi(`/scan/${scanId}/command`, { + method: 'POST', + body: JSON.stringify({ command, reason }), + }), + + // Approvals + getPendingApprovals: () => fetchApi('/approvals'), + approveRequest: (approvalId) => fetchApi(`/approvals/${approvalId}/approve`, { + method: 'POST', + }), + rejectRequest: (approvalId) => fetchApi(`/approvals/${approvalId}/reject`, { + method: 'POST', + }), + + // Monitors + getMonitors: () => fetchApi('/monitors'), + createDefaultMonitors: (scanId) => fetchApi('/monitors/create-defaults', { + method: 'POST', + body: JSON.stringify({ scan_id: scanId }), + }), + createSuggestedMonitors: (scanId, monitorIndices) => fetchApi('/monitors/create-suggested', { + method: 'POST', + body: JSON.stringify({ scan_id: scanId, monitors: monitorIndices }), + }), + + // Uptime Kuma + testKumaConnection: () => fetchApi('/kuma/test'), +}; diff --git a/frontend/src/components/ApprovalModal.jsx b/frontend/src/components/ApprovalModal.jsx new file mode 100644 index 0000000..99ef369 --- /dev/null +++ b/frontend/src/components/ApprovalModal.jsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; + +export default function ApprovalModal({ approvals, onApprove, onReject }) { + const [processing, setProcessing] = useState({}); + + const handleApprove = async (id) => { + setProcessing(p => ({ ...p, [id]: 'approving' })); + try { + await onApprove(id); + } finally { + setProcessing(p => ({ ...p, [id]: null })); + } + }; + + const handleReject = async (id) => { + setProcessing(p => ({ ...p, [id]: 'rejecting' })); + try { + await onReject(id); + } finally { + setProcessing(p => ({ ...p, [id]: null })); + } + }; + + if (approvals.length === 0) return null; + + return ( +
+
+
+

+ + + + Pending Approvals ({approvals.length}) +

+
+ +
+ {approvals.map((approval) => ( +
+
+
+ + {approval.type === 'ssh_command' ? 'SSH Command' : 'Create Monitor'} + +

+ {approval.description} +

+
+
+ + {approval.reason && ( +
+

+ Why: + {approval.reason} +

+
+ )} + + {approval.type === 'ssh_command' && ( +
+

Command to execute:

+ + {approval.details.command} + +

+ On host: {approval.details.hostname} +

+
+ )} + + {approval.type === 'create_monitor' && ( +
+

Name: {approval.details.name}

+

Type: {approval.details.type}

+

Target: {approval.details.target}

+
+ )} + +
+ + +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx new file mode 100644 index 0000000..022da08 --- /dev/null +++ b/frontend/src/components/Dashboard.jsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import { api } from '../api/client'; +import HostCard from './HostCard'; +import DiscoveryResults from './DiscoveryResults'; + +export default function Dashboard({ scanProgress, scanResults, analysisResults, devMode }) { + const [hostname, setHostname] = useState(''); + const [username, setUsername] = useState('root'); + const [port, setPort] = useState('22'); + const [scanning, setScanning] = useState(false); + const [error, setError] = useState(null); + const [activeScanId, setActiveScanId] = useState(null); + + const handleScan = async (e) => { + e.preventDefault(); + if (!hostname) return; + + setScanning(true); + setError(null); + + try { + await api.startScan(hostname, username, parseInt(port, 10)); + } catch (err) { + setError(err.message); + setScanning(false); + } + }; + + // Find the active scan + const currentScanId = Object.keys(scanResults).find( + id => scanResults[id]?.hostname === hostname + ); + + const currentScan = currentScanId ? scanResults[currentScanId] : null; + const currentAnalysis = currentScanId ? analysisResults[currentScanId] : null; + + // Reset scanning state when scan completes + if (scanning && currentScan) { + setScanning(false); + if (!activeScanId && currentScanId) { + setActiveScanId(currentScanId); + } + } + + return ( +
+ {/* Scan Form */} +
+

Scan Host

+
+
+
+ + setHostname(e.target.value)} + placeholder="192.168.1.100 or server.example.com" + className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded focus:ring-2 focus:ring-kuma-green focus:border-transparent outline-none" + /> +
+
+ + setUsername(e.target.value)} + placeholder="root" + className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded focus:ring-2 focus:ring-kuma-green focus:border-transparent outline-none" + /> +
+
+ + setPort(e.target.value)} + placeholder="22" + className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded focus:ring-2 focus:ring-kuma-green focus:border-transparent outline-none" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+
+ + {/* Scan Progress */} + {scanning && hostname && scanProgress[hostname] && ( + + )} + + {/* Scan Results & Analysis */} + {currentScan && currentScanId && ( + + )} +
+ ); +} diff --git a/frontend/src/components/DevModeToggle.jsx b/frontend/src/components/DevModeToggle.jsx new file mode 100644 index 0000000..bbec5d6 --- /dev/null +++ b/frontend/src/components/DevModeToggle.jsx @@ -0,0 +1,28 @@ +export default function DevModeToggle({ enabled, onToggle }) { + return ( +
+ + Dev Mode + + + {enabled && ( + + Commands require approval + + )} +
+ ); +} diff --git a/frontend/src/components/DiscoveryResults.jsx b/frontend/src/components/DiscoveryResults.jsx new file mode 100644 index 0000000..6dcc9b4 --- /dev/null +++ b/frontend/src/components/DiscoveryResults.jsx @@ -0,0 +1,277 @@ +import { useState } from 'react'; +import { api } from '../api/client'; + +export default function DiscoveryResults({ scanId, scan, analysis, devMode }) { + const [selectedMonitors, setSelectedMonitors] = useState([]); + const [creatingDefaults, setCreatingDefaults] = useState(false); + const [creatingSuggested, setCreatingSuggested] = useState(false); + const [createResults, setCreateResults] = useState(null); + + const handleCreateDefaults = async () => { + setCreatingDefaults(true); + try { + const result = await api.createDefaultMonitors(scanId); + setCreateResults(result.created); + } catch (err) { + console.error('Failed to create default monitors:', err); + } finally { + setCreatingDefaults(false); + } + }; + + const handleCreateSuggested = async () => { + if (selectedMonitors.length === 0) return; + + setCreatingSuggested(true); + try { + const result = await api.createSuggestedMonitors(scanId, selectedMonitors); + setCreateResults(prev => [...(prev || []), ...result.created]); + setSelectedMonitors([]); + } catch (err) { + console.error('Failed to create suggested monitors:', err); + } finally { + setCreatingSuggested(false); + } + }; + + const toggleMonitor = (index) => { + setSelectedMonitors(prev => + prev.includes(index) + ? prev.filter(i => i !== index) + : [...prev, index] + ); + }; + + if (!scan.connected) { + return ( +
+

Connection Failed

+

{scan.error}

+
+ ); + } + + return ( +
+ {/* Connection Success */} +
+

+ + + + Connected to {scan.hostname} +

+
+ + {/* Default Monitors */} +
+
+
+

Default Monitors

+

+ Basic monitoring that will be applied automatically (no approval required) +

+
+ +
+ +
+
+ + Ping - {scan.hostname} +
+
+ + TCP - SSH Port 22 +
+
+
+ + {/* Claude Analysis */} + {analysis && ( +
+

+ + + + AI Analysis + {analysis.loading && ( + Analyzing... + )} +

+ + {analysis.error ? ( +
+ {analysis.error} +
+ ) : analysis.loading ? ( +
+ + + + + Claude is analyzing the host... +
+ ) : ( +
+ {/* Analysis Text */} + {analysis.analysis && ( +
+

{analysis.analysis}

+
+ )} + + {/* Suggested Monitors */} + {analysis.monitors && analysis.monitors.length > 0 && ( +
+
+

Suggested Monitors

+ +
+ +
+ {analysis.monitors.map((monitor, index) => ( +
toggleMonitor(index)} + className={`p-4 rounded cursor-pointer transition-colors ${ + selectedMonitors.includes(index) + ? 'bg-purple-900/30 border border-purple-500' + : 'bg-slate-700/50 border border-transparent hover:border-slate-600' + }`} + > +
+ {}} + className="mt-1 w-4 h-4 rounded border-slate-500 bg-slate-700 text-purple-500 focus:ring-purple-500" + /> +
+
+ + {monitor.type.toUpperCase()} + + {monitor.name} +
+

+ {monitor.target} + {monitor.port && `:${monitor.port}`} + {' • '} + Every {monitor.interval}s +

+ {monitor.reason && ( +

+ {monitor.reason} +

+ )} +
+
+
+ ))} +
+
+ )} + + {/* Additional Commands Claude Wants to Run */} + {analysis.additional_commands && analysis.additional_commands.length > 0 && ( +
+

+ + + + Claude wants more information + {devMode && ( + + Requires approval + + )} +

+ +
+ {analysis.additional_commands.map((cmd, index) => ( +
+ + {cmd.command} + +

{cmd.reason}

+
+ ))} +
+
+ )} + + {/* Questions for User */} + {analysis.questions && analysis.questions.length > 0 && ( +
+

Questions from Claude

+
    + {analysis.questions.map((question, index) => ( +
  • + ? + {question} +
  • + ))} +
+
+ )} +
+ )} +
+ )} + + {/* Creation Results */} + {createResults && createResults.length > 0 && ( +
+

Creation Results

+
+ {createResults.map((result, index) => ( +
+ {result.status === 'created' ? ( + + + + ) : ( + + + + )} + {result.monitor} + ({result.type}) + {result.error && ( + {result.error} + )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/HostCard.jsx b/frontend/src/components/HostCard.jsx new file mode 100644 index 0000000..95fd8ca --- /dev/null +++ b/frontend/src/components/HostCard.jsx @@ -0,0 +1,78 @@ +const COMMAND_LABELS = { + connect: 'Connect', + system_info: 'System Info', + os_release: 'OS Release', + docker_containers: 'Docker Containers', + systemd_services: 'Systemd Services', + disk_usage: 'Disk Usage', + memory_usage: 'Memory Usage', + cpu_count: 'CPU Count', + open_ports: 'Open Ports', +}; + +const STATUS_COLORS = { + connecting: 'text-yellow-400', + connected: 'text-green-400', + running: 'text-blue-400', + complete: 'text-green-400', + failed: 'text-red-400', + error: 'text-red-400', +}; + +export default function HostCard({ hostname, progress, status }) { + const commands = Object.entries(COMMAND_LABELS); + + return ( +
+
+

+ + + + {hostname} +

+ {status === 'scanning' && ( + + + + + + Scanning... + + )} +
+ +
+ {commands.map(([key, label]) => { + const cmdStatus = progress?.[key]; + const colorClass = STATUS_COLORS[cmdStatus] || 'text-slate-500'; + + return ( +
+ {cmdStatus === 'running' ? ( + + + + + ) : cmdStatus === 'complete' || cmdStatus === 'connected' ? ( + + + + ) : cmdStatus?.startsWith('failed') || cmdStatus?.startsWith('error') ? ( + + + + ) : ( +
+ )} + {label} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..7ef5e2d --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-slate-900 text-slate-100; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..54b39dd --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..50292c2 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,19 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + kuma: { + green: '#5CDD8B', + dark: '#1a1a2e', + darker: '#16162a', + } + } + }, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..f5d798f --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:5000', + changeOrigin: true, + }, + '/socket.io': { + target: 'http://localhost:5000', + ws: true, + }, + }, + }, +})