feat: dashboard homelab v1.0.0 — stats CPU/RAM/disques/réseau/S.M.A.R.T
This commit is contained in:
+17
@@ -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"]
|
||||
+286
@@ -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")
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.32.1
|
||||
psutil==6.1.0
|
||||
py-cpuinfo==9.0.0
|
||||
@@ -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"
|
||||
@@ -0,0 +1,431 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>HomeLab Dashboard</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--card: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--green: #3fb950;
|
||||
--yellow: #d29922;
|
||||
--red: #f85149;
|
||||
--purple: #bc8cff;
|
||||
--teal: #39d353;
|
||||
--orange: #f0883e;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; font-size: 14px; min-height: 100vh; }
|
||||
|
||||
header {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 16px 24px;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
header h1 { font-size: 18px; font-weight: 600; letter-spacing: .5px; color: var(--accent); }
|
||||
.header-meta { margin-left: auto; display: flex; gap: 20px; align-items: center; }
|
||||
.badge { padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||
.badge-ok { background: #1f4024; color: var(--green); border: 1px solid #2d5a31; }
|
||||
.badge-warn { background: #3b2a1a; color: var(--yellow); border: 1px solid #5a3e1a; }
|
||||
.badge-err { background: #3b1219; color: var(--red); border: 1px solid #5a1a22; }
|
||||
#uptime-label { color: var(--muted); font-size: 13px; }
|
||||
#refresh-dot { width:8px;height:8px;border-radius:50%;background:var(--green);display:inline-block;margin-right:4px; }
|
||||
|
||||
main { padding: 20px 24px; display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
|
||||
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 16px; display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.card.wide { grid-column: 1 / -1; }
|
||||
.card.two { grid-column: span 2; }
|
||||
|
||||
.card-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); display: flex; align-items: center; gap: 8px; }
|
||||
.card-title .icon { font-size: 16px; }
|
||||
|
||||
.info-row { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 1px solid #21262d; padding: 5px 0; }
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-key { color: var(--muted); }
|
||||
.info-val { font-weight: 600; color: var(--text); text-align: right; max-width: 60%; word-break: break-all; }
|
||||
|
||||
.meter-wrap { display: flex; flex-direction: column; gap: 4px; }
|
||||
.meter-label { display: flex; justify-content: space-between; font-size: 12px; }
|
||||
.meter-bar { height: 8px; background: #21262d; border-radius: 4px; overflow: hidden; }
|
||||
.meter-fill { height: 100%; border-radius: 4px; transition: width .5s; }
|
||||
.fill-green { background: var(--green); }
|
||||
.fill-yellow { background: var(--yellow); }
|
||||
.fill-red { background: var(--red); }
|
||||
.fill-blue { background: var(--accent); }
|
||||
.fill-purple { background: var(--purple); }
|
||||
|
||||
.chart-wrap { position: relative; height: 140px; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; padding: 6px 8px; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--border); font-size: 11px; text-transform: uppercase; }
|
||||
td { padding: 7px 8px; border-bottom: 1px solid #21262d; vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #1c2128; }
|
||||
|
||||
.smart-ok { color: var(--green); font-weight: 700; }
|
||||
.smart-fail { color: var(--red); font-weight: 700; }
|
||||
.smart-unk { color: var(--muted); }
|
||||
|
||||
.core-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 6px; }
|
||||
.core-item { background: #21262d; border-radius: 6px; padding: 6px 8px; }
|
||||
.core-item .core-name { font-size: 10px; color: var(--muted); margin-bottom: 3px; }
|
||||
.core-item .core-val { font-weight: 700; font-size: 13px; }
|
||||
|
||||
.net-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.net-stat { background: #21262d; border-radius: 8px; padding: 12px; text-align: center; }
|
||||
.net-stat .ns-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing:.5px; margin-bottom: 4px; }
|
||||
.net-stat .ns-val { font-size: 22px; font-weight: 700; }
|
||||
.net-stat .ns-unit { font-size: 11px; color: var(--muted); }
|
||||
.dl-color { color: var(--teal); }
|
||||
.ul-color { color: var(--orange); }
|
||||
|
||||
.stick-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }
|
||||
.stick { background: #21262d; border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.stick .s-slot { font-size: 10px; color: var(--muted); font-weight: 700; text-transform: uppercase; }
|
||||
.stick .s-size { font-size: 18px; font-weight: 800; color: var(--accent); }
|
||||
.stick .s-info { font-size: 11px; color: var(--muted); }
|
||||
|
||||
#loading { text-align: center; padding: 60px; color: var(--muted); font-size: 16px; grid-column: 1 / -1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span style="font-size:24px">🖥️</span>
|
||||
<div>
|
||||
<h1 id="hostname">HomeLab Dashboard</h1>
|
||||
<div style="font-size:12px;color:var(--muted)" id="os-line">Chargement…</div>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<span id="uptime-label">–</span>
|
||||
<span><span id="refresh-dot"></span><span style="font-size:12px;color:var(--muted)">live</span></span>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main"><div id="loading">⏳ Chargement des données…</div></main>
|
||||
|
||||
<script>
|
||||
const HIST_LEN = 40;
|
||||
const hist = {
|
||||
cpu: [], net_dl: [], net_ul: [], mem: []
|
||||
};
|
||||
let cpuChart, netChart;
|
||||
let hwLoaded = false;
|
||||
|
||||
function fmt_bytes(b, dec=1) {
|
||||
if (b == null) return '–';
|
||||
if (b === 0) return '0 B';
|
||||
const sizes = ['B','KB','MB','GB','TB'];
|
||||
const i = Math.floor(Math.log(Math.abs(b)) / Math.log(1024));
|
||||
return (b / Math.pow(1024, i)).toFixed(dec) + ' ' + sizes[i];
|
||||
}
|
||||
function fmt_bps(bps) {
|
||||
if (bps < 1024) return bps.toFixed(0) + ' B/s';
|
||||
if (bps < 1048576) return (bps/1024).toFixed(1) + ' KB/s';
|
||||
return (bps/1048576).toFixed(2) + ' MB/s';
|
||||
}
|
||||
function fmt_uptime(s) {
|
||||
const d = Math.floor(s/86400), h = Math.floor((s%86400)/3600), m = Math.floor((s%3600)/60);
|
||||
const parts = [];
|
||||
if (d) parts.push(d+'j');
|
||||
if (h) parts.push(h+'h');
|
||||
parts.push(m+'m');
|
||||
return parts.join(' ');
|
||||
}
|
||||
function fill_color(pct) {
|
||||
if (pct > 85) return 'fill-red';
|
||||
if (pct > 60) return 'fill-yellow';
|
||||
return 'fill-green';
|
||||
}
|
||||
function meter(label, pct, detail='') {
|
||||
const cls = fill_color(pct);
|
||||
return `<div class="meter-wrap">
|
||||
<div class="meter-label"><span>${label}</span><span><b>${pct.toFixed(1)}%</b>${detail ? ' · '+detail : ''}</span></div>
|
||||
<div class="meter-bar"><div class="meter-fill ${cls}" style="width:${pct}%"></div></div>
|
||||
</div>`;
|
||||
}
|
||||
function info_row(k, v) {
|
||||
return `<div class="info-row"><span class="info-key">${k}</span><span class="info-val">${v ?? '–'}</span></div>`;
|
||||
}
|
||||
function push_hist(arr, val) {
|
||||
arr.push(val);
|
||||
if (arr.length > HIST_LEN) arr.shift();
|
||||
}
|
||||
|
||||
function build_ui(os, hw) {
|
||||
const main = document.getElementById('main');
|
||||
main.innerHTML = `
|
||||
<!-- OS + Board -->
|
||||
<div class="card" id="card-os">
|
||||
<div class="card-title"><span class="icon">🐧</span> Système</div>
|
||||
${info_row('Hostname', os.hostname)}
|
||||
${info_row('OS', os.os)}
|
||||
${info_row('Kernel', os.kernel)}
|
||||
${info_row('Architecture', os.arch)}
|
||||
${info_row('Carte mère', [hw.board.manufacturer, hw.board.product].filter(Boolean).join(' · '))}
|
||||
</div>
|
||||
|
||||
<!-- CPU info -->
|
||||
<div class="card" id="card-cpu-info">
|
||||
<div class="card-title"><span class="icon">⚡</span> Processeur</div>
|
||||
${info_row('Modèle', hw.cpu.brand)}
|
||||
${info_row('Architecture', hw.cpu.arch)}
|
||||
${info_row('Fréquence annoncée', hw.cpu.hz_advertised)}
|
||||
${info_row('Cœurs physiques', hw.cpu.cores_physical)}
|
||||
${info_row('Threads logiques', hw.cpu.cores_logical)}
|
||||
<div id="cpu-live-rows"></div>
|
||||
</div>
|
||||
|
||||
<!-- CPU usage graph -->
|
||||
<div class="card" id="card-cpu-graph">
|
||||
<div class="card-title"><span class="icon">📈</span> Utilisation CPU</div>
|
||||
<div id="cpu-meter-wrap"></div>
|
||||
<div class="chart-wrap"><canvas id="chart-cpu"></canvas></div>
|
||||
<div class="core-grid" id="core-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- RAM hardware -->
|
||||
<div class="card" id="card-ram-hw">
|
||||
<div class="card-title"><span class="icon">🧩</span> Mémoire — hardware</div>
|
||||
<div class="stick-grid" id="stick-grid">
|
||||
${hw.memory_sticks.length ? hw.memory_sticks.map(s => `
|
||||
<div class="stick">
|
||||
<div class="s-slot">${s.locator}</div>
|
||||
<div class="s-size">${s.size}</div>
|
||||
<div class="s-info">${s.type}${s.speed ? ' · ' + s.speed : ''}</div>
|
||||
<div class="s-info">${s.manufacturer !== 'Unknown' ? s.manufacturer : ''}${s.part_number !== 'Unknown' ? ' · ' + s.part_number : ''}</div>
|
||||
</div>`) .join('') : '<span style="color:var(--muted)">dmidecode indisponible</span>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RAM usage -->
|
||||
<div class="card" id="card-ram-usage">
|
||||
<div class="card-title"><span class="icon">💾</span> Utilisation mémoire</div>
|
||||
<div id="ram-meters"></div>
|
||||
<div class="chart-wrap"><canvas id="chart-mem"></canvas></div>
|
||||
</div>
|
||||
|
||||
<!-- Network graph -->
|
||||
<div class="card two" id="card-net">
|
||||
<div class="card-title"><span class="icon">🌐</span> Réseau</div>
|
||||
<div class="net-stats">
|
||||
<div class="net-stat"><div class="ns-label">⬇ Download</div><div class="ns-val dl-color" id="net-dl">–</div><div class="ns-unit" id="net-dl-tot"></div></div>
|
||||
<div class="net-stat"><div class="ns-label">⬆ Upload</div><div class="ns-val ul-color" id="net-ul">–</div><div class="ns-unit" id="net-ul-tot"></div></div>
|
||||
</div>
|
||||
<div class="chart-wrap" style="height:160px"><canvas id="chart-net"></canvas></div>
|
||||
<div id="net-ifaces" style="margin-top:4px"></div>
|
||||
</div>
|
||||
|
||||
<!-- Disques partitions -->
|
||||
<div class="card wide" id="card-disks">
|
||||
<div class="card-title"><span class="icon">💿</span> Espace disque</div>
|
||||
<div id="disk-meters"></div>
|
||||
</div>
|
||||
|
||||
<!-- SMART -->
|
||||
<div class="card wide" id="card-smart">
|
||||
<div class="card-title"><span class="icon">🔍</span> S.M.A.R.T — état des disques</div>
|
||||
${Object.keys(hw.smart).length ? `
|
||||
<table>
|
||||
<thead><tr><th>Périphérique</th><th>Modèle</th><th>Série</th><th>Capacité</th><th>Santé</th><th>Température</th></tr></thead>
|
||||
<tbody id="smart-tbody">
|
||||
${Object.entries(hw.smart).map(([dev, s]) => `
|
||||
<tr>
|
||||
<td><code>${s.device}</code></td>
|
||||
<td>${s.model || '–'}</td>
|
||||
<td>${s.serial || '–'}</td>
|
||||
<td>${fmt_bytes(s.capacity_bytes)}</td>
|
||||
<td class="${s.health === true ? 'smart-ok' : s.health === false ? 'smart-fail' : 'smart-unk'}">${s.health === true ? '✅ PASSED' : s.health === false ? '❌ FAILED' : '❓ –'}</td>
|
||||
<td>${s.temp_c != null ? s.temp_c + ' °C' : '–'}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>` : '<span style="color:var(--muted)">smartctl indisponible ou aucun disque SMART détecté</span>'}
|
||||
</div>
|
||||
|
||||
<!-- Températures -->
|
||||
<div class="card" id="card-temps">
|
||||
<div class="card-title"><span class="icon">🌡️</span> Températures</div>
|
||||
<div id="temp-table"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// init charts
|
||||
const chartOpts = (label, color1, color2) => ({
|
||||
type: 'line',
|
||||
data: { labels: Array(HIST_LEN).fill(''), datasets: [{
|
||||
label: label, data: Array(HIST_LEN).fill(null),
|
||||
borderColor: color1, backgroundColor: color1 + '22',
|
||||
fill: true, tension: 0.4, pointRadius: 0, borderWidth: 2
|
||||
}] },
|
||||
options: {
|
||||
animation: false, responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: { min: 0, max: 100, grid: { color: '#21262d' }, ticks: { color: '#8b949e', font: { size: 10 } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cpuChart = new Chart(document.getElementById('chart-cpu').getContext('2d'), chartOpts('CPU %', '#58a6ff'));
|
||||
|
||||
const memCtx = document.getElementById('chart-mem').getContext('2d');
|
||||
window._memChart = new Chart(memCtx, {
|
||||
type: 'line',
|
||||
data: { labels: Array(HIST_LEN).fill(''), datasets: [{
|
||||
label: 'RAM %', data: Array(HIST_LEN).fill(null),
|
||||
borderColor: '#bc8cff', backgroundColor: '#bc8cff22',
|
||||
fill: true, tension: 0.4, pointRadius: 0, borderWidth: 2
|
||||
}] },
|
||||
options: { animation: false, responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { x: { display: false }, y: { min: 0, max: 100, grid: { color: '#21262d' }, ticks: { color: '#8b949e', font: { size: 10 } } } }
|
||||
}
|
||||
});
|
||||
|
||||
const netCtx = document.getElementById('chart-net').getContext('2d');
|
||||
window._netChart = new Chart(netCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: Array(HIST_LEN).fill(''),
|
||||
datasets: [
|
||||
{ label: '⬇ DL', data: Array(HIST_LEN).fill(null), borderColor: '#39d353', backgroundColor: '#39d35322', fill: true, tension: 0.4, pointRadius: 0, borderWidth: 2 },
|
||||
{ label: '⬆ UL', data: Array(HIST_LEN).fill(null), borderColor: '#f0883e', backgroundColor: '#f0883e22', fill: true, tension: 0.4, pointRadius: 0, borderWidth: 2 },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
animation: false, responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: true, labels: { color: '#8b949e', boxWidth: 12, font: { size: 11 } } } },
|
||||
scales: { x: { display: false }, y: { min: 0, grid: { color: '#21262d' }, ticks: { color: '#8b949e', font: { size: 10 }, callback: v => fmt_bps(v) } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function update_live(live) {
|
||||
// uptime
|
||||
document.getElementById('uptime-label').textContent = '⏱ ' + fmt_uptime(live.uptime_s);
|
||||
|
||||
// CPU
|
||||
push_hist(hist.cpu, live.cpu.percent);
|
||||
document.getElementById('cpu-live-rows').innerHTML =
|
||||
info_row('Fréquence actuelle', live.cpu.freq_mhz ? live.cpu.freq_mhz + ' MHz' : '–') +
|
||||
info_row('Fréquence max', live.cpu.freq_max_mhz ? live.cpu.freq_max_mhz + ' MHz' : '–');
|
||||
|
||||
document.getElementById('cpu-meter-wrap').innerHTML = meter('Utilisation globale', live.cpu.percent);
|
||||
|
||||
if (cpuChart) {
|
||||
cpuChart.data.datasets[0].data = [...hist.cpu, ...Array(HIST_LEN - hist.cpu.length).fill(null)].slice(-HIST_LEN);
|
||||
cpuChart.update('none');
|
||||
}
|
||||
|
||||
// cœurs
|
||||
const cores = live.cpu.percent_per_core || [];
|
||||
document.getElementById('core-grid').innerHTML = cores.map((p, i) =>
|
||||
`<div class="core-item"><div class="core-name">CPU ${i}</div><div class="core-val" style="color:${p>80?'var(--red)':p>50?'var(--yellow)':'var(--green)'}">${p.toFixed(1)}%</div><div class="meter-bar" style="margin-top:3px"><div class="meter-fill ${fill_color(p)}" style="width:${p}%"></div></div></div>`
|
||||
).join('');
|
||||
|
||||
// RAM
|
||||
const m = live.memory;
|
||||
push_hist(hist.mem, m.percent);
|
||||
const cached = m.cached + m.buffers;
|
||||
document.getElementById('ram-meters').innerHTML =
|
||||
meter('Utilisée', m.percent, fmt_bytes(m.used) + ' / ' + fmt_bytes(m.total)) +
|
||||
(cached > 0 ? `<div style="margin-top:6px">${meter('Cache + buffers', (cached/m.total*100), fmt_bytes(cached))}</div>` : '') +
|
||||
(live.swap.total > 0 ? `<div style="margin-top:6px">${meter('Swap', live.swap.percent, fmt_bytes(live.swap.used) + ' / ' + fmt_bytes(live.swap.total))}</div>` : '');
|
||||
|
||||
if (window._memChart) {
|
||||
window._memChart.data.datasets[0].data = [...hist.mem, ...Array(HIST_LEN - hist.mem.length).fill(null)].slice(-HIST_LEN);
|
||||
window._memChart.update('none');
|
||||
}
|
||||
|
||||
// Réseau
|
||||
push_hist(hist.net_dl, live.network.dl_bps);
|
||||
push_hist(hist.net_ul, live.network.ul_bps);
|
||||
document.getElementById('net-dl').textContent = fmt_bps(live.network.dl_bps);
|
||||
document.getElementById('net-ul').textContent = fmt_bps(live.network.ul_bps);
|
||||
document.getElementById('net-dl-tot').textContent = 'Total reçu : ' + fmt_bytes(live.network.total_recv);
|
||||
document.getElementById('net-ul-tot').textContent = 'Total envoyé : ' + fmt_bytes(live.network.total_sent);
|
||||
|
||||
const ifaces = live.network.interfaces || {};
|
||||
document.getElementById('net-ifaces').innerHTML = Object.entries(ifaces).map(([iface, ip]) =>
|
||||
`<span style="margin-right:16px;font-size:12px;color:var(--muted)"><code style="color:var(--accent)">${iface}</code> ${ip}</span>`
|
||||
).join('');
|
||||
|
||||
if (window._netChart) {
|
||||
const pad = (arr) => [...Array(HIST_LEN - arr.length).fill(null), ...arr].slice(-HIST_LEN);
|
||||
window._netChart.data.datasets[0].data = pad(hist.net_dl);
|
||||
window._netChart.data.datasets[1].data = pad(hist.net_ul);
|
||||
const maxVal = Math.max(...hist.net_dl, ...hist.net_ul, 1024);
|
||||
window._netChart.options.scales.y.max = maxVal * 1.2;
|
||||
window._netChart.update('none');
|
||||
}
|
||||
|
||||
// Disques
|
||||
const diskEl = document.getElementById('disk-meters');
|
||||
if (diskEl) {
|
||||
diskEl.innerHTML = live.disks.map(d =>
|
||||
`<div style="margin-bottom:10px">${meter(d.mountpoint + ' <span style="color:var(--muted);font-size:11px">('+d.device+' · '+d.fstype+')</span>', d.percent, fmt_bytes(d.used) + ' / ' + fmt_bytes(d.total) + ' — ' + fmt_bytes(d.free) + ' libres')}</div>`
|
||||
).join('') || '<span style="color:var(--muted)">Aucune partition détectée</span>';
|
||||
}
|
||||
|
||||
// Températures
|
||||
const tempEl = document.getElementById('temp-table');
|
||||
if (tempEl) {
|
||||
const temps = live.temperatures || {};
|
||||
const rows = Object.values(temps).flat();
|
||||
if (rows.length) {
|
||||
tempEl.innerHTML = `<table><thead><tr><th>Capteur</th><th>Température</th><th>Max</th><th>Critique</th></tr></thead><tbody>` +
|
||||
rows.map(r => {
|
||||
const c = r.current;
|
||||
const color = c > (r.critical || 90) ? 'var(--red)' : c > (r.high || 75) ? 'var(--yellow)' : 'var(--green)';
|
||||
return `<tr><td>${r.label}</td><td style="color:${color};font-weight:700">${c?.toFixed(1)} °C</td><td>${r.high ? r.high+'°C' : '–'}</td><td>${r.critical ? r.critical+'°C' : '–'}</td></tr>`;
|
||||
}).join('') + `</tbody></table>`;
|
||||
} else {
|
||||
tempEl.innerHTML = '<span style="color:var(--muted)">Aucun capteur de température disponible</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// blink refresh dot
|
||||
const dot = document.getElementById('refresh-dot');
|
||||
dot.style.background = 'var(--accent)';
|
||||
setTimeout(() => { dot.style.background = 'var(--green)'; }, 300);
|
||||
}
|
||||
|
||||
async function load_hw_and_os() {
|
||||
const [os, hw] = await Promise.all([
|
||||
fetch('/api/os').then(r => r.json()),
|
||||
fetch('/api/hardware').then(r => r.json()),
|
||||
]);
|
||||
document.getElementById('hostname').textContent = '🖥️ ' + os.hostname;
|
||||
document.getElementById('os-line').textContent = os.os + ' · ' + os.kernel + ' · ' + os.arch;
|
||||
build_ui(os, hw);
|
||||
hwLoaded = true;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const live = await fetch('/api/live').then(r => r.json());
|
||||
if (!hwLoaded) return;
|
||||
update_live(live);
|
||||
} catch(e) {
|
||||
document.getElementById('refresh-dot').style.background = 'var(--red)';
|
||||
}
|
||||
}
|
||||
|
||||
load_hw_and_os().then(() => {
|
||||
poll();
|
||||
setInterval(poll, 5000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user