Files
nano_metrics/docs/superpowers/plans/2026-05-22-nanometrics-dashboard.md
T
Gilles SoulierandClaude Sonnet 4.6 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

1382 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```bash
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**
```bash
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`**
```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`**
```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**
```bash
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`**
```javascript
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**
```bash
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`**
```javascript
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**
```bash
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`**
```javascript
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**
```bash
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`**
```javascript
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**
```bash
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`**
```javascript
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**
```bash
# 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**
```bash
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**
```bash
# 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**
```bash
# 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**
```bash
rtk git add dashboard/
rtk git commit -m "feat(dashboard): intégration complète"
```