Initial commit — homelab dashboard v1.0
This commit is contained in:
@@ -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