infra-dashboard/templates/index.html
Maddox ea9f8fca25 Initial commit - shareable infra dashboard
Externalize hardcoded host inventory and diagram topology into
JSON config files (hosts.json, diagram.json) loaded at runtime.
Add .env for configurable port, SSH key path, and refresh interval.
Include example configs and README for standalone deployment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 14:57:56 -05:00

487 lines
26 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Infrastructure Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'JetBrains Mono', monospace; background: #0d1117; color: #c9d1d9; min-height: 100vh; }
/* Header */
.header { background: #161b22; border-bottom: 1px solid #21262d; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; }
.header h1 { font-size: 1.5rem; color: #58a6ff; }
.stats { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
.stat { background: #21262d; padding: 8px 16px; border-radius: 6px; }
.stat .label { color: #8b949e; font-size: 0.75rem; }
.stat .value { color: #10b981; font-size: 1.25rem; font-weight: 600; }
.status { display: flex; align-items: center; gap: 8px; font-size: 0.875rem; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #10b981; animation: pulse 2s infinite; }
.dot.stale { background: #f59e0b; animation: none; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.refresh { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-family: inherit; }
.refresh:hover { background: #30363d; }
/* Tabs */
.tabs { display: flex; gap: 4px; padding: 12px 24px; background: #161b22; border-bottom: 1px solid #21262d; }
.tab { background: transparent; border: none; color: #8b949e; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-family: inherit; }
.tab:hover { color: #c9d1d9; background: #21262d; }
.tab.active { color: #58a6ff; background: #21262d; }
/* Content */
.content { padding: 24px; }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Diagram */
.diagram-container { display: grid; grid-template-columns: 1fr 350px; gap: 24px; max-width: 1500px; margin: 0 auto; }
.diagram-svg { width: 100%; background: #0d1117; border-radius: 12px; border: 1px solid #21262d; }
.node-box { cursor: pointer; transition: all 0.2s; }
.node-box:hover { filter: brightness(1.2); }
.detail-panel { background: #161b22; border-radius: 12px; border: 1px solid #21262d; padding: 20px; height: fit-content; position: sticky; top: 24px; }
.detail-panel h3 { color: #58a6ff; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #21262d; }
.container-list { max-height: 500px; overflow-y: auto; }
.container-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; margin: 4px 0; background: #0d1117; border-radius: 6px; border: 1px solid #21262d; }
.container-name { font-size: 0.875rem; }
.container-status { font-size: 0.7rem; padding: 2px 8px; border-radius: 4px; }
.container-status.healthy, .container-status.running { background: #10b98120; color: #10b981; }
.container-status.unhealthy { background: #ef444420; color: #ef4444; }
.container-status.unknown { background: #6b728020; color: #6b7280; }
/* Fullscreen diagram */
.fullscreen-svg { width: 100%; max-width: 1400px; margin: 0 auto; display: block; background: #0d1117; border-radius: 12px; border: 1px solid #21262d; }
/* Host cards grid */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.card { background: #161b22; border-radius: 12px; border: 1px solid #21262d; padding: 20px; transition: all 0.2s; }
.card:hover { border-color: #30363d; transform: translateY(-2px); }
.card.offline { opacity: 0.6; border-color: #ef4444; }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; }
.card-title { font-size: 1.125rem; color: #58a6ff; font-weight: 600; }
.card-meta { font-size: 0.75rem; color: #6b7280; margin-bottom: 8px; }
.card-stats { font-size: 0.875rem; color: #8b949e; margin-bottom: 12px; }
.badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
.badge.vm { background: #a855f720; color: #a855f7; }
.badge.lxc { background: #f59e0b20; color: #f59e0b; }
.badge.remote { background: #06b6d420; color: #06b6d4; }
.badge.online { background: #10b98120; color: #10b981; }
.badge.offline { background: #ef444420; color: #ef4444; }
.containers { display: flex; flex-wrap: wrap; gap: 4px; }
.mini { font-size: 0.65rem; padding: 2px 6px; background: #21262d; border-radius: 4px; color: #8b949e; border-left: 2px solid #10b981; }
.mini.unhealthy { border-left-color: #ef4444; }
/* Inventory table */
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 12px 16px; background: #161b22; color: #58a6ff; font-weight: 600; border-bottom: 2px solid #21262d; }
td { padding: 10px 16px; border-bottom: 1px solid #21262d; }
tr:hover { background: #161b22; }
.ip { color: #10b981; }
</style>
</head>
<body>
<div class="header">
<h1>Infrastructure Dashboard</h1>
<div class="stats">
<div class="stat"><div class="label">Containers</div><div class="value" id="total">--</div></div>
<div class="stat"><div class="label">Hosts</div><div class="value" id="hosts-count">--</div></div>
<div class="stat"><div class="label">Proxmox</div><div class="value" id="proxmox-count">--</div></div>
<div class="status"><div class="dot" id="dot"></div><span id="updated">Loading...</span></div>
<button class="refresh" onclick="manualRefresh(this)">Refresh</button>
</div>
</div>
<div class="tabs">
<button class="tab active" onclick="showTab('diagram', this)">Diagram</button>
<button class="tab" onclick="showTab('fullscreen', this)">Fullscreen</button>
<button class="tab" onclick="showTab('hosts', this)">Hosts</button>
<button class="tab" onclick="showTab('inventory', this)">Inventory</button>
</div>
<div class="content">
<!-- Diagram Tab -->
<div id="diagram" class="tab-content active">
<div class="diagram-container">
<svg id="diagram-svg" viewBox="0 0 960 650" class="diagram-svg"></svg>
<div class="detail-panel">
<h3 id="detail-title">Select a host</h3>
<div id="detail-content">
<p style="color: #6b7280;">Click on any host in the diagram to see container details.</p>
</div>
</div>
</div>
</div>
<!-- Fullscreen Tab -->
<div id="fullscreen" class="tab-content">
<svg id="fullscreen-svg" viewBox="0 0 1200 700" class="fullscreen-svg"></svg>
</div>
<!-- Hosts Tab -->
<div id="hosts" class="tab-content">
<div id="hosts-grid" class="grid"></div>
</div>
<!-- Inventory Tab -->
<div id="inventory" class="tab-content"></div>
</div>
<script>
let data = null;
let diagramData = null;
let selectedHost = null;
const interval = {{ refresh_interval }} * 1000;
// Tab switching
function showTab(tabId, btn) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById(tabId).classList.add('active');
}
// Fetch diagram topology (once on load)
async function fetchDiagram() {
try {
const r = await fetch('/api/diagram');
diagramData = await r.json();
} catch(e) { console.error('Diagram fetch error:', e); }
}
// Fetch data
async function fetchData() {
try {
const r = await fetch('/api/data');
data = await r.json();
render();
} catch(e) { console.error('Fetch error:', e); }
}
// Manual refresh
async function manualRefresh(btn) {
btn.disabled = true;
btn.textContent = '...';
await fetch('/api/refresh', {method: 'POST'});
await fetchData();
btn.disabled = false;
btn.textContent = 'Refresh';
}
// Main render function
function render() {
const proxmoxCount = Object.keys(data.config?.proxmox_nodes || {}).length;
document.getElementById('total').textContent = data.summary?.total_containers || 0;
document.getElementById('hosts-count').textContent = `${data.summary?.online_hosts || 0}/${data.summary?.total_hosts || 0}`;
document.getElementById('proxmox-count').textContent = proxmoxCount;
if (data.timestamp) {
const age = Math.round((Date.now() - new Date(data.timestamp).getTime()) / 1000);
document.getElementById('updated').textContent = `Updated ${age}s ago`;
document.getElementById('dot').className = age < 120 ? 'dot' : 'dot stale';
}
if (diagramData) {
renderDiagram('diagram-svg', 960, 650, false);
renderDiagram('fullscreen-svg', 1200, 700, true);
}
renderHostsGrid();
renderInventory();
if (selectedHost) showHostDetail(selectedHost);
}
// Get container count for a host
function getHostData(hostname) {
return data.hosts?.[hostname] || { container_count: 0, status: 'unknown', containers: [] };
}
// Render SVG diagram from diagramData JSON
function renderDiagram(svgId, width, height, isFullscreen) {
const svg = document.getElementById(svgId);
const ox = isFullscreen ? 150 : 0;
const net = diagramData.network || {};
const nodes = diagramData.proxmox_nodes || {};
const remote = diagramData.remote || {};
const positions = diagramData.layout?.positions || {};
const nodeCount = Object.keys(nodes).length;
let html = `
<defs>
<pattern id="grid-${svgId}" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#1a1f26" stroke-width="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid-${svgId})"/>
<!-- Legend -->
<g transform="translate(15, 15)">
<text fill="#58a6ff" font-size="11" font-weight="bold">LEGEND</text>
<rect x="0" y="8" width="10" height="10" fill="#3b82f6" opacity="0.3" stroke="#3b82f6"/>
<text x="14" y="17" fill="#8b949e" font-size="9">Proxmox</text>
<rect x="70" y="8" width="10" height="10" fill="#a855f7" opacity="0.3" stroke="#a855f7"/>
<text x="84" y="17" fill="#8b949e" font-size="9">VM</text>
<rect x="110" y="8" width="10" height="10" fill="#f59e0b" opacity="0.3" stroke="#f59e0b"/>
<text x="124" y="17" fill="#8b949e" font-size="9">LXC</text>
<rect x="155" y="8" width="10" height="10" fill="#06b6d4" opacity="0.3" stroke="#06b6d4"/>
<text x="169" y="17" fill="#8b949e" font-size="9">Remote</text>
</g>
<!-- Stats Box -->
<g transform="translate(${width - 140}, 15)">
<rect width="125" height="85" rx="6" fill="#0d1117" stroke="#30363d"/>
<text x="62" y="18" fill="#58a6ff" font-size="10" font-weight="bold" text-anchor="middle">CLUSTER</text>
<text x="10" y="36" fill="#8b949e" font-size="9">Containers:</text>
<text x="115" y="36" fill="#10b981" font-size="9" text-anchor="end">${data.summary?.total_containers || 0}</text>
<text x="10" y="52" fill="#8b949e" font-size="9">Hosts:</text>
<text x="115" y="52" fill="#10b981" font-size="9" text-anchor="end">${data.summary?.online_hosts || 0}/${data.summary?.total_hosts || 0}</text>
<text x="10" y="68" fill="#8b949e" font-size="9">Proxmox:</text>
<text x="115" y="68" fill="#10b981" font-size="9" text-anchor="end">${nodeCount} nodes</text>
<text x="10" y="80" fill="#6b7280" font-size="7">${net.subnet || ''}</text>
</g>
<!-- Internet -->
<rect x="${340 + ox}" y="40" width="180" height="40" rx="5" fill="#6366f120" stroke="#6366f1" stroke-width="1.5"/>
<text x="${430 + ox}" y="62" fill="#6366f1" font-size="12" font-weight="bold" text-anchor="middle">${net.internet?.label || 'INTERNET'}</text>
<text x="${430 + ox}" y="75" fill="#888" font-size="9" text-anchor="middle">${net.internet?.description || ''}</text>
<!-- Internet to Router line -->
<line x1="${430 + ox}" y1="80" x2="${430 + ox}" y2="100" stroke="#6366f1" stroke-width="2" opacity="0.7"/>
<!-- Router -->
<rect x="${350 + ox}" y="100" width="160" height="40" rx="5" fill="#10b98120" stroke="#10b981" stroke-width="1.5"/>
<text x="${430 + ox}" y="120" fill="#10b981" font-size="12" font-weight="bold" text-anchor="middle">${net.router?.label || 'Router'}</text>
<text x="${430 + ox}" y="133" fill="#888" font-size="9" text-anchor="middle">${net.router?.description || ''}</text>
`;
// Remote hosts (VPN connections)
for (const [name, r] of Object.entries(remote)) {
const pos = positions.remote || {x_offset: 665, y: 145};
const x = pos.x_offset + ox;
const y = pos.y;
html += `
<line x1="${510 + ox}" y1="120" x2="${x + 75}" y2="120" stroke="#06b6d4" stroke-width="2" stroke-dasharray="5,5" opacity="0.7"/>
<line x1="${x + 75}" y1="120" x2="${x + 75}" y2="${y}" stroke="#06b6d4" stroke-width="2" stroke-dasharray="5,5" opacity="0.7"/>
<text x="${(510 + ox + x + 75) / 2}" y="112" fill="#06b6d4" font-size="8" text-anchor="middle">${r.connection || 'VPN'}</text>
`;
html += renderRemoteHost(name, r, x, y, isFullscreen);
}
html += `
<!-- Router to Switch line -->
<line x1="${430 + ox}" y1="140" x2="${430 + ox}" y2="170" stroke="#10b981" stroke-width="2" opacity="0.7"/>
`;
// Hub line to first node (if positions suggest it)
const nodeNames = Object.keys(nodes);
if (nodeNames.length > 0) {
const firstPos = positions[nodeNames[0]] || {x_offset: 60, y: 240};
const firstX = firstPos.x_offset + ox + 100;
if (firstX < 430 + ox) {
html += `
<line x1="${430 + ox}" y1="155" x2="${firstX}" y2="155" stroke="#10b981" stroke-width="2" opacity="0.7"/>
<line x1="${firstX}" y1="155" x2="${firstX}" y2="${firstPos.y}" stroke="#10b981" stroke-width="2" opacity="0.7"/>
<text x="${(430 + ox + firstX) / 2}" y="150" fill="#666" font-size="8" text-anchor="middle">via Hub</text>
`;
}
}
// Switch
html += `
<rect x="${340 + ox}" y="170" width="180" height="40" rx="5" fill="#3b82f620" stroke="#3b82f6" stroke-width="1.5"/>
<text x="${430 + ox}" y="190" fill="#3b82f6" font-size="12" font-weight="bold" text-anchor="middle">${net.switch?.label || 'Switch'}</text>
<text x="${430 + ox}" y="203" fill="#888" font-size="9" text-anchor="middle">${net.switch?.description || ''}</text>
<!-- Switch connections down -->
<line x1="${430 + ox}" y1="210" x2="${430 + ox}" y2="240" stroke="#3b82f6" stroke-width="2" opacity="0.7"/>
`;
// Connect switch to last node if it's to the right
if (nodeNames.length > 1) {
const lastPos = positions[nodeNames[nodeNames.length - 1]] || {};
if (lastPos.x_offset > 430) {
const lastX = lastPos.x_offset + ox + 100;
html += `
<line x1="${430 + ox}" y1="225" x2="${lastX}" y2="225" stroke="#3b82f6" stroke-width="2" opacity="0.7"/>
<line x1="${lastX}" y1="225" x2="${lastX}" y2="${lastPos.y}" stroke="#3b82f6" stroke-width="2" opacity="0.7"/>
`;
}
}
// NAS
if (net.nas) {
html += `
<rect x="${540 + ox}" y="175" width="120" height="35" rx="5" fill="#f59e0b20" stroke="#f59e0b" stroke-width="1.5"/>
<text x="${600 + ox}" y="193" fill="#f59e0b" font-size="11" font-weight="bold" text-anchor="middle">${net.nas.label || 'NAS'}</text>
<text x="${600 + ox}" y="205" fill="#888" font-size="8" text-anchor="middle">${net.nas.description || ''}</text>
<line x1="${520 + ox}" y1="192" x2="${540 + ox}" y2="192" stroke="#f59e0b" stroke-width="2" opacity="0.7"/>
`;
}
// Proxmox nodes from diagram config
for (const [nodeName, node] of Object.entries(nodes)) {
const pos = positions[nodeName] || {x_offset: 280, y: 240};
html += renderProxmoxNode(nodeName, pos.x_offset + ox, pos.y, node.ip, node.hardware, node.gpu_label, node.children || []);
}
svg.innerHTML = html;
}
// Render remote host box
function renderRemoteHost(name, config, x, y, isFullscreen) {
const h = getHostData(name);
const color = h.status === 'online' ? '#06b6d4' : '#ef4444';
const w = isFullscreen ? 170 : 150;
return `
<rect x="${x}" y="${y}" width="${w}" height="80" rx="5" fill="${color}20" stroke="${color}" stroke-width="1.5"
class="node-box" onclick="showHostDetail('${name}')"/>
<text x="${x+10}" y="${y+18}" fill="${color}" font-size="12" font-weight="bold">${config.label || name}</text>
<text x="${x+10}" y="${y+32}" fill="#888" font-size="9">${config.description || ''}</text>
<text x="${x+10}" y="${y+50}" fill="${color}" font-size="11" font-weight="bold">${h.container_count} containers</text>
<text x="${x+10}" y="${y+65}" fill="#666" font-size="8">${config.services || ''}</text>
<text x="${x+w-5}" y="${y+18}" fill="#666" font-size="8" text-anchor="end">${config.ip || ''}</text>
`;
}
// Render a Proxmox node with its children
function renderProxmoxNode(nodeName, x, y, ip, hardware, gpuLabel, children) {
const nodeHeight = 55 + children.length * 30;
let html = `
<rect x="${x}" y="${y}" width="200" height="${nodeHeight}" rx="5" fill="#3b82f620" stroke="#3b82f6" stroke-width="1.5"/>
<text x="${x+10}" y="${y+18}" fill="#3b82f6" font-size="12" font-weight="bold">${nodeName}</text>
<text x="${x+10}" y="${y+32}" fill="#888" font-size="9">${hardware}</text>
<text x="${x+190}" y="${y+18}" fill="#666" font-size="9" text-anchor="end">${ip}</text>
`;
if (gpuLabel) {
html += `<text x="${x+10}" y="${y+45}" fill="#ec4899" font-size="8" font-weight="bold">${gpuLabel} Node</text>`;
}
let childY = y + (gpuLabel ? 52 : 42);
children.forEach(child => {
const hostData = getHostData(child.name);
const isOnline = hostData.status === 'online';
const isStatic = child.type === 'static';
let color;
if (child.type === 'vm') color = child.gpu ? '#ec4899' : '#a855f7';
else if (child.type === 'lxc') color = '#f59e0b';
else color = '#6b7280';
const strokeColor = isStatic ? '#6b7280' : (isOnline ? color : '#ef4444');
const clickAttr = isStatic ? '' : `class="node-box" onclick="showHostDetail('${child.name}')"`;
html += `
<rect x="${x+10}" y="${childY}" width="180" height="25" rx="4" fill="#1a1a2e" stroke="${strokeColor}" stroke-width="1" ${clickAttr}/>
<text x="${x+18}" y="${childY+12}" fill="${strokeColor}" font-size="9" font-weight="bold">${child.name}</text>
<text x="${x+18}" y="${childY+21}" fill="#666" font-size="7">${child.type.toUpperCase()} ${child.vmid} | ${isStatic ? '-' : hostData.container_count + ' containers'}</text>
`;
childY += 28;
});
return html;
}
// Show host detail in panel
function showHostDetail(hostname) {
selectedHost = hostname;
const hostData = getHostData(hostname);
const config = data.config?.docker_hosts?.[hostname] || {};
document.getElementById('detail-title').textContent = hostname;
let html = `
<div style="margin-bottom: 16px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span class="badge ${config.type}">${(config.type || '').toUpperCase()}</span>
<span class="badge ${hostData.status}">${hostData.status}</span>
</div>
<div style="color: #6b7280; font-size: 0.75rem;">${config.ip || ''} | ${config.node || ''}</div>
<div style="color: #8b949e; font-size: 0.75rem; margin-top: 4px;">${config.purpose || ''}</div>
</div>
<div style="font-size: 0.875rem; color: #c9d1d9; margin-bottom: 12px;">
<strong>${hostData.container_count}</strong> containers
${hostData.healthy ? `<span style="color:#10b981;">(${hostData.healthy} healthy)</span>` : ''}
${hostData.unhealthy ? `<span style="color:#ef4444;">(${hostData.unhealthy} unhealthy)</span>` : ''}
</div>
`;
if (hostData.containers && hostData.containers.length > 0) {
html += '<div class="container-list">';
hostData.containers.forEach(c => {
html += `
<div class="container-item">
<span class="container-name">${c.name}</span>
<span class="container-status ${c.health}">${c.health}</span>
</div>
`;
});
html += '</div>';
} else if (hostData.error) {
html += `<p style="color: #ef4444; font-size: 0.875rem;">${hostData.error}</p>`;
}
document.getElementById('detail-content').innerHTML = html;
}
// Render hosts grid
function renderHostsGrid() {
let html = '';
const sorted = Object.entries(data.hosts || {}).sort((a,b) => b[1].container_count - a[1].container_count);
for (const [name, h] of sorted) {
const cfg = data.config?.docker_hosts?.[name] || {};
const offline = h.status !== 'online';
html += `<div class="card ${offline ? 'offline' : ''}">
<div class="card-header">
<span class="card-title">${name}</span>
<span class="badge ${cfg.type}">${(cfg.type||'').toUpperCase()}</span>
</div>
<div class="card-meta">${cfg.ip || ''} | ${cfg.node || ''} | ${cfg.purpose || ''}</div>
<div class="card-stats">${h.container_count} containers
${h.healthy ? `<span style="color:#10b981">(${h.healthy} healthy)</span>` : ''}
${h.unhealthy ? `<span style="color:#ef4444">(${h.unhealthy} unhealthy)</span>` : ''}
<span class="badge ${h.status}">${h.status}</span>
</div>
<div class="containers">${(h.containers||[]).slice(0,15).map(c =>
`<span class="mini ${c.health === 'unhealthy' ? 'unhealthy' : ''}">${c.name}</span>`
).join('')}${h.containers?.length > 15 ? `<span class="mini">+${h.containers.length-15}</span>` : ''}</div>
</div>`;
}
document.getElementById('hosts-grid').innerHTML = html;
}
// Render inventory table
function renderInventory() {
let html = `<table><thead><tr>
<th>Host</th><th>Type</th><th>IP</th><th>VMID</th><th>Node</th><th>Containers</th><th>Status</th>
</tr></thead><tbody>`;
for (const [name, cfg] of Object.entries(data.config?.docker_hosts || {})) {
const h = data.hosts?.[name] || {};
html += `<tr>
<td><strong>${name}</strong></td>
<td><span class="badge ${cfg.type}">${(cfg.type||'').toUpperCase()}</span></td>
<td class="ip">${cfg.ip}</td>
<td>${cfg.vmid || '-'}</td>
<td>${cfg.node || '-'}</td>
<td>${h.container_count || 0}</td>
<td><span class="badge ${h.status}">${h.status || '?'}</span></td>
</tr>`;
}
html += '</tbody></table>';
document.getElementById('inventory').innerHTML = html;
}
// Initialize
fetchDiagram().then(() => fetchData());
setInterval(fetchData, interval);
</script>
</body>
</html>