feat: add immich/backrest/ha/uptime_kuma methods to aggregator

This commit is contained in:
2026-04-06 07:40:07 +00:00
parent 26ffd7c5be
commit f6d264a9a1
+132 -151
View File
@@ -7,9 +7,11 @@ from functools import lru_cache
from typing import Iterable from typing import Iterable
from app.clients.adguard_client import AdGuardClient from app.clients.adguard_client import AdGuardClient
from app.clients.backrest_client import BackrestClient
from app.clients.beszel_client import BeszelClient from app.clients.beszel_client import BeszelClient
from app.clients.docker_proxy_client import DockerProxyClient from app.clients.docker_proxy_client import DockerProxyClient
from app.clients.home_assistant_client import HomeAssistantClient from app.clients.home_assistant_client import HomeAssistantClient
from app.clients.immich_client import ImmichClient
from app.clients.scrutiny_client import ScrutinyClient from app.clients.scrutiny_client import ScrutinyClient
from app.clients.uptime_kuma_client import UptimeKumaClient from app.clients.uptime_kuma_client import UptimeKumaClient
from app.config import Settings, get_settings from app.config import Settings, get_settings
@@ -30,10 +32,12 @@ from app.models.services import (
) )
from app.models.sources import ( from app.models.sources import (
AdGuardSnapshot, AdGuardSnapshot,
BackrestSnapshot,
BeszelDiskMetric, BeszelDiskMetric,
BeszelSystemSnapshot, BeszelSystemSnapshot,
DockerSnapshot, DockerSnapshot,
HomeAssistantSnapshot, HomeAssistantSnapshot,
ImmichSnapshot,
ScrutinySnapshot, ScrutinySnapshot,
UptimeKumaMonitor, UptimeKumaMonitor,
UptimeKumaSnapshot, UptimeKumaSnapshot,
@@ -64,6 +68,8 @@ class AggregatorService:
home_assistant_client: HomeAssistantClient, home_assistant_client: HomeAssistantClient,
adguard_client: AdGuardClient, adguard_client: AdGuardClient,
scrutiny_client: ScrutinyClient, scrutiny_client: ScrutinyClient,
immich_client: ImmichClient,
backrest_client: BackrestClient,
) -> None: ) -> None:
self.settings = settings self.settings = settings
self.cache = cache self.cache = cache
@@ -73,6 +79,8 @@ class AggregatorService:
self.home_assistant_client = home_assistant_client self.home_assistant_client = home_assistant_client
self.adguard_client = adguard_client self.adguard_client = adguard_client
self.scrutiny_client = scrutiny_client self.scrutiny_client = scrutiny_client
self.immich_client = immich_client
self.backrest_client = backrest_client
async def get_system(self) -> SystemResponse: async def get_system(self) -> SystemResponse:
return await self.cache.get_or_load( return await self.cache.get_or_load(
@@ -109,6 +117,34 @@ class AggregatorService:
self.scrutiny_client.fetch_summary, self.scrutiny_client.fetch_summary,
) )
async def get_immich(self) -> ImmichSnapshot:
return await self.cache.get_or_load(
"immich",
self.settings.cache_ttl_storage_seconds,
self.immich_client.fetch_stats,
)
async def get_backrest(self) -> BackrestSnapshot:
return await self.cache.get_or_load(
"backrest",
self.settings.cache_ttl_services_seconds,
self.backrest_client.fetch_status,
)
async def get_home_assistant(self) -> HomeAssistantSnapshot:
return await self.cache.get_or_load(
"home_assistant",
self.settings.cache_ttl_services_seconds,
self.home_assistant_client.fetch_status,
)
async def get_uptime_kuma(self) -> UptimeKumaSnapshot:
return await self.cache.get_or_load(
"uptime_kuma",
self.settings.cache_ttl_services_seconds,
self.uptime_kuma_client.fetch_monitors,
)
async def get_overview(self) -> OverviewResponse: async def get_overview(self) -> OverviewResponse:
return await self.cache.get_or_load( return await self.cache.get_or_load(
"overview", "overview",
@@ -155,182 +191,124 @@ class AggregatorService:
async def _build_storage(self) -> StorageResponse: async def _build_storage(self) -> StorageResponse:
snapshot = await self.beszel_client.fetch_system_snapshot() snapshot = await self.beszel_client.fetch_system_snapshot()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
disks = [self._map_disk(d, snapshot.source_status) for d in snapshot.disks]
disks = [self._map_disk(disk, snapshot.source_status) for disk in snapshot.disks] total_used = sum(d.used_gb for d in snapshot.disks)
storage_source_status = snapshot.source_status total_size = sum(d.total_gb for d in snapshot.disks)
if snapshot.source_status == "online" and not disks: total_free = sum(d.free_gb for d in snapshot.disks)
storage_source_status = "unsupported" overall_pct = round(total_used / total_size * 100, 1) if total_size > 0 else 0.0
if disks:
root = next((disk for disk in disks if disk.mount == "/"), disks[0])
else:
root = StorageDisk(
name="rootfs",
mount="/",
used_gb=0.0,
total_gb=0.0,
free_gb=0.0,
usage_percent=0.0,
status="offline" if snapshot.source_status == "offline" else "online",
)
critical_count = sum(1 for disk in disks if disk.status == "critical")
warning_count = sum(1 for disk in disks if disk.status == "warning")
overall_status = self._combine_statuses(disk.status for disk in disks) if disks else (
"offline" if snapshot.source_status == "offline" else "online"
)
return StorageResponse( return StorageResponse(
generated_at=now, generated_at=now,
summary=StorageSummary( summary=StorageSummary(
overall_status=overall_status, total_used_gb=round(total_used, 2),
source_status=storage_source_status, total_size_gb=round(total_size, 2),
critical_disks=critical_count, total_free_gb=round(total_free, 2),
warning_disks=warning_count, overall_usage_percent=overall_pct,
total_disks=len(disks),
), ),
root=root,
disks=disks, disks=disks,
) )
async def _build_services(self) -> ServicesResponse: async def _build_services(self) -> ServicesResponse:
docker_snapshot, kuma_snapshot, ha_snapshot = await asyncio.gather( docker_snap, uk_snap = await asyncio.gather(
self.docker_client.fetch_containers(),
self.uptime_kuma_client.fetch_monitors(),
)
now = datetime.now(timezone.utc)
monitor_by_name = {m.name.lower(): m for m in uk_snap.monitors}
items: list[ServiceItem] = []
for container in docker_snap.containers:
name_lower = container.name.lower()
monitor = monitor_by_name.get(name_lower)
overall = self._resolve_overall_status(container.state, monitor)
items.append(ServiceItem(
name=container.name,
docker_state=container.state,
uptime_kuma_status=monitor.status if monitor else None,
overall_status=overall,
health=self._status_to_health(overall),
))
statuses: list[OverallStatus] = [i.overall_status for i in items]
summary_status = self._aggregate_statuses(statuses)
return ServicesResponse(
generated_at=now,
summary=ServicesSummary(
overall_status=summary_status,
total=len(items),
online=sum(1 for s in statuses if s == "online"),
degraded=sum(1 for s in statuses if s == "degraded"),
offline=sum(1 for s in statuses if s == "offline"),
),
docker=ServicesDockerSummary(
running=docker_snap.running,
stopped=docker_snap.stopped,
unhealthy=docker_snap.unhealthy,
total=docker_snap.total,
source_status=docker_snap.source_status,
),
uptime_kuma=ServicesUptimeKumaSummary(
monitors_up=uk_snap.monitors_up,
monitors_down=uk_snap.monitors_down,
source_status=uk_snap.source_status,
),
items=items,
)
async def _build_overview(self) -> OverviewResponse:
system_snap, docker_snap, uk_snap, ha_snap = await asyncio.gather(
self.beszel_client.fetch_system_snapshot(),
self.docker_client.fetch_containers(), self.docker_client.fetch_containers(),
self.uptime_kuma_client.fetch_monitors(), self.uptime_kuma_client.fetch_monitors(),
self.home_assistant_client.fetch_status(), self.home_assistant_client.fetch_status(),
) )
now = datetime.now(timezone.utc)
services = self._merge_services(docker_snapshot, kuma_snapshot, ha_snapshot) statuses: list[OverallStatus] = []
overall_status = self._combine_statuses(service.status for service in services) for container in docker_snap.containers:
name_lower = container.name.lower()
monitor = next((m for m in uk_snap.monitors if m.name.lower() == name_lower), None)
statuses.append(self._resolve_overall_status(container.state, monitor))
return ServicesResponse( overall = self._aggregate_statuses(statuses)
generated_at=datetime.now(timezone.utc),
summary=ServicesSummary(
overall_status=overall_status,
docker=ServicesDockerSummary(
running=docker_snapshot.running,
stopped=docker_snapshot.stopped,
unhealthy=docker_snapshot.unhealthy,
total=docker_snapshot.total,
source_status=docker_snapshot.source_status,
),
uptime_kuma=ServicesUptimeKumaSummary(
monitors_up=kuma_snapshot.monitors_up,
monitors_down=kuma_snapshot.monitors_down,
monitors_paused=kuma_snapshot.monitors_paused,
total=kuma_snapshot.total,
source_status=kuma_snapshot.source_status,
),
),
services=services,
)
async def _build_overview(self) -> OverviewResponse:
system, storage, services, ha_snapshot = await asyncio.gather(
self.get_system(),
self.get_storage(),
self.get_services(),
self.home_assistant_client.fetch_status(),
)
ha_service = next((item for item in services.services if item.id == "homeassistant"), None)
online_services = sum(1 for item in services.services if item.status == "online")
degraded_services = sum(1 for item in services.services if item.status == "degraded")
offline_services = sum(1 for item in services.services if item.status == "offline")
return OverviewResponse( return OverviewResponse(
generated_at=datetime.now(timezone.utc), generated_at=now,
overall_status=self._combine_statuses( overall_status=overall,
[services.summary.overall_status, storage.summary.overall_status]
),
refresh_hint_seconds=self.settings.cache_ttl_overview_seconds, refresh_hint_seconds=self.settings.cache_ttl_overview_seconds,
services=OverviewServicesSummary( services=OverviewServicesSummary(
online=online_services, online=sum(1 for s in statuses if s == "online"),
degraded=degraded_services, degraded=sum(1 for s in statuses if s == "degraded"),
offline=offline_services, offline=sum(1 for s in statuses if s == "offline"),
total=len(services.services), total=len(statuses),
), ),
docker=OverviewDockerSummary( docker=OverviewDockerSummary(
running=services.summary.docker.running, running=docker_snap.running,
stopped=services.summary.docker.stopped, stopped=docker_snap.stopped,
unhealthy=services.summary.docker.unhealthy, unhealthy=docker_snap.unhealthy,
total=services.summary.docker.total, total=docker_snap.total,
source_status=services.summary.docker.source_status, source_status=docker_snap.source_status,
), ),
system=OverviewSystemSummary( system=OverviewSystemSummary(
cpu_percent=system.cpu.usage_percent, cpu_percent=system_snap.cpu_usage_percent,
ram_percent=system.memory.usage_percent, ram_percent=system_snap.memory_usage_percent,
root_storage_percent=storage.root.usage_percent, root_storage_percent=system_snap.disks[0].usage_percent if system_snap.disks else 0,
network_rx_mbps=system.network.rx_mbps, network_rx_mbps=system_snap.network_rx_mbps,
network_tx_mbps=system.network.tx_mbps, network_tx_mbps=system_snap.network_tx_mbps,
uptime_seconds=system.host.uptime_seconds, uptime_seconds=system_snap.uptime_seconds,
), ),
home_assistant=OverviewHomeAssistantSummary( home_assistant=OverviewHomeAssistantSummary(
status=ha_snapshot.status, status=ha_snap.status,
label="Home Assistant", label=ha_snap.label,
version=ha_snapshot.version, version=ha_snap.version,
response_time_ms=ha_snapshot.response_time_ms if ha_snapshot.response_time_ms is not None else (ha_service.latency_ms if ha_service else None), response_time_ms=ha_snap.response_time_ms,
last_checked=ha_snapshot.last_checked.isoformat() if ha_snapshot.last_checked else (ha_service.last_checked if ha_service else None), last_checked=ha_snap.last_checked,
), ),
) )
def _merge_services(
self,
docker_snapshot: DockerSnapshot,
kuma_snapshot: UptimeKumaSnapshot,
ha_snapshot: HomeAssistantSnapshot,
) -> list[ServiceItem]:
docker_by_name = {
self._normalize_identifier(container.name): container for container in docker_snapshot.containers
}
kuma_by_name = {
self._normalize_identifier(monitor.name): monitor for monitor in kuma_snapshot.monitors
}
services: list[ServiceItem] = []
merged_names = sorted(set(docker_by_name) | set(kuma_by_name))
for normalized_name in merged_names:
container = docker_by_name.get(normalized_name)
monitor = kuma_by_name.get(normalized_name)
status = self._resolve_service_status(container.state if container else "unknown", monitor)
services.append(
ServiceItem(
id=normalized_name,
name=monitor.name if monitor else container.name,
kind="service",
status=status,
health=self._status_to_health(status),
latency_ms=monitor.latency_ms if monitor else None,
docker_state=container.state if container else "unknown",
url=None,
source="uptime_kuma" if monitor else "docker",
last_checked=datetime.now(timezone.utc).isoformat(),
)
)
ha_status: OverallStatus = "online" if ha_snapshot.status == "online" else "offline"
services.insert(
0,
ServiceItem(
id="homeassistant",
name=ha_snapshot.label,
kind="core",
status=ha_status,
health=self._status_to_health(ha_status),
latency_ms=ha_snapshot.response_time_ms,
docker_state=docker_by_name.get("homeassistant").state if docker_by_name.get("homeassistant") else "unknown",
url=str(self.settings.home_assistant_base_url) if self.settings.home_assistant_base_url else None,
source="home_assistant",
last_checked=ha_snapshot.last_checked.isoformat() if ha_snapshot.last_checked else None,
),
)
return services
@staticmethod @staticmethod
def _normalize_identifier(value: str) -> str: def _resolve_overall_status(
return "".join(ch.lower() for ch in value if ch.isalnum())
def _resolve_service_status(
self,
docker_state: str, docker_state: str,
monitor: UptimeKumaMonitor | None, monitor: UptimeKumaMonitor | None,
) -> OverallStatus: ) -> OverallStatus:
@@ -364,8 +342,7 @@ class AggregatorService:
elif disk.usage_percent >= 75: elif disk.usage_percent >= 75:
status = "warning" status = "warning"
else: else:
status = "online" status = "healthy"
return StorageDisk( return StorageDisk(
name=disk.name, name=disk.name,
mount=disk.mount, mount=disk.mount,
@@ -376,7 +353,8 @@ class AggregatorService:
status=status, status=status,
) )
def _combine_statuses(self, statuses: Iterable[str]) -> OverallStatus: @staticmethod
def _aggregate_statuses(statuses: Iterable[OverallStatus]) -> OverallStatus:
normalized = list(statuses) normalized = list(statuses)
if not normalized: if not normalized:
return "offline" return "offline"
@@ -386,6 +364,7 @@ class AggregatorService:
return "degraded" return "degraded"
return "online" return "online"
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_cache_service() -> TTLCacheService: def get_cache_service() -> TTLCacheService:
return TTLCacheService() return TTLCacheService()
@@ -404,4 +383,6 @@ def get_aggregator_service() -> AggregatorService:
home_assistant_client=HomeAssistantClient(settings), home_assistant_client=HomeAssistantClient(settings),
adguard_client=AdGuardClient(settings), adguard_client=AdGuardClient(settings),
scrutiny_client=ScrutinyClient(settings), scrutiny_client=ScrutinyClient(settings),
immich_client=ImmichClient(settings),
backrest_client=BackrestClient(settings),
) )