From a034842cd38e73ff47c3ef5dc1a19f955a16a9a7 Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 5 Jan 2026 03:16:19 +0000 Subject: [PATCH] Fix Uptime Kuma integration and add interactive UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch to uptime-kuma-api library (Socket.io based) - Add Approve & Run buttons for Claude's additional commands - Add answer input fields for Claude's questions - Add push monitor type support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/requirements.txt | 1 + backend/services/kuma_client.py | 169 ++++++++++--------- frontend/src/components/Dashboard.jsx | 7 + frontend/src/components/DiscoveryResults.jsx | 75 ++++++-- 4 files changed, 160 insertions(+), 92 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index d02b19c..31e566a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ requests==2.31.0 python-dotenv==1.0.0 gevent==23.9.1 gevent-websocket==0.10.1 +uptime-kuma-api==1.2.1 diff --git a/backend/services/kuma_client.py b/backend/services/kuma_client.py index 35b2106..15ed636 100644 --- a/backend/services/kuma_client.py +++ b/backend/services/kuma_client.py @@ -1,15 +1,26 @@ from typing import Optional -from dataclasses import dataclass, asdict -import requests +from dataclasses import dataclass +from uptime_kuma_api import UptimeKumaApi, MonitorType from config import get_config +# Map our monitor types to Uptime Kuma types +TYPE_MAP = { + "http": MonitorType.HTTP, + "tcp": MonitorType.PORT, + "ping": MonitorType.PING, + "docker": MonitorType.DOCKER, + "keyword": MonitorType.KEYWORD, + "push": MonitorType.PUSH, +} + + @dataclass class Monitor: """Uptime Kuma monitor configuration.""" - type: str # http, tcp, ping, docker, keyword + type: str # http, tcp, ping, docker, keyword, push name: str url: Optional[str] = None # For HTTP monitors hostname: Optional[str] = None # For TCP/Ping monitors @@ -18,147 +29,143 @@ class Monitor: keyword: Optional[str] = None # For keyword monitors docker_container: Optional[str] = None # For Docker monitors docker_host: Optional[str] = None # For Docker monitors + push_token: Optional[str] = None # For Push 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.""" + """Client for Uptime Kuma using Socket.io 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", - }) + self._api = None - 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_api(self) -> UptimeKumaApi: + """Get connected API instance.""" + if self._api is None: + self._api = UptimeKumaApi(self.base_url) + # Login with API key as token + self._api.login_by_token(self.api_key) + return self._api + + def _disconnect(self): + """Disconnect API.""" + if self._api: + try: + self._api.disconnect() + except Exception: + pass + self._api = None def get_monitors(self) -> list[dict]: """Get all monitors.""" try: - result = self._request("GET", "/monitors") - return result.get("monitors", []) + api = self._get_api() + return api.get_monitors() except Exception as e: + self._disconnect() 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", {}) + api = self._get_api() + return api.get_monitor(monitor_id) except Exception as e: + self._disconnect() 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)}") + api = self._get_api() - 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) + monitor_type = TYPE_MAP.get(monitor.type) + if not monitor_type: + raise ValueError(f"Unknown monitor type: {monitor.type}") + + kwargs = { + "type": monitor_type, + "name": monitor.name, + "interval": monitor.interval, + "maxretries": monitor.retries, + "retryInterval": monitor.retry_interval, + } + + # Add type-specific fields + if monitor.type == "http": + kwargs["url"] = monitor.url + kwargs["maxredirects"] = monitor.max_redirects + kwargs["accepted_statuscodes"] = monitor.accepted_statuscodes + elif monitor.type == "keyword": + kwargs["url"] = monitor.url + kwargs["keyword"] = monitor.keyword + elif monitor.type == "tcp": + kwargs["hostname"] = monitor.hostname + kwargs["port"] = monitor.port + elif monitor.type == "ping": + kwargs["hostname"] = monitor.hostname + elif monitor.type == "docker": + kwargs["docker_container"] = monitor.docker_container + if monitor.docker_host: + kwargs["docker_host"] = monitor.docker_host + elif monitor.type == "push": + # Push monitors are created and get a token back + pass + + result = api.add_monitor(**kwargs) return result except Exception as e: - raise Exception(f"Failed to update monitor {monitor_id}: {str(e)}") + self._disconnect() + raise Exception(f"Failed to create monitor: {str(e)}") def delete_monitor(self, monitor_id: int) -> dict: """Delete a monitor.""" try: - result = self._request("DELETE", f"/monitors/{monitor_id}") + api = self._get_api() + result = api.delete_monitor(monitor_id) return result except Exception as e: + self._disconnect() 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") + api = self._get_api() + result = api.pause_monitor(monitor_id) return result except Exception as e: + self._disconnect() 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") + api = self._get_api() + result = api.resume_monitor(monitor_id) return result except Exception as e: + self._disconnect() 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") + api = self._get_api() + api.get_monitors() return True except Exception: + self._disconnect() return False diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 022da08..26ff0b1 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -134,6 +134,13 @@ export default function Dashboard({ scanProgress, scanResults, analysisResults, scan={currentScan} analysis={currentAnalysis} devMode={devMode} + onCommandApproved={async (command) => { + await api.runCommand(currentScanId, command, 'User approved from UI'); + }} + onQuestionAnswered={async (question, answer) => { + // TODO: Implement question answering API + console.log('Question answered:', question, answer); + }} /> )} diff --git a/frontend/src/components/DiscoveryResults.jsx b/frontend/src/components/DiscoveryResults.jsx index 6dcc9b4..1a168f5 100644 --- a/frontend/src/components/DiscoveryResults.jsx +++ b/frontend/src/components/DiscoveryResults.jsx @@ -1,11 +1,34 @@ import { useState } from 'react'; import { api } from '../api/client'; -export default function DiscoveryResults({ scanId, scan, analysis, devMode }) { +export default function DiscoveryResults({ scanId, scan, analysis, devMode, onCommandApproved, onQuestionAnswered }) { const [selectedMonitors, setSelectedMonitors] = useState([]); const [creatingDefaults, setCreatingDefaults] = useState(false); const [creatingSuggested, setCreatingSuggested] = useState(false); const [createResults, setCreateResults] = useState(null); + const [runningCommands, setRunningCommands] = useState({}); + const [questionAnswers, setQuestionAnswers] = useState({}); + + const handleRunCommand = async (command, index) => { + setRunningCommands(prev => ({ ...prev, [index]: true })); + try { + if (onCommandApproved) { + await onCommandApproved(command); + } + } finally { + setRunningCommands(prev => ({ ...prev, [index]: false })); + } + }; + + const handleAnswerQuestion = async (question, index) => { + const answer = questionAnswers[index]; + if (!answer || !answer.trim()) return; + + if (onQuestionAnswered) { + await onQuestionAnswered(question, answer); + } + setQuestionAnswers(prev => ({ ...prev, [index]: '' })); + }; const handleCreateDefaults = async () => { setCreatingDefaults(true); @@ -210,10 +233,21 @@ export default function DiscoveryResults({ scanId, scan, analysis, devMode }) {
{analysis.additional_commands.map((cmd, index) => (
- - {cmd.command} - -

{cmd.reason}

+
+
+ + {cmd.command} + +

{cmd.reason}

+
+ +
))}
@@ -224,14 +258,33 @@ export default function DiscoveryResults({ scanId, scan, analysis, devMode }) { {analysis.questions && analysis.questions.length > 0 && (

Questions from Claude

-
    +
    {analysis.questions.map((question, index) => ( -
  • - ? - {question} -
  • +
    +

    + ? + {question} +

    +
    + setQuestionAnswers(prev => ({ ...prev, [index]: e.target.value }))} + placeholder="Type your answer..." + className="flex-1 px-3 py-1 text-sm bg-slate-800 border border-slate-600 rounded focus:outline-none focus:border-purple-500" + onKeyDown={(e) => e.key === 'Enter' && handleAnswerQuestion(question, index)} + /> + +
    +
    ))} -
+
)}