Files
nano_metrics/docs/superpowers/plans/2026-05-22-nanometrics-dashboard.md
T
Gilles Soulier a0f47bf966 feat: add plans, design system, CONSIGNE and brainstorm assets
Ajoute les trois plans d'implémentation (agent Rust, serveur Go, dashboard),
les consignes de design, les fichiers de brainstorming et le .gitignore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:13:53 +02:00

70 KiB
Raw Blame History

Nanometrics Dashboard — Plan d'implémentation

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Créer le dashboard web Nanometrics en HTML/CSS/JS vanilla, servi par Nginx, avec grille d'agents temps réel via WebSocket, popups détail/config/SMART, et design system Gruvbox seventies.

Architecture: Single Page Application sans build step. WebSocket vers le serveur Go pour les mises à jour temps réel. Appels REST pour la config et l'historique. Design system chargé depuis design_system/tokens/tokens.css. Assets (polices, Font Awesome) versionnés localement.

Tech Stack: HTML5, CSS3, vanilla JS (ES2022), Font Awesome 6 (local), polices locales (Inter, JetBrains Mono, Share Tech Mono)


Structure des fichiers

dashboard/
├── index.html           — point d'entrée unique
├── js/
│   ├── app.js           — init, WebSocket, boucle principale
│   ├── grid.js          — rendu grille + tuiles
│   ├── popups.js        — popups détail, config agent, SMART, config serveur
│   ├── api.js           — appels REST (fetch)
│   └── charts.js        — courbes SVG sparklines
├── css/
│   └── app.css          — styles app (import tokens.css)
├── fonts/               — Inter, JetBrains Mono, Share Tech Mono (woff2)
└── vendor/
    └── fontawesome/
        ├── css/all.min.css
        └── webfonts/

Task 1 : Structure HTML + design system

Files:

  • Create: dashboard/index.html

  • Create: dashboard/css/app.css

  • Télécharger Font Awesome 6 en local

mkdir -p dashboard/vendor/fontawesome
cd dashboard/vendor/fontawesome
curl -L https://use.fontawesome.com/releases/v6.5.1/fontawesome-free-6.5.1-web.zip -o fa.zip
unzip fa.zip
cp -r fontawesome-free-6.5.1-web/css .
cp -r fontawesome-free-6.5.1-web/webfonts .
rm -rf fontawesome-free-6.5.1-web fa.zip
  • Télécharger les polices Google localement
mkdir -p dashboard/fonts
# Inter variable
curl -o dashboard/fonts/inter.woff2 \
  "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hiA.woff2"
# JetBrains Mono
curl -o dashboard/fonts/jetbrains-mono.woff2 \
  "https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjOVmNeaAh.woff2"
# Share Tech Mono
curl -o dashboard/fonts/share-tech-mono.woff2 \
  "https://fonts.gstatic.com/s/sharetechmono/v15/J7aHnp1uDWRBEqV98dVQztYldFc7pAsEIc3Xew.woff2"
  • Créer dashboard/css/app.css
/* Polices locales */
@font-face {
  font-family: 'Inter';
  src: url('../fonts/inter.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}
@font-face {
  font-family: 'JetBrains Mono';
  src: url('../fonts/jetbrains-mono.woff2') format('woff2');
  font-weight: 400 700;
  font-display: swap;
}
@font-face {
  font-family: 'Share Tech Mono';
  src: url('../fonts/share-tech-mono.woff2') format('woff2');
  font-weight: 400;
  font-display: swap;
}

/* Tokens design system — copié depuis design_system/tokens/tokens.css */
/* À remplacer par @import '../design_system/tokens/tokens.css' en prod */
:root[data-theme="dark"] {
  --accent:#fe8019;--accent-soft:#d65d0e;--accent-glow:rgba(254,128,25,.28);--accent-tint:rgba(254,128,25,.1);
  --bg-0:#1d1813;--bg-1:#2a231d;--bg-2:#32291f;--bg-3:#3c332a;--bg-4:#4a3f33;--bg-5:#57493c;
  --ink-1:#f2e5c7;--ink-2:#d5c4a1;--ink-3:#a89984;--ink-4:#7c6f64;
  --ok:#4dbb26;--warn:#fabd2f;--err:#fb4934;--blue:#3db0d1;--purple:#c882c8;
  --border-1:rgba(255,255,255,.06);--border-2:rgba(255,255,255,.12);--border-3:rgba(255,255,255,.26);
  --tile-3d:0 1px 0 rgba(255,255,255,.08) inset,0 -1px 0 rgba(0,0,0,.3) inset,0 6px 20px rgba(0,0,0,.5);
  --tile-press:inset 0 2px 8px rgba(0,0,0,.5),inset 0 1px 3px rgba(0,0,0,.4);
  --hover-glow:0 0 0 1px var(--accent-soft),0 0 24px var(--accent-glow),0 6px 20px rgba(0,0,0,.5);
  --font-ui:'Inter',system-ui,sans-serif;
  --font-mono:'JetBrains Mono',monospace;
  --font-terminal:'Share Tech Mono',monospace;
}
:root[data-theme="light"] {
  --accent:#af3a03;--accent-soft:#d65d0e;--accent-glow:rgba(175,58,3,.18);--accent-tint:rgba(175,58,3,.08);
  --bg-0:#d5c4a1;--bg-1:#ebdbb2;--bg-2:#d5c4a1;--bg-3:#bdae93;--bg-4:#a89984;--bg-5:#928374;
  --ink-1:#3c3836;--ink-2:#504945;--ink-3:#665c54;--ink-4:#7c6f64;
  --ok:#3c911c;--warn:#b57614;--err:#9d0006;--blue:#2d82a3;--purple:#8c468c;
  --border-1:rgba(0,0,0,.08);--border-2:rgba(0,0,0,.15);--border-3:rgba(0,0,0,.3);
  --tile-3d:0 1px 0 rgba(255,255,255,.55) inset,0 -1px 0 rgba(0,0,0,.08) inset,0 4px 14px rgba(0,0,0,.13);
  --tile-press:inset 0 2px 6px rgba(0,0,0,.2);
  --hover-glow:0 0 0 1px var(--accent-soft),0 0 18px var(--accent-glow),0 4px 14px rgba(0,0,0,.13);
  --font-ui:'Inter',system-ui,sans-serif;
  --font-mono:'JetBrains Mono',monospace;
  --font-terminal:'Share Tech Mono',monospace;
}

*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-size:13px;
     height:100vh;display:flex;flex-direction:column;overflow:hidden;transition:background .2s,color .2s}

/* TOOLTIP global position:fixed */
#tooltip{position:fixed;z-index:9999;pointer-events:none;background:var(--bg-0);color:var(--ink-1);
         border:1px solid var(--border-3);border-radius:5px;padding:4px 9px;font-size:11px;
         font-family:var(--font-ui);white-space:nowrap;opacity:0;transition:opacity .12s;
         box-shadow:0 4px 12px rgba(0,0,0,.4)}
#tooltip.show{opacity:1}

/* HEADER */
.header{background:var(--bg-2);border-bottom:1px solid var(--border-2);padding:0 20px;
        height:48px;display:flex;align-items:center;gap:12px;flex-shrink:0}
.logo{display:flex;align-items:center;gap:8px}
.logo-led{width:9px;height:9px;border-radius:50%;background:var(--accent);
          box-shadow:0 0 8px var(--accent-glow);animation:blink 2s infinite}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.4}}
.logo-name{font-weight:700;font-size:14px;letter-spacing:.05em;font-family:var(--font-terminal)}
.logo-ver{font-size:10px;color:var(--ink-4);font-family:var(--font-terminal)}
.h-sep{width:1px;height:24px;background:var(--border-2)}
.h-spacer{flex:1}
.h-stats{display:flex;gap:14px}
.h-stat{display:flex;align-items:center;gap:5px}
.h-stat .lbl{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em}
.h-stat .val{font-family:var(--font-mono);font-weight:700;font-size:13px}
.c-ok{color:var(--ok)}.c-warn{color:var(--warn)}.c-err{color:var(--err)}.c-n{color:var(--ink-2)}
.hbtn{width:34px;height:34px;border-radius:8px;border:1px solid var(--border-2);background:var(--bg-3);
      color:var(--ink-2);font-size:14px;display:flex;align-items:center;justify-content:center;
      cursor:pointer;user-select:none;transition:background .12s,color .12s,transform .08s,box-shadow .08s}
.hbtn:hover{background:var(--bg-4);color:var(--accent)}
.hbtn:active{transform:translateY(1px) scale(.96);box-shadow:var(--tile-press)}
.hbtn.active-btn{background:var(--accent);color:var(--bg-0);border-color:var(--accent-soft)}

/* GRILLE */
.main{flex:1;padding:14px 16px;overflow-y:auto}
.agents-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--tile-min,220px),1fr));gap:10px}
.tile{background:var(--bg-3);border-radius:10px;padding:12px 14px;border:1px solid var(--border-1);
      box-shadow:var(--tile-3d);cursor:pointer;display:flex;flex-direction:column;gap:9px;
      transition:box-shadow .15s,transform .08s,border-color .15s}
.tile:hover{box-shadow:var(--hover-glow);border-color:var(--accent-soft)}
.tile:active{transform:translateY(2px) scale(.99);box-shadow:var(--tile-press)}
.tile.t-warn{border-color:rgba(250,189,47,.3)}
.tile.t-warn:hover{border-color:var(--warn);box-shadow:0 0 0 1px var(--warn),0 0 22px rgba(250,189,47,.22),0 6px 20px rgba(0,0,0,.5)}
.tile.t-err{border-color:rgba(251,73,52,.35)}
.tile.t-err:hover{border-color:var(--err);box-shadow:0 0 0 1px var(--err),0 0 22px rgba(251,73,52,.25),0 6px 20px rgba(0,0,0,.5)}
.tile.t-off{opacity:.5;cursor:default}.tile.t-off:hover,.tile.t-off:active{box-shadow:var(--tile-3d);border-color:var(--border-1);transform:none}
.tile-head{display:flex;align-items:center;gap:8px;user-select:none}
.t-icon{width:28px;height:28px;border-radius:7px;background:var(--bg-4);display:flex;align-items:center;
        justify-content:center;color:var(--accent);font-size:13px;flex-shrink:0;overflow:hidden}
.t-icon img{width:100%;height:100%;object-fit:cover}
.t-names{flex:1;min-width:0}
.t-host{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.t-ip{font-family:var(--font-mono);font-size:10px;color:var(--ink-4)}
.t-led{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.s-ok{background:var(--ok);box-shadow:0 0 6px var(--ok)}
.s-warn{background:var(--warn);box-shadow:0 0 6px var(--warn);animation:blink 1.5s infinite}
.s-err{background:var(--err);box-shadow:0 0 8px var(--err);animation:blink 1s infinite}
.s-off{background:var(--ink-4)}
.tile-gauges{display:flex;flex-direction:column;gap:5px}
.g-row{display:flex;align-items:center;gap:7px}
.g-ico{width:18px;height:18px;display:flex;align-items:center;justify-content:center;
       font-size:11px;color:var(--ink-3);flex-shrink:0;cursor:help}
.g-bar{flex:1;height:5px;border-radius:3px;background:var(--bg-1);overflow:hidden}
.g-fill{height:100%;border-radius:3px;background:var(--ok);transition:width .3s}
.g-fill.w{background:var(--warn)}.g-fill.e{background:var(--err)}
.g-val{font-family:var(--font-mono);font-size:11px;color:var(--ink-2);width:34px;text-align:right}
.tile-foot{font-family:var(--font-terminal);font-size:10px;color:var(--ink-4);
           display:flex;align-items:center;gap:5px;user-select:none}

/* FOOTER */
.footer{background:var(--bg-0);border-top:1px solid var(--border-2);height:26px;
        display:flex;align-items:center;font-family:var(--font-terminal);font-size:11px;
        color:var(--ink-4);flex-shrink:0}
.f-mode{background:var(--accent);color:var(--bg-0);padding:0 12px;height:100%;
        display:flex;align-items:center;font-weight:700;letter-spacing:.04em;user-select:none}
.f-cell{padding:0 12px;border-right:1px solid var(--border-1);display:flex;align-items:center;gap:5px;height:100%}
.f-val{font-family:var(--font-mono);color:var(--ink-2)}.f-val.w{color:var(--warn)}
.f-minibar{width:36px;height:4px;border-radius:2px;background:var(--bg-3);overflow:hidden}
.f-minifill{height:100%;border-radius:2px;background:var(--ok)}.f-minifill.w{background:var(--warn)}
.f-spacer{flex:1}.f-right{padding:0 12px;display:flex;align-items:center;gap:6px;color:var(--ink-3)}
.f-time{font-family:var(--font-mono);color:var(--ink-2)}

/* OVERLAY + POPUP */
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:100;display:flex;
         align-items:center;justify-content:center;backdrop-filter:blur(2px)}
.popup{background:var(--bg-2);border:1px solid var(--border-3);border-radius:12px;
       box-shadow:0 24px 64px rgba(0,0,0,.7);display:flex;flex-direction:column;overflow:hidden}
.pop-close{width:28px;height:28px;border-radius:6px;background:var(--bg-5);color:var(--ink-3);
           display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:12px;
           border:1px solid var(--border-1);user-select:none;transition:background .12s,color .12s,transform .08s}
.pop-close:hover{background:var(--err);color:#fff}
.pop-close:active{transform:translateY(1px) scale(.93)}
.btn{padding:6px 14px;border-radius:8px;border:1px solid var(--border-2);background:var(--bg-4);
     color:var(--ink-2);font-size:12px;font-family:var(--font-ui);cursor:pointer;
     display:flex;align-items:center;gap:6px;user-select:none;transition:background .1s,transform .08s}
.btn:hover{background:var(--bg-5)}.btn:active{transform:translateY(1px) scale(.97)}
.btn.primary{background:var(--accent);color:var(--bg-0);border-color:var(--accent-soft);font-weight:600}
.btn.primary:hover{background:var(--accent-soft)}

/* Popup détail agent */
#popup-detail{width:560px;max-width:96vw;max-height:92vh;resize:both;overflow:hidden;
              min-width:400px;min-height:320px}
#popup-detail .pop-body{overflow-y:auto;flex:1}
.pop-head{background:var(--bg-3);padding:14px 18px;border-bottom:1px solid var(--border-2);
          display:flex;align-items:center;gap:12px;flex-shrink:0}
.agent-icon-wrap{position:relative;width:44px;height:44px;border-radius:10px;flex-shrink:0;
                 cursor:pointer;overflow:hidden;background:var(--bg-4);display:flex;
                 align-items:center;justify-content:center;color:var(--accent);font-size:18px;
                 border:2px solid var(--border-2);transition:border-color .15s}
.agent-icon-wrap:hover{border-color:var(--accent)}
.agent-icon-overlay{position:absolute;inset:0;background:rgba(0,0,0,.6);display:flex;
                    flex-direction:column;align-items:center;justify-content:center;
                    gap:2px;opacity:0;transition:opacity .15s;font-size:10px;color:#fff}
.agent-icon-wrap:hover .agent-icon-overlay{opacity:1}
.pop-host{font-weight:700;font-size:15px}.pop-ip{font-family:var(--font-mono);font-size:11px;color:var(--ink-4)}
.pop-led{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 8px var(--ok);flex-shrink:0}
.pop-body{padding:16px 18px;display:flex;flex-direction:column;gap:14px}
.sec-title{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.08em;margin-bottom:8px}
.kpi-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:7px}
.kpi{background:var(--bg-3);border-radius:8px;padding:10px 12px;border:1px solid var(--border-1);box-shadow:var(--tile-3d)}
.kpi-lbl{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em}
.kpi-val{font-family:var(--font-mono);font-size:20px;font-weight:700;line-height:1.1;margin-top:2px}
.kpi-val .u{font-size:10px;color:var(--ink-3);font-weight:400}
.kpi-sub{font-size:10px;color:var(--ink-4);font-family:var(--font-mono);margin-top:2px}
.charts-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.chart-card{background:var(--bg-3);border-radius:8px;padding:10px 12px;border:1px solid var(--border-1)}
.chart-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.chart-label{display:flex;align-items:center;gap:6px;font-size:10px;font-family:var(--font-terminal);color:var(--ink-3)}
.chart-cur{font-family:var(--font-mono);font-size:16px;font-weight:700}
.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)}
.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)}
.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}
.meta-val{font-family:var(--font-mono);font-size:12px;color:var(--ink-2);margin-top:2px}
.proto-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 7px;border-radius:999px;
             font-size:10px;font-family:var(--font-terminal);font-weight:600}
.proto-badge.udp{background:rgba(61,176,209,.15);color:var(--blue);border:1px solid rgba(61,176,209,.3)}
.proto-badge.mqtt{background:rgba(200,130,200,.15);color:var(--purple);border:1px solid rgba(200,130,200,.3)}
.pop-foot{padding:10px 18px;border-top:1px solid var(--border-2);background:var(--bg-3);
          display:flex;align-items:center;gap:8px;flex-shrink:0}
.pop-uptime{font-family:var(--font-terminal);font-size:11px;color:var(--ink-4);flex:1}
.btn-agent-cfg{width:34px;height:34px;border-radius:8px;border:1px solid var(--border-2);
               background:var(--bg-4);color:var(--ink-3);font-size:15px;display:flex;
               align-items:center;justify-content:center;cursor:pointer;user-select:none;
               transition:background .12s,color .12s,transform .08s}
.btn-agent-cfg:hover{background:var(--bg-5);color:var(--accent)}

/* Métriques tableau 3 colonnes */
.metrics-table{display:flex;flex-direction:column;border:1px solid var(--border-2);border-radius:8px;overflow:hidden}
.metrics-header{display:grid;grid-template-columns:1fr 56px 56px;background:var(--bg-4);
                padding:8px 12px;gap:4px;align-items:center}
.mh-label{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em}
.mh-proto{font-size:9px;font-family:var(--font-terminal);font-weight:700;text-align:center;
          display:flex;flex-direction:column;align-items:center;gap:2px}
.mh-proto.udp{color:var(--blue)}.mh-proto.mqtt{color:var(--purple)}
.metric-row{display:grid;grid-template-columns:1fr 56px 56px;padding:8px 12px;gap:4px;
            align-items:center;background:var(--bg-3);border-top:1px solid var(--border-1);transition:background .1s}
.metric-row:hover{background:var(--bg-4)}
.metric-cell{display:flex;align-items:center;gap:8px}
.metric-ico{font-size:13px;color:var(--ink-3);width:16px;text-align:center}
.metric-name{font-size:12px;color:var(--ink-2);font-family:var(--font-terminal)}
.metric-chk{display:flex;justify-content:center}
.cbox{width:20px;height:20px;border-radius:5px;border:2px solid var(--border-2);background:var(--bg-1);
      display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:11px;
      color:transparent;transition:background .12s,border-color .12s,color .12s,transform .08s;
      user-select:none;flex-shrink:0}
.cbox:hover{border-color:var(--border-3);transform:scale(1.08)}
.cbox.udp-on{background:rgba(61,176,209,.18);border-color:var(--blue);color:var(--blue)}
.cbox.mqtt-on{background:rgba(200,130,200,.18);border-color:var(--purple);color:var(--purple)}

/* Config serveur */
.scfg-body{padding:18px;display:flex;flex-direction:column;gap:14px;overflow-y:auto}
.scfg-sec-title{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);
                letter-spacing:.08em;padding-bottom:6px;border-bottom:1px solid var(--border-1)}
.scfg-row{display:flex;align-items:center;gap:10px}
.scfg-row>label{font-size:12px;color:var(--ink-3);width:110px;flex-shrink:0;font-family:var(--font-terminal)}
.scfg-slider{flex:1;accent-color:var(--accent)}
.scfg-val{font-family:var(--font-mono);font-size:12px;color:var(--ink-2);width:48px;text-align:right}
.scfg-select{flex:1;background:var(--bg-3);border:1px solid var(--border-2);border-radius:6px;
             color:var(--ink-1);padding:6px 10px;font-size:12px}
.scfg-toggle-row{display:flex;align-items:center;justify-content:space-between;
                 padding:8px 10px;background:var(--bg-3);border-radius:7px;border:1px solid var(--border-1)}
.toggle{position:relative;width:34px;height:18px;flex-shrink:0}
.toggle input{opacity:0;width:0;height:0}
.toggle-slider{position:absolute;inset:0;border-radius:9px;background:var(--bg-4);
               border:1px solid var(--border-2);cursor:pointer;transition:background .2s}
.toggle-slider::before{content:'';position:absolute;width:12px;height:12px;border-radius:50%;
                       background:var(--ink-4);top:2px;left:2px;transition:transform .2s,background .2s}
.toggle input:checked+.toggle-slider{background:rgba(254,128,25,.3);border-color:var(--accent)}
.toggle input:checked+.toggle-slider::before{transform:translateX(16px);background:var(--accent)}

/* SMART */
.smart-verdict{display:flex;align-items:center;gap:14px;background:rgba(77,187,38,.1);
               border:1px solid rgba(77,187,38,.3);border-radius:10px;padding:14px 18px}
.si-val{font-family:var(--font-mono);font-size:18px;font-weight:700}
.si-val .u{font-size:11px;color:var(--ink-3);font-weight:400}
.si-desc{font-size:11px;color:var(--ink-3);margin-top:4px;line-height:1.4}
.attr-row{display:flex;align-items:center;gap:10px;padding:6px 10px;
          border-radius:6px;background:var(--bg-3);border:1px solid var(--border-1)}
.attr-ok{color:var(--ok)}

::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:var(--bg-1)}
::-webkit-scrollbar-thumb{background:var(--bg-4);border-radius:3px}
  • Créer dashboard/index.html
<!DOCTYPE html>
<html data-theme="dark" lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nanometrics</title>
<link rel="stylesheet" href="vendor/fontawesome/css/all.min.css">
<link rel="stylesheet" href="css/app.css">
</head>
<body>
<div id="tooltip"></div>

<!-- HEADER -->
<div class="header">
  <div class="logo">
    <div class="logo-led"></div>
    <span class="logo-name">NANOMETRICS</span>
    <span class="logo-ver">v1.0</span>
  </div>
  <div class="h-sep"></div>
  <div class="h-stats" id="h-stats">
    <div class="h-stat"><span class="lbl">AGENTS</span><span class="val c-n" id="stat-total">0</span></div>
    <div class="h-stat"><span class="lbl">OK</span><span class="val c-ok" id="stat-ok">0</span></div>
    <div class="h-stat"><span class="lbl">WARN</span><span class="val c-warn" id="stat-warn">0</span></div>
    <div class="h-stat"><span class="lbl">ERR</span><span class="val c-err" id="stat-err">0</span></div>
  </div>
  <div class="h-spacer"></div>
  <div class="hbtn" id="btn-theme" onclick="App.toggleTheme()" data-tip="Thème clair / sombre">
    <i class="fa-solid fa-moon" id="theme-icon"></i>
  </div>
  <div class="hbtn" id="btn-srvcfg" onclick="Popups.showSrvCfg()" data-tip="Configuration serveur / interface">
    <i class="fa-solid fa-sliders"></i>
  </div>
</div>

<!-- GRILLE -->
<div class="main"><div class="agents-grid" id="agents-grid"></div></div>

<!-- FOOTER -->
<div class="footer">
  <div class="f-mode">LIVE</div>
  <div class="f-cell"><i class="fa-solid fa-server" style="font-size:10px"></i><span>SERVEUR</span></div>
  <div class="f-cell">
    <i class="fa-solid fa-microchip" style="font-size:10px"></i>
    <span class="f-val" id="srv-cpu"></span>
    <div class="f-minibar"><div class="f-minifill" id="srv-cpu-bar"></div></div>
  </div>
  <div class="f-cell">
    <i class="fa-solid fa-memory" style="font-size:10px"></i>
    <span class="f-val" id="srv-mem"></span>
    <div class="f-minibar"><div class="f-minifill" id="srv-mem-bar"></div></div>
  </div>
  <div class="f-spacer"></div>
  <div class="f-right">
    <i class="fa-solid fa-rotate"></i>
    <span>Actualisation : <span class="f-time" id="f-time"></span></span>
  </div>
</div>

<!-- POPUP DÉTAIL AGENT -->
<div class="overlay" id="overlay-detail" style="display:none" onclick="if(event.target===this)Popups.hideDetail()">
  <div class="popup" id="popup-detail" onclick="event.stopPropagation()">
    <div class="pop-head">
      <div class="agent-icon-wrap" id="pop-icon-wrap" data-tip="Changer l'icône">
        <span id="pop-icon-fa"><i class="fa-solid fa-server"></i></span>
        <img id="pop-icon-img" src="" alt="" style="display:none">
        <div class="agent-icon-overlay"><i class="fa-solid fa-camera"></i><span>Changer</span></div>
      </div>
      <input type="file" id="icon-upload" accept=".svg,.jpg,.jpeg,.png,.webp" style="display:none">
      <div style="flex:1">
        <div class="pop-host" id="pop-host"></div>
        <div class="pop-ip" id="pop-ip"></div>
        <div style="font-size:10px;color:var(--ink-4);font-family:var(--font-terminal);margin-top:2px">
          Cliquer sur l'icône pour personnaliser · SVG JPG PNG WEBP · max 128×128 px
        </div>
      </div>
      <div class="pop-led" id="pop-led"></div>
      <div class="pop-close" onclick="Popups.hideDetail()" data-tip="Fermer"><i class="fa-solid fa-xmark"></i></div>
    </div>
    <div class="pop-body" id="pop-body"></div>
    <div class="pop-foot">
      <span class="pop-uptime" id="pop-uptime"></span>
      <span style="font-family:var(--font-terminal);font-size:9px;color:var(--ink-4);display:flex;align-items:center;gap:4px"
            data-tip="Taille sauvegardée sur le serveur">
        <i class="fa-solid fa-up-right-and-down-left-from-center"></i>Redimensionnable
      </span>
      <div class="btn-agent-cfg" onclick="Popups.showAgentCfg()" data-tip="Configurer l'agent">
        <i class="fa-solid fa-gears"></i>
      </div>
    </div>
  </div>
</div>

<!-- POPUP CONFIG AGENT -->
<div class="overlay" id="overlay-agentcfg" style="display:none;z-index:200" onclick="if(event.target===this)this.style.display='none'">
  <div class="popup" id="popup-agentcfg" style="width:520px;max-width:96vw;max-height:90vh" onclick="event.stopPropagation()">
    <div style="background:var(--bg-3);padding:14px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px">
      <div style="width:32px;height:32px;border-radius:8px;background:var(--bg-4);display:flex;align-items:center;justify-content:center;color:var(--accent);font-size:15px"><i class="fa-solid fa-gears"></i></div>
      <div style="flex:1"><div style="font-weight:700;font-size:14px">Configuration de l'agent</div>
        <div style="font-size:11px;color:var(--ink-4);font-family:var(--font-terminal)" id="agentcfg-sub"></div></div>
      <div class="pop-close" onclick="this.closest('.overlay').style.display='none'" data-tip="Fermer"><i class="fa-solid fa-xmark"></i></div>
    </div>
    <div style="padding:18px;display:flex;flex-direction:column;gap:16px;overflow-y:auto;max-height:62vh" id="agentcfg-body"></div>
    <div style="padding:12px 18px;border-top:1px solid var(--border-2);background:var(--bg-3);display:flex;align-items:center;gap:8px">
      <div style="flex:1;display:flex;align-items:center;gap:6px;font-family:var(--font-terminal);font-size:11px;color:var(--ink-4)">
        <div style="width:6px;height:6px;border-radius:50%;background:var(--ok)"></div>
        <span>Config synchronisée avec l'agent</span>
      </div>
      <button class="btn" onclick="this.closest('.overlay').style.display='none'">Annuler</button>
      <button class="btn primary" onclick="Popups.sendAgentConfig()"><i class="fa-solid fa-paper-plane"></i> Envoyer à l'agent</button>
    </div>
  </div>
</div>

<!-- POPUP CONFIG SERVEUR -->
<div class="overlay" id="overlay-srvcfg" style="display:none" onclick="if(event.target===this)Popups.hideSrvCfg()">
  <div class="popup" id="popup-srvcfg" style="width:400px;max-width:96vw;max-height:88vh" onclick="event.stopPropagation()">
    <div style="background:var(--bg-3);padding:14px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px">
      <div style="width:32px;height:32px;border-radius:8px;background:var(--accent);display:flex;align-items:center;justify-content:center;color:var(--bg-0);font-size:15px"><i class="fa-solid fa-sliders"></i></div>
      <span style="flex:1;font-weight:700;font-size:14px">Configuration interface</span>
      <div class="pop-close" onclick="Popups.hideSrvCfg()" data-tip="Fermer"><i class="fa-solid fa-xmark"></i></div>
    </div>
    <div class="scfg-body" id="srvcfg-body"></div>
    <div style="padding:12px 18px;border-top:1px solid var(--border-2);background:var(--bg-3);display:flex;gap:8px;justify-content:flex-end">
      <button class="btn" onclick="Popups.hideSrvCfg()">Annuler</button>
      <button class="btn primary" onclick="Popups.saveSrvCfg()"><i class="fa-solid fa-floppy-disk"></i> Sauvegarder</button>
    </div>
  </div>
</div>

<!-- POPUP SMART -->
<div class="overlay" id="overlay-smart" style="display:none;z-index:300" onclick="if(event.target===this)this.style.display='none'">
  <div class="popup" id="popup-smart" style="width:500px;max-width:96vw;max-height:88vh" onclick="event.stopPropagation()">
    <div style="background:var(--bg-3);padding:14px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px">
      <div style="width:32px;height:32px;border-radius:8px;background:var(--bg-4);display:flex;align-items:center;justify-content:center;color:var(--ok);font-size:15px"><i class="fa-solid fa-shield-heart"></i></div>
      <div style="flex:1"><div style="font-weight:700;font-size:14px">Santé du disque dur</div>
        <div style="font-size:11px;color:var(--ink-4);font-family:var(--font-terminal)" id="smart-sub"></div></div>
      <div class="pop-close" onclick="this.closest('.overlay').style.display='none'" data-tip="Fermer"><i class="fa-solid fa-xmark"></i></div>
    </div>
    <div style="padding:18px;display:flex;flex-direction:column;gap:16px;overflow-y:auto;max-height:70vh" id="smart-body"></div>
    <div style="padding:12px 18px;border-top:1px solid var(--border-2);background:var(--bg-3);display:flex;align-items:center;gap:8px">
      <span style="flex:1;font-size:10px;color:var(--ink-4);font-family:var(--font-terminal)">
        <i class="fa-solid fa-circle-info" style="margin-right:4px"></i>Données via smartctl
      </span>
      <button class="btn primary" onclick="this.closest('.overlay').style.display='none'"><i class="fa-solid fa-xmark"></i> Fermer</button>
    </div>
  </div>
</div>

<script src="js/api.js"></script>
<script src="js/charts.js"></script>
<script src="js/grid.js"></script>
<script src="js/popups.js"></script>
<script src="js/app.js"></script>
</body>
</html>
  • Commit
rtk git add dashboard/index.html dashboard/css/
rtk git commit -m "feat(dashboard): structure HTML + CSS complet"

Task 2 : API REST client

Files:

  • Create: dashboard/js/api.js

  • Créer dashboard/js/api.js

const API = (() => {
    const BASE = '';  // même origine, proxy Nginx vers le serveur Go

    async function get(path) {
        const r = await fetch(BASE + path);
        if (!r.ok) throw new Error(`GET ${path}: ${r.status}`);
        return r.json();
    }

    async function put(path, body) {
        const r = await fetch(BASE + path, {
            method: 'PUT',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(body),
        });
        if (!r.ok) throw new Error(`PUT ${path}: ${r.status}`);
    }

    async function postForm(path, formData) {
        const r = await fetch(BASE + path, { method: 'POST', body: formData });
        if (!r.ok) throw new Error(`POST ${path}: ${r.status}`);
    }

    return {
        getAgents:        () => get('/api/agents'),
        getAgentHistory:  (id, from, to) => get(`/api/agents/${id}/history?from=${from}&to=${to}`),
        getAgentConfig:   (id) => get(`/api/agents/${id}/config`),
        putAgentConfig:   (id, cfg) => put(`/api/agents/${id}/config`, cfg),
        getServerConfig:  () => get('/api/config'),
        putServerConfig:  (cfg) => put('/api/config', cfg),
        uploadIcon:       (id, file) => {
            const fd = new FormData();
            fd.append('icon', file);
            return postForm(`/api/agents/${id}/icon`, fd);
        },
        iconUrl:          (id) => `/api/agents/${id}/icon`,
    };
})();
  • Commit
rtk git add dashboard/js/api.js
rtk git commit -m "feat(dashboard): client API REST"

Task 3 : Courbes SVG (sparklines)

Files:

  • Create: dashboard/js/charts.js

  • Créer dashboard/js/charts.js

const Charts = (() => {
    // Génère le SVG d'une courbe lissée à partir de points (0-100).
    function makeCurve(pts, stroke, fill, w, h) {
        if (!pts || pts.length < 2) return '';
        const xs = pts.map((_, i) => (i / (pts.length - 1)) * w);
        const ys = pts.map(v => h - (v / 100) * (h - 6) - 3);
        const wy = h - (70 / 100) * (h - 6) - 3;
        let d = `M${xs[0]} ${ys[0]}`;
        for (let i = 1; i < pts.length; i++) {
            const cx = (xs[i - 1] + xs[i]) / 2;
            d += ` C${cx} ${ys[i - 1]},${cx} ${ys[i]},${xs[i]} ${ys[i]}`;
        }
        const uid = Math.random().toString(36).slice(2);
        return `<defs>
            <linearGradient id="g${uid}" x1="0" y1="0" x2="0" y2="1">
                <stop offset="0%" stop-color="${fill}" stop-opacity=".4"/>
                <stop offset="100%" stop-color="${fill}" stop-opacity=".02"/>
            </linearGradient>
        </defs>
        <line x1="0" y1="${wy}" x2="${w}" y2="${wy}"
              stroke="var(--warn)" stroke-width=".8" stroke-dasharray="3,3" opacity=".5"/>
        <path d="${d} L${xs.at(-1)} ${h} L${xs[0]} ${h}Z" fill="url(#g${uid})"/>
        <path d="${d}" fill="none" stroke="${stroke}" stroke-width="1.6"
              stroke-linecap="round" stroke-linejoin="round"/>
        <circle cx="${xs.at(-1)}" cy="${ys.at(-1)}" r="2.5" fill="${stroke}"/>`;
    }

    // Convertit un tableau de points historiques {ts, cpu_percent} en valeurs 0-100.
    function historyToCpuPts(history) {
        return history.map(h => h.cpu_percent ?? 0);
    }

    function historyToMemPts(history) {
        return history.map(h => {
            if (!h.memory_total || h.memory_total === 0) return 0;
            return (h.memory_used / h.memory_total) * 100;
        });
    }

    function renderChart(svgEl, pts, color) {
        if (!svgEl) return;
        const cs = getComputedStyle(document.documentElement);
        const c = cs.getPropertyValue(color).trim() || color;
        svgEl.innerHTML = makeCurve(pts, c, c, 200, 52);
    }

    return { makeCurve, historyToCpuPts, historyToMemPts, renderChart };
})();
  • Commit
rtk git add dashboard/js/charts.js
rtk git commit -m "feat(dashboard): courbes SVG sparklines"

Task 4 : Rendu de la grille

Files:

  • Create: dashboard/js/grid.js

  • Créer dashboard/js/grid.js

const Grid = (() => {
    // agents: Map<id, {agent, metrics}>
    const _agents = new Map();

    function statusClass(agent) {
        if (agent.status === 'offline') return 't-off';
        // Calculer warn/err depuis les métriques
        const m = _agents.get(agent.id)?.metrics;
        if (!m) return '';
        const cfg = App.serverConfig;
        const errThreshold = cfg?.err_cpu ?? 85;
        const warnThreshold = cfg?.warn_cpu ?? 70;
        if ((m.cpu_percent ?? 0) >= errThreshold) return 't-err';
        if ((m.cpu_percent ?? 0) >= warnThreshold || (m.hdd_used && m.hdd_total && (m.hdd_used / m.hdd_total * 100) >= (cfg?.warn_disk ?? 75))) return 't-warn';
        return '';
    }

    function ledClass(status) {
        return { online: 's-ok', warn: 's-warn', err: 's-err', offline: 's-off' }[status] ?? 's-off';
    }

    function fmt(bytes) {
        if (!bytes) return '—';
        if (bytes < 1024) return bytes + 'o';
        if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + 'Ko';
        if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + 'Mo';
        return (bytes / 1024 ** 3).toFixed(1) + 'Go';
    }

    function fmtPct(val) {
        return val != null ? val.toFixed(0) + '%' : '—';
    }

    function gFill(pct) {
        const cfg = App.serverConfig;
        if (pct >= (cfg?.err_cpu ?? 85)) return 'e';
        if (pct >= (cfg?.warn_cpu ?? 70)) return 'w';
        return '';
    }

    function renderTile(agent, metrics) {
        const id = agent.id;
        const sc = statusClass(agent);
        const offline = agent.status === 'offline';

        const cpu = metrics?.cpu_percent ?? null;
        const memPct = (metrics?.memory_used && metrics?.memory_total)
            ? metrics.memory_used / metrics.memory_total * 100 : null;
        const diskPct = (metrics?.hdd_used && metrics?.hdd_total)
            ? metrics.hdd_used / metrics.hdd_total * 100 : null;

        const uptimeSec = metrics?.uptime;
        let uptimeStr = '';
        if (uptimeSec) {
            const d = Math.floor(uptimeSec / 86400);
            const h = Math.floor((uptimeSec % 86400) / 3600);
            uptimeStr = d > 0 ? `${d}j ${h}h` : `${h}h`;
        }

        // Icône custom ou défaut
        const iconContent = `<img src="${API.iconUrl(id)}" alt=""
            style="width:100%;height:100%;object-fit:cover;border-radius:7px"
            onerror="this.style.display='none';this.nextSibling.style.display='flex'">
            <span style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--accent)">
              <i class="fa-solid fa-server"></i></span>`;

        return `<div class="tile ${sc}" id="tile-${id}" onclick="Popups.showDetail('${id}')">
          <div class="tile-head">
            <div class="t-icon">${iconContent}</div>
            <div class="t-names">
              <div class="t-host">${agent.hostname}</div>
              <div class="t-ip">${agent.ip || '—'}</div>
            </div>
            <div class="t-led ${ledClass(agent.status)}"></div>
          </div>
          <div class="tile-gauges">
            <div class="g-row">
              <div class="g-ico" data-tip="CPU"><i class="fa-solid fa-microchip"></i></div>
              <div class="g-bar"><div class="g-fill ${offline ? '' : gFill(cpu ?? 0)}"
                style="width:${offline ? 0 : (cpu ?? 0).toFixed(0)}%"></div></div>
              <span class="g-val">${offline ? '—' : fmtPct(cpu)}</span>
            </div>
            <div class="g-row">
              <div class="g-ico" data-tip="RAM"><i class="fa-solid fa-memory"></i></div>
              <div class="g-bar"><div class="g-fill ${offline ? '' : gFill(memPct ?? 0)}"
                style="width:${offline ? 0 : (memPct ?? 0).toFixed(0)}%"></div></div>
              <span class="g-val">${offline ? '—' : fmtPct(memPct)}</span>
            </div>
            <div class="g-row">
              <div class="g-ico" data-tip="Disque"><i class="fa-solid fa-hard-drive"></i></div>
              <div class="g-bar"><div class="g-fill ${offline ? '' : (diskPct >= (App.serverConfig?.warn_disk ?? 75) ? 'w' : '')}"
                style="width:${offline ? 0 : (diskPct ?? 0).toFixed(0)}%"></div></div>
              <span class="g-val">${offline ? '—' : fmtPct(diskPct)}</span>
            </div>
          </div>
          <div class="tile-foot">
            ${offline
                ? '<i class="fa-solid fa-circle-xmark" style="color:var(--err)"></i><span style="color:var(--err)">Hors ligne</span>'
                : `<i class="fa-solid fa-clock"></i><span>${uptimeStr}</span>`}
          </div>
        </div>`;
    }

    function update(agentId, metrics) {
        const entry = _agents.get(agentId);
        if (!entry) return;
        entry.metrics = metrics;
        const el = document.getElementById('tile-' + agentId);
        if (el) {
            el.outerHTML = renderTile(entry.agent, metrics);
        }
        updateStats();
    }

    function refresh(agents) {
        agents.forEach(a => {
            if (!_agents.has(a.id)) {
                _agents.set(a.id, { agent: a, metrics: null });
            } else {
                _agents.get(a.id).agent = a;
            }
        });
        const grid = document.getElementById('agents-grid');
        if (!grid) return;
        grid.innerHTML = agents.map(a => renderTile(a, _agents.get(a.id)?.metrics)).join('');
        updateStats();
    }

    function updateStats() {
        let total = 0, ok = 0, warn = 0, err = 0;
        _agents.forEach(({ agent }) => {
            total++;
            const sc = statusClass(agent);
            if (agent.status === 'offline') {}
            else if (sc === 't-err') err++;
            else if (sc === 't-warn') warn++;
            else ok++;
        });
        document.getElementById('stat-total').textContent = total;
        document.getElementById('stat-ok').textContent = ok;
        document.getElementById('stat-warn').textContent = warn;
        document.getElementById('stat-err').textContent = err;
    }

    function getAgent(id) { return _agents.get(id); }

    return { refresh, update, getAgent, fmt, fmtPct };
})();
  • Commit
rtk git add dashboard/js/grid.js
rtk git commit -m "feat(dashboard): rendu grille + tuiles dynamiques"

Task 5 : Popups

Files:

  • Create: dashboard/js/popups.js

  • Créer dashboard/js/popups.js

const Popups = (() => {
    let _currentAgentId = null;
    let _agentCfgData = null;

    // ══ POPUP DÉTAIL ══
    async function showDetail(agentId) {
        _currentAgentId = agentId;
        const entry = Grid.getAgent(agentId);
        if (!entry) return;
        const { agent, metrics } = entry;

        document.getElementById('pop-host').textContent = agent.hostname;
        document.getElementById('pop-ip').textContent = agent.ip || '—';
        const led = document.getElementById('pop-led');
        led.className = 'pop-led';
        led.style.background = agent.status === 'online' ? 'var(--ok)' : 'var(--err)';
        led.style.boxShadow = `0 0 8px ${agent.status === 'online' ? 'var(--ok)' : 'var(--err)'}`;

        // Icône
        const img = document.getElementById('pop-icon-img');
        const fa = document.getElementById('pop-icon-fa');
        img.src = API.iconUrl(agentId) + '?t=' + Date.now();
        img.style.display = 'block';
        img.onerror = () => { img.style.display = 'none'; fa.style.display = 'flex'; };
        fa.style.display = 'none';

        // Upload icône
        document.getElementById('pop-icon-wrap').onclick = () => document.getElementById('icon-upload').click();
        document.getElementById('icon-upload').onchange = async (e) => {
            const file = e.target.files[0];
            if (!file) return;
            await API.uploadIcon(agentId, file);
            img.src = API.iconUrl(agentId) + '?t=' + Date.now();
        };

        // Uptime
        const up = metrics?.uptime;
        if (up) {
            const d = Math.floor(up / 86400), h = Math.floor((up % 86400) / 3600);
            document.getElementById('pop-uptime').innerHTML =
                `<i class="fa-solid fa-clock" style="margin-right:4px"></i>En ligne depuis ${d}j ${h}h`;
        }

        // Corps du popup
        const now = Math.floor(Date.now() / 1000);
        let history = [];
        try { history = await API.getAgentHistory(agentId, now - 1800, now); } catch {}

        const cpuPts = Charts.historyToCpuPts(history);
        const memPts = Charts.historyToMemPts(history);

        const smartBtn = metrics?.smart
            ? `<div class="smart-btn ok" onclick="Popups.showSmart('${agentId}')" data-tip="Voir la santé complète du disque">
                <div class="smart-dot"></div>
                <span style="font-weight:600">SMART</span>
                <span>·</span>
                <span>${metrics.smart.passed ? 'PASSED' : 'FAILED'}</span>
                ${metrics.smart.temperature ? `<span style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3)"><i class="fa-solid fa-temperature-half"></i> ${metrics.smart.temperature}°C</span>` : ''}
                <i class="fa-solid fa-chevron-right" style="font-size:10px;color:var(--ink-4);margin-left:auto"></i>
              </div>`
            : '';

        const protos = [
            metrics?.cpu_percent != null ? `<span class="proto-badge udp"><i class="fa-solid fa-arrow-up"></i>UDP</span>` : '',
        ].filter(Boolean).join('');

        document.getElementById('pop-body').innerHTML = `
          <div>
            <div class="sec-title">MÉTRIQUES ACTUELLES</div>
            <div class="kpi-grid">
              <div class="kpi"><div class="kpi-lbl">CPU</div>
                <div class="kpi-val c-ok">${(metrics?.cpu_percent ?? 0).toFixed(0)}<span class="u">%</span></div></div>
              <div class="kpi"><div class="kpi-lbl">MÉMOIRE</div>
                <div class="kpi-val">${Grid.fmt(metrics?.memory_used)}</div>
                <div class="kpi-sub">/ ${Grid.fmt(metrics?.memory_total)}</div></div>
              <div class="kpi"><div class="kpi-lbl">DISQUE</div>
                <div class="kpi-val">${Grid.fmt(metrics?.hdd_used)}</div>
                <div class="kpi-sub">/ ${Grid.fmt(metrics?.hdd_total)}</div></div>
              <div class="kpi"><div class="kpi-lbl">UPTIME</div>
                <div class="kpi-val" style="font-size:15px">${document.getElementById('pop-uptime').textContent.replace(/.*depuis /,'')}</div></div>
            </div>
          </div>
          <div>
            <div class="sec-title">HISTORIQUE — 30 MIN</div>
            <div class="charts-grid">
              <div class="chart-card">
                <div class="chart-header">
                  <div class="chart-label" style="color:var(--accent)"><i class="fa-solid fa-microchip"></i>CPU</div>
                  <span class="chart-cur c-ok">${(metrics?.cpu_percent ?? 0).toFixed(0)}%</span>
                </div>
                <svg class="chart-svg" viewBox="0 0 200 52" preserveAspectRatio="none" id="det-cpu-chart"></svg>
                <div class="chart-axis"><span>30min</span><span>15min</span><span>now</span></div>
              </div>
              <div class="chart-card">
                <div class="chart-header">
                  <div class="chart-label" style="color:var(--blue)"><i class="fa-solid fa-memory"></i>RAM</div>
                  <span class="chart-cur" style="color:var(--blue)">${Grid.fmtPct(metrics?.memory_used && metrics?.memory_total ? metrics.memory_used / metrics.memory_total * 100 : null)}</span>
                </div>
                <svg class="chart-svg" viewBox="0 0 200 52" preserveAspectRatio="none" id="det-mem-chart"></svg>
                <div class="chart-axis"><span>30min</span><span>15min</span><span>now</span></div>
              </div>
            </div>
          </div>
          <div>
            <div class="sec-title">STOCKAGE</div>
            <div style="display:flex;flex-direction:column;gap:8px">
              <div style="display:flex;align-items:center;gap:10px">
                <div style="width:22px;text-align:center;font-size:13px;cursor:help" data-tip="Utilisé"><i class="fa-solid fa-hard-drive"></i></div>
                <div style="flex:1;height:7px;border-radius:4px;background:var(--bg-1);overflow:hidden">
                  <div style="height:100%;border-radius:4px;background:var(--ok);width:${metrics?.hdd_total ? (metrics.hdd_used/metrics.hdd_total*100).toFixed(0) : 0}%"></div></div>
                <span style="font-family:var(--font-mono);font-size:12px;color:var(--ink-2);width:90px;text-align:right">${Grid.fmt(metrics?.hdd_used)} / ${Grid.fmt(metrics?.hdd_total)}</span>
              </div>
              ${smartBtn}
            </div>
          </div>
          <div>
            <div class="sec-title">INFORMATIONS</div>
            <div class="meta-grid">
              <div class="meta"><div class="meta-lbl">HOSTNAME</div><div class="meta-val">${agent.hostname}</div></div>
              <div class="meta"><div class="meta-lbl">ADRESSE IP</div><div class="meta-val">${agent.ip || '—'}</div></div>
              <div class="meta"><div class="meta-lbl">PROTOCOLES ACTIFS</div><div style="display:flex;gap:5px;margin-top:4px">${protos || '—'}</div></div>
              <div class="meta"><div class="meta-lbl">DERNIER CONTACT</div><div class="meta-val">${new Date(agent.last_seen * 1000).toLocaleTimeString('fr-FR')}</div></div>
            </div>
          </div>`;

        // Dessiner les courbes après rendu
        requestAnimationFrame(() => {
            Charts.renderChart(document.getElementById('det-cpu-chart'), cpuPts, '--accent');
            Charts.renderChart(document.getElementById('det-mem-chart'), memPts, '--blue');
        });

        // Resize → sauvegarder sur serveur
        const pd = document.getElementById('popup-detail');
        new ResizeObserver(() => {
            API.putServerConfig({
                ...App.serverConfig,
                popup_detail_w: pd.offsetWidth,
                popup_detail_h: pd.offsetHeight,
            }).catch(() => {});
        }).observe(pd);

        document.getElementById('overlay-detail').style.display = 'flex';
    }

    function hideDetail() {
        document.getElementById('overlay-detail').style.display = 'none';
    }

    // ══ CONFIG AGENT ══
    async function showAgentCfg() {
        if (!_currentAgentId) return;
        let cfg = {};
        try { cfg = await API.getAgentConfig(_currentAgentId); } catch {}
        _agentCfgData = cfg;

        document.getElementById('agentcfg-sub').textContent =
            `${_currentAgentId} · config récupérée`;

        const metrics = ['cpu','memory','disk','smart','uptime','network','temperature'];
        const icons = {
            cpu:'fa-microchip',memory:'fa-memory',disk:'fa-hard-drive',
            smart:'fa-shield-heart',uptime:'fa-clock',network:'fa-network-wired',
            temperature:'fa-thermometer-half'
        };
        const mqttCfg = cfg.protocols?.mqtt ?? {};

        document.getElementById('agentcfg-body').innerHTML = `
          <div style="display:flex;flex-direction:column;gap:8px">
            <div style="font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.08em;padding-bottom:6px;border-bottom:1px solid var(--border-1)">MÉTRIQUES PAR PROTOCOLE</div>
            <div class="metrics-table">
              <div class="metrics-header">
                <span class="mh-label">MÉTRIQUE</span>
                <span class="mh-proto udp"><i class="fa-solid fa-arrow-up"></i> UDP</span>
                <span class="mh-proto mqtt"><i class="fa-solid fa-tower-broadcast" style="font-size:8px"></i> MQTT</span>
              </div>
              ${metrics.map(m => {
                const udpOn = cfg.metrics?.[m]?.udp ? 'udp-on' : '';
                const mqttOn = cfg.metrics?.[m]?.mqtt ? 'mqtt-on' : '';
                return `<div class="metric-row">
                  <div class="metric-cell">
                    <div class="metric-ico"><i class="fa-solid ${icons[m]}"></i></div>
                    <span class="metric-name">${m}</span>
                  </div>
                  <div class="metric-chk"><div class="cbox ${udpOn}" id="cbox-${m}-udp" onclick="Popups.toggleCbox(this,'${m}','udp')" data-tip="${m} via UDP"><i class="fa-solid fa-check"></i></div></div>
                  <div class="metric-chk"><div class="cbox ${mqttOn}" id="cbox-${m}-mqtt" onclick="Popups.toggleCbox(this,'${m}','mqtt')" data-tip="${m} via MQTT"><i class="fa-solid fa-check"></i></div></div>
                </div>`;
              }).join('')}
            </div>
          </div>
          <div style="display:flex;flex-direction:column;gap:8px">
            <div style="font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.08em;padding-bottom:6px;border-bottom:1px solid var(--border-1)">PARAMÈTRES MQTT</div>
            <div style="background:var(--bg-3);border-radius:8px;border:1px solid rgba(200,130,200,.2);padding:12px 14px;display:flex;flex-direction:column;gap:10px">
              <div style="display:flex;align-items:center;gap:10px"><label style="font-size:11px;color:var(--ink-3);font-family:var(--font-terminal);width:90px">Broker</label>
                <input id="mqtt-host" style="flex:1;background:var(--bg-1);border:1px solid var(--border-2);border-radius:6px;color:var(--ink-1);padding:6px 10px;font-size:12px;font-family:var(--font-mono)" value="${mqttCfg.host ?? '10.0.0.3'}"></div>
              <div style="display:flex;align-items:center;gap:10px"><label style="font-size:11px;color:var(--ink-3);font-family:var(--font-terminal);width:90px">Port</label>
                <input id="mqtt-port" type="number" style="width:90px;background:var(--bg-1);border:1px solid var(--border-2);border-radius:6px;color:var(--ink-1);padding:6px 10px;font-size:12px;font-family:var(--font-mono)" value="${mqttCfg.port ?? 1883}"></div>
              <div style="display:flex;align-items:center;gap:10px"><label style="font-size:11px;color:var(--ink-3);font-family:var(--font-terminal);width:90px">Topic base</label>
                <input id="mqtt-topic" style="flex:1;background:var(--bg-1);border:1px solid var(--border-2);border-radius:6px;color:var(--ink-1);padding:6px 10px;font-size:12px;font-family:var(--font-mono)" value="${mqttCfg.topic_base ?? 'nanometrics/agents'}"></div>
              <div style="border-top:1px solid var(--border-1);padding-top:8px;display:flex;flex-direction:column;gap:5px">
                ${['auto_discovery:Auto-discovery (Home Assistant):fa-satellite-dish',
                   'birth_message:Birth message:fa-arrow-right-to-bracket',
                   'last_will:Last Will message:fa-skull'].map(s => {
                    const [key, label, icon] = s.split(':');
                    return `<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0">
                      <label style="font-size:12px;color:var(--ink-2);display:flex;align-items:center;gap:7px;cursor:pointer">
                        <i class="fa-solid ${icon}" style="color:var(--purple);font-size:11px"></i>${label}
                      </label>
                      <label class="toggle">
                        <input type="checkbox" id="mqtt-${key}" ${mqttCfg[key] !== false ? 'checked' : ''}>
                        <span class="toggle-slider"></span>
                      </label>
                    </div>`;
                }).join('')}
              </div>
            </div>
          </div>
          <div style="display:flex;flex-direction:column;gap:8px">
            <div style="font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.08em;padding-bottom:6px;border-bottom:1px solid var(--border-1)">
              COMMANDES DISTANTES <span style="color:var(--ink-4);font-size:8px;margin-left:6px">— BIENTÔT</span>
            </div>
            <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px">
              ${[['fa-rotate-right','reboot'],['fa-power-off','shutdown'],['fa-display','screen off'],
                 ['fa-arrow-up-from-bracket','update'],['fa-arrow-up-right-dots','upgrade'],['fa-terminal','shell cmd']].map(
                ([icon, label]) => `<div style="display:flex;flex-direction:column;align-items:center;gap:4px;padding:10px 8px;border-radius:8px;background:var(--bg-3);border:1px solid var(--border-1);cursor:not-allowed;opacity:.4">
                  <i class="fa-solid ${icon}" style="font-size:16px;color:var(--ink-3)"></i>
                  <span style="font-size:10px;color:var(--ink-4);font-family:var(--font-terminal)">${label}</span>
                  <span style="font-size:8px;color:var(--ink-4);font-family:var(--font-terminal)">bientôt</span>
                </div>`).join('')}
            </div>
          </div>`;

        document.getElementById('overlay-agentcfg').style.display = 'flex';
    }

    function toggleCbox(el, metric, proto) {
        const isOn = el.classList.contains(proto + '-on');
        el.classList.toggle(proto + '-on', !isOn);
        el.style.color = isOn ? 'transparent' : '';
        if (!_agentCfgData.metrics) _agentCfgData.metrics = {};
        if (!_agentCfgData.metrics[metric]) _agentCfgData.metrics[metric] = {};
        _agentCfgData.metrics[metric][proto] = !isOn;
    }

    async function sendAgentConfig() {
        if (!_currentAgentId || !_agentCfgData) return;
        // Lire les champs MQTT
        if (!_agentCfgData.protocols) _agentCfgData.protocols = {};
        _agentCfgData.protocols.mqtt = {
            ..._agentCfgData.protocols.mqtt,
            host: document.getElementById('mqtt-host')?.value ?? '10.0.0.3',
            port: parseInt(document.getElementById('mqtt-port')?.value ?? '1883'),
            topic_base: document.getElementById('mqtt-topic')?.value ?? 'nanometrics/agents',
            auto_discovery: document.getElementById('mqtt-auto_discovery')?.checked ?? true,
            birth_message: document.getElementById('mqtt-birth_message')?.checked ?? true,
            last_will: document.getElementById('mqtt-last_will')?.checked ?? true,
        };
        try {
            await API.putAgentConfig(_currentAgentId, _agentCfgData);
            document.getElementById('overlay-agentcfg').style.display = 'none';
        } catch (e) {
            alert('Erreur lors de l\'envoi : ' + e.message);
        }
    }

    // ══ CONFIG SERVEUR ══
    async function showSrvCfg() {
        const cfg = App.serverConfig ?? {};
        document.getElementById('btn-srvcfg').classList.add('active-btn');
        document.getElementById('srvcfg-body').innerHTML = `
          <div style="display:flex;flex-direction:column;gap:8px">
            <div class="scfg-sec-title">AFFICHAGE DES TUILES</div>
            <div class="scfg-row"><label>Largeur min.</label>
              <input type="range" class="scfg-slider" min="160" max="420" value="${cfg.tile_min_width ?? 220}"
                oninput="this.nextElementSibling.textContent=this.value+'px'" id="s-tile-w">
              <span class="scfg-val">${cfg.tile_min_width ?? 220}px</span></div>
            <div class="scfg-row"><label>Taille du texte</label>
              <input type="range" class="scfg-slider" min="10" max="18" value="${cfg.font_size ?? 13}"
                oninput="this.nextElementSibling.textContent=this.value+'px'" id="s-font">
              <span class="scfg-val">${cfg.font_size ?? 13}px</span></div>
          </div>
          <div style="display:flex;flex-direction:column;gap:8px">
            <div class="scfg-sec-title">SEUILS D'ALERTE</div>
            <div class="scfg-row"><label>Warning CPU/RAM</label>
              <input type="range" class="scfg-slider" min="50" max="95" value="${cfg.warn_cpu ?? 70}"
                oninput="this.nextElementSibling.textContent=this.value+'%'" id="s-warn-cpu">
              <span class="scfg-val">${cfg.warn_cpu ?? 70}%</span></div>
            <div class="scfg-row"><label>Erreur CPU/RAM</label>
              <input type="range" class="scfg-slider" min="60" max="100" value="${cfg.err_cpu ?? 85}"
                oninput="this.nextElementSibling.textContent=this.value+'%'" id="s-err-cpu">
              <span class="scfg-val">${cfg.err_cpu ?? 85}%</span></div>
            <div class="scfg-row"><label>Warning Disque</label>
              <input type="range" class="scfg-slider" min="50" max="95" value="${cfg.warn_disk ?? 75}"
                oninput="this.nextElementSibling.textContent=this.value+'%'" id="s-warn-disk">
              <span class="scfg-val">${cfg.warn_disk ?? 75}%</span></div>
          </div>
          <div style="display:flex;flex-direction:column;gap:8px">
            <div class="scfg-sec-title">DONNÉES & RÉTENTION</div>
            <div class="scfg-row"><label>Historique</label>
              <select class="scfg-select" id="s-retention">
                ${[7,30,90,365].map(d => `<option value="${d}" ${(cfg.retention_days??30)==d?'selected':''}>${d} jours</option>`).join('')}
              </select></div>
            <div class="scfg-row"><label>Courbes (durée)</label>
              <select class="scfg-select" id="s-chart-dur">
                ${[[15,'15 min'],[30,'30 min'],[60,'1 heure'],[360,'6 heures']].map(([v,l]) =>
                  `<option value="${v}" ${(cfg.chart_duration_min??30)==v?'selected':''}>${l}</option>`).join('')}
              </select></div>
          </div>`;
        document.getElementById('overlay-srvcfg').style.display = 'flex';
    }

    function hideSrvCfg() {
        document.getElementById('overlay-srvcfg').style.display = 'none';
        document.getElementById('btn-srvcfg').classList.remove('active-btn');
    }

    async function saveSrvCfg() {
        const cfg = {
            ...App.serverConfig,
            tile_min_width: parseInt(document.getElementById('s-tile-w')?.value ?? 220),
            font_size: parseInt(document.getElementById('s-font')?.value ?? 13),
            warn_cpu: parseInt(document.getElementById('s-warn-cpu')?.value ?? 70),
            err_cpu: parseInt(document.getElementById('s-err-cpu')?.value ?? 85),
            warn_disk: parseInt(document.getElementById('s-warn-disk')?.value ?? 75),
            retention_days: parseInt(document.getElementById('s-retention')?.value ?? 30),
            chart_duration_min: parseInt(document.getElementById('s-chart-dur')?.value ?? 30),
        };
        await API.putServerConfig(cfg);
        App.serverConfig = cfg;
        document.documentElement.style.setProperty('--tile-min', cfg.tile_min_width + 'px');
        document.body.style.fontSize = cfg.font_size + 'px';
        hideSrvCfg();
    }

    // ══ POPUP SMART ══
    function showSmart(agentId) {
        const m = Grid.getAgent(agentId)?.metrics?.smart;
        if (!m) return;
        document.getElementById('smart-sub').textContent = 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.';

        document.getElementById('smart-body').innerHTML = `
          <div class="smart-verdict" style="${m.passed ? '' : 'background:rgba(251,73,52,.1);border-color:rgba(251,73,52,.3)'}">
            <div style="font-size:28px;color:${passColor}"><i class="fa-solid ${m.passed ? 'fa-circle-check' : 'fa-circle-xmark'}"></i></div>
            <div><div style="font-size:16px;font-weight:700;color:${passColor}">${passText}</div>
              <div style="font-size:12px;color:var(--ink-3);margin-top:3px">${passSub}</div></div>
          </div>
          <div>
            <div class="sec-title">POINTS DE CONTRÔLE</div>
            <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
              ${m.temperature != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
                <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
                  <span style="color:var(--warn);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-temperature-half"></i></span>
                  <span style="font-weight:600;font-size:12px;flex:1">Température</span>
                  <span style="font-size:10px;font-family:var(--font-terminal);font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(77,187,38,.15);color:var(--ok)">Normale</span>
                </div>
                <div class="si-val">${m.temperature}<span class="u">°C</span></div>
                <div class="si-desc">Idéal : 2050°C. Au-delà de 60°C le disque risque de s'abîmer.</div>
              </div>` : ''}
              ${m.reallocated_sectors != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
                <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
                  <span style="color:${m.reallocated_sectors > 0 ? 'var(--err)' : 'var(--ok)'};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-circle-check"></i></span>
                  <span style="font-weight:600;font-size:12px;flex:1">Secteurs défectueux</span>
                </div>
                <div class="si-val">${m.reallocated_sectors}<span class="u"> sect.</span></div>
                <div class="si-desc">S'ils apparaissent en grand nombre, une panne est imminente.</div>
              </div>` : ''}
              ${m.power_on_hours != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
                <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
                  <span style="color:var(--blue);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-clock-rotate-left"></i></span>
                  <span style="font-weight:600;font-size:12px;flex:1">Heures de fonctionnement</span>
                </div>
                <div class="si-val">${m.power_on_hours.toLocaleString('fr-FR')}<span class="u">h</span></div>
                <div class="si-desc">≈${Math.floor(m.power_on_hours/24)} jours. Un disque dure en moyenne 3 à 5 ans.</div>
              </div>` : ''}
              ${m.wear_level != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
                <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
                  <span style="color:var(--ok);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-battery-full"></i></span>
                  <span style="font-weight:600;font-size:12px;flex:1">Durée de vie SSD</span>
                </div>
                <div class="si-val">${m.wear_level}<span class="u">%</span></div>
                <div class="si-desc">100% = neuf · 0% = fin de vie recommandée.</div>
              </div>` : ''}
            </div>
          </div>`;

        document.getElementById('overlay-smart').style.display = 'flex';
    }

    return {
        showDetail, hideDetail,
        showAgentCfg, sendAgentConfig, toggleCbox,
        showSrvCfg, hideSrvCfg, saveSrvCfg,
        showSmart,
    };
})();
  • Commit
rtk git add dashboard/js/popups.js
rtk git commit -m "feat(dashboard): popups détail, config agent, SMART, config serveur"

Task 6 : App principale (WebSocket + orchestration)

Files:

  • Create: dashboard/js/app.js

  • Créer dashboard/js/app.js

const App = (() => {
    let _ws = null;
    let _reconnectDelay = 1000;
    let serverConfig = null;

    // Tooltip global position:fixed
    const tip = document.getElementById('tooltip');
    let _tt;
    document.addEventListener('mouseover', e => {
        const el = e.target.closest('[data-tip]');
        if (!el) return;
        clearTimeout(_tt);
        _tt = setTimeout(() => {
            tip.textContent = el.dataset.tip;
            tip.classList.add('show');
        }, 120);
    });
    document.addEventListener('mousemove', e => {
        if (!tip.classList.contains('show')) return;
        const w = tip.offsetWidth, h = tip.offsetHeight;
        let x = e.clientX - w / 2, y = e.clientY - h - 10;
        x = Math.max(6, Math.min(x, window.innerWidth - w - 6));
        if (y < 6) y = e.clientY + 18;
        tip.style.left = x + 'px';
        tip.style.top = y + 'px';
    });
    document.addEventListener('mouseout', e => {
        if (!e.target.closest('[data-tip]')) return;
        clearTimeout(_tt);
        tip.classList.remove('show');
    });

    function toggleTheme() {
        const h = document.documentElement;
        h.dataset.theme = h.dataset.theme === 'dark' ? 'light' : 'dark';
        document.getElementById('theme-icon').className =
            h.dataset.theme === 'dark' ? 'fa-solid fa-moon' : 'fa-solid fa-sun';
    }

    function updateClock() {
        document.getElementById('f-time').textContent =
            new Date().toLocaleTimeString('fr-FR');
    }

    function connectWS() {
        const proto = location.protocol === 'https:' ? 'wss' : 'ws';
        _ws = new WebSocket(`${proto}://${location.host}/ws`);

        _ws.onopen = () => {
            _reconnectDelay = 1000;
            document.querySelector('.logo-led').style.animation = 'blink 2s infinite';
        };

        _ws.onmessage = (event) => {
            try {
                const msg = JSON.parse(event.data);
                if (msg.type === 'metrics_update') {
                    Grid.update(msg.agent_id, msg.data);
                    updateClock();
                }
            } catch {}
        };

        _ws.onclose = () => {
            setTimeout(connectWS, _reconnectDelay);
            _reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
        };
    }

    async function init() {
        // Charger la config serveur
        try {
            serverConfig = await API.getServerConfig();
            if (serverConfig.tile_min_width) {
                document.documentElement.style.setProperty('--tile-min', serverConfig.tile_min_width + 'px');
            }
            if (serverConfig.font_size) {
                document.body.style.fontSize = serverConfig.font_size + 'px';
            }
            // Restaurer taille popup
            if (serverConfig.popup_detail_w && serverConfig.popup_detail_h) {
                const pd = document.getElementById('popup-detail');
                pd.style.width = serverConfig.popup_detail_w + 'px';
                pd.style.height = serverConfig.popup_detail_h + 'px';
            }
        } catch {}

        // Charger la liste des agents
        try {
            const agents = await API.getAgents();
            Grid.refresh(agents);
        } catch {}

        // WebSocket
        connectWS();

        // Clock
        updateClock();
        setInterval(updateClock, 1000);
    }

    document.addEventListener('DOMContentLoaded', init);

    return { toggleTheme, get serverConfig() { return serverConfig; }, set serverConfig(v) { serverConfig = v; } };
})();
  • Vérifier la page dans un navigateur
# Démarrer un serveur statique de test
cd dashboard && python3 -m http.server 8081
# Ouvrir http://localhost:8081

Vérifier :

  • La page s'affiche sans erreur console (sauf les erreurs réseau attendues sans serveur)

  • Le tooltip apparaît au survol des éléments data-tip

  • Le bouton thème bascule dark/light correctement

  • La grille est vide mais présente

  • Commit

rtk git add dashboard/js/app.js
rtk git commit -m "feat(dashboard): app principale WebSocket + orchestration"

Task 7 : Test d'intégration complet

  • Démarrer le serveur Go + agent de test
# Terminal 1 — serveur Go
cd server
DB_PATH=/tmp/test.db HTTP_ADDR="0.0.0.0:8080" UDP_ADDR="0.0.0.0:9999" go run .

# Terminal 2 — agent Rust (simulé avec netcat)
echo '{"hostname":"test-01","ip":"127.0.0.1","status":"online","cpu_percent":42.5,"memory_used":3000000000,"memory_total":8000000000,"hdd_used":60000000000,"hdd_total":200000000000,"uptime":86400}' | nc -u 127.0.0.1 9999
  • Ouvrir le dashboard en pointant vers le serveur
# Modifier api.js temporairement : BASE = 'http://localhost:8080'
# Ouvrir dashboard/index.html dans le navigateur

Vérifier :

  • La tuile test-01 apparaît dans la grille

  • Les jauges CPU/RAM/disque affichent les bonnes valeurs

  • Clic sur la tuile → popup détail s'ouvre

  • Courbes présentes (même sans historique)

  • Commit final

rtk git add dashboard/
rtk git commit -m "feat(dashboard): intégration complète"