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>
This commit is contained in:
Maddox 2026-02-03 14:57:56 -05:00
commit ea9f8fca25
11 changed files with 836 additions and 0 deletions

5
.env.example Normal file
View file

@ -0,0 +1,5 @@
DASHBOARD_PORT=5050
REFRESH_INTERVAL=60
SSH_TIMEOUT=10
SSH_KEY_PATH=/root/.ssh/id_ed25519
TZ=America/New_York

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.env
__pycache__/
*.pyc

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends openssh-client gosu && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
COPY templates/ templates/
COPY entrypoint.sh /entrypoint.sh
RUN mkdir -p /app/ssh /app/config && chmod 700 /app/ssh
RUN useradd -m -s /bin/bash dashboard && chown -R dashboard:dashboard /app
RUN chmod +x /entrypoint.sh
ENV PYTHONUNBUFFERED=1 REFRESH_INTERVAL=60 SSH_TIMEOUT=10 SSH_KEY_PATH=/app/ssh/id_ed25519
EXPOSE 5000
ENTRYPOINT ["/entrypoint.sh"]

113
README.md Normal file
View file

@ -0,0 +1,113 @@
# Infrastructure Dashboard
A lightweight, real-time infrastructure monitoring dashboard for Docker-based homelabs. Connects to hosts via SSH to collect container status and displays everything in a network topology diagram.
## Features
- **Network Diagram** — SVG topology view of your Proxmox nodes, VMs, LXCs, and remote hosts
- **Live Container Status** — SSH-based polling shows container health across all hosts
- **Host Cards** — Grid view with per-host container breakdown
- **Inventory Table** — Flat table of all managed hosts
- **Auto-refresh** — Configurable background polling interval
## Quick Start
1. **Clone the repo**
```bash
git clone ssh://git@your-server:2222/your-user/infra-dashboard.git
cd infra-dashboard
```
2. **Configure environment**
```bash
cp .env.example .env
# Edit .env with your port, SSH key path, and timezone
```
3. **Configure hosts**
```bash
cp hosts.json.example hosts.json
# Edit hosts.json with your Proxmox nodes and Docker hosts
```
4. **Configure diagram**
```bash
cp diagram.json.example diagram.json
# Edit diagram.json with your network topology
```
5. **Deploy**
```bash
docker compose up -d --build
```
6. **Access** at `http://your-host:5050`
## Configuration Files
| File | Purpose |
|------|---------|
| `.env` | Runtime environment variables (port, intervals, SSH key path) |
| `hosts.json` | Docker hosts and Proxmox nodes inventory |
| `diagram.json` | Network topology layout for SVG diagram |
### hosts.json
Defines your infrastructure inventory:
```json
{
"proxmox_nodes": {
"node-name": {"ip": "192.168.1.5", "hardware": "CPU • RAM", "role": "General"}
},
"docker_hosts": {
"host-name": {"ip": "192.168.1.80", "user": "root", "type": "vm", "vmid": "100", "node": "node-name", "purpose": "Description"}
}
}
```
- `type`: `vm`, `lxc`, or `remote`
- `vmid`: Proxmox VM/LXC ID (null for remote hosts)
- `node`: Which Proxmox node this guest runs on
### diagram.json
Defines network topology and layout positions for the SVG diagram. Includes network infrastructure (router, switch, NAS), remote hosts, and Proxmox node children with their positions.
Children with `"type": "static"` are shown in the diagram but not polled for container data.
### .env
| Variable | Default | Description |
|----------|---------|-------------|
| `DASHBOARD_PORT` | `5050` | Host port for the web UI |
| `REFRESH_INTERVAL` | `60` | Seconds between background polls |
| `SSH_TIMEOUT` | `10` | SSH connection timeout in seconds |
| `SSH_KEY_PATH` | `/root/.ssh/id_ed25519` | Path to SSH private key on the host |
| `TZ` | `America/New_York` | Container timezone |
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Dashboard web UI |
| `/api/data` | GET | All host data and container status |
| `/api/diagram` | GET | Network topology JSON |
| `/api/refresh` | POST | Trigger immediate data refresh |
| `/health` | GET | Health check with last update timestamp |
## SSH Requirements
The dashboard connects to each host via SSH to run `docker ps`. Ensure:
- The SSH key specified in `SSH_KEY_PATH` exists on the Docker host
- The key is authorized on all target hosts
- Target hosts are reachable from the container's network
## Architecture
- **Flask** web server with Gunicorn (2 workers, 4 threads)
- **Paramiko** for SSH connections
- Background thread polls all hosts on the configured interval
- Container runs as non-root `dashboard` user (entrypoint copies SSH key)
- Memory limited to 256MB, CPU limited to 0.5 cores

136
app.py Normal file
View file

@ -0,0 +1,136 @@
#!/usr/bin/env python3
import os, json, time, logging
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Lock
from flask import Flask, render_template, jsonify
import paramiko
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
app = Flask(__name__)
CONFIG = {
"refresh_interval": int(os.getenv("REFRESH_INTERVAL", "60")),
"ssh_timeout": int(os.getenv("SSH_TIMEOUT", "10")),
"ssh_key_path": os.getenv("SSH_KEY_PATH", "/app/ssh/id_ed25519"),
}
CONFIG_DIR = os.getenv("CONFIG_DIR", "/app/config")
def load_json_config(filename, default):
for path in [os.path.join(CONFIG_DIR, filename), os.path.join(os.path.dirname(__file__), filename)]:
if os.path.exists(path):
with open(path) as f:
return json.load(f)
logger.warning(f"{filename} not found, using defaults")
return default
hosts_config = load_json_config("hosts.json", {"proxmox_nodes": {}, "docker_hosts": {}})
PROXMOX_NODES = hosts_config["proxmox_nodes"]
DOCKER_HOSTS = hosts_config["docker_hosts"]
DIAGRAM_DATA = load_json_config("diagram.json", {})
class DataCache:
def __init__(self):
self.data = {}
self.lock = Lock()
def set(self, key, value):
with self.lock: self.data[key] = value
def get(self, key, default=None):
with self.lock: return self.data.get(key, default)
cache = DataCache()
def get_ssh_client(host, user):
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
pkey = None
key_path = CONFIG["ssh_key_path"]
if os.path.exists(key_path):
try: pkey = paramiko.Ed25519Key.from_private_key_file(key_path)
except:
try: pkey = paramiko.RSAKey.from_private_key_file(key_path)
except: pass
client.connect(hostname=host, username=user, pkey=pkey, timeout=CONFIG["ssh_timeout"], allow_agent=True, look_for_keys=True)
return client
def get_docker_containers(hostname, host_config):
try:
client = get_ssh_client(host_config["ip"], host_config["user"])
cmd = "docker ps --format '{{.Names}}|{{.Status}}|{{.Image}}' 2>/dev/null"
stdin, stdout, stderr = client.exec_command(cmd, timeout=15)
output = stdout.read().decode('utf-8').strip()
containers = []
for line in output.split('\n'):
if line and '|' in line:
parts = line.split('|')
if len(parts) >= 3:
status = parts[1].lower()
health = 'healthy' if 'healthy' in status else 'unhealthy' if 'unhealthy' in status else 'running' if 'up' in status else 'unknown'
containers.append({"name": parts[0], "status": parts[1], "image": parts[2], "health": health})
client.close()
return {"hostname": hostname, "status": "online", "container_count": len(containers), "containers": containers,
"healthy": sum(1 for c in containers if c["health"] in ["healthy", "running"]),
"unhealthy": sum(1 for c in containers if c["health"] == "unhealthy")}
except Exception as e:
logger.warning(f"Failed to connect to {hostname}: {e}")
return {"hostname": hostname, "status": "offline", "container_count": 0, "containers": [], "error": str(e)}
def collect_all_hosts():
results = {}
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(get_docker_containers, name, config): name for name, config in DOCKER_HOSTS.items()}
for future in as_completed(futures):
hostname = futures[future]
try: results[hostname] = future.result()
except Exception as e: results[hostname] = {"hostname": hostname, "status": "error", "error": str(e)}
return results
def refresh_data():
logger.info("Refreshing data...")
hosts = collect_all_hosts()
cache.set("hosts", hosts)
total = sum(h.get("container_count", 0) for h in hosts.values())
online = sum(1 for h in hosts.values() if h.get("status") == "online")
cache.set("summary", {"total_containers": total, "total_hosts": len(DOCKER_HOSTS), "online_hosts": online, "proxmox_nodes": len(PROXMOX_NODES)})
cache.set("last_update", datetime.now().isoformat())
logger.info(f"Refresh complete - {total} containers across {online}/{len(DOCKER_HOSTS)} hosts")
@app.route("/")
def index():
return render_template("index.html", refresh_interval=CONFIG["refresh_interval"], docker_hosts=DOCKER_HOSTS, proxmox_nodes=PROXMOX_NODES)
@app.route("/api/data")
def api_data():
return jsonify({"timestamp": cache.get("last_update"), "summary": cache.get("summary", {}), "hosts": cache.get("hosts", {}),
"config": {"docker_hosts": DOCKER_HOSTS, "proxmox_nodes": PROXMOX_NODES}})
@app.route("/api/refresh", methods=["POST"])
def api_refresh():
refresh_data()
return jsonify({"status": "ok", "timestamp": cache.get("last_update")})
@app.route("/api/diagram")
def api_diagram():
return jsonify(DIAGRAM_DATA)
@app.route("/health")
def health():
return jsonify({"status": "healthy", "last_update": cache.get("last_update")})
def background_refresh():
while True:
try: refresh_data()
except Exception as e: logger.error(f"Refresh error: {e}")
time.sleep(CONFIG["refresh_interval"])
import threading
refresh_data()
threading.Thread(target=background_refresh, daemon=True).start()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)

26
diagram.json.example Normal file
View file

@ -0,0 +1,26 @@
{
"network": {
"subnet": "192.168.1.0/24",
"internet": {"label": "INTERNET", "description": "ISP Connection"},
"router": {"label": "Router", "description": "Gateway .1"},
"switch": {"label": "Network Switch", "description": "Managed Switch .2"},
"nas": {"label": "NAS", "description": "Storage .100"}
},
"remote": {},
"proxmox_nodes": {
"pve-node1": {
"ip": ".5",
"hardware": "CPU Model | 64GB",
"gpu_label": null,
"children": [
{"name": "my-vm", "vmid": "100", "type": "vm"},
{"name": "my-lxc", "vmid": "121", "type": "lxc"}
]
}
},
"layout": {
"positions": {
"pve-node1": {"x_offset": 280, "y": 240}
}
}
}

29
docker-compose.yml Normal file
View file

@ -0,0 +1,29 @@
services:
infra-dashboard:
build: .
container_name: infra-dashboard
restart: unless-stopped
env_file: .env
ports:
- "${DASHBOARD_PORT:-5050}:5000"
volumes:
- ${SSH_KEY_PATH:-/root/.ssh/id_ed25519}:/app/ssh/id_ed25519:ro
- ./hosts.json:/app/config/hosts.json:ro
- ./diagram.json:/app/config/diagram.json:ro
networks:
- proxy
deploy:
resources:
limits:
memory: 256M
cpus: '0.5'
labels:
- "autoheal=true"
- "com.centurylinklabs.watchtower.enable=true"
- "homepage.group=Infrastructure"
- "homepage.name=Infra Dashboard"
- "homepage.icon=grafana.png"
networks:
proxy:
external: true

9
entrypoint.sh Normal file
View file

@ -0,0 +1,9 @@
#!/bin/bash
# Copy the mounted SSH key so the dashboard user can read it
if [ -f /app/ssh/id_ed25519 ]; then
cp /app/ssh/id_ed25519 /tmp/id_ed25519
chown dashboard:dashboard /tmp/id_ed25519
chmod 600 /tmp/id_ed25519
export SSH_KEY_PATH=/tmp/id_ed25519
fi
exec gosu dashboard gunicorn --bind 0.0.0.0:5000 --workers 2 --threads 4 app:app

9
hosts.json.example Normal file
View file

@ -0,0 +1,9 @@
{
"proxmox_nodes": {
"pve-node1": {"ip": "192.168.1.5", "hardware": "CPU Model • 64GB", "role": "General"}
},
"docker_hosts": {
"my-vm": {"ip": "192.168.1.80", "user": "root", "type": "vm", "vmid": "100", "node": "pve-node1", "purpose": "Web services"},
"my-lxc": {"ip": "192.168.1.121", "user": "root", "type": "lxc", "vmid": "121", "node": "pve-node1", "purpose": "Databases"}
}
}

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
flask==3.0.0
paramiko==3.4.0
requests==2.31.0
urllib3==2.1.0
gunicorn==21.2.0

487
templates/index.html Normal file
View file

@ -0,0 +1,487 @@
<!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>