commit 9c1e45a7bc2cdbf2c34524157e7e3f1ad6550453 Author: perco Date: Sun Jun 28 09:04:24 2026 +0200 feat: dashboard homelab v1.0.0 — stats CPU/RAM/disques/réseau/S.M.A.R.T diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..34ebdea --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + smartmontools \ + dmidecode \ + lsb-release \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY app/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/main.py . +COPY static/ /app/static/ + +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..086bb53 --- /dev/null +++ b/app/main.py @@ -0,0 +1,286 @@ +import subprocess, json, time, re, os +from pathlib import Path + +import psutil +import cpuinfo +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +app = FastAPI() + +HOST_ROOT = os.environ.get("HOST_ROOT", "") # "/rootfs" en container, "" en local +_net_last = {"t": 0.0, "bytes_sent": 0, "bytes_recv": 0} +_hw_cache: dict = {} +_hw_cache_ts = 0.0 +HW_TTL = 60.0 + +SKIP_FS = { + "tmpfs", "devtmpfs", "sysfs", "proc", "cgroup", "cgroup2", + "pstore", "securityfs", "debugfs", "configfs", "fusectl", + "hugetlbfs", "mqueue", "devpts", "overlay", "aufs", "squashfs", + "nsfs", "rpc_pipefs", "nfsd", "bpf", "tracefs", +} + + +def _run(cmd: list[str]) -> str: + try: + return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, timeout=10).decode(errors="ignore") + except Exception: + return "" + + +def _smartctl_all() -> dict: + result = {} + try: + devs = json.loads(_run(["smartctl", "--scan-open", "-j"])) + for d in devs.get("devices", []): + name = d.get("name", "") + if not name: + continue + info = json.loads(_run(["smartctl", "-a", "-j", name])) + short = name.split("/")[-1] + result[name] = { + "device": short, + "model": info.get("model_name", info.get("model_family", "")), + "serial": info.get("serial_number", ""), + "health": info.get("smart_status", {}).get("passed", None), + "temp_c": info.get("temperature", {}).get("current", None), + "capacity_bytes": info.get("user_capacity", {}).get("bytes", None), + "rotation_rate": info.get("rotation_rate", None), + } + except Exception: + pass + return result + + +def _dmidecode_memory() -> list[dict]: + out = _run(["dmidecode", "-t", "17"]) + if not out: + return [] + sticks = [] + for block in out.split("\nMemory Device\n"): + if "Size:" not in block: + continue + def get(k): + m = re.search(rf"\t{k}:\s*(.+)", block) + return m.group(1).strip() if m else "" + size_str = get("Size") + if size_str in ("", "No Module Installed", "Unknown"): + continue + sticks.append({ + "size": size_str, + "type": get("Type"), + "speed": get("Speed"), + "manufacturer": get("Manufacturer"), + "part_number": get("Part Number").strip(), + "locator": get("Locator"), + }) + return sticks + + +def _dmidecode_board() -> dict: + out = _run(["dmidecode", "-t", "2"]) + def get(k): + m = re.search(rf"\t{k}:\s*(.+)", out) + return m.group(1).strip() if m else "" + return { + "manufacturer": get("Manufacturer"), + "product": get("Product Name"), + "version": get("Version"), + } + + +def get_hardware() -> dict: + global _hw_cache, _hw_cache_ts + if time.time() - _hw_cache_ts < HW_TTL and _hw_cache: + return _hw_cache + + cpu = cpuinfo.get_cpu_info() + _hw_cache = { + "cpu": { + "brand": cpu.get("brand_raw", ""), + "arch": cpu.get("arch", ""), + "hz_advertised": cpu.get("hz_advertised_friendly", ""), + "cores_physical": psutil.cpu_count(logical=False), + "cores_logical": psutil.cpu_count(logical=True), + }, + "board": _dmidecode_board(), + "memory_sticks": _dmidecode_memory(), + "smart": _smartctl_all(), + } + _hw_cache_ts = time.time() + return _hw_cache + + +def _host_path(p: str) -> str: + return HOST_ROOT + p if HOST_ROOT else p + + +def _read_host_mounts() -> list[dict]: + """Lit /proc/mounts depuis le système hôte (via HOST_ROOT/proc/mounts).""" + mounts_file = _host_path("/proc/mounts") + parts = [] + try: + with open(mounts_file) as f: + for line in f: + cols = line.split() + if len(cols) < 3: + continue + device, mountpoint, fstype = cols[0], cols[1], cols[2] + if fstype in SKIP_FS: + continue + if not device.startswith("/dev/"): + continue + # accéder au point de montage via le chemin hôte + real_path = _host_path(mountpoint) + try: + u = psutil.disk_usage(real_path) + if u.total == 0: + continue + parts.append({ + "device": device, + "mountpoint": mountpoint, + "fstype": fstype, + "total": u.total, + "used": u.used, + "free": u.free, + "percent": u.percent, + }) + except Exception: + continue + except Exception: + # fallback : partitions vues par le container + for p in psutil.disk_partitions(all=False): + if p.fstype in SKIP_FS: + continue + try: + u = psutil.disk_usage(p.mountpoint) + parts.append({ + "device": p.device, "mountpoint": p.mountpoint, + "fstype": p.fstype, + "total": u.total, "used": u.used, "free": u.free, "percent": u.percent, + }) + except Exception: + pass + # dédupliquer par device+mountpoint + seen = set() + result = [] + for p in parts: + key = (p["device"], p["mountpoint"]) + if key not in seen: + seen.add(key) + result.append(p) + return result + + +def get_live() -> dict: + global _net_last + + cpu_percent = psutil.cpu_percent(interval=0.3) + cpu_freq = psutil.cpu_freq() + mem = psutil.virtual_memory() + swap = psutil.swap_memory() + + disks = _read_host_mounts() + + # réseau — avec network_mode:host on lit directement les stats hôte + net_now = psutil.net_io_counters() + now = time.time() + dt = now - _net_last["t"] if _net_last["t"] else 1.0 + dl = (net_now.bytes_recv - _net_last["bytes_recv"]) / dt if _net_last["t"] else 0 + ul = (net_now.bytes_sent - _net_last["bytes_sent"]) / dt if _net_last["t"] else 0 + _net_last = {"t": now, "bytes_sent": net_now.bytes_sent, "bytes_recv": net_now.bytes_recv} + + net_ifaces = {} + for iface, addrs in psutil.net_if_addrs().items(): + for a in addrs: + if a.family == 2: # AF_INET + net_ifaces[iface] = a.address + break + + uptime_s = int(time.time() - psutil.boot_time()) + + temps = {} + try: + for name, entries in psutil.sensors_temperatures().items(): + if entries: + temps[name] = [ + {"label": e.label or name, "current": e.current, + "high": e.high, "critical": e.critical} + for e in entries + ] + except Exception: + pass + + return { + "ts": int(now), + "uptime_s": uptime_s, + "cpu": { + "percent": cpu_percent, + "percent_per_core": psutil.cpu_percent(interval=None, percpu=True), + "freq_mhz": round(cpu_freq.current, 0) if cpu_freq else None, + "freq_max_mhz": round(cpu_freq.max, 0) if cpu_freq else None, + }, + "memory": { + "total": mem.total, "available": mem.available, "used": mem.used, + "percent": mem.percent, + "buffers": getattr(mem, "buffers", 0), + "cached": getattr(mem, "cached", 0), + }, + "swap": { + "total": swap.total, "used": swap.used, "percent": swap.percent, + }, + "disks": disks, + "network": { + "dl_bps": max(0.0, dl), + "ul_bps": max(0.0, ul), + "total_recv": net_now.bytes_recv, + "total_sent": net_now.bytes_sent, + "interfaces": net_ifaces, + }, + "temperatures": temps, + } + + +_os_cache: dict = {} + + +def _get_os_info() -> dict: + uname = os.uname() + os_name = _run(["lsb_release", "-ds"]).strip() + if not os_name: + try: + with open(_host_path("/etc/os-release")) as f: + for line in f: + if line.startswith("PRETTY_NAME="): + os_name = line.split("=", 1)[1].strip().strip('"') + break + except Exception: + os_name = uname.sysname + return { + "hostname": uname.nodename, + "os": os_name, + "kernel": uname.release, + "arch": uname.machine, + } + + +@app.get("/api/os") +def api_os(): + global _os_cache + if not _os_cache: + _os_cache = _get_os_info() + return _os_cache + + +@app.get("/api/hardware") +def api_hardware(): + return get_hardware() + + +@app.get("/api/live") +def api_live(): + return get_live() + + +app.mount("/", StaticFiles(directory="/app/static", html=True), name="static") diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..b0b61e6 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.6 +uvicorn==0.32.1 +psutil==6.1.0 +py-cpuinfo==9.0.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..62eb96f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + homelabdash: + build: . + container_name: homelabdash + restart: unless-stopped + privileged: true + network_mode: host + volumes: + - /:/rootfs:ro,rslave + - /sys:/sys:ro + - /dev:/dev + - /run/udev:/run/udev:ro + environment: + - HOST_ROOT=/rootfs + - TZ=Europe/Paris + labels: + - "traefik.enable=true" + - "traefik.http.routers.homelabdash.rule=Host(`homelabdash.nas.percolouco.com`)" + - "traefik.http.routers.homelabdash.entrypoints=websecure" + - "traefik.http.routers.homelabdash.tls.certresolver=letsencrypt" + - "traefik.http.services.homelabdash.loadbalancer.server.url=http://192.168.1.29:8000" diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..a2c5fe0 --- /dev/null +++ b/static/index.html @@ -0,0 +1,431 @@ + + + + + +HomeLab Dashboard + + + + +
+ 🖥️ +
+

HomeLab Dashboard

+
Chargement…
+
+
+ + live +
+
+
⏳ Chargement des données…
+ + + +