Files
homelabdash/static/index.html
T
percoandClaude Sonnet 4.6 8d9eaf98c2 fix(netmap): résolution DNS via Livebox pour les noms d'appareils
nmap forcé sur 192.168.1.1 avec -R --dns-servers pour récupérer
les hostnames DHCP enregistrés par la Livebox (.home).
Ajout du champ netbios (script nbstat) et affichage du meilleur
nom détecté (NetBIOS > hostname DNS) avec badge de source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 12:04:48 +02:00

609 lines
28 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); }
/* 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); }
.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>'
));
// 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);
}
// ──────────────────────────────── 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);
setInterval(async () => {
try {
const live = await fetch('/api/live').then(r=>r.json());
update_live(live);
} catch {
document.getElementById('refresh-dot').style.background = 'var(--red)';
}
}, 5000);
}
init();
</script>
</body>
</html>