# 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 Nanometrics
AGENTS0
OK0
WARN0
ERR0
``` - [ ] **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 ` `; } // 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 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 = ` `; return `
${iconContent}
${agent.hostname}
${agent.ip || '—'}
${offline ? '—' : fmtPct(cpu)}
${offline ? '—' : fmtPct(memPct)}
${offline ? '—' : fmtPct(diskPct)}
${offline ? 'Hors ligne' : `${uptimeStr}`}
`; } 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 = `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 ? `
SMART · ${metrics.smart.passed ? 'PASSED' : 'FAILED'} ${metrics.smart.temperature ? ` ${metrics.smart.temperature}°C` : ''}
` : ''; const protos = [ metrics?.cpu_percent != null ? `UDP` : '', ].filter(Boolean).join(''); document.getElementById('pop-body').innerHTML = `
MÉTRIQUES ACTUELLES
CPU
${(metrics?.cpu_percent ?? 0).toFixed(0)}%
MÉMOIRE
${Grid.fmt(metrics?.memory_used)}
/ ${Grid.fmt(metrics?.memory_total)}
DISQUE
${Grid.fmt(metrics?.hdd_used)}
/ ${Grid.fmt(metrics?.hdd_total)}
UPTIME
${document.getElementById('pop-uptime').textContent.replace(/.*depuis /,'')}
HISTORIQUE — 30 MIN
CPU
${(metrics?.cpu_percent ?? 0).toFixed(0)}%
−30min−15minnow
RAM
${Grid.fmtPct(metrics?.memory_used && metrics?.memory_total ? metrics.memory_used / metrics.memory_total * 100 : null)}
−30min−15minnow
STOCKAGE
${Grid.fmt(metrics?.hdd_used)} / ${Grid.fmt(metrics?.hdd_total)}
${smartBtn}
INFORMATIONS
HOSTNAME
${agent.hostname}
ADRESSE IP
${agent.ip || '—'}
PROTOCOLES ACTIFS
${protos || '—'}
DERNIER CONTACT
${new Date(agent.last_seen * 1000).toLocaleTimeString('fr-FR')}
`; // 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 = `
MÉTRIQUES PAR PROTOCOLE
MÉTRIQUE UDP MQTT
${metrics.map(m => { const udpOn = cfg.metrics?.[m]?.udp ? 'udp-on' : ''; const mqttOn = cfg.metrics?.[m]?.mqtt ? 'mqtt-on' : ''; return `
${m}
`; }).join('')}
PARAMÈTRES MQTT
${['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 `
`; }).join('')}
COMMANDES DISTANTES — BIENTÔT
${[['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]) => `
${label} bientôt
`).join('')}
`; 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 = `
AFFICHAGE DES TUILES
${cfg.tile_min_width ?? 220}px
${cfg.font_size ?? 13}px
SEUILS D'ALERTE
${cfg.warn_cpu ?? 70}%
${cfg.err_cpu ?? 85}%
${cfg.warn_disk ?? 75}%
DONNÉES & RÉTENTION
`; 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 = `
${passText}
${passSub}
POINTS DE CONTRÔLE
${m.temperature != null ? `
Température Normale
${m.temperature}°C
Idéal : 20–50°C. Au-delà de 60°C le disque risque de s'abîmer.
` : ''} ${m.reallocated_sectors != null ? `
Secteurs défectueux
${m.reallocated_sectors} sect.
S'ils apparaissent en grand nombre, une panne est imminente.
` : ''} ${m.power_on_hours != null ? `
Heures de fonctionnement
${m.power_on_hours.toLocaleString('fr-FR')}h
≈${Math.floor(m.power_on_hours/24)} jours. Un disque dure en moyenne 3 à 5 ans.
` : ''} ${m.wear_level != null ? `
Durée de vie SSD
${m.wear_level}%
100% = neuf · 0% = fin de vie recommandée.
` : ''}
`; 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" ```