diff --git a/.env.example b/.env.example index 95c71f3..9b3ad58 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,10 @@ MONITORINK_CACHE_TTL=120 MONITORINK_LAT=48.8566 MONITORINK_LON=2.3522 +# NAS (optionnel) — endpoint /api/status du moniteur maison nas_monitor. +# Laisser vide pour masquer la section NAS. Depuis le conteneur, viser l'IP LAN du host. +MONITORINK_NAS_URL=http://192.168.0.43:8766/api/status + # Home Assistant (optionnel) — laisser vide pour désactiver MONITORINK_HA_URL=http://homeassistant.local:8123 MONITORINK_HA_TOKEN= diff --git a/backend/config.py b/backend/config.py index 52086d7..19234c1 100644 --- a/backend/config.py +++ b/backend/config.py @@ -74,6 +74,9 @@ class Config: ha_base_url: str = field(default_factory=lambda: _get("MONITORINK_HA_URL").rstrip("/")) ha_token: str = field(default_factory=lambda: _get("MONITORINK_HA_TOKEN")) + # --- NAS (moniteur maison nas_monitor, endpoint /api/status) --- + nas_url: str = field(default_factory=lambda: _get("MONITORINK_NAS_URL")) + # --- Cache / rafraîchissement serveur --- cache_ttl_seconds: int = field( default_factory=lambda: int(_get("MONITORINK_CACHE_TTL", "120")) diff --git a/backend/integrations/nas.py b/backend/integrations/nas.py new file mode 100644 index 0000000..150fb9f --- /dev/null +++ b/backend/integrations/nas.py @@ -0,0 +1,96 @@ +"""Statuts du NAS via le moniteur maison `nas_monitor` (`GET /api/status`). + +Expose : occupation des disques de données (`/mnt/*`), santé des conteneurs Docker, +et l'état du port forwardé entre gluetun (VPN) et qBittorrent. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +import httpx + +from config import config + + +@dataclass +class NasDisk: + label: str + percent: float + free_gb: float + + @property + def free_human(self) -> str: + if self.free_gb >= 1024: + return f"{self.free_gb / 1024:.1f}".replace(".", ",") + " To" + return f"{self.free_gb:.0f} Go" + + +@dataclass +class NasStatus: + ok: bool + error: str | None = None + disks: list[NasDisk] = field(default_factory=list) + docker_running: int = 0 + docker_total: int = 0 + docker_unhealthy: int = 0 + vpn_port: int | None = None + vpn_ok: bool = False + + +def _disk_label(mount: str) -> str: + """`/mnt/disk1` -> `Disque 1`, sinon le dernier segment capitalisé.""" + base = mount.rstrip("/").split("/")[-1] + if base.startswith("disk"): + return "Disque " + base[len("disk"):] + return base.capitalize() or mount + + +def _as_int(value: object) -> int | None: + try: + return int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + + +async def fetch_status() -> NasStatus: + if not config.nas_url: + return NasStatus(ok=False, error="NAS désactivé") + + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(config.nas_url) + if resp.status_code != 200: + return NasStatus(ok=False, error=f"HTTP {resp.status_code}") + data = resp.json() + except httpx.HTTPError: + return NasStatus(ok=False, error="injoignable") + + metrics = (data.get("host") or {}).get("metrics") or {} + disks: list[NasDisk] = [] + for disk in metrics.get("disks") or []: + mount = str(disk.get("mount", "")) + if not mount.startswith("/mnt"): # on ne garde que les disques de données + continue + disks.append(NasDisk( + label=_disk_label(mount), + percent=float(disk.get("percent", 0) or 0), + free_gb=float(disk.get("free_gb", 0) or 0), + )) + + summary = (data.get("docker") or {}).get("summary") or {} + + # Port VPN : OK quand le port forwardé par gluetun == port d'écoute qBittorrent. + qb = data.get("qbittorrent") or {} + listen = _as_int((qb.get("prefs") or {}).get("listen_port")) + fwd = _as_int(qb.get("forwarded_port_file")) + vpn_ok = listen is not None and fwd is not None and listen == fwd + + return NasStatus( + ok=True, + disks=disks, + docker_running=int(summary.get("running", 0) or 0), + docker_total=int(summary.get("total", 0) or 0), + docker_unhealthy=int(summary.get("unhealthy", 0) or 0), + vpn_port=fwd if fwd is not None else listen, + vpn_ok=vpn_ok, + ) diff --git a/backend/render.py b/backend/render.py index 9998d24..6b67929 100644 --- a/backend/render.py +++ b/backend/render.py @@ -13,7 +13,7 @@ from PIL import Image from playwright.async_api import async_playwright from config import config -from integrations import claude_usage, homeassistant, weather +from integrations import claude_usage, homeassistant, nas, weather TEMPLATES = Path(__file__).parent / "templates" @@ -54,10 +54,11 @@ def _gauges(usage: claude_usage.ClaudeUsage) -> list[dict]: async def build_context() -> dict: """Récupère toutes les sources en parallèle et assemble le contexte du template.""" - usage, wx, ha = await asyncio.gather( + usage, wx, ha, nas_status = await asyncio.gather( claude_usage.fetch_usage(), weather.fetch_weather(), homeassistant.fetch_states(), + nas.fetch_status(), ) now = datetime.now(ZoneInfo(config.timezone)) @@ -71,6 +72,7 @@ async def build_context() -> dict: "claude": usage, "gauges": _gauges(usage), "ha_states": ha, + "nas": nas_status, "updated": now.strftime("%H:%M"), "stale": False, } diff --git a/backend/templates/dashboard.html b/backend/templates/dashboard.html index 6760041..eedc396 100644 --- a/backend/templates/dashboard.html +++ b/backend/templates/dashboard.html @@ -68,6 +68,10 @@ .gauge .sub { font-size: 30px; color: var(--muted); margin-top: 10px; } .err { font-size: 40px; font-weight: 700; padding: 24px; border: 4px dashed var(--ink); } + /* Liste NAS (valeurs larges -> une colonne pleine largeur) */ + .nas-list { display: flex; flex-direction: column; gap: 16px; } + .nas-list .bad { font-weight: 800; } + /* Grille Home Assistant */ .ha-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 44px; } .ha-item { display: flex; justify-content: space-between; align-items: baseline; @@ -138,6 +142,19 @@ {% endif %} + {% if nas.ok %} +