Remove Uptime Kuma integration
- Delete kuma_client.py and all Kuma-related code - Remove /infrastructure/monitors endpoints - Update service start/stop to use Portainer only - Simplify service control widget to show container status - Remove Kuma config and credentials - Delete stale memory tests (moved to core-ai service) - Add test infrastructure with pytest - Add tests for service_groups, config, and health endpoints - Bump version to 1.1.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,561 +0,0 @@
|
||||
"""
|
||||
Uptime Kuma Socket.IO Client
|
||||
|
||||
Provides interface to Uptime Kuma via Socket.IO for monitor management.
|
||||
Also provides metrics API access for real-time status data.
|
||||
"""
|
||||
import socketio
|
||||
import asyncio
|
||||
import httpx
|
||||
import re
|
||||
from typing import Optional, Dict, List, Any
|
||||
from src.logging_config import get_logger
|
||||
from src.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class KumaClient:
|
||||
"""
|
||||
Socket.IO client for Uptime Kuma
|
||||
|
||||
Uses Socket.IO for real-time communication with Uptime Kuma.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
timeout: int = 30
|
||||
):
|
||||
"""
|
||||
Initialize Kuma client
|
||||
|
||||
Args:
|
||||
base_url: Kuma base URL (default from settings)
|
||||
username: Kuma username (default from settings)
|
||||
password: Kuma password (default from settings)
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.base_url = (base_url or settings.kuma_url).rstrip("/")
|
||||
self.username = username or settings.kuma_username
|
||||
self.password = password or settings.kuma_password
|
||||
self.timeout = timeout
|
||||
|
||||
self.sio = socketio.AsyncClient(
|
||||
reconnection=True,
|
||||
reconnection_attempts=3,
|
||||
reconnection_delay=1,
|
||||
)
|
||||
self._connected = False
|
||||
self._authenticated = False
|
||||
self._monitors_cache: Dict[int, Dict[str, Any]] = {}
|
||||
|
||||
if not self.username or not self.password:
|
||||
logger.warning("Uptime Kuma credentials not configured")
|
||||
|
||||
async def _ensure_connected(self):
|
||||
"""Ensure we have an active connection and authentication"""
|
||||
if not self._connected:
|
||||
await self.connect()
|
||||
if not self._authenticated:
|
||||
await self.login()
|
||||
|
||||
async def connect(self):
|
||||
"""Connect to Uptime Kuma Socket.IO server"""
|
||||
if self._connected:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.sio.connect(self.base_url, transports=['websocket'])
|
||||
self._connected = True
|
||||
logger.info(f"Connected to Uptime Kuma at {self.base_url}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Uptime Kuma: {e}")
|
||||
raise
|
||||
|
||||
async def disconnect(self):
|
||||
"""Disconnect from Uptime Kuma"""
|
||||
if self._connected:
|
||||
await self.sio.disconnect()
|
||||
self._connected = False
|
||||
self._authenticated = False
|
||||
logger.info("Disconnected from Uptime Kuma")
|
||||
|
||||
async def login(self):
|
||||
"""Authenticate with Uptime Kuma"""
|
||||
if not self._connected:
|
||||
await self.connect()
|
||||
|
||||
try:
|
||||
# Uptime Kuma login event
|
||||
login_response = await self.sio.call(
|
||||
'login',
|
||||
{
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'token': None
|
||||
},
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
if login_response and login_response.get('ok'):
|
||||
self._authenticated = True
|
||||
logger.info("Successfully authenticated with Uptime Kuma")
|
||||
else:
|
||||
error_msg = login_response.get('msg', 'Unknown error') if login_response else 'No response'
|
||||
raise Exception(f"Login failed: {error_msg}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to authenticate with Uptime Kuma: {e}")
|
||||
raise
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Check if Uptime Kuma is accessible
|
||||
|
||||
Returns:
|
||||
True if accessible, False otherwise
|
||||
"""
|
||||
try:
|
||||
await self._ensure_connected()
|
||||
return self._authenticated
|
||||
except Exception as e:
|
||||
logger.error(f"Uptime Kuma health check failed: {e}")
|
||||
return False
|
||||
|
||||
async def get_monitors(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all monitors with uptime data
|
||||
|
||||
Returns:
|
||||
List of monitor configurations with uptime_24h field
|
||||
"""
|
||||
await self._ensure_connected()
|
||||
|
||||
try:
|
||||
# Storage for monitor list and uptime data received via events
|
||||
monitor_list_data = {}
|
||||
uptime_list_data = {}
|
||||
monitor_event_received = asyncio.Event()
|
||||
uptime_event_received = asyncio.Event()
|
||||
|
||||
# Register event handler for monitorList
|
||||
@self.sio.event
|
||||
async def monitorList(data):
|
||||
nonlocal monitor_list_data
|
||||
monitor_list_data = data
|
||||
monitor_event_received.set()
|
||||
|
||||
# Register event handler for uptimeList (24h uptime percentages)
|
||||
@self.sio.event
|
||||
async def uptimeList(monitor_id, uptime_data):
|
||||
nonlocal uptime_list_data
|
||||
# uptime_data is typically a dict with time periods: {"24": 99.5, "720": 98.2, ...}
|
||||
uptime_list_data[str(monitor_id)] = uptime_data
|
||||
# Don't set event here as we'll get multiple calls
|
||||
|
||||
# Request monitor list - this triggers the server to send monitorList event
|
||||
response = await self.sio.call('getMonitorList', timeout=self.timeout)
|
||||
logger.info(f"getMonitorList call response: {response}")
|
||||
|
||||
# Wait for the monitorList event (with timeout)
|
||||
try:
|
||||
await asyncio.wait_for(monitor_event_received.wait(), timeout=5.0)
|
||||
logger.info(f"Received monitorList event with {len(monitor_list_data)} items")
|
||||
|
||||
# Give time for uptimeList events to arrive
|
||||
await asyncio.sleep(0.5)
|
||||
logger.info(f"Received uptime data for {len(uptime_list_data)} monitors")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Timeout waiting for monitorList event")
|
||||
|
||||
# Process the monitor list data
|
||||
if monitor_list_data and isinstance(monitor_list_data, dict):
|
||||
monitors = []
|
||||
for monitor_id, monitor_data in monitor_list_data.items():
|
||||
if isinstance(monitor_data, dict):
|
||||
monitor_data['id'] = int(monitor_id)
|
||||
|
||||
# Add uptime data if available
|
||||
uptime_info = uptime_list_data.get(str(monitor_id), {})
|
||||
if isinstance(uptime_info, dict):
|
||||
# Uptime Kuma provides 24h uptime as key "24"
|
||||
monitor_data['uptime_24h'] = float(uptime_info.get('24', 0))
|
||||
else:
|
||||
monitor_data['uptime_24h'] = 0.0
|
||||
|
||||
monitors.append(monitor_data)
|
||||
self._monitors_cache[int(monitor_id)] = monitor_data
|
||||
|
||||
logger.info(f"Found {len(monitors)} monitors total")
|
||||
return monitors
|
||||
|
||||
logger.warning(f"No valid monitor data received")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get monitors: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def get_monitor(self, monitor_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Get details of a specific monitor
|
||||
|
||||
Args:
|
||||
monitor_id: Monitor identifier
|
||||
|
||||
Returns:
|
||||
Monitor configuration details
|
||||
"""
|
||||
await self._ensure_connected()
|
||||
|
||||
try:
|
||||
response = await self.sio.call('getMonitor', monitor_id, timeout=self.timeout)
|
||||
|
||||
if response:
|
||||
self._monitors_cache[monitor_id] = response
|
||||
return response
|
||||
|
||||
raise Exception(f"Monitor {monitor_id} not found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get monitor {monitor_id}: {e}")
|
||||
raise
|
||||
|
||||
async def find_monitor_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Find a monitor by its name (case-insensitive)
|
||||
|
||||
Args:
|
||||
name: Monitor name to search for
|
||||
|
||||
Returns:
|
||||
Monitor object if found, None otherwise
|
||||
"""
|
||||
monitors = await self.get_monitors()
|
||||
name_lower = name.lower()
|
||||
|
||||
for monitor in monitors:
|
||||
if monitor.get("name", "").lower() == name_lower:
|
||||
return monitor
|
||||
|
||||
return None
|
||||
|
||||
async def find_monitors_by_tag(self, tag: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Find all monitors with a specific tag
|
||||
|
||||
Args:
|
||||
tag: Tag name to search for
|
||||
|
||||
Returns:
|
||||
List of monitors with the tag
|
||||
"""
|
||||
monitors = await self.get_monitors()
|
||||
tagged_monitors = []
|
||||
|
||||
for monitor in monitors:
|
||||
monitor_tags = monitor.get("tags", [])
|
||||
if any(t.get("name", "").lower() == tag.lower() for t in monitor_tags):
|
||||
tagged_monitors.append(monitor)
|
||||
|
||||
return tagged_monitors
|
||||
|
||||
async def pause_monitor(self, monitor_id: int) -> bool:
|
||||
"""
|
||||
Pause a monitor (disable monitoring)
|
||||
|
||||
Args:
|
||||
monitor_id: Monitor identifier
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
await self._ensure_connected()
|
||||
|
||||
try:
|
||||
# Uptime Kuma pause event
|
||||
response = await self.sio.call('pauseMonitor', monitor_id, timeout=self.timeout)
|
||||
|
||||
if response and response.get('ok'):
|
||||
logger.info(f"Paused monitor {monitor_id}")
|
||||
return True
|
||||
|
||||
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
|
||||
raise Exception(f"Failed to pause monitor: {error_msg}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to pause monitor {monitor_id}: {e}")
|
||||
raise
|
||||
|
||||
async def resume_monitor(self, monitor_id: int) -> bool:
|
||||
"""
|
||||
Resume a monitor (enable monitoring)
|
||||
|
||||
Args:
|
||||
monitor_id: Monitor identifier
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
await self._ensure_connected()
|
||||
|
||||
try:
|
||||
# Uptime Kuma resume event
|
||||
response = await self.sio.call('resumeMonitor', monitor_id, timeout=self.timeout)
|
||||
|
||||
if response and response.get('ok'):
|
||||
logger.info(f"Resumed monitor {monitor_id}")
|
||||
return True
|
||||
|
||||
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
|
||||
raise Exception(f"Failed to resume monitor: {error_msg}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to resume monitor {monitor_id}: {e}")
|
||||
raise
|
||||
|
||||
async def pause_monitor_by_name(self, name: str) -> bool:
|
||||
"""
|
||||
Pause a monitor by its name
|
||||
|
||||
Args:
|
||||
name: Monitor name
|
||||
|
||||
Returns:
|
||||
True if successful, False if monitor not found
|
||||
"""
|
||||
monitor = await self.find_monitor_by_name(name)
|
||||
if not monitor:
|
||||
logger.warning(f"Monitor '{name}' not found")
|
||||
return False
|
||||
|
||||
await self.pause_monitor(monitor["id"])
|
||||
return True
|
||||
|
||||
async def resume_monitor_by_name(self, name: str) -> bool:
|
||||
"""
|
||||
Resume a monitor by its name
|
||||
|
||||
Args:
|
||||
name: Monitor name
|
||||
|
||||
Returns:
|
||||
True if successful, False if monitor not found
|
||||
"""
|
||||
monitor = await self.find_monitor_by_name(name)
|
||||
if not monitor:
|
||||
logger.warning(f"Monitor '{name}' not found")
|
||||
return False
|
||||
|
||||
await self.resume_monitor(monitor["id"])
|
||||
return True
|
||||
|
||||
async def add_monitor(self, monitor_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new monitor
|
||||
|
||||
Args:
|
||||
monitor_config: Monitor configuration dict
|
||||
|
||||
Returns:
|
||||
Created monitor details including ID
|
||||
"""
|
||||
await self._ensure_connected()
|
||||
|
||||
try:
|
||||
# Uptime Kuma add monitor event
|
||||
response = await self.sio.call('add', monitor_config, timeout=self.timeout)
|
||||
|
||||
if response and response.get('ok'):
|
||||
monitor_id = response.get('monitorID')
|
||||
logger.info(f"Created monitor '{monitor_config.get('name')}' with ID {monitor_id}")
|
||||
|
||||
# Get full monitor details
|
||||
monitor = await self.get_monitor(monitor_id)
|
||||
return monitor
|
||||
|
||||
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
|
||||
raise Exception(f"Failed to create monitor: {error_msg}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create monitor '{monitor_config.get('name')}': {e}")
|
||||
raise
|
||||
|
||||
async def update_monitor(self, monitor_id: int, monitor_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Update an existing monitor
|
||||
|
||||
Args:
|
||||
monitor_id: Monitor identifier
|
||||
monitor_config: Updated monitor configuration
|
||||
|
||||
Returns:
|
||||
Updated monitor details
|
||||
"""
|
||||
await self._ensure_connected()
|
||||
|
||||
try:
|
||||
# Ensure ID is in the config
|
||||
monitor_config['id'] = monitor_id
|
||||
|
||||
# Uptime Kuma edit monitor event
|
||||
response = await self.sio.call('editMonitor', monitor_config, timeout=self.timeout)
|
||||
|
||||
if response and response.get('ok'):
|
||||
logger.info(f"Updated monitor {monitor_id}")
|
||||
|
||||
# Get updated monitor details
|
||||
monitor = await self.get_monitor(monitor_id)
|
||||
return monitor
|
||||
|
||||
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
|
||||
raise Exception(f"Failed to update monitor: {error_msg}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update monitor {monitor_id}: {e}")
|
||||
raise
|
||||
|
||||
async def delete_monitor(self, monitor_id: int) -> bool:
|
||||
"""
|
||||
Delete a monitor
|
||||
|
||||
Args:
|
||||
monitor_id: Monitor identifier
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
await self._ensure_connected()
|
||||
|
||||
try:
|
||||
# Uptime Kuma delete monitor event
|
||||
response = await self.sio.call('deleteMonitor', monitor_id, timeout=self.timeout)
|
||||
|
||||
if response and response.get('ok'):
|
||||
logger.info(f"Deleted monitor {monitor_id}")
|
||||
|
||||
# Remove from cache
|
||||
self._monitors_cache.pop(monitor_id, None)
|
||||
return True
|
||||
|
||||
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
|
||||
raise Exception(f"Failed to delete monitor: {error_msg}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete monitor {monitor_id}: {e}")
|
||||
raise
|
||||
|
||||
async def delete_monitor_by_name(self, name: str) -> bool:
|
||||
"""
|
||||
Delete a monitor by its name
|
||||
|
||||
Args:
|
||||
name: Monitor name
|
||||
|
||||
Returns:
|
||||
True if successful, False if monitor not found
|
||||
"""
|
||||
monitor = await self.find_monitor_by_name(name)
|
||||
if not monitor:
|
||||
logger.warning(f"Monitor '{name}' not found")
|
||||
return False
|
||||
|
||||
await self.delete_monitor(monitor["id"])
|
||||
return True
|
||||
|
||||
async def get_metrics_status(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get monitor status from Prometheus metrics endpoint
|
||||
|
||||
This is simpler and more reliable than Socket.IO for getting current status.
|
||||
Returns real-time UP/DOWN status but not historical uptime percentages.
|
||||
|
||||
Returns:
|
||||
Dict mapping monitor names to status info:
|
||||
{
|
||||
"Portainer": {
|
||||
"status": 1, # 1=UP, 0=DOWN, 2=PENDING, 3=MAINTENANCE
|
||||
"response_time": 5, # ms
|
||||
"monitor_type": "http",
|
||||
"url": "http://192.168.86.149:8001"
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Use API key authentication
|
||||
api_key = settings.kuma_api_key
|
||||
if not api_key:
|
||||
logger.warning("Kuma API key not configured")
|
||||
return {}
|
||||
|
||||
# Fetch metrics with HTTP Basic Auth (empty username, API key as password)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/metrics",
|
||||
auth=("", api_key)
|
||||
)
|
||||
response.raise_for_status()
|
||||
metrics_text = response.text
|
||||
|
||||
# Parse Prometheus format metrics
|
||||
# Format: metric_name{label1="value1",label2="value2"} value
|
||||
monitor_data = {}
|
||||
|
||||
# Parse monitor_status lines
|
||||
status_pattern = r'monitor_status\{monitor_name="([^"]+)",.*?\} (\d+)'
|
||||
for match in re.finditer(status_pattern, metrics_text):
|
||||
monitor_name = match.group(1)
|
||||
status = int(match.group(2))
|
||||
|
||||
if monitor_name not in monitor_data:
|
||||
monitor_data[monitor_name] = {}
|
||||
monitor_data[monitor_name]['status'] = status
|
||||
|
||||
# Parse monitor_response_time lines
|
||||
response_pattern = r'monitor_response_time\{monitor_name="([^"]+)",monitor_type="([^"]+)",monitor_url="([^"]+)",.*?\} ([\d.]+)'
|
||||
for match in re.finditer(response_pattern, metrics_text):
|
||||
monitor_name = match.group(1)
|
||||
monitor_type = match.group(2)
|
||||
monitor_url = match.group(3)
|
||||
response_time = float(match.group(4))
|
||||
|
||||
if monitor_name not in monitor_data:
|
||||
monitor_data[monitor_name] = {}
|
||||
monitor_data[monitor_name].update({
|
||||
'response_time': response_time,
|
||||
'monitor_type': monitor_type,
|
||||
'url': monitor_url
|
||||
})
|
||||
|
||||
logger.info(f"Fetched metrics for {len(monitor_data)} monitors")
|
||||
return monitor_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch metrics: {e}")
|
||||
return {}
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry"""
|
||||
await self._ensure_connected()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit"""
|
||||
await self.disconnect()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_kuma_client: Optional[KumaClient] = None
|
||||
|
||||
|
||||
def get_kuma_client() -> KumaClient:
|
||||
"""Get singleton Kuma client instance"""
|
||||
global _kuma_client
|
||||
if _kuma_client is None:
|
||||
_kuma_client = KumaClient()
|
||||
return _kuma_client
|
||||
Reference in New Issue
Block a user