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_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=
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
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 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user