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:
commit
ea9f8fca25
11 changed files with 836 additions and 0 deletions
5
.env.example
Normal file
5
.env.example
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal 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
113
README.md
Normal 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
136
app.py
Normal 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
26
diagram.json.example
Normal 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
29
docker-compose.yml
Normal 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
9
entrypoint.sh
Normal 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
9
hosts.json.example
Normal 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
5
requirements.txt
Normal 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
487
templates/index.html
Normal 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>
|
||||
Loading…
Reference in a new issue