commit b8ae4d138593c1978772cc8fad5905c8b5bf8828 Author: Nox (OpenClaw) Date: Sat Mar 14 16:16:58 2026 +0000 Initial commit — homelab dashboard v1.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9478a9a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9159d54 --- /dev/null +++ b/README.md @@ -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=`) + - `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 diff --git a/docker-compose.qnap.yml b/docker-compose.qnap.yml new file mode 100644 index 0000000..1f84a86 --- /dev/null +++ b/docker-compose.qnap.yml @@ -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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a02b6f2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "homelab-dashboard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "homelab-dashboard", + "version": "1.0.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ed5caad --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..adcf5d8 --- /dev/null +++ b/public/app.js @@ -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 = '
Aucun LXC trouvé
'; + 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 ` +
+
+ ${ct.name} + ${ct.status} +
+
+ Node: ${ct.node} · VMID: ${ct.vmid} + ${ct.status === 'running' ? ` +
+ CPU +
+
+
+ ${cpuPct}% +
+
+ RAM +
+
+
+ ${formatBytes(ct.mem)} / ${formatBytes(ct.maxmem)} +
+ ` : ''} +
+
+ `; + }).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 = '
Aucun conteneur trouvé
'; + return; + } + + gridEl.innerHTML = containers.map((ct) => { + const stackHtml = ct.stack + ? `${ct.stack}` + : ''; + + // Tronquer le nom d'image (retirer le registry si trop long) + const shortImage = ct.image.length > 45 + ? '...' + ct.image.slice(-42) + : ct.image; + + return ` +
+
+ ${ct.name} + ${ct.state} +
+
+ ${shortImage} + + ${stackHtml} + ${ct.status ? `${ct.status}` : ''} + +
+
+ `; + }).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); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..fe6d3ce --- /dev/null +++ b/public/index.html @@ -0,0 +1,40 @@ + + + + + + Homelab Dashboard + + + +
+

Homelab Dashboard

+
+ Chargement... + -- +
+
+ +
+ +
+

Proxmox LXC

+ +
+
Chargement...
+
+
+ + +
+

Docker (Portainer)

+ +
+
Chargement...
+
+
+
+ + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..f8449ca --- /dev/null +++ b/public/style.css @@ -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; } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..6a0afb4 --- /dev/null +++ b/server.js @@ -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}`); +});