initial checking; monitor list, maintenance {add,list,delete}

This commit is contained in:
Pim van Pelt
2025-08-01 16:17:29 +02:00
commit ae853172b7
2 changed files with 570 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
bin
include
lib
lib64
pyvenv.cfg
__pycache__

564
kuma_client.py Executable file
View File

@@ -0,0 +1,564 @@
#!/usr/bin/env python3
import argparse
import fnmatch
import os
import re
import sys
from datetime import datetime, timedelta
from uptime_kuma_api import UptimeKumaApi
class KumaClient:
def __init__(self, url, username=None, password=None):
self.url = url
self.username = username
self.password = password
self.api = None
def parse_duration(self, duration_str):
"""Parse duration string like '90m', '1h', '3600s' into seconds"""
if not duration_str:
return 90 * 60 # Default 90 minutes in seconds
match = re.match(r"^(\d+)([smh])$", duration_str.lower())
if not match:
raise ValueError(
f"Invalid duration format: {duration_str}. Use format like '90m', '1h', '3600s'"
)
value, unit = match.groups()
value = int(value)
if unit == "s":
return value
elif unit == "m":
return value * 60
elif unit == "h":
return value * 3600
raise ValueError(f"Invalid duration unit: {unit}")
def parse_start_time(self, start_str):
"""Parse start time string or use current time if None"""
if not start_str:
return datetime.utcnow()
# Try to parse ISO format first
try:
return datetime.fromisoformat(start_str.replace("Z", "+00:00")).replace(
tzinfo=None
)
except ValueError:
pass
# Try common formats
formats = [
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
"%Y-%m-%d",
]
for fmt in formats:
try:
return datetime.strptime(start_str, fmt)
except ValueError:
continue
raise ValueError(
f"Invalid start time format: {start_str}. Use ISO format or YYYY-MM-DD HH:MM:SS"
)
def connect(self):
"""Connect and login to Uptime Kuma"""
try:
self.api = UptimeKumaApi(self.url)
if self.username and self.password:
result = self.api.login(self.username, self.password)
print(f"Connected to {self.url}")
return True
except Exception as e:
print(f"Failed to connect: {e}")
return False
def disconnect(self):
"""Disconnect from Uptime Kuma"""
if self.api:
self.api.disconnect()
def list_monitors(self, monitor_patterns=None, group_patterns=None):
"""List all monitors or filter by patterns and/or groups"""
try:
all_monitors = self.api.get_monitors()
if not all_monitors:
print("No monitors found")
return
monitors = all_monitors[:] # Start with all monitors
# Filter by group if specified
if group_patterns:
group_monitors = self.get_monitors_in_groups(group_patterns)
if not group_monitors:
print("No monitors found in the specified groups")
return
monitor_ids = {m["id"] for m in group_monitors}
monitors = [m for m in monitors if m.get("id") in monitor_ids]
# Filter monitors by name patterns if specified
if monitor_patterns:
matched_monitors = self.find_monitors_by_pattern(monitor_patterns)
if not matched_monitors:
print("No monitors found matching the specified patterns")
return
pattern_monitor_ids = {m["id"] for m in matched_monitors}
monitors = [m for m in monitors if m.get("id") in pattern_monitor_ids]
# Display results with group information
print(
f"{'ID':<5} {'Name':<25} {'Type':<12} {'Group':<20} {'URL':<35} {'Status':<10}"
)
print("-" * 112)
for monitor in monitors:
monitor_id = monitor.get("id", "N/A")
name = monitor.get("name", "N/A")
monitor_type = monitor.get("type", "N/A")
url = monitor.get("url", "N/A")
active = "Active" if monitor.get("active") else "Inactive"
# Find parent group name
parent_id = monitor.get("parent")
parent_name = "None"
if parent_id:
parent_monitor = next(
(m for m in all_monitors if m.get("id") == parent_id), None
)
if parent_monitor:
parent_name = parent_monitor.get("name", f"Group {parent_id}")
print(
f"{monitor_id:<5} {name:<25} {monitor_type:<12} {parent_name:<20} {url:<35} {active:<10}"
)
except Exception as e:
print(f"Error listing monitors: {e}")
def list_maintenances(self):
"""List all maintenances"""
try:
maintenances = self.api.get_maintenances()
if not maintenances:
print("No maintenances found")
return
print(
f"{'ID':<5} {'Title':<30} {'Strategy':<15} {'Active':<10} {'Description':<50}"
)
print("-" * 115)
for maintenance in maintenances:
maintenance_id = maintenance.get("id", "N/A")
title = maintenance.get("title", "N/A")
strategy = maintenance.get("strategy", "N/A")
active = "Active" if maintenance.get("active") else "Inactive"
description = maintenance.get("description", "N/A")
print(
f"{maintenance_id:<5} {title:<30} {strategy:<15} {active:<10} {description:<50}"
)
except Exception as e:
print(f"Error listing maintenances: {e}")
def find_monitors_by_pattern(self, patterns):
"""Find monitor IDs by name patterns (case insensitive, supports wildcards)"""
try:
monitors = self.api.get_monitors()
matched_monitors = []
for pattern in patterns:
pattern_lower = pattern.lower()
for monitor in monitors:
monitor_name = monitor.get("name", "").lower()
if fnmatch.fnmatch(monitor_name, pattern_lower):
matched_monitors.append(
{"id": monitor.get("id"), "name": monitor.get("name")}
)
# Remove duplicates while preserving order
seen = set()
unique_monitors = []
for monitor in matched_monitors:
if monitor["id"] not in seen:
seen.add(monitor["id"])
unique_monitors.append(monitor)
return unique_monitors
except Exception as e:
print(f"Error finding monitors: {e}")
return []
def find_groups_by_pattern(self, patterns):
"""Find group IDs by name patterns (case insensitive, supports wildcards)"""
try:
monitors = self.api.get_monitors()
groups = [m for m in monitors if m.get("type") == "group"]
matched_groups = []
for pattern in patterns:
pattern_lower = pattern.lower()
for group in groups:
group_name = group.get("name", "").lower()
if fnmatch.fnmatch(group_name, pattern_lower):
matched_groups.append(
{"id": group.get("id"), "name": group.get("name")}
)
# Remove duplicates while preserving order
seen = set()
unique_groups = []
for group in matched_groups:
if group["id"] not in seen:
seen.add(group["id"])
unique_groups.append(group)
return unique_groups
except Exception as e:
print(f"Error finding groups: {e}")
return []
def get_monitors_in_groups(self, group_patterns):
"""Get all monitors that belong to specified groups"""
try:
monitors = self.api.get_monitors()
matched_groups = self.find_groups_by_pattern(group_patterns)
if not matched_groups:
return []
print(f"Found {len(matched_groups)} matching groups:")
for group in matched_groups:
print(f" - {group['name']} (ID: {group['id']})")
group_ids = {g["id"] for g in matched_groups}
group_members = []
for monitor in monitors:
# Check if monitor's parent is in our group list
if monitor.get("parent") in group_ids:
group_members.append(monitor)
return group_members
except Exception as e:
print(f"Error getting group members: {e}")
return []
def add_maintenance(
self,
title,
description,
start_time=None,
duration="90m",
monitor_patterns=None,
group_patterns=None,
):
"""Add a new maintenance"""
try:
# Check if we have either monitor patterns or group patterns
if not monitor_patterns and not group_patterns:
print(
"Error: Either --monitor or --group flag is required. Specify at least one pattern."
)
return
matched_monitors = []
# Find monitors by patterns if specified
if monitor_patterns:
pattern_monitors = self.find_monitors_by_pattern(monitor_patterns)
matched_monitors.extend(pattern_monitors)
# Find monitors by groups if specified
if group_patterns:
group_monitors = self.get_monitors_in_groups(group_patterns)
# Convert to the same format as find_monitors_by_pattern
group_monitor_objs = [
{"id": m.get("id"), "name": m.get("name")} for m in group_monitors
]
matched_monitors.extend(group_monitor_objs)
# Remove duplicates while preserving order
seen = set()
unique_monitors = []
for monitor in matched_monitors:
if monitor["id"] not in seen:
seen.add(monitor["id"])
unique_monitors.append(monitor)
matched_monitors = unique_monitors
if not matched_monitors:
print(
"Error: No monitors found matching the specified patterns or groups"
)
return
print(f"Found {len(matched_monitors)} matching monitors:")
for monitor in matched_monitors:
print(f" - {monitor['name']} (ID: {monitor['id']})")
# Set default description if not provided
if not description:
monitor_names = [monitor["name"] for monitor in matched_monitors]
description = "Maintenance on:\n" + "\n".join(monitor_names)
# Parse start time and duration
start_dt = self.parse_start_time(start_time)
duration_seconds = self.parse_duration(duration)
end_dt = start_dt + timedelta(seconds=duration_seconds)
print(
f"Maintenance window: {start_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} - {end_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} ({duration})"
)
# Create the maintenance with single strategy and date range
maintenance_data = {
"title": title,
"description": description,
"strategy": "single",
"active": True,
"dateRange": [
start_dt.strftime("%Y-%m-%d %H:%M:%S"),
end_dt.strftime("%Y-%m-%d %H:%M:%S"),
],
}
result = self.api.add_maintenance(**maintenance_data)
maintenance_id = result.get("maintenanceID")
# Add monitors to maintenance
if maintenance_id:
print(f"Maintenance added successfully: {result}")
# Prepare monitors list in the correct format for the API
monitors_list = [{"id": monitor["id"]} for monitor in matched_monitors]
try:
result = self.api.add_monitor_maintenance(
maintenance_id, monitors_list
)
print(
f"Successfully added {len(matched_monitors)} monitors to maintenance"
)
print(f"API response: {result}")
except Exception as e:
print(f"Error: Failed to add monitors to maintenance: {e}")
print(
"This might be due to API compatibility issues or server configuration"
)
except Exception as e:
print(f"Error adding maintenance: {e}")
def delete_maintenance(self, maintenance_id=None, delete_all=False):
"""Delete a specific maintenance or all maintenances"""
try:
if delete_all:
# Get all maintenances first
maintenances = self.api.get_maintenances()
if not maintenances:
print("No maintenances found to delete")
return
print(f"Found {len(maintenances)} maintenances to delete:")
for maintenance in maintenances:
print(
f" - {maintenance.get('title', 'N/A')} (ID: {maintenance.get('id', 'N/A')})"
)
# Delete all maintenances
deleted_count = 0
for maintenance in maintenances:
try:
result = self.api.delete_maintenance(maintenance.get("id"))
print(
f"Deleted maintenance '{maintenance.get('title', 'N/A')}' (ID: {maintenance.get('id')})"
)
deleted_count += 1
except Exception as e:
print(
f"Failed to delete maintenance '{maintenance.get('title', 'N/A')}': {e}"
)
print(
f"Successfully deleted {deleted_count} out of {len(maintenances)} maintenances"
)
elif maintenance_id:
# Delete specific maintenance
try:
# First get the maintenance info for confirmation
maintenance = self.api.get_maintenance(maintenance_id)
maintenance_title = maintenance.get("title", "N/A")
result = self.api.delete_maintenance(maintenance_id)
print(
f"Successfully deleted maintenance '{maintenance_title}' (ID: {maintenance_id})"
)
print(f"API response: {result}")
except Exception as e:
print(f"Failed to delete maintenance ID {maintenance_id}: {e}")
else:
print(
"Error: Either --id or --all flag is required for delete operation"
)
except Exception as e:
print(f"Error during maintenance deletion: {e}")
def main():
parser = argparse.ArgumentParser(description="Uptime Kuma CLI Client")
parser.add_argument(
"--url", help="Uptime Kuma server URL (can also use KUMA_URL env var)"
)
parser.add_argument(
"--username",
help="Username for authentication (can also use KUMA_USERNAME env var)",
)
parser.add_argument(
"--password",
help="Password for authentication (can also use KUMA_PASSWORD env var)",
)
subparsers = parser.add_subparsers(dest="resource", help="Resource to operate on")
# Monitor commands
monitor_parser = subparsers.add_parser("monitor", help="Monitor operations")
monitor_subparsers = monitor_parser.add_subparsers(
dest="monitor_action", help="Monitor actions"
)
list_monitors_parser = monitor_subparsers.add_parser(
"list", help="List all monitors"
)
list_monitors_parser.add_argument(
"--monitor",
action="append",
help="Monitor name pattern to filter by (supports wildcards, can be repeated)",
)
list_monitors_parser.add_argument(
"--group",
action="append",
help="Group name pattern to filter by (supports wildcards, can be repeated)",
)
# Maintenance commands
maintenance_parser = subparsers.add_parser(
"maintenance", help="Maintenance operations"
)
maintenance_subparsers = maintenance_parser.add_subparsers(
dest="maintenance_action", help="Maintenance actions"
)
maintenance_subparsers.add_parser("list", help="List all maintenances")
# Delete maintenance command
delete_maintenance_parser = maintenance_subparsers.add_parser(
"delete", help="Delete maintenance(s)"
)
delete_maintenance_parser.add_argument(
"--id", type=int, help="Maintenance ID to delete"
)
delete_maintenance_parser.add_argument(
"--all", action="store_true", help="Delete all maintenances"
)
# Add maintenance command
add_maintenance_parser = maintenance_subparsers.add_parser(
"add", help="Add a new maintenance"
)
add_maintenance_parser.add_argument(
"--title",
help='Maintenance title (defaults to "Update started on <current UTC time>")',
)
add_maintenance_parser.add_argument("--description", help="Maintenance description")
add_maintenance_parser.add_argument(
"--start",
help="Start time (defaults to now, format: YYYY-MM-DD HH:MM:SS or ISO format)",
)
add_maintenance_parser.add_argument(
"--duration",
default="90m",
help="Duration (default: 90m, format: 3600s, 1h, 60m)",
)
add_maintenance_parser.add_argument(
"--monitor",
action="append",
help="Monitor name pattern to add to maintenance (supports wildcards like *NextCloud*, can be repeated)",
)
add_maintenance_parser.add_argument(
"--group",
action="append",
help="Group name pattern to add all group members to maintenance (supports wildcards, can be repeated)",
)
args = parser.parse_args()
if not args.resource:
parser.print_help()
sys.exit(1)
# Get configuration from environment variables or command line args
url = args.url or os.getenv("KUMA_URL")
username = args.username or os.getenv("KUMA_USERNAME")
password = args.password or os.getenv("KUMA_PASSWORD")
if not url:
print(
"Error: URL is required. Provide --url or set KUMA_URL environment variable."
)
sys.exit(1)
client = KumaClient(url, username, password)
if not client.connect():
sys.exit(1)
try:
if args.resource == "monitor":
if args.monitor_action == "list":
client.list_monitors(
monitor_patterns=args.monitor, group_patterns=args.group
)
else:
monitor_parser.print_help()
elif args.resource == "maintenance":
if args.maintenance_action == "list":
client.list_maintenances()
elif args.maintenance_action == "add":
title = (
args.title
or f"Update started on {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}"
)
client.add_maintenance(
title,
args.description,
args.start,
args.duration,
monitor_patterns=args.monitor,
group_patterns=args.group,
)
elif args.maintenance_action == "delete":
client.delete_maintenance(maintenance_id=args.id, delete_all=args.all)
else:
maintenance_parser.print_help()
finally:
client.disconnect()
if __name__ == "__main__":
main()