Initial commit with CI workflow
All checks were successful
Build Container / build (push) Successful in 1m18s

- 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 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-01-04 21:38:50 +00:00
commit ea49143a13
31 changed files with 3037 additions and 0 deletions

23
.env.example Normal file
View File

@@ -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

View File

@@ -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

44
.gitignore vendored Normal file
View File

@@ -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

82
CLAUDE.md Normal file
View File

@@ -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/<id>` - Get scan results
- `POST /api/scan/<id>/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

57
Dockerfile Normal file
View File

@@ -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"]

444
backend/app.py Normal file
View File

@@ -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("/<path:path>")
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/<scan_id>", 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/<scan_id>/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/<approval_id>/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/<approval_id>/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")

65
backend/config.py Normal file
View File

@@ -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

9
backend/requirements.txt Normal file
View File

@@ -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

View File

@@ -0,0 +1 @@
# Services package

View File

@@ -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 <container>` - get container details
- `systemctl status <service>` - 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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
# Utils package

213
backend/utils/approval.py Normal file
View File

@@ -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

28
docker-compose.yml Normal file
View File

@@ -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

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kuma Strapper</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

25
frontend/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

170
frontend/src/App.jsx Normal file
View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-kuma-green"></div>
</div>
);
}
return (
<div className="min-h-screen">
{/* Header */}
<header className="bg-slate-800 border-b border-slate-700 sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-kuma-green">
Kuma Strapper
</h1>
<div className={`flex items-center gap-2 text-sm ${connected ? 'text-green-400' : 'text-red-400'}`}>
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-400' : 'bg-red-400'}`}></span>
{connected ? 'Connected' : 'Disconnected'}
</div>
</div>
<DevModeToggle enabled={settings.dev_mode} onToggle={toggleDevMode} />
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 py-8">
{/* Config Status */}
{(!settings.has_ssh_key || !settings.has_claude_key || !settings.has_kuma_key) && (
<div className="mb-6 p-4 bg-yellow-900/30 border border-yellow-600 rounded-lg">
<h3 className="font-semibold text-yellow-400 mb-2">Configuration Required</h3>
<ul className="text-sm text-yellow-200 space-y-1">
{!settings.has_ssh_key && <li> SSH_PRIVATE_KEY environment variable is not set</li>}
{!settings.has_claude_key && <li> CLAUDE_API_KEY environment variable is not set</li>}
{!settings.has_kuma_key && <li> UPTIME_KUMA_API_KEY environment variable is not set</li>}
</ul>
</div>
)}
<Dashboard
scanProgress={scanProgress}
scanResults={scanResults}
analysisResults={analysisResults}
devMode={settings.dev_mode}
/>
</main>
{/* Approval Modal */}
{pendingApprovals.length > 0 && settings.dev_mode && (
<ApprovalModal
approvals={pendingApprovals}
onApprove={handleApprove}
onReject={handleReject}
/>
)}
</div>
);
}

View File

@@ -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'),
};

View File

@@ -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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-slate-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden">
<div className="bg-amber-600 px-6 py-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Pending Approvals ({approvals.length})
</h2>
</div>
<div className="overflow-y-auto max-h-[60vh]">
{approvals.map((approval) => (
<div key={approval.id} className="border-b border-slate-700 p-6">
<div className="flex items-start justify-between mb-4">
<div>
<span className={`inline-block px-2 py-1 text-xs font-medium rounded ${
approval.type === 'ssh_command'
? 'bg-blue-900 text-blue-200'
: 'bg-purple-900 text-purple-200'
}`}>
{approval.type === 'ssh_command' ? 'SSH Command' : 'Create Monitor'}
</span>
<h3 className="text-lg font-semibold text-slate-100 mt-2">
{approval.description}
</h3>
</div>
</div>
{approval.reason && (
<div className="mb-4 p-3 bg-slate-700/50 rounded">
<p className="text-sm text-slate-300">
<span className="font-medium text-slate-200">Why: </span>
{approval.reason}
</p>
</div>
)}
{approval.type === 'ssh_command' && (
<div className="mb-4">
<p className="text-xs text-slate-400 mb-1">Command to execute:</p>
<code className="block p-3 bg-slate-900 rounded text-sm text-green-400 font-mono overflow-x-auto">
{approval.details.command}
</code>
<p className="text-xs text-slate-500 mt-1">
On host: {approval.details.hostname}
</p>
</div>
)}
{approval.type === 'create_monitor' && (
<div className="mb-4 p-3 bg-slate-700/50 rounded text-sm">
<p><span className="text-slate-400">Name:</span> {approval.details.name}</p>
<p><span className="text-slate-400">Type:</span> {approval.details.type}</p>
<p><span className="text-slate-400">Target:</span> {approval.details.target}</p>
</div>
)}
<div className="flex gap-3">
<button
onClick={() => handleApprove(approval.id)}
disabled={processing[approval.id]}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-500 disabled:bg-green-800 text-white font-medium rounded transition-colors"
>
{processing[approval.id] === 'approving' ? 'Approving...' : 'Approve'}
</button>
<button
onClick={() => handleReject(approval.id)}
disabled={processing[approval.id]}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-500 disabled:bg-red-800 text-white font-medium rounded transition-colors"
>
{processing[approval.id] === 'rejecting' ? 'Rejecting...' : 'Reject'}
</button>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-8">
{/* Scan Form */}
<div className="bg-slate-800 rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Scan Host</h2>
<form onSubmit={handleScan} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-1">
<label className="block text-sm font-medium text-slate-300 mb-1">
Hostname / IP
</label>
<input
type="text"
value={hostname}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">
SSH Port
</label>
<input
type="number"
value={port}
onChange={(e) => 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"
/>
</div>
</div>
{error && (
<div className="p-3 bg-red-900/30 border border-red-600 rounded text-red-300 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={scanning || !hostname}
className="px-6 py-2 bg-kuma-green hover:bg-green-400 disabled:bg-slate-600 text-slate-900 font-semibold rounded transition-colors flex items-center gap-2"
>
{scanning ? (
<>
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Scanning...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Scan Host
</>
)}
</button>
</form>
</div>
{/* Scan Progress */}
{scanning && hostname && scanProgress[hostname] && (
<HostCard
hostname={hostname}
progress={scanProgress[hostname]}
status="scanning"
/>
)}
{/* Scan Results & Analysis */}
{currentScan && currentScanId && (
<DiscoveryResults
scanId={currentScanId}
scan={currentScan}
analysis={currentAnalysis}
devMode={devMode}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,28 @@
export default function DevModeToggle({ enabled, onToggle }) {
return (
<div className="flex items-center gap-3">
<span className={`text-sm font-medium ${enabled ? 'text-amber-400' : 'text-slate-400'}`}>
Dev Mode
</span>
<button
onClick={onToggle}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-800 ${
enabled
? 'bg-amber-500 focus:ring-amber-500'
: 'bg-slate-600 focus:ring-slate-500'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
{enabled && (
<span className="text-xs text-amber-400 bg-amber-900/30 px-2 py-1 rounded">
Commands require approval
</span>
)}
</div>
);
}

View File

@@ -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 (
<div className="bg-red-900/30 border border-red-600 rounded-lg p-6">
<h3 className="text-lg font-semibold text-red-300 mb-2">Connection Failed</h3>
<p className="text-red-200">{scan.error}</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Connection Success */}
<div className="bg-green-900/30 border border-green-600 rounded-lg p-4">
<p className="text-green-300 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Connected to {scan.hostname}
</p>
</div>
{/* Default Monitors */}
<div className="bg-slate-800 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">Default Monitors</h3>
<p className="text-sm text-slate-400">
Basic monitoring that will be applied automatically (no approval required)
</p>
</div>
<button
onClick={handleCreateDefaults}
disabled={creatingDefaults}
className="px-4 py-2 bg-kuma-green hover:bg-green-400 disabled:bg-slate-600 text-slate-900 font-medium rounded transition-colors"
>
{creatingDefaults ? 'Creating...' : 'Apply Default Monitors'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2 p-3 bg-slate-700/50 rounded">
<span className="w-2 h-2 rounded-full bg-green-400"></span>
<span>Ping - {scan.hostname}</span>
</div>
<div className="flex items-center gap-2 p-3 bg-slate-700/50 rounded">
<span className="w-2 h-2 rounded-full bg-blue-400"></span>
<span>TCP - SSH Port 22</span>
</div>
</div>
</div>
{/* Claude Analysis */}
{analysis && (
<div className="bg-slate-800 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
AI Analysis
{analysis.loading && (
<span className="text-sm text-slate-400 ml-2">Analyzing...</span>
)}
</h3>
{analysis.error ? (
<div className="p-4 bg-red-900/30 border border-red-600 rounded text-red-300">
{analysis.error}
</div>
) : analysis.loading ? (
<div className="flex items-center gap-3 text-slate-400">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Claude is analyzing the host...
</div>
) : (
<div className="space-y-6">
{/* Analysis Text */}
{analysis.analysis && (
<div className="prose prose-invert prose-sm max-w-none">
<p className="text-slate-300">{analysis.analysis}</p>
</div>
)}
{/* Suggested Monitors */}
{analysis.monitors && analysis.monitors.length > 0 && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium">Suggested Monitors</h4>
<button
onClick={handleCreateSuggested}
disabled={selectedMonitors.length === 0 || creatingSuggested}
className="px-3 py-1 text-sm bg-purple-600 hover:bg-purple-500 disabled:bg-slate-600 text-white rounded transition-colors"
>
{creatingSuggested
? 'Creating...'
: `Create Selected (${selectedMonitors.length})`}
</button>
</div>
<div className="space-y-2">
{analysis.monitors.map((monitor, index) => (
<div
key={index}
onClick={() => 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'
}`}
>
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={selectedMonitors.includes(index)}
onChange={() => {}}
className="mt-1 w-4 h-4 rounded border-slate-500 bg-slate-700 text-purple-500 focus:ring-purple-500"
/>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
monitor.type === 'http' ? 'bg-blue-900 text-blue-200' :
monitor.type === 'tcp' ? 'bg-green-900 text-green-200' :
monitor.type === 'docker' ? 'bg-cyan-900 text-cyan-200' :
'bg-slate-600 text-slate-200'
}`}>
{monitor.type.toUpperCase()}
</span>
<span className="font-medium">{monitor.name}</span>
</div>
<p className="text-sm text-slate-400">
{monitor.target}
{monitor.port && `:${monitor.port}`}
{' • '}
Every {monitor.interval}s
</p>
{monitor.reason && (
<p className="text-sm text-slate-500 mt-1 italic">
{monitor.reason}
</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Additional Commands Claude Wants to Run */}
{analysis.additional_commands && analysis.additional_commands.length > 0 && (
<div>
<h4 className="font-medium mb-3 flex items-center gap-2">
<svg className="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Claude wants more information
{devMode && (
<span className="text-xs text-amber-400 bg-amber-900/30 px-2 py-0.5 rounded">
Requires approval
</span>
)}
</h4>
<div className="space-y-2">
{analysis.additional_commands.map((cmd, index) => (
<div key={index} className="p-3 bg-slate-700/50 rounded">
<code className="text-sm text-green-400 font-mono block mb-1">
{cmd.command}
</code>
<p className="text-xs text-slate-400">{cmd.reason}</p>
</div>
))}
</div>
</div>
)}
{/* Questions for User */}
{analysis.questions && analysis.questions.length > 0 && (
<div>
<h4 className="font-medium mb-3">Questions from Claude</h4>
<ul className="space-y-2">
{analysis.questions.map((question, index) => (
<li key={index} className="text-sm text-slate-300 flex items-start gap-2">
<span className="text-purple-400">?</span>
{question}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
)}
{/* Creation Results */}
{createResults && createResults.length > 0 && (
<div className="bg-slate-800 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">Creation Results</h3>
<div className="space-y-2">
{createResults.map((result, index) => (
<div
key={index}
className={`p-3 rounded flex items-center gap-2 ${
result.status === 'created'
? 'bg-green-900/30 text-green-300'
: 'bg-red-900/30 text-red-300'
}`}
>
{result.status === 'created' ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<span className="font-medium">{result.monitor}</span>
<span className="text-sm opacity-75">({result.type})</span>
{result.error && (
<span className="text-sm ml-auto">{result.error}</span>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="bg-slate-800 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
{hostname}
</h3>
{status === 'scanning' && (
<span className="flex items-center gap-2 text-sm text-blue-400">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Scanning...
</span>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{commands.map(([key, label]) => {
const cmdStatus = progress?.[key];
const colorClass = STATUS_COLORS[cmdStatus] || 'text-slate-500';
return (
<div
key={key}
className="flex items-center gap-2 text-sm"
>
{cmdStatus === 'running' ? (
<svg className="animate-spin h-4 w-4 text-blue-400" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : cmdStatus === 'complete' || cmdStatus === 'connected' ? (
<svg className="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : cmdStatus?.startsWith('failed') || cmdStatus?.startsWith('error') ? (
<svg className="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<div className="w-4 h-4 rounded-full border-2 border-slate-600" />
)}
<span className={colorClass}>{label}</span>
</div>
);
})}
</div>
</div>
);
}

7
frontend/src/index.css Normal file
View File

@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-slate-900 text-slate-100;
}

10
frontend/src/main.jsx Normal file
View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -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: [],
}

19
frontend/vite.config.js Normal file
View File

@@ -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,
},
},
},
})