Ajout section NAS (disques, docker, port VPN) via nas_monitor /api/status

This commit is contained in:
jerem
2026-06-15 14:58:56 +02:00
parent 8dad6454f5
commit ca8f725b3a
5 changed files with 124 additions and 2 deletions

View File

@@ -22,6 +22,10 @@ MONITORINK_CACHE_TTL=120
MONITORINK_LAT=48.8566 MONITORINK_LAT=48.8566
MONITORINK_LON=2.3522 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 # Home Assistant (optionnel) — laisser vide pour désactiver
MONITORINK_HA_URL=http://homeassistant.local:8123 MONITORINK_HA_URL=http://homeassistant.local:8123
MONITORINK_HA_TOKEN= MONITORINK_HA_TOKEN=

View File

@@ -74,6 +74,9 @@ class Config:
ha_base_url: str = field(default_factory=lambda: _get("MONITORINK_HA_URL").rstrip("/")) ha_base_url: str = field(default_factory=lambda: _get("MONITORINK_HA_URL").rstrip("/"))
ha_token: str = field(default_factory=lambda: _get("MONITORINK_HA_TOKEN")) 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 / rafraîchissement serveur ---
cache_ttl_seconds: int = field( cache_ttl_seconds: int = field(
default_factory=lambda: int(_get("MONITORINK_CACHE_TTL", "120")) default_factory=lambda: int(_get("MONITORINK_CACHE_TTL", "120"))

View File

@@ -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,
)

View File

@@ -13,7 +13,7 @@ from PIL import Image
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
from config import config from config import config
from integrations import claude_usage, homeassistant, weather from integrations import claude_usage, homeassistant, nas, weather
TEMPLATES = Path(__file__).parent / "templates" TEMPLATES = Path(__file__).parent / "templates"
@@ -54,10 +54,11 @@ def _gauges(usage: claude_usage.ClaudeUsage) -> list[dict]:
async def build_context() -> dict: async def build_context() -> dict:
"""Récupère toutes les sources en parallèle et assemble le contexte du template.""" """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(), claude_usage.fetch_usage(),
weather.fetch_weather(), weather.fetch_weather(),
homeassistant.fetch_states(), homeassistant.fetch_states(),
nas.fetch_status(),
) )
now = datetime.now(ZoneInfo(config.timezone)) now = datetime.now(ZoneInfo(config.timezone))
@@ -71,6 +72,7 @@ async def build_context() -> dict:
"claude": usage, "claude": usage,
"gauges": _gauges(usage), "gauges": _gauges(usage),
"ha_states": ha, "ha_states": ha,
"nas": nas_status,
"updated": now.strftime("%H:%M"), "updated": now.strftime("%H:%M"),
"stale": False, "stale": False,
} }

View File

@@ -68,6 +68,10 @@
.gauge .sub { font-size: 30px; color: var(--muted); margin-top: 10px; } .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); } .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 */ /* Grille Home Assistant */
.ha-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 44px; } .ha-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 44px; }
.ha-item { display: flex; justify-content: space-between; align-items: baseline; .ha-item { display: flex; justify-content: space-between; align-items: baseline;
@@ -138,6 +142,19 @@
{% endif %} {% endif %}
</div> </div>
{% if nas.ok %}
<div class="section">
<div class="title">NAS</div>
<div class="nas-list">
{% for d in nas.disks %}
<div class="ha-item"><span class="k">{{ d.label }}</span><span class="v">{{ d.percent | round | int }}% · {{ d.free_human }} libre</span></div>
{% endfor %}
<div class="ha-item"><span class="k">Docker</span><span class="v">{{ nas.docker_running }}/{{ nas.docker_total }}{% if nas.docker_unhealthy %} · <span class="bad">{{ nas.docker_unhealthy }} KO</span>{% else %} ✓{% endif %}</span></div>
<div class="ha-item"><span class="k">Port VPN</span><span class="v">{% if nas.vpn_ok %}OK{% if nas.vpn_port %} · {{ nas.vpn_port }}{% endif %}{% else %}<span class="bad">✗ désync</span>{% endif %}</span></div>
</div>
</div>
{% endif %}
{% if ha_states %} {% if ha_states %}
<div class="section"> <div class="section">
<div class="title">Maison</div> <div class="title">Maison</div>