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).
772 lines
35 KiB
HTML
772 lines
35 KiB
HTML
<!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.10–150',
|
||
`<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 (30–60 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,'"').replace(/</g,'<')}" 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>
|