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 = `
-
-
-
+
+
+
+
${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"`
}