Files
homelabdash/static/index.html
T
perco ef63eb3a50 feat: card RAM par conteneur — live, avec seuil configurable
Nouvel endpoint /api/dockermem (Docker API stats en parallèle sur tous les
conteneurs, ~2s pour 68 conteneurs). Carte affichant jusqu'à 20 conteneurs
triés par RAM utilisée décroissante, filtrés par un seuil en Mo réglable
dans l'interface (persisté en localStorage).
2026-07-04 11:33:18 +02:00

772 lines
35 KiB
HTML
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.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>HomeLab Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"></script>
<style>
:root {
--bg: #0d1117; --card: #161b22; --border: #30363d;
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
--green: #3fb950; --yellow: #d29922; --red: #f85149;
--purple: #bc8cff; --teal: #39d353; --orange: #f0883e;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; font-size: 14px; }
header {
display: flex; align-items: center; gap: 16px;
padding: 14px 24px; background: var(--card);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 100;
}
header h1 { font-size: 17px; font-weight: 600; color: var(--accent); }
.header-right { margin-left: auto; display: flex; gap: 20px; align-items: center; }
#uptime-label { color: var(--muted); font-size: 13px; }
#refresh-dot { width:8px;height:8px;border-radius:50%;background:var(--green);display:inline-block;margin-right:5px; transition: background .2s; }
.hint { font-size: 11px; color: var(--muted); display: flex; align-items: center; gap: 5px; }
.drag-icon { cursor: grab; opacity: .5; }
/* GRID */
#grid { padding: 20px 24px; display: grid; gap: 16px; grid-template-columns: repeat(12, 1fr); }
.card {
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
padding: 16px; display: flex; flex-direction: column; gap: 10px;
cursor: default; transition: box-shadow .15s;
}
.card:hover { box-shadow: 0 0 0 1px var(--border); }
.card.sortable-ghost { opacity: .35; background: #21262d; }
.card.sortable-chosen { box-shadow: 0 4px 20px #00000088; z-index: 50; }
/* column spans */
.col-4 { grid-column: span 4; }
.col-6 { grid-column: span 6; }
.col-8 { grid-column: span 8; }
.col-12 { grid-column: span 12; }
@media (max-width: 1100px) { .col-4,.col-6,.col-8 { grid-column: span 12; } }
@media (min-width: 1101px) and (max-width: 1400px) { .col-4 { grid-column: span 6; } }
.card-title {
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px;
color: var(--muted); display: flex; align-items: center; gap: 8px;
cursor: grab; user-select: none;
}
.card-title .icon { font-size: 15px; }
.card-title .drag-handle { margin-left: auto; opacity: .4; font-size: 14px; }
.info-row { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 1px solid #21262d; padding: 5px 0; }
.info-row:last-child { border-bottom: none; }
.info-key { color: var(--muted); white-space: nowrap; }
.info-val { font-weight: 600; text-align: right; max-width: 65%; word-break: break-all; }
.meter-wrap { display: flex; flex-direction: column; gap: 4px; margin-bottom: 4px; }
.meter-label { display: flex; justify-content: space-between; font-size: 12px; }
.meter-bar { height: 8px; background: #21262d; border-radius: 4px; overflow: hidden; }
.meter-fill { height: 100%; border-radius: 4px; transition: width .5s; }
.fill-green { background: var(--green); } .fill-yellow { background: var(--yellow); } .fill-red { background: var(--red); }
.fill-blue { background: var(--accent); } .fill-purple { background: var(--purple); }
.chart-wrap { position: relative; height: 130px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 6px 8px; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--border); font-size: 11px; text-transform: uppercase; }
td { padding: 7px 8px; border-bottom: 1px solid #21262d; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #1c2128; }
.ok { color: var(--green); font-weight: 700; }
.warn { color: var(--yellow); font-weight: 700; }
.err { color: var(--red); font-weight: 700; }
.unk { color: var(--muted); }
.core-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 5px; }
.core-item { background: #21262d; border-radius: 6px; padding: 6px 8px; }
.core-name { font-size: 10px; color: var(--muted); margin-bottom: 2px; }
.core-val { font-weight: 700; font-size: 13px; }
.net-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.net-stat { background: #21262d; border-radius: 8px; padding: 10px; text-align: center; }
.ns-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }
.ns-val { font-size: 22px; font-weight: 700; }
.ns-unit { font-size: 11px; color: var(--muted); }
.dl-color { color: var(--teal); } .ul-color { color: var(--orange); }
.iface-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
.iface-chip { background: #21262d; border-radius: 6px; padding: 4px 10px; font-size: 12px; display: flex; align-items: center; gap: 6px; }
.iface-dot { width: 6px; height: 6px; border-radius: 50%; }
.stick-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.stick { background: #21262d; border-radius: 8px; padding: 10px 12px; }
.s-slot { font-size: 10px; color: var(--muted); font-weight: 700; text-transform: uppercase; }
.s-size { font-size: 18px; font-weight: 800; color: var(--accent); }
.s-info { font-size: 11px; color: var(--muted); }
/* DOCKERNET */
.dn-net { font-weight: 700; color: var(--accent); }
.dn-containers { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
.dn-container { background: #21262d; border-radius: 4px; padding: 1px 7px; font-size: 11px; color: var(--muted); }
/* NETCONN */
.nc-table td { padding: 6px 8px; font-size: 12px; }
.nc-ip { font-family: monospace; color: var(--accent); font-weight: 700; font-size: 13px; }
.nc-bps { font-weight: 700; }
.nc-dl { color: var(--teal); }
.nc-ul { color: var(--orange); }
.nc-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.nc-chip { background: #21262d; border-radius: 4px; padding: 2px 7px; font-size: 11px; color: var(--muted); font-family: monospace; }
/* NETMAP */
.netmap-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 8px; }
.netmap-btn { background: var(--accent); color: #0d1117; border: none; border-radius: 6px; padding: 6px 16px; font-size: 12px; font-weight: 700; cursor: pointer; transition: opacity .15s; }
.netmap-btn:hover { opacity: .8; }
.netmap-btn:disabled { opacity: .4; cursor: not-allowed; }
.netmap-status { font-size: 12px; color: var(--muted); }
.netmap-name-input { background: #21262d; border: 1px solid var(--border); color: var(--text); border-radius: 5px; padding: 3px 8px; font-size: 12px; width: 130px; transition: border-color .15s; }
.netmap-name-input:focus { outline: none; border-color: var(--accent); }
/* DOCKERMEM */
.dm-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; font-size: 13px; }
.dm-toolbar input[type="number"] { background: #21262d; border: 1px solid var(--border); color: var(--text); border-radius: 5px; padding: 3px 8px; font-size: 12px; width: 70px; }
.dm-toolbar input[type="number"]:focus { outline: none; border-color: var(--accent); }
.netmap-save-btn { background: none; border: 1px solid var(--border); color: var(--accent); border-radius: 5px; padding: 3px 10px; font-size: 11px; cursor: pointer; transition: background .15s; }
.netmap-save-btn:hover { background: #21262d; }
.netmap-flash { font-size: 11px; color: var(--green); width: 14px; display: inline-block; }
/* RAID */
.raid-grid { display: grid; gap: 10px; }
.raid-array { background: #21262d; border-radius: 8px; padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; }
.raid-header { display: flex; align-items: center; gap: 10px; }
.raid-name { font-size: 16px; font-weight: 800; color: var(--accent); }
.raid-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.raid-ok { background: #1f4024; color: var(--green); border: 1px solid #2d5a31; }
.raid-warn { background: #3b2a1a; color: var(--yellow); border: 1px solid #5a3e1a; }
.raid-err { background: #3b1219; color: var(--red); border: 1px solid #5a1a22; }
.raid-info { font-size: 12px; color: var(--muted); margin-left: auto; }
.raid-members { display: flex; flex-wrap: wrap; gap: 6px; }
.raid-member { padding: 3px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; }
.member-ok { background: #1f4024; color: var(--green); }
.member-spare { background: #1c2332; color: var(--accent); }
.member-fault { background: #3b1219; color: var(--red); }
.raid-disks { font-size: 12px; letter-spacing: 2px; }
.disk-u { color: var(--green); } .disk-d { color: var(--red); }
.raid-sync { font-size: 12px; color: var(--yellow); }
</style>
</head>
<body>
<header>
<span style="font-size:22px">🖥️</span>
<div>
<h1 id="hostname">HomeLab</h1>
<div style="font-size:12px;color:var(--muted)" id="os-line">Chargement…</div>
</div>
<div class="header-right">
<span id="uptime-label"></span>
<span class="hint"><span id="refresh-dot"></span>live</span>
<span class="hint drag-icon" title="Glisser les blocs pour les réorganiser">⠿ Réorganisable</span>
</div>
</header>
<div id="grid"></div>
<script>
const HIST_LEN = 40;
const hist = { cpu: [], net_dl: [], net_ul: [], mem: [] };
let charts = {};
let hwLoaded = false;
// ──────────────────────────────── helpers ──
const fmt_bytes = (b, d=1) => {
if (b == null || b === 0) return '0 B';
const s = ['B','KB','MB','GB','TB'];
const i = Math.floor(Math.log(Math.abs(b))/Math.log(1024));
return (b/Math.pow(1024,i)).toFixed(d)+' '+s[Math.min(i,4)];
};
const fmt_bps = b => {
if (b < 1024) return b.toFixed(0)+' B/s';
if (b < 1048576) return (b/1024).toFixed(1)+' KB/s';
return (b/1048576).toFixed(2)+' MB/s';
};
const fmt_uptime = s => {
const d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);
return [d&&d+'j',h&&h+'h',m+'m'].filter(Boolean).join(' ');
};
const fill_cls = p => p>85?'fill-red':p>60?'fill-yellow':'fill-green';
const meter = (label, pct, detail='') =>
`<div class="meter-wrap">
<div class="meter-label"><span>${label}</span><span><b>${pct.toFixed(1)}%</b>${detail?' · '+detail:''}</span></div>
<div class="meter-bar"><div class="meter-fill ${fill_cls(pct)}" style="width:${Math.min(pct,100)}%"></div></div>
</div>`;
const info_row = (k,v) => `<div class="info-row"><span class="info-key">${k}</span><span class="info-val">${v??''}</span></div>`;
const push = (arr,v) => { arr.push(v); if(arr.length>HIST_LEN) arr.shift(); };
const pad = arr => [...Array(HIST_LEN-arr.length).fill(null),...arr].slice(-HIST_LEN);
// ──────────────────────────────── drag & drop order ──
const ORDER_KEY = 'hld_card_order';
function save_order() {
const ids = [...document.querySelectorAll('#grid .card')].map(c=>c.id);
localStorage.setItem(ORDER_KEY, JSON.stringify(ids));
}
function apply_order() {
const saved = JSON.parse(localStorage.getItem(ORDER_KEY)||'null');
if (!saved) return;
const grid = document.getElementById('grid');
saved.forEach(id => {
const el = document.getElementById(id);
if (el) grid.appendChild(el);
});
}
// ──────────────────────────────── card builder ──
function make_card(id, col, icon, title, content) {
const d = document.createElement('div');
d.className = `card ${col}`;
d.id = id;
d.innerHTML = `
<div class="card-title"><span class="icon">${icon}</span>${title}<span class="drag-handle" title="Déplacer">⠿</span></div>
<div class="card-body">${content}</div>`;
return d;
}
function set_body(id, html) {
const el = document.getElementById(id);
if (el) el.querySelector('.card-body').innerHTML = html;
}
// ──────────────────────────────── charts ──
function make_line(id, label, color) {
const ctx = document.getElementById(id)?.getContext('2d');
if (!ctx) return null;
return new Chart(ctx, {
type: 'line',
data: {
labels: Array(HIST_LEN).fill(''),
datasets: [{ label, data: Array(HIST_LEN).fill(null),
borderColor: color, backgroundColor: color+'22', fill: true,
tension: .4, pointRadius: 0, borderWidth: 2 }]
},
options: {
animation: false, responsive: true, maintainAspectRatio: false,
plugins:{ legend:{ display: false } },
scales:{ x:{display:false}, y:{ min:0, max:100, grid:{color:'#21262d'}, ticks:{color:'#8b949e',font:{size:10}} } }
}
});
}
function make_net_chart(id) {
const ctx = document.getElementById(id)?.getContext('2d');
if (!ctx) return null;
return new Chart(ctx, {
type: 'line',
data: {
labels: Array(HIST_LEN).fill(''),
datasets: [
{ label:'⬇ DL', data: Array(HIST_LEN).fill(null), borderColor:'#39d353', backgroundColor:'#39d35322', fill:true, tension:.4, pointRadius:0, borderWidth:2 },
{ label:'⬆ UL', data: Array(HIST_LEN).fill(null), borderColor:'#f0883e', backgroundColor:'#f0883e22', fill:true, tension:.4, pointRadius:0, borderWidth:2 },
]
},
options: {
animation:false, responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ display:true, labels:{ color:'#8b949e', boxWidth:12, font:{size:11} } } },
scales:{ x:{display:false}, y:{ min:0, grid:{color:'#21262d'}, ticks:{color:'#8b949e',font:{size:10},callback:v=>fmt_bps(v)} } }
}
});
}
// ──────────────────────────────── build UI ──
function build_ui(os, hw, raid) {
document.getElementById('hostname').textContent = '🖥️ ' + os.hostname;
document.getElementById('os-line').textContent = os.os + ' · ' + os.kernel + ' · ' + os.arch;
const grid = document.getElementById('grid');
grid.innerHTML = '';
// OS
grid.appendChild(make_card('card-os','col-4','🐧','Système',
info_row('Hostname', os.hostname) +
info_row('OS', os.os) +
info_row('Kernel', os.kernel) +
info_row('Architecture', os.arch) +
info_row('Carte mère', [hw.board.manufacturer, hw.board.product].filter(Boolean).join(' · '))
));
// CPU info
grid.appendChild(make_card('card-cpu-info','col-4','⚡','Processeur',
info_row('Modèle', hw.cpu.brand) +
info_row('Architecture', hw.cpu.arch) +
info_row('Fréquence', hw.cpu.hz_advertised) +
info_row('Cœurs physiques', hw.cpu.cores_physical) +
info_row('Threads', hw.cpu.cores_logical) +
'<div id="cpu-live-rows"></div>'
));
// CPU graph
grid.appendChild(make_card('card-cpu-graph','col-4','📈','Utilisation CPU',
'<div id="cpu-meter-wrap"></div>' +
'<div class="chart-wrap"><canvas id="chart-cpu"></canvas></div>' +
'<div class="core-grid" id="core-grid"></div>'
));
// RAM hw
grid.appendChild(make_card('card-ram-hw','col-4','🧩','Mémoire — hardware',
'<div class="stick-grid">' + (hw.memory_sticks.length
? hw.memory_sticks.map(s => `
<div class="stick">
<div class="s-slot">${s.locator}</div>
<div class="s-size">${s.size}</div>
<div class="s-info">${s.type}${s.speed?' · '+s.speed:''}</div>
<div class="s-info">${[s.manufacturer!=='Unknown'?s.manufacturer:'', s.part_number!=='Unknown'?s.part_number:''].filter(Boolean).join(' · ')}</div>
</div>`).join('')
: '<span style="color:var(--muted)">dmidecode indisponible</span>') + '</div>'
));
// RAM usage
grid.appendChild(make_card('card-ram-usage','col-4','💾','Utilisation mémoire',
'<div id="ram-meters"></div>' +
'<div class="chart-wrap"><canvas id="chart-mem"></canvas></div>'
));
// Network
grid.appendChild(make_card('card-net','col-4','🌐','Réseau',
'<div class="net-stats">' +
'<div class="net-stat"><div class="ns-label">⬇ Download</div><div class="ns-val dl-color" id="net-dl"></div><div class="ns-unit" id="net-dl-tot"></div></div>' +
'<div class="net-stat"><div class="ns-label">⬆ Upload</div><div class="ns-val ul-color" id="net-ul"></div><div class="ns-unit" id="net-ul-tot"></div></div>' +
'</div>' +
'<div class="chart-wrap"><canvas id="chart-net"></canvas></div>' +
'<div class="iface-row" id="net-ifaces"></div>'
));
// RAID
const raidHtml = raid.length
? '<div class="raid-grid">' + raid.map(r => {
const health = r.degraded ? 'warn' : (r.state !== 'active' ? 'err' : 'ok');
const label = r.degraded ? '⚠ DÉGRADÉ' : (r.state !== 'active' ? '✗ INACTIF' : '✅ OK');
const disks = [...r.disk_status].map(c =>
`<span class="${c==='U'?'disk-u':'disk-d'}">${c==='U'?'█':'░'}</span>`).join('');
const members = r.members.map(m =>
`<span class="raid-member member-${m.state}">${m.device}</span>`).join('');
const sync = r.sync_action
? `<div class="raid-sync">🔄 ${r.sync_action} ${r.sync_pct!=null?r.sync_pct.toFixed(1)+'%':''} ${r.sync_speed||''}</div>`
: '';
return `<div class="raid-array">
<div class="raid-header">
<span class="raid-name">${r.name}</span>
<span class="raid-badge raid-${health}">${label}</span>
<span class="raid-info">${r.level.toUpperCase()} · ${r.active_devs}/${r.total_devs} disques · ${fmt_bytes(r.size_bytes)}</span>
</div>
<div class="raid-disks" title="U=sain ░=absent">${disks}</div>
<div class="raid-members">${members}</div>
${sync}
</div>`;
}).join('') + '</div>'
: '<span style="color:var(--muted)">Aucun array RAID détecté</span>';
grid.appendChild(make_card('card-raid','col-6','⚙️','Arrays RAID', raidHtml));
// Disques
grid.appendChild(make_card('card-disks','col-6','💿','Espace disque',
'<div id="disk-meters"></div>'
));
// SMART
const smartRows = Object.values(hw.smart);
const smartHtml = smartRows.length
? `<table><thead><tr><th>Disque</th><th>Modèle</th><th>Capacité</th><th>Santé</th><th>Temp.</th><th>Allumé</th><th>Réalloués</th><th>Pending</th><th>Erreurs ATA</th></tr></thead><tbody>` +
smartRows.map(s => {
const realloc = s.reallocated_sectors ?? null;
const pending = s.pending_sectors ?? null;
const ataErr = s.ata_errors ?? null;
const warn = (realloc > 0) || (pending > 0) || (ataErr > 100);
const rowCls = warn ? 'style="background:#1c1510"' : '';
const num = (v, bad) => v == null ? ''
: v === 0 ? `<span style="color:var(--green)">0</span>`
: `<span class="${bad?'err':'warn'}">${v.toLocaleString()}</span>`;
return `<tr ${rowCls}>
<td><code>${s.device}</code></td>
<td>${s.model||''}</td>
<td>${fmt_bytes(s.capacity_bytes)}</td>
<td class="${s.health===true?'ok':s.health===false?'err':'unk'}">${s.health===true?'✅ PASSED':s.health===false?'❌ FAILED':'❓ '}</td>
<td>${s.temp_c!=null?s.temp_c+' °C':''}</td>
<td>${s.power_on_hours!=null?Math.round(s.power_on_hours/24)+' j':''}</td>
<td>${num(realloc, realloc>100)}</td>
<td>${num(pending, pending>0)}</td>
<td>${num(ataErr, ataErr>500)}</td>
</tr>`;
}).join('') + '</tbody></table>'
: '<span style="color:var(--muted)">smartctl indisponible</span>';
grid.appendChild(make_card('card-smart','col-12','🔍','S.M.A.R.T — état des disques', smartHtml));
// Températures
grid.appendChild(make_card('card-temps','col-4','🌡️','Températures',
'<div id="temp-table"></div>'
));
// DockerNet
grid.appendChild(make_card('card-dockernet','col-12','🐳','Trafic par réseau Docker — live',
'<div id="dockernet-body"><span style="color:var(--muted)">Chargement…</span></div>'
));
// NetConn
grid.appendChild(make_card('card-netconn','col-12','🔌','Trafic réseau par appareil LAN — live',
'<div id="netconn-body"><span style="color:var(--muted)">Chargement…</span></div>'
));
// DockerMem
grid.appendChild(make_card('card-dockermem','col-12','💾','RAM par conteneur — live',
`<div class="dm-toolbar">
Seuil : <input type="number" id="dockermem-threshold" min="0" step="10" value="${dmThreshold}" oninput="dmOnThresholdChange(this)"> Mo
<span style="color:var(--muted);font-size:12px">— jusqu'à 20 conteneurs affichés, triés par RAM décroissante</span>
</div>
<div id="dockermem-body"><span style="color:var(--muted)">Chargement…</span></div>`
));
// NetMap
grid.appendChild(make_card('card-netmap','col-12','📡','Appareils réseau — 192.168.1.10150',
`<div class="netmap-toolbar">
<button class="netmap-btn" id="netmap-scan-btn" onclick="netmapScan()">🔍 Scanner</button>
<span class="netmap-status" id="netmap-status">Cliquez sur Scanner pour démarrer</span>
</div>
<div id="netmap-table"></div>`
));
// Restaurer l'ordre mémorisé
apply_order();
// Init charts
charts.cpu = make_line('chart-cpu', 'CPU %', '#58a6ff');
charts.mem = new Chart(document.getElementById('chart-mem').getContext('2d'), {
type:'line', data:{ labels:Array(HIST_LEN).fill(''), datasets:[{
label:'RAM %', data:Array(HIST_LEN).fill(null),
borderColor:'#bc8cff', backgroundColor:'#bc8cff22', fill:true, tension:.4, pointRadius:0, borderWidth:2
}]},
options:{ animation:false, responsive:true, maintainAspectRatio:false,
plugins:{legend:{display:false}},
scales:{x:{display:false}, y:{min:0,max:100,grid:{color:'#21262d'},ticks:{color:'#8b949e',font:{size:10}}}} }
});
charts.net = make_net_chart('chart-net');
// Sortable drag & drop
Sortable.create(document.getElementById('grid'), {
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
handle: '.card-title',
onEnd: save_order,
});
hwLoaded = true;
}
// ──────────────────────────────── update live ──
function update_live(live) {
document.getElementById('uptime-label').textContent = '⏱ ' + fmt_uptime(live.uptime_s);
// CPU
push(hist.cpu, live.cpu.percent);
document.getElementById('cpu-live-rows').innerHTML =
info_row('Fréquence actuelle', live.cpu.freq_mhz ? live.cpu.freq_mhz+' MHz' : '') +
info_row('Fréquence max', live.cpu.freq_max_mhz ? live.cpu.freq_max_mhz+' MHz' : '');
document.getElementById('cpu-meter-wrap').innerHTML = meter('Utilisation globale', live.cpu.percent);
if (charts.cpu) {
charts.cpu.data.datasets[0].data = pad(hist.cpu);
charts.cpu.update('none');
}
const cores = live.cpu.percent_per_core || [];
document.getElementById('core-grid').innerHTML = cores.map((p,i) =>
`<div class="core-item"><div class="core-name">CPU ${i}</div>
<div class="core-val" style="color:${p>80?'var(--red)':p>50?'var(--yellow)':'var(--green)'}">${p.toFixed(1)}%</div>
<div class="meter-bar" style="margin-top:3px"><div class="meter-fill ${fill_cls(p)}" style="width:${p}%"></div></div></div>`
).join('');
// RAM
const m = live.memory;
push(hist.mem, m.percent);
const cached = (m.cached||0) + (m.buffers||0);
document.getElementById('ram-meters').innerHTML =
meter('Utilisée', m.percent, fmt_bytes(m.used)+' / '+fmt_bytes(m.total)) +
(cached > 0 ? meter('Cache + buffers', cached/m.total*100, fmt_bytes(cached)) : '') +
(live.swap.total > 0 ? meter('Swap', live.swap.percent, fmt_bytes(live.swap.used)+' / '+fmt_bytes(live.swap.total)) : '');
if (charts.mem) {
charts.mem.data.datasets[0].data = pad(hist.mem);
charts.mem.update('none');
}
// Réseau
push(hist.net_dl, live.network.dl_bps);
push(hist.net_ul, live.network.ul_bps);
document.getElementById('net-dl').textContent = fmt_bps(live.network.dl_bps);
document.getElementById('net-ul').textContent = fmt_bps(live.network.ul_bps);
document.getElementById('net-dl-tot').textContent = 'Total : ' + fmt_bytes(live.network.total_recv);
document.getElementById('net-ul-tot').textContent = 'Total : ' + fmt_bytes(live.network.total_sent);
const ifaces = live.network.interfaces || {};
document.getElementById('net-ifaces').innerHTML = Object.entries(ifaces).map(([iface, inf]) => {
const up = typeof inf === 'object' ? inf.up : true;
const ip = typeof inf === 'object' ? inf.ip : inf;
return `<div class="iface-chip"><span class="iface-dot" style="background:${up?'var(--green)':'var(--red)'}"></span><code style="color:var(--accent)">${iface}</code> <span style="color:var(--muted)">${ip}</span></div>`;
}).join('');
if (charts.net) {
charts.net.data.datasets[0].data = pad(hist.net_dl);
charts.net.data.datasets[1].data = pad(hist.net_ul);
const mx = Math.max(...hist.net_dl, ...hist.net_ul, 1024);
charts.net.options.scales.y.max = mx * 1.2;
charts.net.update('none');
}
// Disques
const diskEl = document.getElementById('disk-meters');
if (diskEl) {
diskEl.innerHTML = live.disks.map(d => {
const name = d.label ? `${d.mountpoint} <span style="color:var(--muted);font-size:11px">${d.label} · ${d.device}</span>`
: `${d.mountpoint} <span style="color:var(--muted);font-size:11px">${d.device} · ${d.fstype}</span>`;
return `<div style="margin-bottom:8px">${meter(name, d.percent, fmt_bytes(d.used)+'/'+fmt_bytes(d.total)+' — '+fmt_bytes(d.free)+' libres')}</div>`;
}).join('') || '<span style="color:var(--muted)">Aucune partition</span>';
}
// Températures
const tempEl = document.getElementById('temp-table');
if (tempEl) {
const rows = Object.values(live.temperatures||{}).flat();
tempEl.innerHTML = rows.length
? `<table><thead><tr><th>Capteur</th><th>Temp.</th><th>Max</th><th>Critique</th></tr></thead><tbody>` +
rows.map(r => {
const c = r.current;
const col = c > (r.critical||90) ? 'var(--red)' : c > (r.high||75) ? 'var(--yellow)' : 'var(--green)';
return `<tr><td>${r.label}</td><td style="color:${col};font-weight:700">${c?.toFixed(1)} °C</td><td>${r.high?r.high+'°C':''}</td><td>${r.critical?r.critical+'°C':''}</td></tr>`;
}).join('') + `</tbody></table>`
: '<span style="color:var(--muted)">Aucun capteur disponible</span>';
}
// blink
const dot = document.getElementById('refresh-dot');
dot.style.background = 'var(--accent)';
setTimeout(() => { dot.style.background = 'var(--green)'; }, 250);
}
// ──────────────────────────────── dockernet ──
async function dnRefresh() {
try {
const data = await fetch('/api/dockernet').then(r => r.json());
dnRender(data.networks || []);
} catch { /* silencieux */ }
}
function dnRender(nets) {
const el = document.getElementById('dockernet-body');
if (!el) return;
const active = nets.filter(n => n.dl_bps > 100 || n.ul_bps > 100);
const rest = nets.filter(n => n.dl_bps <= 100 && n.ul_bps <= 100);
const renderRow = n => {
const busy = n.dl_bps > 1024 || n.ul_bps > 1024;
const chips = n.containers.slice(0,6).map(c =>
`<span class="dn-container">${c}</span>`).join('');
return `<tr${busy ? ' style="background:#1a1e2a"' : ''}>
<td class="dn-net">${n.name}</td>
<td class="nc-bps nc-dl">${fmt_bps(n.dl_bps)}</td>
<td class="nc-bps nc-ul">${fmt_bps(n.ul_bps)}</td>
<td><div class="dn-containers">${chips}</div></td>
</tr>`;
};
const rows = [...active, ...rest].map(renderRow).join('');
el.innerHTML = `<table class="nc-table">
<thead><tr><th>Réseau Docker</th><th>⬇ Download</th><th>⬆ Upload</th><th>Conteneurs</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
// ──────────────────────────────── dockermem ──
let dmThreshold = parseInt(localStorage.getItem('dm_threshold_mb') || '50', 10);
let _dmLastContainers = [];
async function dmRefresh() {
try {
const data = await fetch('/api/dockermem').then(r => r.json());
_dmLastContainers = data.containers || [];
dmRender(_dmLastContainers);
} catch { /* silencieux */ }
}
function dmOnThresholdChange(input) {
const v = parseInt(input.value, 10);
dmThreshold = Number.isFinite(v) && v >= 0 ? v : 0;
localStorage.setItem('dm_threshold_mb', String(dmThreshold));
dmRender(_dmLastContainers);
}
function dmRender(containers) {
const el = document.getElementById('dockermem-body');
if (!el) return;
const thresholdBytes = dmThreshold * 1024 * 1024;
const rows = containers.filter(c => c.mem_bytes >= thresholdBytes).slice(0, 20);
if (!rows.length) {
el.innerHTML = `<span style="color:var(--muted)">Aucun conteneur au-dessus de ${dmThreshold} Mo</span>`;
return;
}
const trs = rows.map(c => {
const pct = c.mem_pct || 0;
const pctColor = pct > 50 ? 'var(--red)' : (pct > 20 ? 'var(--yellow)' : 'var(--muted)');
return `<tr>
<td class="nc-ip">${c.name}</td>
<td class="nc-bps">${fmt_bytes(c.mem_bytes)}</td>
<td style="color:${pctColor}">${pct.toFixed(1)}%</td>
</tr>`;
}).join('');
el.innerHTML = `<table class="nc-table">
<thead><tr><th>Conteneur</th><th>RAM utilisée</th><th>% limite</th></tr></thead>
<tbody>${trs}</tbody>
</table>`;
}
// ──────────────────────────────── netconn ──
let _ncNames = {};
async function ncRefresh() {
try {
const data = await fetch('/api/netconn').then(r => r.json());
ncRender(data.hosts || []);
} catch { /* silencieux */ }
}
function ncRender(hosts) {
const el = document.getElementById('netconn-body');
if (!el) return;
if (!hosts.length) { el.innerHTML = '<span style="color:var(--muted)">Aucune connexion LAN active</span>'; return; }
const rows = hosts.map(h => {
const name = _ncNames[h.ip] || '';
const dlBps = fmt_bps(h.dl_bps || 0);
const ulBps = fmt_bps(h.ul_bps || 0);
const chips = (h.top || []).map(c => {
const label = c.service ? `${c.service}:${c.port}` : c.port;
return `<span class="nc-chip" title="${c.remote}">${label}</span>`;
}).join('');
const active = h.dl_bps > 1024 || h.ul_bps > 1024;
return `<tr${active ? ' style="background:#1a2020"' : ''}>
<td class="nc-ip">${h.ip}${name ? ` <span style="color:var(--muted);font-size:11px">${name}</span>` : ''}</td>
<td style="text-align:center;color:var(--muted)">${h.established}</td>
<td class="nc-bps nc-dl">${dlBps}</td>
<td class="nc-bps nc-ul">${ulBps}</td>
<td><div class="nc-chips">${chips || '<span style="color:var(--muted);font-size:11px">—</span>'}</div></td>
</tr>`;
}).join('');
el.innerHTML = `<table class="nc-table">
<thead><tr>
<th>Appareil</th><th style="text-align:center">Connexions</th>
<th>⬇ Download</th><th>⬆ Upload</th><th>Services actifs</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
// ──────────────────────────────── netmap ──
let _netmapNames = {};
async function netmapScan() {
const btn = document.getElementById('netmap-scan-btn');
const status = document.getElementById('netmap-status');
if (!btn) return;
btn.disabled = true;
status.style.color = 'var(--yellow)';
status.textContent = '⏳ Scan en cours (3060 s)…';
try {
const data = await fetch('/api/netmap/scan').then(r => r.json());
if (data.error) {
status.style.color = 'var(--red)';
status.textContent = '❌ ' + data.error;
} else {
status.style.color = 'var(--green)';
status.textContent = `✅ ${data.hosts.length} appareil(s) détecté(s)`;
_netmapNames = data.names || {};
netmapRender(data.hosts);
}
} catch(e) {
status.style.color = 'var(--red)';
status.textContent = '❌ Erreur : ' + e;
}
btn.disabled = false;
}
function netmapRender(hosts) {
const el = document.getElementById('netmap-table');
if (!el) return;
if (!hosts.length) { el.innerHTML = '<span style="color:var(--muted)">Aucun appareil détecté.</span>'; return; }
const rows = hosts.map(h => {
const k = h.ip.replace(/\./g, '_');
// Meilleur nom détecté : NetBIOS > hostname DNS > fabricant
const detected = h.netbios || h.hostname || '';
const savedName = _netmapNames[h.ip] || '';
// Pré-remplir le champ avec le nom détecté si pas encore de nom sauvegardé
const inputVal = savedName || detected;
const detectedBadge = detected
? `<span style="font-size:10px;color:var(--muted);margin-left:4px" title="${h.netbios ? 'NetBIOS' : 'DNS'}">${h.netbios ? '🔵' : '🔤'} ${detected}</span>`
: '';
return `<tr>
<td><code style="color:var(--accent)">${h.ip}</code></td>
<td><code style="color:var(--muted);font-size:12px">${h.mac || '—'}</code></td>
<td style="color:var(--muted);font-size:12px">${h.vendor || '—'}</td>
<td style="font-size:12px">${detectedBadge || '<span style="color:var(--muted)">—</span>'}</td>
<td>
<input class="netmap-name-input" id="nm_${k}" value="${inputVal.replace(/"/g,'&quot;').replace(/</g,'&lt;')}" placeholder="Nom perso…">
<button class="netmap-save-btn" onclick="netmapSaveName('${h.ip}')">Sauver</button>
<span class="netmap-flash" id="nmf_${k}"></span>
</td>
</tr>`;
}).join('');
el.innerHTML = `<table>
<thead><tr><th>IP</th><th>MAC</th><th>Fabricant</th><th>Nom détecté</th><th>Nom perso</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
async function netmapSaveName(ip) {
const k = ip.replace(/\./g, '_');
const input = document.getElementById('nm_' + k);
const flash = document.getElementById('nmf_' + k);
if (!input) return;
const name = input.value.trim();
try {
const r = await fetch('/api/netmap/name', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ip, name})
});
const d = await r.json();
if (d.ok) { _netmapNames[ip] = name; flash.textContent = '✓'; }
else flash.textContent = '✗';
} catch { flash.textContent = '✗'; }
setTimeout(() => { if (flash) flash.textContent = ''; }, 2000);
}
// ──────────────────────────────── init ──
async function init() {
const [os, hw, raid] = await Promise.all([
fetch('/api/os').then(r=>r.json()),
fetch('/api/hardware').then(r=>r.json()),
fetch('/api/raid').then(r=>r.json()),
]);
build_ui(os, hw, raid);
const live = await fetch('/api/live').then(r=>r.json());
update_live(live);
// Charger les noms netmap pour les labels d'appareils dans netconn
fetch('/api/netmap/names').then(r => r.json()).then(d => { _ncNames = d; });
// Premiers chargements
dnRefresh();
ncRefresh();
dmRefresh();
setInterval(async () => {
try {
const live = await fetch('/api/live').then(r=>r.json());
update_live(live);
dnRefresh();
ncRefresh();
dmRefresh();
} catch {
document.getElementById('refresh-dot').style.background = 'var(--red)';
}
}, 5000);
}
init();
</script>
</body>
</html>