diff --git a/dashboard/js/popups.js b/dashboard/js/popups.js
new file mode 100644
index 0000000..6a7b3a1
--- /dev/null
+++ b/dashboard/js/popups.js
@@ -0,0 +1,397 @@
+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
+
+
+
+
STOCKAGE
+
+
+
+
+
${Grid.fmt(metrics?.hdd_used)} / ${Grid.fmt(metrics?.hdd_total)}
+
+ ${smartBtn}
+
+
+ `;
+
+ 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
+
+
+ ${metrics.map(m => {
+ const udpOn = cfg.metrics?.[m]?.udp ? 'udp-on' : '';
+ const mqttOn = cfg.metrics?.[m]?.mqtt ? 'mqtt-on' : '';
+ 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;
+ 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 = `
+
+
+
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,
+ };
+})();