Initial commit — homelab dashboard v1.0
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copier les fichiers de dépendances
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
# Copier le reste du projet
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3900
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Homelab Dashboard
|
||||||
|
|
||||||
|
Dashboard web temps réel affichant l'état des LXC Proxmox et des conteneurs Docker (via Portainer).
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- Variables d'environnement :
|
||||||
|
- `PVE_TOKEN` — Token API Proxmox (format `root@pam!openclaw=<uuid>`)
|
||||||
|
- `PORTAINER_API_KEY` — Clé API Portainer
|
||||||
|
|
||||||
|
## Lancement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/node/.openclaw/workspace/projets/homelab-dashboard
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Le dashboard est accessible sur `http://localhost:3900`.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- Affichage des LXC Proxmox avec CPU% et RAM% en temps réel
|
||||||
|
- Affichage des conteneurs Docker avec image, état et stack
|
||||||
|
- Polling automatique toutes les 10 secondes
|
||||||
|
- Gestion d'erreurs (affiche "injoignable" si un service est down)
|
||||||
|
- Design sombre responsive
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
homelab-dashboard:
|
||||||
|
image: node:22-alpine
|
||||||
|
container_name: homelab-dashboard
|
||||||
|
working_dir: /app
|
||||||
|
command: sh -c "npm install --production && node server.js"
|
||||||
|
volumes:
|
||||||
|
- /share/ZFS24_DATA/docker/homelab-dashboard/app:/app
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Paris
|
||||||
|
- PVE_URL=${PVE_URL}
|
||||||
|
- PVE_TOKEN=${PVE_TOKEN}
|
||||||
|
- PORTAINER_API_KEY=${PORTAINER_API_KEY}
|
||||||
|
ports:
|
||||||
|
- "3900:3900"
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: swag_lan
|
||||||
|
external: true
|
||||||
Generated
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "homelab-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "homelab-dashboard",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "homelab-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Dashboard temps réel pour LXC Proxmox et conteneurs Docker Portainer",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
// Intervalle de polling (ms)
|
||||||
|
const POLL_INTERVAL = 10000;
|
||||||
|
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
|
||||||
|
// --- Formatage ---
|
||||||
|
|
||||||
|
/** Convertit des octets en chaîne lisible (Mo/Go) */
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' Ko';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(0) + ' Mo';
|
||||||
|
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' Go';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retourne la classe CSS du badge selon l'état */
|
||||||
|
function badgeClass(state) {
|
||||||
|
const s = (state || '').toLowerCase();
|
||||||
|
if (s === 'running') return 'badge-running';
|
||||||
|
if (s === 'stopped' || s === 'exited') return 'badge-stopped';
|
||||||
|
if (s === 'paused') return 'badge-paused';
|
||||||
|
if (s === 'created') return 'badge-created';
|
||||||
|
return 'badge-unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retourne la classe de la barre selon le pourcentage */
|
||||||
|
function barColorClass(pct) {
|
||||||
|
if (pct > 90) return 'critical';
|
||||||
|
if (pct > 70) return 'high';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rendu Proxmox ---
|
||||||
|
|
||||||
|
function renderProxmox(data) {
|
||||||
|
const errorEl = $('#proxmox-error');
|
||||||
|
const gridEl = $('#proxmox-grid');
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
errorEl.textContent = data.error;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
gridEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
const containers = data.containers || [];
|
||||||
|
|
||||||
|
if (containers.length === 0) {
|
||||||
|
gridEl.innerHTML = '<div class="loading">Aucun LXC trouvé</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gridEl.innerHTML = containers.map((ct) => {
|
||||||
|
const cpuPct = (ct.cpu * 100).toFixed(1);
|
||||||
|
const ramPct = ct.maxmem > 0 ? ((ct.mem / ct.maxmem) * 100).toFixed(1) : 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-name" title="${ct.name}">${ct.name}</span>
|
||||||
|
<span class="badge ${badgeClass(ct.status)}">${ct.status}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span>Node: <strong>${ct.node}</strong> · VMID: ${ct.vmid}</span>
|
||||||
|
${ct.status === 'running' ? `
|
||||||
|
<div class="bar-container">
|
||||||
|
<span class="bar-label">CPU</span>
|
||||||
|
<div class="bar-track">
|
||||||
|
<div class="bar-fill cpu ${barColorClass(cpuPct)}" style="width:${cpuPct}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="bar-value">${cpuPct}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar-container">
|
||||||
|
<span class="bar-label">RAM</span>
|
||||||
|
<div class="bar-track">
|
||||||
|
<div class="bar-fill ram ${barColorClass(ramPct)}" style="width:${ramPct}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="bar-value">${formatBytes(ct.mem)} / ${formatBytes(ct.maxmem)}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rendu Portainer ---
|
||||||
|
|
||||||
|
function renderPortainer(data) {
|
||||||
|
const errorEl = $('#portainer-error');
|
||||||
|
const gridEl = $('#portainer-grid');
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
errorEl.textContent = data.error;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
gridEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
const containers = data.containers || [];
|
||||||
|
|
||||||
|
if (containers.length === 0) {
|
||||||
|
gridEl.innerHTML = '<div class="loading">Aucun conteneur trouvé</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gridEl.innerHTML = containers.map((ct) => {
|
||||||
|
const stackHtml = ct.stack
|
||||||
|
? `<span class="stack-tag">${ct.stack}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Tronquer le nom d'image (retirer le registry si trop long)
|
||||||
|
const shortImage = ct.image.length > 45
|
||||||
|
? '...' + ct.image.slice(-42)
|
||||||
|
: ct.image;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-name" title="${ct.name}">${ct.name}</span>
|
||||||
|
<span class="badge ${badgeClass(ct.state)}">${ct.state}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span title="${ct.image}">${shortImage}</span>
|
||||||
|
<span>
|
||||||
|
${stackHtml}
|
||||||
|
${ct.status ? `<span style="color:var(--text-muted)">${ct.status}</span>` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Polling ---
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/status');
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
renderProxmox(data.proxmox);
|
||||||
|
renderPortainer(data.portainer);
|
||||||
|
|
||||||
|
// Mettre à jour le timestamp
|
||||||
|
const now = new Date(data.timestamp);
|
||||||
|
$('#last-update').textContent = `Mis à jour : ${now.toLocaleTimeString('fr-FR')}`;
|
||||||
|
$('#connection-status').className = 'badge badge-running';
|
||||||
|
$('#connection-status').textContent = 'OK';
|
||||||
|
} catch (err) {
|
||||||
|
$('#last-update').textContent = `Erreur: ${err.message}`;
|
||||||
|
$('#connection-status').className = 'badge badge-stopped';
|
||||||
|
$('#connection-status').textContent = 'ERR';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Premier chargement puis polling
|
||||||
|
fetchStatus();
|
||||||
|
setInterval(fetchStatus, POLL_INTERVAL);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Homelab Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Homelab Dashboard</h1>
|
||||||
|
<div id="status-bar">
|
||||||
|
<span id="last-update">Chargement...</span>
|
||||||
|
<span id="connection-status" class="badge badge-unknown">--</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Section Proxmox LXC -->
|
||||||
|
<section id="proxmox-section">
|
||||||
|
<h2>Proxmox LXC</h2>
|
||||||
|
<div id="proxmox-error" class="error-banner hidden"></div>
|
||||||
|
<div id="proxmox-grid" class="card-grid">
|
||||||
|
<div class="loading">Chargement...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section Docker / Portainer -->
|
||||||
|
<section id="portainer-section">
|
||||||
|
<h2>Docker (Portainer)</h2>
|
||||||
|
<div id="portainer-error" class="error-banner hidden"></div>
|
||||||
|
<div id="portainer-grid" class="card-grid">
|
||||||
|
<div class="loading">Chargement...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/* --- Reset & base --- */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f1117;
|
||||||
|
--surface: #1a1d27;
|
||||||
|
--surface-hover: #222633;
|
||||||
|
--border: #2a2e3d;
|
||||||
|
--text: #e4e6ed;
|
||||||
|
--text-muted: #8b8fa3;
|
||||||
|
--accent: #5b8def;
|
||||||
|
--green: #34d058;
|
||||||
|
--red: #ea4a5a;
|
||||||
|
--orange: #f9826c;
|
||||||
|
--yellow: #e3b341;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Header --- */
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Badges --- */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2em 0.6em;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.badge-running { background: rgba(52,208,88,0.15); color: var(--green); }
|
||||||
|
.badge-stopped, .badge-exited { background: rgba(234,74,90,0.15); color: var(--red); }
|
||||||
|
.badge-paused { background: rgba(249,130,108,0.15); color: var(--orange); }
|
||||||
|
.badge-created { background: rgba(227,179,65,0.15); color: var(--yellow); }
|
||||||
|
.badge-unknown { background: rgba(139,143,163,0.15); color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* --- Sections --- */
|
||||||
|
section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bannière d'erreur --- */
|
||||||
|
.error-banner {
|
||||||
|
background: rgba(234,74,90,0.1);
|
||||||
|
border: 1px solid rgba(234,74,90,0.3);
|
||||||
|
color: var(--red);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.hidden { display: none; }
|
||||||
|
|
||||||
|
/* --- Grille de cartes --- */
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Carte --- */
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.card:hover { background: var(--surface-hover); }
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Barres de progression --- */
|
||||||
|
.bar-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill.cpu { background: var(--accent); }
|
||||||
|
.bar-fill.ram { background: var(--green); }
|
||||||
|
.bar-fill.high { background: var(--orange); }
|
||||||
|
.bar-fill.critical { background: var(--red); }
|
||||||
|
|
||||||
|
.bar-value {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 32px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Stack tag (Docker) --- */
|
||||||
|
.stack-tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: rgba(91,141,239,0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0.15em 0.5em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Loading --- */
|
||||||
|
.loading {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Responsive --- */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
body { padding: 0.5rem; }
|
||||||
|
header { flex-direction: column; gap: 0.5rem; text-align: center; }
|
||||||
|
.card-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PORT = 3900;
|
||||||
|
const PVE_BASE = 'https://192.168.1.250:8006';
|
||||||
|
const PORTAINER_BASE = 'https://192.168.1.150:9443';
|
||||||
|
|
||||||
|
// Agent HTTPS qui ignore les certificats auto-signés
|
||||||
|
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
/** Requête HTTPS avec timeout et gestion d'erreurs */
|
||||||
|
function fetchJSON(url, headers, timeoutMs = 8000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const req = https.request({
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port,
|
||||||
|
path: parsed.pathname + parsed.search,
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
agent,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
}, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk) => { body += chunk; });
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(body));
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`Réponse non-JSON de ${url}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('timeout', () => { req.destroy(); reject(new Error(`Timeout: ${url}`)); });
|
||||||
|
req.on('error', (err) => reject(err));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Proxmox ---
|
||||||
|
|
||||||
|
/** Récupère la liste des LXC sur tous les nodes Proxmox */
|
||||||
|
async function getProxmoxLXC() {
|
||||||
|
const token = process.env.PVE_TOKEN;
|
||||||
|
if (!token) return { error: 'PVE_TOKEN non défini' };
|
||||||
|
|
||||||
|
const headers = { Authorization: `PVEAPIToken=${token}` };
|
||||||
|
|
||||||
|
let nodes;
|
||||||
|
try {
|
||||||
|
const res = await fetchJSON(`${PVE_BASE}/api2/json/nodes`, headers);
|
||||||
|
nodes = res.data;
|
||||||
|
} catch (err) {
|
||||||
|
return { error: `Proxmox injoignable: ${err.message}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Récupérer les LXC de chaque node en parallèle
|
||||||
|
await Promise.all(nodes.map(async (node) => {
|
||||||
|
try {
|
||||||
|
const lxcRes = await fetchJSON(`${PVE_BASE}/api2/json/nodes/${node.node}/lxc`, headers);
|
||||||
|
const lxcList = lxcRes.data || [];
|
||||||
|
|
||||||
|
// Récupérer les détails de chaque LXC en parallèle
|
||||||
|
const details = await Promise.all(lxcList.map(async (lxc) => {
|
||||||
|
try {
|
||||||
|
const statusRes = await fetchJSON(
|
||||||
|
`${PVE_BASE}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/status/current`,
|
||||||
|
headers
|
||||||
|
);
|
||||||
|
const d = statusRes.data || {};
|
||||||
|
return {
|
||||||
|
vmid: lxc.vmid,
|
||||||
|
name: d.name || lxc.name || `CT ${lxc.vmid}`,
|
||||||
|
node: node.node,
|
||||||
|
status: d.status || lxc.status || 'unknown',
|
||||||
|
cpu: d.cpu || 0, // fraction (0-1)
|
||||||
|
maxcpu: d.cpus || 1,
|
||||||
|
mem: d.mem || 0, // octets utilisés
|
||||||
|
maxmem: d.maxmem || 1, // octets max
|
||||||
|
uptime: d.uptime || 0,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Si le détail échoue, on renvoie les infos de base
|
||||||
|
return {
|
||||||
|
vmid: lxc.vmid,
|
||||||
|
name: lxc.name || `CT ${lxc.vmid}`,
|
||||||
|
node: node.node,
|
||||||
|
status: lxc.status || 'unknown',
|
||||||
|
cpu: 0, maxcpu: 1, mem: 0, maxmem: 1, uptime: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
results.push(...details);
|
||||||
|
} catch {
|
||||||
|
// Node injoignable, on l'ignore
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Trier par node puis par vmid
|
||||||
|
results.sort((a, b) => a.node.localeCompare(b.node) || a.vmid - b.vmid);
|
||||||
|
return { containers: results };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Portainer ---
|
||||||
|
|
||||||
|
/** Récupère la liste des conteneurs Docker via Portainer */
|
||||||
|
async function getPortainerContainers() {
|
||||||
|
const apiKey = process.env.PORTAINER_API_KEY;
|
||||||
|
if (!apiKey) return { error: 'PORTAINER_API_KEY non défini' };
|
||||||
|
|
||||||
|
const headers = { 'X-API-Key': apiKey };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const containers = await fetchJSON(
|
||||||
|
`${PORTAINER_BASE}/api/endpoints/2/docker/containers/json?all=true`,
|
||||||
|
headers
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extraire les infos utiles
|
||||||
|
const results = (Array.isArray(containers) ? containers : []).map((c) => {
|
||||||
|
// Le nom Docker commence par "/" — on le retire
|
||||||
|
const name = (c.Names && c.Names[0] || '').replace(/^\//, '') || c.Id?.slice(0, 12);
|
||||||
|
const image = c.Image || '';
|
||||||
|
const state = c.State || 'unknown'; // running, exited, created, etc.
|
||||||
|
const status = c.Status || ''; // ex: "Up 3 days"
|
||||||
|
|
||||||
|
// Chercher le label de stack (docker-compose)
|
||||||
|
const labels = c.Labels || {};
|
||||||
|
const stack = labels['com.docker.compose.project'] || '';
|
||||||
|
|
||||||
|
return { name, image, state, status, stack };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trier : running d'abord, puis par stack, puis par nom
|
||||||
|
results.sort((a, b) => {
|
||||||
|
if (a.state === 'running' && b.state !== 'running') return -1;
|
||||||
|
if (a.state !== 'running' && b.state === 'running') return 1;
|
||||||
|
return (a.stack || '').localeCompare(b.stack || '') || a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { containers: results };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: `Portainer injoignable: ${err.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Endpoint API ---
|
||||||
|
|
||||||
|
async function handleAPI(req, res) {
|
||||||
|
const [proxmox, portainer] = await Promise.all([
|
||||||
|
getProxmoxLXC(),
|
||||||
|
getPortainerContainers(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ proxmox, portainer, timestamp: Date.now() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Serveur de fichiers statiques ---
|
||||||
|
|
||||||
|
const MIME_TYPES = {
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.js': 'application/javascript',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
};
|
||||||
|
|
||||||
|
function serveStatic(req, res) {
|
||||||
|
let filePath = path.join(__dirname, 'public', req.url === '/' ? 'index.html' : req.url);
|
||||||
|
const ext = path.extname(filePath);
|
||||||
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
// Empêcher la traversée de répertoire
|
||||||
|
if (!filePath.startsWith(path.join(__dirname, 'public'))) {
|
||||||
|
res.writeHead(403);
|
||||||
|
res.end('Forbidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.readFile(filePath, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('404 Not Found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': contentType });
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Serveur HTTP ---
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
if (req.url === '/api/status' && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
await handleAPI(req, res);
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serveStatic(req, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Dashboard homelab démarré sur http://0.0.0.0:${PORT}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user