diff --git a/apps/dashboard/backend/app/clients/uptime_kuma_client.py b/apps/dashboard/backend/app/clients/uptime_kuma_client.py index f2b22ef..41c2104 100644 --- a/apps/dashboard/backend/app/clients/uptime_kuma_client.py +++ b/apps/dashboard/backend/app/clients/uptime_kuma_client.py @@ -10,7 +10,7 @@ from app.config import Settings from app.models.sources import UptimeKumaMonitor, UptimeKumaSnapshot -METRIC_LINE_RE = re.compile(r'^(?P[a-zA-Z_:][a-zA-Z0-9_:]*)\{(?P[^}]*)\}\s+(?P.+)$') +METRIC_LINE_RE = re.compile(r'^(?P[a-zA-Z_:][a-zA-Z0-9_:]*){(?P[^}]*)}\s+(?P.+)$') LABEL_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"') logger = logging.getLogger(__name__) @@ -28,22 +28,18 @@ class UptimeKumaClient(BaseHTTPClient): async def fetch_monitors(self) -> UptimeKumaSnapshot: snapshot = UptimeKumaSnapshot() if not self.base_url: - logger.info("uptime kuma skipped: base URL missing") + logger.info("uptime kuma skipped: no base URL configured") return snapshot - raw_metrics = None + raw_metrics: str | None = None if self.settings.uptime_kuma_api_key: raw_metrics = await self._request_metrics_with_mode( - "basic-api-key", - auth=("", self.settings.uptime_kuma_api_key), + "api-key", + headers={"Authorization": f"Bearer {self.settings.uptime_kuma_api_key}"}, ) - if ( - not raw_metrics - and self.settings.uptime_kuma_username - and self.settings.uptime_kuma_password - ): + if raw_metrics is None and self.settings.uptime_kuma_username and self.settings.uptime_kuma_password: raw_metrics = await self._request_metrics_with_mode( "basic-user", auth=(self.settings.uptime_kuma_username, self.settings.uptime_kuma_password), @@ -73,7 +69,7 @@ class UptimeKumaClient(BaseHTTPClient): monitors_down=down, monitors_paused=paused, total=len(monitors), - monitors=sorted(monitors, key=lambda monitor: monitor.name.lower()), + monitors=monitors, ) logger.info("uptime kuma normalized snapshot: %s", normalized.model_dump()) return normalized @@ -104,10 +100,8 @@ class UptimeKumaClient(BaseHTTPClient): logger.info("uptime kuma metrics auth succeeded via %s", mode) return response.text - if response.status_code in {401, 403}: - logger.warning("uptime kuma auth failed (401/403)") - else: - logger.info( + if response.status_code in (401, 403): + logger.warning( "uptime kuma metrics auth failed via %s with status %s", mode, response.status_code, @@ -133,18 +127,15 @@ class UptimeKumaClient(BaseHTTPClient): if not monitor_id or not monitor_name: continue - monitor = status_by_id.setdefault( - monitor_id, - UptimeKumaMonitor( - id=monitor_id, + if monitor_id not in status_by_id: + status_by_id[monitor_id] = UptimeKumaMonitor( + id=self._as_int(monitor_id), name=monitor_name, - status="offline", - monitor_type=labels.get("monitor_type") or labels.get("type"), - ), - ) + ) + monitor = status_by_id[monitor_id] if metric_name == "monitor_status": - status_code = self._as_float(raw_value) + status_code = self._as_int_from_float(raw_value) if status_code == 1: monitor.status = "online" elif status_code == 3: @@ -154,6 +145,23 @@ class UptimeKumaClient(BaseHTTPClient): elif metric_name == "monitor_response_time": latency = self._as_float(raw_value) monitor.latency_ms = int(latency) if latency >= 0 else None + elif metric_name == "monitor_uptime": + duration = labels.get("duration", "") + if duration == "24": + uptime = self._as_float(raw_value) + if 0.0 <= uptime <= 1.0: + monitor.uptime_24h = round(uptime * 100, 1) + + # Build synthetic heartbeat bar (20 segments) from uptime_24h + for monitor in status_by_id.values(): + if not monitor.heartbeats: + if monitor.uptime_24h >= 99.9: + monitor.heartbeats = [1] * 20 + elif monitor.uptime_24h <= 0.1: + monitor.heartbeats = [0] * 20 + else: + green_count = round(monitor.uptime_24h / 100 * 20) + monitor.heartbeats = [0] * (20 - green_count) + [1] * green_count return list(status_by_id.values()) @@ -174,5 +182,19 @@ class UptimeKumaClient(BaseHTTPClient): def _as_float(value: str) -> float: try: return float(value) - except ValueError: + except (ValueError, TypeError): return -1.0 + + @staticmethod + def _as_int(value: str) -> int: + try: + return int(value) + except (ValueError, TypeError): + return 0 + + @staticmethod + def _as_int_from_float(value: str) -> int: + try: + return int(float(value)) + except (ValueError, TypeError): + return 0