from __future__ import annotations import logging from app.clients.beszel_client import BeszelClient from app.clients.base import BaseHTTPClient from app.config import Settings from app.models.sources import DockerContainerSummary, DockerSnapshot logger = logging.getLogger(__name__) class DockerProxyClient(BaseHTTPClient): def __init__(self, settings: Settings) -> None: super().__init__(settings, "docker-proxy", settings.docker_proxy_base_url) self.beszel_client = BeszelClient(settings) async def fetch_containers(self) -> DockerSnapshot: snapshot = DockerSnapshot() payload = await self._request_json("GET", "/containers/json", params={"all": "true"}) if not isinstance(payload, list): logger.warning("docker proxy returned non-list payload: %s", payload) fallback = await self.beszel_client.fetch_container_snapshot() if fallback.source_status == "online": logger.info("docker proxy fallback to beszel containers succeeded") return fallback logger.warning( "docker integration unavailable: docker proxy unreachable and beszel container fallback returned no usable data" ) return snapshot logger.info("docker proxy raw payload count: %s", len(payload)) logger.info("docker proxy raw payload sample: %s", payload[:3]) containers: list[DockerContainerSummary] = [] running = 0 stopped = 0 unhealthy = 0 for item in payload: if not isinstance(item, dict): continue state = self._normalize_state(item) if state == "running": running += 1 elif state == "unhealthy": unhealthy += 1 else: stopped += 1 containers.append( DockerContainerSummary( id=str(item.get("Id") or item.get("ID") or ""), name=self._normalize_name(item.get("Names")), state=state, status_text=str(item.get("Status") or item.get("State") or "unknown"), image=str(item.get("Image") or ""), health=self._extract_health(item), ) ) normalized = DockerSnapshot( source_status="online", running=running, stopped=stopped, unhealthy=unhealthy, total=len(containers), containers=containers, ) logger.info("docker proxy normalized snapshot: %s", normalized.model_dump()) return normalized @staticmethod def _normalize_name(names: object) -> str: if isinstance(names, list) and names: return str(names[0]).lstrip("/") return "unknown" @classmethod def _normalize_state(cls, item: dict) -> str: status_text = str(item.get("Status") or "").lower() state = str(item.get("State") or "").lower() health = cls._extract_health(item) if health == "unhealthy" or "unhealthy" in status_text: return "unhealthy" if state == "running": return "running" if state: return "stopped" return "unknown" @staticmethod def _extract_health(item: dict) -> str | None: if isinstance(item.get("Health"), str): return str(item["Health"]).lower() if isinstance(item.get("State"), dict): health = item["State"].get("Health") if isinstance(health, dict): return str(health.get("Status") or "").lower() or None return None