Initial commit with CI workflow
All checks were successful
Build Container / build (push) Successful in 1m18s
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:
23
.env.example
Normal file
23
.env.example
Normal 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
|
||||
30
.gitea/workflows/build.yml
Normal file
30
.gitea/workflows/build.yml
Normal 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
44
.gitignore
vendored
Normal 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
82
CLAUDE.md
Normal 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
57
Dockerfile
Normal 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
444
backend/app.py
Normal 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
65
backend/config.py
Normal 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
9
backend/requirements.txt
Normal 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
|
||||
1
backend/services/__init__.py
Normal file
1
backend/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
273
backend/services/claude_agent.py
Normal file
273
backend/services/claude_agent.py
Normal 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()
|
||||
205
backend/services/discovery.py
Normal file
205
backend/services/discovery.py
Normal 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
|
||||
174
backend/services/kuma_client.py
Normal file
174
backend/services/kuma_client.py
Normal 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
|
||||
249
backend/services/monitors.py
Normal file
249
backend/services/monitors.py
Normal 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
|
||||
175
backend/services/ssh_manager.py
Normal file
175
backend/services/ssh_manager.py
Normal 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
|
||||
1
backend/utils/__init__.py
Normal file
1
backend/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Utils package
|
||||
213
backend/utils/approval.py
Normal file
213
backend/utils/approval.py
Normal 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
28
docker-compose.yml
Normal 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
13
frontend/index.html
Normal 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
25
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
170
frontend/src/App.jsx
Normal file
170
frontend/src/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
frontend/src/api/client.js
Normal file
64
frontend/src/api/client.js
Normal 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'),
|
||||
};
|
||||
107
frontend/src/components/ApprovalModal.jsx
Normal file
107
frontend/src/components/ApprovalModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
frontend/src/components/Dashboard.jsx
Normal file
141
frontend/src/components/Dashboard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/DevModeToggle.jsx
Normal file
28
frontend/src/components/DevModeToggle.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
277
frontend/src/components/DiscoveryResults.jsx
Normal file
277
frontend/src/components/DiscoveryResults.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/HostCard.jsx
Normal file
78
frontend/src/components/HostCard.jsx
Normal 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
7
frontend/src/index.css
Normal 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
10
frontend/src/main.jsx
Normal 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>,
|
||||
)
|
||||
19
frontend/tailwind.config.js
Normal file
19
frontend/tailwind.config.js
Normal 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
19
frontend/vite.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user