From f93f5741da511bf47d1ccfe0511f6a258d1d54df Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Sat, 23 May 2026 05:56:44 +0200 Subject: [PATCH] feat: badges SMART pills, versionning serveur, fix copier HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard: icônes SMART → pills OK/USAGÉ/PREFAIL/HS cliquables (tuile + popup détail + popup SMART redessiné pour novices) - Serveur: constante version 0.1.0 exposée via WS server_stats → footer - Fix copier script install en HTTP (isSecureContext avant clipboard API) - install.sh: ajout ethtool, suppression logique OVERWRITE_CONFIG Co-Authored-By: Claude Sonnet 4.6 --- dashboard/css/app.css | 15 +++--- dashboard/index.html | 4 ++ dashboard/js/app.js | 2 + dashboard/js/grid.js | 26 ++++++++-- dashboard/js/popups.js | 108 ++++++++++++++++++++++++++++------------ deploy/install.sh | 70 +++++++++++--------------- server/main.go | 3 ++ server/models/models.go | 1 + 8 files changed, 144 insertions(+), 85 deletions(-) diff --git a/dashboard/css/app.css b/dashboard/css/app.css index 8dc3f38..f10fb3e 100644 --- a/dashboard/css/app.css +++ b/dashboard/css/app.css @@ -199,12 +199,15 @@ body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-s .chart-svg{width:100%;height:52px;display:block} .chart-axis{display:flex;justify-content:space-between;margin-top:2px;font-family:var(--font-terminal);font-size:9px;color:var(--ink-4)} .chart-minmax{display:flex;justify-content:space-between;margin-top:3px;font-family:var(--font-mono);font-size:9px;color:var(--ink-4)} -.smart-btn{display:inline-flex;align-items:center;gap:8px;padding:7px 12px;border-radius:8px; - border:1px solid var(--border-2);background:var(--bg-3);cursor:pointer; - transition:background .12s,border-color .12s,transform .08s;font-family:var(--font-terminal);font-size:11px} -.smart-btn:hover{background:var(--bg-4)}.smart-btn:active{transform:translateY(1px)} -.smart-btn.ok{border-color:rgba(77,187,38,.3);color:var(--ok)} -.smart-dot{width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 5px var(--ok)} +.smart-pill{display:inline-flex;align-items:center;gap:3px;padding:1px 7px;border-radius:999px; + font-size:9px;font-family:var(--font-terminal);font-weight:700;border:1px solid; + cursor:pointer;user-select:none;flex-shrink:0; + transition:opacity .12s,transform .08s,box-shadow .12s} +.smart-pill:hover{opacity:.82;transform:scale(1.06)} +.smart-pill.ok{color:var(--ok);background:rgba(77,187,38,.12);border-color:rgba(77,187,38,.32)} +.smart-pill.old{color:var(--warn);background:rgba(250,189,47,.12);border-color:rgba(250,189,47,.32)} +.smart-pill.prefail{color:var(--accent);background:var(--accent-tint);border-color:rgba(254,128,25,.32)} +.smart-pill.hs{color:var(--err);background:rgba(251,73,52,.12);border-color:rgba(251,73,52,.32)} .meta-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px} .meta{background:var(--bg-3);border-radius:6px;padding:8px 10px;border:1px solid var(--border-1)} .meta-lbl{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em} diff --git a/dashboard/index.html b/dashboard/index.html index 1ceefa5..59ea265 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -57,6 +57,10 @@
+
+ + +
diff --git a/dashboard/js/app.js b/dashboard/js/app.js index 1bb79a3..829e626 100644 --- a/dashboard/js/app.js +++ b/dashboard/js/app.js @@ -50,6 +50,8 @@ const App = (() => { const memEl = document.getElementById('srv-mem'); const cpuBar = document.getElementById('srv-cpu-bar'); const memBar = document.getElementById('srv-mem-bar'); + const verEl = document.getElementById('srv-ver'); + if (verEl && stats.version) verEl.textContent = 'v' + stats.version; if (cpuEl) { cpuEl.textContent = cpu.toFixed(0) + '%'; cpuEl.className = 'f-val' + (cpu >= 70 ? ' w' : ''); diff --git a/dashboard/js/grid.js b/dashboard/js/grid.js index 6d3fafb..33a329b 100644 --- a/dashboard/js/grid.js +++ b/dashboard/js/grid.js @@ -58,6 +58,18 @@ const Grid = (() => {
`; } + const _stateLabel = { ok: 'OK', old: 'USAGÉ', prefail: 'PREFAIL', hs: 'HS' }; + + function smartState(s) { + if (!s.passed) return 'hs'; + if (s.reallocated_sectors > 0 || + (s.wear_level != null && s.wear_level < 20) || + (s.power_on_hours != null && s.power_on_hours > 40000)) return 'prefail'; + if ((s.wear_level != null && s.wear_level < 50) || + (s.power_on_hours != null && s.power_on_hours > 25000)) return 'old'; + return 'ok'; + } + function renderTile(agent, metrics) { const id = agent.id; const sc = statusClass(agent); @@ -78,10 +90,14 @@ const Grid = (() => { } const smartIco = !offline && metrics?.smart?.length > 0 - ? metrics.smart.map(s => s.passed - ? `` - : `` - ).join('') + ? '
' + + metrics.smart.map((s, i) => { + const st = smartState(s); + const lbl = _stateLabel[st]; + return `${lbl}`; + }).join('') + '
' : ''; const iconContent = ` { updateStats(); } - return { refresh, update, updateStatus, removeAgent, rerenderAll, getAgent, fmt, fmtPct }; + return { refresh, update, updateStatus, removeAgent, rerenderAll, getAgent, fmt, fmtPct, smartState }; })(); diff --git a/dashboard/js/popups.js b/dashboard/js/popups.js index f0239a7..d66d35c 100644 --- a/dashboard/js/popups.js +++ b/dashboard/js/popups.js @@ -80,16 +80,18 @@ const Popups = (() => { ? `
min ${Grid.fmt(ramMin)}max ${Grid.fmt(ramMax)}
` : ''; - const smartBtn = metrics?.smart?.length > 0 - ? metrics.smart.map((s, i) => ` -
-
- ${esc(s.device) || 'disque'} - · - ${s.passed ? 'PASSED' : 'FAILED'} - ${s.temperature != null ? ` ${s.temperature}°C` : ''} - -
`).join('') + const smartBadges = metrics?.smart?.length > 0 + ? '
' + + metrics.smart.map((s, i) => { + const st = Grid.smartState(s); + const lbl = { ok: 'OK', old: 'USAGÉ', prefail: 'PREFAIL', hs: 'HS' }[st]; + return ` + + ${esc(s.device)} · ${lbl} + `; + }).join('') + '
' : ''; const protos = [ @@ -143,7 +145,7 @@ const Popups = (() => {
${Grid.fmt(metrics?.hdd_used)} / ${Grid.fmt(metrics?.hdd_total)} - ${smartBtn} + ${smartBadges}
@@ -394,54 +396,94 @@ const Popups = (() => { const smartList = Grid.getAgent(agentId)?.metrics?.smart; if (!smartList?.length) return; const m = smartList[diskIdx] ?? smartList[0]; + const state = Grid.smartState(m); + document.getElementById('smart-sub').textContent = m.device ? `${agentId} — ${m.device}` : agentId; - const passColor = m.passed ? 'var(--ok)' : 'var(--err)'; - const passText = m.passed ? 'Disque en bonne santé' : 'Disque en mauvais état'; - const passSub = m.passed - ? 'Aucun problème détecté. Le disque fonctionne normalement.' - : 'Des problèmes ont été détectés. Envisagez un remplacement.'; + + const stateInfo = { + ok: { color:'var(--ok)', bg:'rgba(77,187,38,.1)', border:'rgba(77,187,38,.3)', icon:'fa-circle-check', + title:'Disque en bonne santé', + desc:'Aucun problème détecté. Votre disque fonctionne normalement.' }, + old: { color:'var(--warn)', bg:'rgba(250,189,47,.1)', border:'rgba(250,189,47,.3)', icon:'fa-clock-rotate-left', + title:'Disque ancien ou très utilisé', + desc:'Votre disque fonctionne encore, mais il a accumulé beaucoup d\'heures. Pensez à prévoir un remplacement.' }, + prefail: { color:'var(--accent)', bg:'var(--accent-tint)', border:'rgba(254,128,25,.3)', icon:'fa-triangle-exclamation', + title:'Signes de défaillance imminente', + desc:'Ce disque présente des indicateurs préoccupants. Sauvegardez vos données dès maintenant et envisagez un remplacement rapide.' }, + hs: { color:'var(--err)', bg:'rgba(251,73,52,.1)', border:'rgba(251,73,52,.3)', icon:'fa-circle-xmark', + title:'Disque défaillant', + desc:'Ce disque a échoué au test SMART. Il peut tomber en panne à tout moment. Sauvegardez immédiatement et remplacez-le.' }, + }; + const si = stateInfo[state]; + + const tempColor = m.temperature == null ? null + : m.temperature > 60 ? 'var(--err)' : m.temperature > 50 ? 'var(--warn)' : 'var(--ok)'; + const tempLabel = m.temperature == null ? null + : m.temperature > 60 ? 'Critique' : m.temperature > 50 ? 'Élevée' : 'Normale'; + const tempBg = tempColor === 'var(--ok)' ? 'rgba(77,187,38,.15)' + : tempColor === 'var(--warn)' ? 'rgba(250,189,47,.15)' : 'rgba(251,73,52,.15)'; + + const secColor = m.reallocated_sectors == null ? null + : m.reallocated_sectors === 0 ? 'var(--ok)' : m.reallocated_sectors < 10 ? 'var(--warn)' : 'var(--err)'; + const secDesc = m.reallocated_sectors === 0 + ? 'Aucun secteur défectueux — parfait.' + : m.reallocated_sectors < 10 ? 'Quelques secteurs remplacés. Surveillez l\'évolution.' + : 'Nombreux secteurs défectueux — risque de panne élevé.'; + + const hoursColor = m.power_on_hours == null ? null + : m.power_on_hours > 40000 ? 'var(--err)' : m.power_on_hours > 25000 ? 'var(--warn)' : 'var(--ok)'; + + const wearColor = m.wear_level == null ? null + : m.wear_level < 20 ? 'var(--err)' : m.wear_level < 50 ? 'var(--warn)' : 'var(--ok)'; + const wearDesc = m.wear_level == null ? '' + : m.wear_level >= 80 ? 'Très bonne durée de vie restante.' + : m.wear_level >= 50 ? 'Durée de vie acceptable, à surveiller.' + : m.wear_level >= 20 ? 'Durée de vie réduite — pensez au remplacement.' + : 'Durée de vie critique — remplacez ce SSD rapidement.'; document.getElementById('smart-body').innerHTML = ` -
-
-
${passText}
-
${passSub}
+
+
+
+
${si.title}
+
${si.desc}
+
POINTS DE CONTRÔLE
${m.temperature != null ? `
- + Température - Normale + ${tempLabel}
${m.temperature}°C
-
Idéal : 20–50°C. Au-delà de 60°C le disque risque de s'abîmer.
+
Normale entre 20–50°C. Au-delà de 60°C le disque risque de s'abîmer.
` : ''} ${m.reallocated_sectors != null ? `
- + Secteurs défectueux
${m.reallocated_sectors} sect.
-
S'ils apparaissent en grand nombre, une panne est imminente.
+
${secDesc}
` : ''} ${m.power_on_hours != null ? `
- - Heures de fonctionnement + + Durée de fonctionnement
-
${m.power_on_hours.toLocaleString('fr-FR')}h
-
≈${Math.floor(m.power_on_hours/24)} jours. Un disque dure en moyenne 3 à 5 ans.
+
${m.power_on_hours.toLocaleString('fr-FR')} h
+
≈${Math.floor(m.power_on_hours / 24)} jours d'utilisation. Un disque dur dure en moyenne 3 à 5 ans (25 000–40 000 h).
` : ''} ${m.wear_level != null ? `
- - Durée de vie SSD + + Durée de vie SSD restante
${m.wear_level}%
-
100% = neuf · 0% = fin de vie recommandée.
+
${wearDesc}
` : ''}
`; @@ -474,7 +516,7 @@ const Popups = (() => { function _copyInstallCmd(btn) { const text = document.getElementById('s-install-cmd').value; const done = () => { btn.textContent = '✓ Copié'; setTimeout(() => btn.textContent = 'Copier', 1500); }; - if (navigator.clipboard && navigator.clipboard.writeText) { + if (window.isSecureContext && navigator.clipboard?.writeText) { navigator.clipboard.writeText(text).then(done).catch(() => _copyFallback(text, done)); } else { _copyFallback(text, done); diff --git a/deploy/install.sh b/deploy/install.sh index 3050d8f..7f18be9 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -29,7 +29,23 @@ echo " Nanometrics Agent — Installation" echo "======================================" echo "" -# ── 1. Détection de l'architecture ──────────────────────────────────────────── +# ── 1. Dépendances système ───────────────────────────────────────────────────── +PKGS_NEEDED=() +for pkg in curl python3 smartmontools ethtool; do + dpkg -l "$pkg" 2>/dev/null | grep -q '^ii' || PKGS_NEEDED+=("$pkg") +done + +if [ ${#PKGS_NEEDED[@]} -gt 0 ]; then + echo "→ Installation des paquets manquants : ${PKGS_NEEDED[*]}" + apt-get update -qq + apt-get install -y -qq "${PKGS_NEEDED[@]}" + ok "Paquets installés : ${PKGS_NEEDED[*]}" +else + ok "Dépendances système déjà présentes" +fi +echo "" + +# ── 3. Détection de l'architecture ──────────────────────────────────────────── ARCH="$(uname -m)" case "$ARCH" in x86_64) LABEL="linux-amd64" ;; @@ -42,7 +58,7 @@ case "$ARCH" in esac ok "Architecture détectée : $ARCH → $LABEL" -# ── 2. Récupérer l'URL du binaire depuis la dernière release ────────────────── +# ── 4. Récupérer l'URL du binaire depuis la dernière release ────────────────── echo "→ Récupération de la dernière release..." ASSETS_JSON=$(curl -sf "$REPO_API/releases?limit=1&page=1") @@ -69,7 +85,7 @@ print(releases[0]['tag_name']) ok "Release : $TAG — URL : $ASSET_URL" -# ── 3. Télécharger le binaire ───────────────────────────────────────────────── +# ── 5. Télécharger le binaire ───────────────────────────────────────────────── TMP_BIN="$(mktemp)" trap 'rm -f "$TMP_BIN"' EXIT @@ -78,7 +94,7 @@ curl -fsSL -o "$TMP_BIN" "$ASSET_URL" chmod 755 "$TMP_BIN" ok "Binaire téléchargé ($(du -sh "$TMP_BIN" | cut -f1))" -# ── 4. Paramètres de configuration ──────────────────────────────────────────── +# ── 6. Paramètres de configuration ──────────────────────────────────────────── echo "" echo "--- Configuration du serveur ---" @@ -89,9 +105,9 @@ MQTT_ENABLED="${MQTT_ENABLED:-false}" ok "Serveur : $SERVER_IP:$SERVER_PORT | MQTT broker : $MQTT_HOST" -# ── 5. Installer le binaire ──────────────────────────────────────────────────── +# ── 7. Installer le binaire ──────────────────────────────────────────────────── echo "" -echo "[1/5] Installation du binaire dans /usr/local/bin/" +echo "[1/5] Installation du binaire..." # Arrêter le service si en cours (le binaire ne peut pas être écrasé à chaud) if systemctl is-active --quiet nanometrics-agent 2>/dev/null; then @@ -103,42 +119,15 @@ cp "$TMP_BIN" "$INSTALL_BIN" chmod 755 "$INSTALL_BIN" ok "Binaire installé" -# ── 6. Créer le répertoire de configuration ─────────────────────────────────── +# ── 8. Créer le répertoire de configuration ─────────────────────────────────── echo "[2/5] Création de $CONFIG_DIR" mkdir -p "$CONFIG_DIR" chmod 755 "$CONFIG_DIR" ok "Répertoire créé" -# ── 7. Écrire config.toml ───────────────────────────────────────────────────── +# ── 9. Écrire config.toml ───────────────────────────────────────────────────── echo "[3/5] Écriture de $CONFIG_FILE" -OVERWRITE_CONFIG="${OVERWRITE_CONFIG:-}" -WRITE_CONFIG=true - -if [ -f "$CONFIG_FILE" ]; then - if [ "${OVERWRITE_CONFIG}" = "true" ]; then - warn "OVERWRITE_CONFIG=true — config.toml sera écrasé" - WRITE_CONFIG=true - elif [ -t 0 ]; then - # Mode interactif (bash local, pas curl | bash) - echo "" - warn "Un config.toml existe déjà :" - echo " $CONFIG_FILE" - printf " Écraser la configuration existante ? [o/N] : " - read -r _ANS - if [[ "$_ANS" =~ ^[Oo]$ ]]; then - WRITE_CONFIG=true - else - ok "config.toml conservé" - WRITE_CONFIG=false - fi - else - warn "config.toml déjà présent — conservé (relancez avec OVERWRITE_CONFIG=true pour écraser)" - WRITE_CONFIG=false - fi -fi - -if [ "$WRITE_CONFIG" = "true" ]; then cat > "$CONFIG_FILE" << TOML [server] ip = "$SERVER_IP" @@ -184,15 +173,14 @@ mqtt = false udp = true mqtt = false TOML - chmod 644 "$CONFIG_FILE" - ok "config.toml créé" -fi +chmod 644 "$CONFIG_FILE" +ok "config.toml écrit" -# S'assurer que le fichier est toujours lisible (cas d'un config existant en 640) +# S'assurer que le répertoire est accessible chmod 644 "$CONFIG_FILE" 2>/dev/null || true chmod 755 "$CONFIG_DIR" -# ── 8. Installer le fichier service ────────────────────────────────────────── +# ── 10. Installer le fichier service ───────────────────────────────────────── echo "[4/5] Installation du service systemd" curl -fsSL -o "$SERVICE_FILE" "$SERVICE_URL" chmod 644 "$SERVICE_FILE" @@ -200,7 +188,7 @@ systemctl daemon-reload systemctl enable nanometrics-agent ok "Service installé et activé" -# ── 9. Démarrer le service ──────────────────────────────────────────────────── +# ── 11. Démarrer le service ─────────────────────────────────────────────────── echo "[5/5] Démarrage du service" systemctl restart nanometrics-agent sleep 2 diff --git a/server/main.go b/server/main.go index b0f9acc..db252f3 100644 --- a/server/main.go +++ b/server/main.go @@ -20,6 +20,8 @@ import ( ws "github.com/user/nanometrics/server/websocket" ) +const serverVersion = "0.1.0" + func main() { cfg := config.Load() @@ -92,6 +94,7 @@ func main() { if err != nil { continue } + stats.Version = serverVersion hub.Broadcast(models.WSMessage{Type: "server_stats", Data: stats}) } }() diff --git a/server/models/models.go b/server/models/models.go index f8d4c39..e635f5c 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -127,4 +127,5 @@ type ServerStats struct { CPUPercent float64 `json:"cpu_percent"` MemUsed int64 `json:"mem_used"` MemTotal int64 `json:"mem_total"` + Version string `json:"version"` }