Ajout section NAS (disques, docker, port VPN) via nas_monitor /api/status
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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"))
|
||||
|
||||
96
backend/integrations/nas.py
Normal file
96
backend/integrations/nas.py
Normal 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,
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
</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 %}
|
||||
<div class="section">
|
||||
<div class="title">Maison</div>
|
||||
|
||||
Reference in New Issue
Block a user