Add dashboard summary view, server detail routing, and VM listing
Rearchitect to pure aggregator dashboard (no /proc) with bare agents on each host. Agents report VM data via sudo virsh (cached 10s). UI rewritten with hash-based routing: summary card grid and per-server detail view with CPU grid, memory, load, uptime, and VM table. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
89
app.py
89
app.py
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -10,7 +11,7 @@ app = Flask(__name__)
|
|||||||
|
|
||||||
PROC = os.environ.get("SYSMON_PROC", "/host/proc")
|
PROC = os.environ.get("SYSMON_PROC", "/host/proc")
|
||||||
|
|
||||||
# Server configuration: "name:url,name:url" where "local" means read from /proc
|
# Server configuration: "name:url,name:url"
|
||||||
SYSMON_SERVERS = os.environ.get("SYSMON_SERVERS", "")
|
SYSMON_SERVERS = os.environ.get("SYSMON_SERVERS", "")
|
||||||
servers = []
|
servers = []
|
||||||
if SYSMON_SERVERS:
|
if SYSMON_SERVERS:
|
||||||
@@ -18,13 +19,20 @@ if SYSMON_SERVERS:
|
|||||||
name, url = entry.strip().split(":", 1)
|
name, url = entry.strip().split(":", 1)
|
||||||
servers.append({"name": name.strip(), "url": url.strip()})
|
servers.append({"name": name.strip(), "url": url.strip()})
|
||||||
|
|
||||||
|
# Dashboard-only mode: no local /proc reading when SYSMON_SERVERS is set
|
||||||
|
IS_DASHBOARD = bool(servers)
|
||||||
|
|
||||||
# Shared state for per-core CPU usage
|
# Shared state for per-core CPU usage
|
||||||
cpu_snapshot = {"cores": [], "overall": 0.0}
|
cpu_snapshot = {"cores": [], "overall": 0.0}
|
||||||
cpu_lock = threading.Lock()
|
cpu_lock = threading.Lock()
|
||||||
|
|
||||||
|
# VM cache
|
||||||
|
_vm_cache = {"data": [], "ts": 0}
|
||||||
|
_vm_lock = threading.Lock()
|
||||||
|
VM_CACHE_TTL = 10
|
||||||
|
|
||||||
|
|
||||||
def parse_proc_stat():
|
def parse_proc_stat():
|
||||||
"""Parse /proc/stat and return per-cpu jiffies as list of (id, user+nice+system, total)."""
|
|
||||||
cores = []
|
cores = []
|
||||||
overall = None
|
overall = None
|
||||||
with open(f"{PROC}/stat") as f:
|
with open(f"{PROC}/stat") as f:
|
||||||
@@ -45,7 +53,6 @@ def parse_proc_stat():
|
|||||||
|
|
||||||
|
|
||||||
def cpu_sampler():
|
def cpu_sampler():
|
||||||
"""Background thread: sample /proc/stat every 1s, compute deltas."""
|
|
||||||
global cpu_snapshot
|
global cpu_snapshot
|
||||||
prev_cores, prev_overall = parse_proc_stat()
|
prev_cores, prev_overall = parse_proc_stat()
|
||||||
prev_map = {cid: (busy, total) for cid, busy, total in prev_cores}
|
prev_map = {cid: (busy, total) for cid, busy, total in prev_cores}
|
||||||
@@ -79,7 +86,6 @@ def cpu_sampler():
|
|||||||
|
|
||||||
|
|
||||||
def get_memory():
|
def get_memory():
|
||||||
"""Parse /proc/meminfo, return dict with MB values."""
|
|
||||||
info = {}
|
info = {}
|
||||||
with open(f"{PROC}/meminfo") as f:
|
with open(f"{PROC}/meminfo") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
@@ -103,7 +109,6 @@ def get_memory():
|
|||||||
|
|
||||||
|
|
||||||
def get_load():
|
def get_load():
|
||||||
"""Parse /proc/loadavg."""
|
|
||||||
with open(f"{PROC}/loadavg") as f:
|
with open(f"{PROC}/loadavg") as f:
|
||||||
parts = f.read().split()
|
parts = f.read().split()
|
||||||
return {
|
return {
|
||||||
@@ -114,7 +119,6 @@ def get_load():
|
|||||||
|
|
||||||
|
|
||||||
def get_uptime():
|
def get_uptime():
|
||||||
"""Parse /proc/uptime, return human-readable string."""
|
|
||||||
with open(f"{PROC}/uptime") as f:
|
with open(f"{PROC}/uptime") as f:
|
||||||
secs = float(f.read().split()[0])
|
secs = float(f.read().split()[0])
|
||||||
days = int(secs // 86400)
|
days = int(secs // 86400)
|
||||||
@@ -129,22 +133,72 @@ def get_uptime():
|
|||||||
return {"seconds": round(secs), "human": " ".join(parts)}
|
return {"seconds": round(secs), "human": " ".join(parts)}
|
||||||
|
|
||||||
|
|
||||||
|
def get_vms():
|
||||||
|
"""Get VM list via sudo virsh. Returns list of dicts. Cached for VM_CACHE_TTL seconds."""
|
||||||
|
with _vm_lock:
|
||||||
|
now = time.time()
|
||||||
|
if now - _vm_cache["ts"] < VM_CACHE_TTL:
|
||||||
|
return _vm_cache["data"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["sudo", "virsh", "list", "--all", "--name"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
names = [n.strip() for n in result.stdout.strip().split("\n") if n.strip()]
|
||||||
|
vms = []
|
||||||
|
for name in names:
|
||||||
|
info = subprocess.run(
|
||||||
|
["sudo", "virsh", "dominfo", name],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if info.returncode != 0:
|
||||||
|
continue
|
||||||
|
vm = {"name": name, "state": "unknown", "vcpus": 0, "memory_mb": 0, "autostart": False}
|
||||||
|
for line in info.stdout.split("\n"):
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, val = line.split(":", 1)
|
||||||
|
key = key.strip()
|
||||||
|
val = val.strip()
|
||||||
|
if key == "State":
|
||||||
|
vm["state"] = val
|
||||||
|
elif key == "CPU(s)":
|
||||||
|
vm["vcpus"] = int(val)
|
||||||
|
elif key == "Max memory":
|
||||||
|
# virsh reports in KiB
|
||||||
|
vm["memory_mb"] = int(val.split()[0]) // 1024
|
||||||
|
elif key == "Autostart":
|
||||||
|
vm["autostart"] = val.lower() in ("enable", "enabled")
|
||||||
|
vms.append(vm)
|
||||||
|
|
||||||
|
with _vm_lock:
|
||||||
|
_vm_cache["data"] = vms
|
||||||
|
_vm_cache["ts"] = time.time()
|
||||||
|
return vms
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def get_local_stats():
|
def get_local_stats():
|
||||||
"""Build stats dict from local /proc."""
|
|
||||||
with cpu_lock:
|
with cpu_lock:
|
||||||
snap = cpu_snapshot.copy()
|
snap = cpu_snapshot.copy()
|
||||||
return {
|
stats = {
|
||||||
"cores": snap["cores"],
|
"cores": snap["cores"],
|
||||||
"overall_cpu": snap["overall"],
|
"overall_cpu": snap["overall"],
|
||||||
"memory": get_memory(),
|
"memory": get_memory(),
|
||||||
"load": get_load(),
|
"load": get_load(),
|
||||||
"uptime": get_uptime(),
|
"uptime": get_uptime(),
|
||||||
"num_cores": len(snap["cores"]),
|
"num_cores": len(snap["cores"]),
|
||||||
|
"vms": get_vms(),
|
||||||
}
|
}
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
def fetch_remote_stats(url, timeout=2):
|
def fetch_remote_stats(url, timeout=3):
|
||||||
"""Fetch /api/stats from a remote agent. Returns dict or None on failure."""
|
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url.rstrip("/") + "/api/stats")
|
req = urllib.request.Request(url.rstrip("/") + "/api/stats")
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
@@ -160,6 +214,8 @@ def index():
|
|||||||
|
|
||||||
@app.route("/api/stats")
|
@app.route("/api/stats")
|
||||||
def stats():
|
def stats():
|
||||||
|
if IS_DASHBOARD:
|
||||||
|
return jsonify({"error": "dashboard-only mode, use /api/servers"}), 400
|
||||||
return jsonify(get_local_stats())
|
return jsonify(get_local_stats())
|
||||||
|
|
||||||
|
|
||||||
@@ -167,12 +223,6 @@ def stats():
|
|||||||
def all_servers():
|
def all_servers():
|
||||||
results = []
|
results = []
|
||||||
for srv in servers:
|
for srv in servers:
|
||||||
if srv["url"] == "local":
|
|
||||||
data = get_local_stats()
|
|
||||||
data["name"] = srv["name"]
|
|
||||||
data["status"] = "online"
|
|
||||||
results.append(data)
|
|
||||||
else:
|
|
||||||
data = fetch_remote_stats(srv["url"])
|
data = fetch_remote_stats(srv["url"])
|
||||||
if data:
|
if data:
|
||||||
data["name"] = srv["name"]
|
data["name"] = srv["name"]
|
||||||
@@ -184,6 +234,7 @@ def all_servers():
|
|||||||
return jsonify(servers=results)
|
return jsonify(servers=results)
|
||||||
|
|
||||||
|
|
||||||
# Start background sampler
|
# Only start CPU sampler in agent mode (not dashboard-only)
|
||||||
t = threading.Thread(target=cpu_sampler, daemon=True)
|
if not IS_DASHBOARD:
|
||||||
t.start()
|
t = threading.Thread(target=cpu_sampler, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
HOST="${1:-console}"
|
HOST="${1:-console}"
|
||||||
|
PORT="${2:-8083}"
|
||||||
|
|
||||||
echo "Deploying sysmon agent to $HOST..."
|
echo "Deploying sysmon agent to $HOST on port $PORT..."
|
||||||
|
|
||||||
# Create directory and copy app
|
# Create directory and copy app
|
||||||
ssh "$HOST" 'mkdir -p ~/sysmon-agent/templates'
|
ssh "$HOST" 'mkdir -p ~/sysmon-agent/templates'
|
||||||
@@ -11,7 +12,7 @@ scp ~/sysmon/app.py "$HOST":~/sysmon-agent/
|
|||||||
scp ~/sysmon/templates/index.html "$HOST":~/sysmon-agent/templates/
|
scp ~/sysmon/templates/index.html "$HOST":~/sysmon-agent/templates/
|
||||||
|
|
||||||
# Install dependencies and set up systemd service
|
# Install dependencies and set up systemd service
|
||||||
ssh "$HOST" bash <<'REMOTE'
|
ssh "$HOST" PORT="$PORT" bash <<'REMOTE'
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
pip3 install --break-system-packages --quiet flask gunicorn 2>/dev/null || \
|
pip3 install --break-system-packages --quiet flask gunicorn 2>/dev/null || \
|
||||||
@@ -28,7 +29,7 @@ Type=simple
|
|||||||
User=kamaji
|
User=kamaji
|
||||||
WorkingDirectory=/home/kamaji/sysmon-agent
|
WorkingDirectory=/home/kamaji/sysmon-agent
|
||||||
Environment=SYSMON_PROC=/proc
|
Environment=SYSMON_PROC=/proc
|
||||||
ExecStart=/home/kamaji/.local/bin/gunicorn -b 0.0.0.0:8083 -w 1 --threads 2 app:app
|
ExecStart=/usr/bin/python3 -m gunicorn -b 0.0.0.0:${PORT} -w 1 --threads 2 app:app
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
@@ -45,4 +46,4 @@ sudo systemctl status sysmon-agent --no-pager -l
|
|||||||
REMOTE
|
REMOTE
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Verify: curl http://$HOST:8083/api/stats"
|
echo "Verify: curl http://$HOST:$PORT/api/stats"
|
||||||
|
|||||||
4
run.sh
4
run.sh
@@ -15,8 +15,8 @@ podman run -d \
|
|||||||
--name sysmon \
|
--name sysmon \
|
||||||
-p 8083:8083 \
|
-p 8083:8083 \
|
||||||
--security-opt label=disable \
|
--security-opt label=disable \
|
||||||
-v /proc:/host/proc:ro \
|
--add-host=host.containers.internal:host-gateway \
|
||||||
-e SYSMON_SERVERS="compute1:local,console:http://192.168.88.5:8083" \
|
-e SYSMON_SERVERS="compute1:http://host.containers.internal:8084,console:http://192.168.88.5:8083" \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
sysmon
|
sysmon
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
font-size: 14px; padding: 20px;
|
font-size: 14px; padding: 20px;
|
||||||
}
|
}
|
||||||
h1 { font-size: 18px; color: #58a6ff; margin-bottom: 4px; }
|
a { color: inherit; text-decoration: none; }
|
||||||
|
h1 { font-size: 18px; color: #58a6ff; margin-bottom: 4px; cursor: pointer; }
|
||||||
.subtitle { color: #484f58; font-size: 12px; margin-bottom: 20px; }
|
.subtitle { color: #484f58; font-size: 12px; margin-bottom: 20px; }
|
||||||
.section { margin-bottom: 24px; }
|
.section { margin-bottom: 24px; }
|
||||||
.section-title {
|
.section-title {
|
||||||
@@ -20,23 +21,60 @@
|
|||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Server sections */
|
/* Status indicator */
|
||||||
.server-section {
|
.status {
|
||||||
margin-bottom: 32px; padding: 16px;
|
position: fixed; top: 12px; right: 20px; font-size: 11px; color: #484f58;
|
||||||
border: 1px solid #21262d; border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
.server-header {
|
.status-dot {
|
||||||
display: flex; align-items: center; gap: 10px; margin-bottom: 16px;
|
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: #238636; margin-right: 4px; vertical-align: middle;
|
||||||
}
|
}
|
||||||
.server-name { font-size: 16px; color: #58a6ff; font-weight: bold; }
|
|
||||||
.server-badge {
|
/* Dashboard cards */
|
||||||
|
.card-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.server-card {
|
||||||
|
background: #161b22; border: 1px solid #21262d; border-radius: 8px;
|
||||||
|
padding: 16px; cursor: pointer; transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.server-card:hover { border-color: #58a6ff; }
|
||||||
|
.server-card.unreachable { opacity: 0.5; cursor: default; }
|
||||||
|
.card-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.card-name { font-size: 16px; color: #58a6ff; font-weight: bold; }
|
||||||
|
.badge {
|
||||||
font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: bold;
|
font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: bold;
|
||||||
}
|
}
|
||||||
.server-badge.online { background: #238636; color: #fff; }
|
.badge.online { background: #238636; color: #fff; }
|
||||||
.server-badge.unreachable { background: #da3633; color: #fff; }
|
.badge.unreachable { background: #da3633; color: #fff; }
|
||||||
.server-unreachable {
|
.card-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
color: #484f58; font-style: italic; padding: 20px; text-align: center;
|
.card-stat {
|
||||||
|
background: #0d1117; border-radius: 4px; padding: 8px;
|
||||||
}
|
}
|
||||||
|
.card-stat-label { font-size: 10px; color: #484f58; text-transform: uppercase; }
|
||||||
|
.card-stat-value { font-size: 16px; font-weight: bold; margin-top: 2px; }
|
||||||
|
.card-mem-bar {
|
||||||
|
height: 6px; background: #21262d; border-radius: 3px; overflow: hidden;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.card-mem-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
|
||||||
|
|
||||||
|
/* Back link */
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
color: #8b949e; font-size: 13px; margin-bottom: 16px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.back-link:hover { color: #58a6ff; }
|
||||||
|
|
||||||
|
/* Detail view server header */
|
||||||
|
.detail-header {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.detail-name { font-size: 20px; color: #58a6ff; font-weight: bold; }
|
||||||
|
|
||||||
/* CPU grid */
|
/* CPU grid */
|
||||||
.cpu-grid { display: grid; gap: 4px; }
|
.cpu-grid { display: grid; gap: 4px; }
|
||||||
@@ -61,13 +99,6 @@
|
|||||||
background: #161b22; border: 1px solid #21262d; border-radius: 6px;
|
background: #161b22; border: 1px solid #21262d; border-radius: 6px;
|
||||||
height: 32px; position: relative; overflow: hidden;
|
height: 32px; position: relative; overflow: hidden;
|
||||||
}
|
}
|
||||||
.bar-fill {
|
|
||||||
height: 100%; transition: width 0.3s ease;
|
|
||||||
display: flex; align-items: center; padding-left: 10px;
|
|
||||||
font-size: 13px; font-weight: bold; white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Memory bar */
|
|
||||||
.mem-bar { display: flex; height: 100%; }
|
.mem-bar { display: flex; height: 100%; }
|
||||||
.mem-bar > div {
|
.mem-bar > div {
|
||||||
height: 100%; display: flex; align-items: center; justify-content: center;
|
height: 100%; display: flex; align-items: center; justify-content: center;
|
||||||
@@ -99,31 +130,48 @@
|
|||||||
padding: 10px 16px; display: inline-block; font-size: 15px;
|
padding: 10px 16px; display: inline-block; font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status indicator */
|
/* VM table */
|
||||||
.status {
|
.vm-table {
|
||||||
position: fixed; top: 12px; right: 20px; font-size: 11px; color: #484f58;
|
width: 100%; border-collapse: collapse;
|
||||||
|
background: #161b22; border: 1px solid #21262d; border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.status-dot {
|
.vm-table th {
|
||||||
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
text-align: left; padding: 8px 12px; font-size: 11px; color: #484f58;
|
||||||
background: #238636; margin-right: 4px; vertical-align: middle;
|
text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid #21262d; background: #0d1117;
|
||||||
}
|
}
|
||||||
|
.vm-table td {
|
||||||
|
padding: 8px 12px; border-bottom: 1px solid #21262d; font-size: 13px;
|
||||||
|
}
|
||||||
|
.vm-table tr:last-child td { border-bottom: none; }
|
||||||
|
.vm-state {
|
||||||
|
font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: bold;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.vm-state.running { background: #238636; color: #fff; }
|
||||||
|
.vm-state.shut-off { background: #484f58; color: #c9d1d9; }
|
||||||
|
.vm-state.other { background: #d29922; color: #fff; }
|
||||||
|
.vm-none { color: #484f58; font-style: italic; padding: 16px; }
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
body { padding: 12px; }
|
body { padding: 12px; }
|
||||||
.server-section { padding: 10px; }
|
.card-grid { grid-template-columns: 1fr; }
|
||||||
|
.card-stats { grid-template-columns: 1fr 1fr; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>System Monitor</h1>
|
<h1 onclick="navigateTo('dashboard')">System Monitor</h1>
|
||||||
<div class="subtitle" id="subtitle">connecting...</div>
|
<div class="subtitle" id="subtitle">connecting...</div>
|
||||||
<div class="status"><span class="status-dot" id="status-dot"></span><span id="status-text">connecting...</span></div>
|
<div class="status"><span class="status-dot" id="status-dot"></span><span id="status-text">connecting...</span></div>
|
||||||
|
|
||||||
<div id="servers-container"></div>
|
<div id="app"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const container = document.getElementById('servers-container');
|
const appEl = document.getElementById('app');
|
||||||
const serverSections = {}; // track built sections by name
|
let latestData = null;
|
||||||
|
let currentView = 'dashboard';
|
||||||
|
|
||||||
function usageColor(pct) {
|
function usageColor(pct) {
|
||||||
if (pct < 30) return '#238636';
|
if (pct < 30) return '#238636';
|
||||||
@@ -152,135 +200,183 @@ function gridCols(numCores) {
|
|||||||
return 8;
|
return 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildServerSection(name) {
|
function vmStateClass(state) {
|
||||||
const section = document.createElement('div');
|
if (state === 'running') return 'running';
|
||||||
section.className = 'server-section';
|
if (state === 'shut off') return 'shut-off';
|
||||||
section.id = 'srv-' + name;
|
return 'other';
|
||||||
section.innerHTML =
|
|
||||||
'<div class="server-header">' +
|
|
||||||
'<div class="server-name">' + name + '</div>' +
|
|
||||||
'<div class="server-badge" id="badge-' + name + '">...</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div id="content-' + name + '">' +
|
|
||||||
'<div class="section">' +
|
|
||||||
'<div class="section-title">CPU Cores \u2014 <span id="overall-' + name + '">0</span>% overall</div>' +
|
|
||||||
'<div class="cpu-grid" id="grid-' + name + '"></div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="section">' +
|
|
||||||
'<div class="section-title">Memory</div>' +
|
|
||||||
'<div class="bar-container"><div class="mem-bar" id="mem-' + name + '"></div></div>' +
|
|
||||||
'<div class="mem-details" id="memd-' + name + '"></div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="section">' +
|
|
||||||
'<div class="section-title">Load Average</div>' +
|
|
||||||
'<div class="load-row" id="load-' + name + '"></div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="section">' +
|
|
||||||
'<div class="section-title">Uptime</div>' +
|
|
||||||
'<div class="uptime" id="up-' + name + '">\u2014</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
container.appendChild(section);
|
|
||||||
serverSections[name] = { built: true, coreCount: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureCores(name, numCores) {
|
function navigateTo(view) {
|
||||||
if (serverSections[name].coreCount === numCores) return;
|
if (view === 'dashboard') {
|
||||||
const grid = document.getElementById('grid-' + name);
|
window.location.hash = '';
|
||||||
grid.style.gridTemplateColumns = 'repeat(' + gridCols(numCores) + ', 1fr)';
|
} else {
|
||||||
grid.innerHTML = '';
|
window.location.hash = view;
|
||||||
for (let i = 0; i < numCores; i++) {
|
|
||||||
const cell = document.createElement('div');
|
|
||||||
cell.className = 'core-cell';
|
|
||||||
cell.innerHTML =
|
|
||||||
'<div class="core-id">' + i + '</div>' +
|
|
||||||
'<div class="core-pct" id="cpct-' + name + '-' + i + '">\u2014</div>' +
|
|
||||||
'<div class="core-bar"><div class="core-bar-fill" id="cbar-' + name + '-' + i + '"></div></div>';
|
|
||||||
grid.appendChild(cell);
|
|
||||||
}
|
}
|
||||||
serverSections[name].coreCount = numCores;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateServer(srv) {
|
function getView() {
|
||||||
if (!serverSections[srv.name]) buildServerSection(srv.name);
|
const hash = window.location.hash.replace('#', '');
|
||||||
|
if (!hash || hash === 'dashboard') return 'dashboard';
|
||||||
const badge = document.getElementById('badge-' + srv.name);
|
return hash;
|
||||||
const content = document.getElementById('content-' + srv.name);
|
}
|
||||||
|
|
||||||
|
// ---- Dashboard View ----
|
||||||
|
function renderDashboard(servers) {
|
||||||
|
let html = '<div class="card-grid">';
|
||||||
|
for (const srv of servers) {
|
||||||
if (srv.status === 'unreachable') {
|
if (srv.status === 'unreachable') {
|
||||||
badge.textContent = 'unreachable';
|
html += '<div class="server-card unreachable">' +
|
||||||
badge.className = 'server-badge unreachable';
|
'<div class="card-header">' +
|
||||||
content.innerHTML = '<div class="server-unreachable">Server unreachable</div>';
|
'<span class="card-name">' + srv.name + '</span>' +
|
||||||
serverSections[srv.name].coreCount = 0; // force rebuild on reconnect
|
'<span class="badge unreachable">unreachable</span>' +
|
||||||
return;
|
'</div>' +
|
||||||
|
'<div style="color:#484f58;font-style:italic;text-align:center;padding:12px">Server unreachable</div>' +
|
||||||
|
'</div>';
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
const mem = srv.memory;
|
||||||
|
const vmCount = (srv.vms || []).length;
|
||||||
|
const runningVms = (srv.vms || []).filter(v => v.state === 'running').length;
|
||||||
|
html += '<div class="server-card" onclick="navigateTo(\'' + srv.name + '\')">' +
|
||||||
|
'<div class="card-header">' +
|
||||||
|
'<span class="card-name">' + srv.name + '</span>' +
|
||||||
|
'<span class="badge online">online</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card-stats">' +
|
||||||
|
'<div class="card-stat">' +
|
||||||
|
'<div class="card-stat-label">CPU</div>' +
|
||||||
|
'<div class="card-stat-value" style="color:' + usageColor(srv.overall_cpu) + '">' + srv.overall_cpu + '%</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card-stat">' +
|
||||||
|
'<div class="card-stat-label">Memory</div>' +
|
||||||
|
'<div class="card-stat-value" style="color:' + usageColor(mem.percent) + '">' + mem.percent + '%</div>' +
|
||||||
|
'<div class="card-mem-bar"><div class="card-mem-fill" style="width:' + mem.percent + '%;background:' + usageColor(mem.percent) + '"></div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card-stat">' +
|
||||||
|
'<div class="card-stat-label">Load (1m)</div>' +
|
||||||
|
'<div class="card-stat-value" style="color:' + loadColor(srv.load.load1, srv.num_cores) + '">' + srv.load.load1.toFixed(2) + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card-stat">' +
|
||||||
|
'<div class="card-stat-label">Uptime</div>' +
|
||||||
|
'<div class="card-stat-value">' + srv.uptime.human + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
(vmCount > 0 ? '<div style="margin-top:10px;font-size:12px;color:#8b949e">VMs: ' + runningVms + ' running / ' + vmCount + ' total</div>' : '') +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
badge.textContent = 'online';
|
// ---- Detail View ----
|
||||||
badge.className = 'server-badge online';
|
function renderDetail(srv) {
|
||||||
|
if (!srv || srv.status === 'unreachable') {
|
||||||
// Restore content if it was showing unreachable
|
return '<div class="back-link" onclick="navigateTo(\'dashboard\')">← Dashboard</div>' +
|
||||||
if (content.querySelector('.server-unreachable')) {
|
'<div style="color:#484f58;font-style:italic;text-align:center;padding:40px">Server unreachable</div>';
|
||||||
const section = document.getElementById('srv-' + srv.name);
|
|
||||||
section.remove();
|
|
||||||
delete serverSections[srv.name];
|
|
||||||
buildServerSection(srv.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const numCores = srv.num_cores || srv.cores.length;
|
const numCores = srv.num_cores || srv.cores.length;
|
||||||
ensureCores(srv.name, numCores);
|
let html = '<div class="back-link" onclick="navigateTo(\'dashboard\')">← Dashboard</div>';
|
||||||
|
html += '<div class="detail-header"><span class="detail-name">' + srv.name + '</span><span class="badge online">online</span></div>';
|
||||||
|
|
||||||
// CPU cores
|
// CPU
|
||||||
|
html += '<div class="section"><div class="section-title">CPU Cores — ' + srv.overall_cpu + '% overall</div>';
|
||||||
|
html += '<div class="cpu-grid" style="grid-template-columns:repeat(' + gridCols(numCores) + ',1fr)">';
|
||||||
for (const core of srv.cores) {
|
for (const core of srv.cores) {
|
||||||
const pctEl = document.getElementById('cpct-' + srv.name + '-' + core.id);
|
const pct = core.usage_percent;
|
||||||
const barEl = document.getElementById('cbar-' + srv.name + '-' + core.id);
|
const color = usageColor(pct);
|
||||||
if (pctEl) {
|
html += '<div class="core-cell">' +
|
||||||
pctEl.textContent = core.usage_percent + '%';
|
'<div class="core-id">' + core.id + '</div>' +
|
||||||
pctEl.style.color = usageColor(core.usage_percent);
|
'<div class="core-pct" style="color:' + color + '">' + pct + '%</div>' +
|
||||||
|
'<div class="core-bar"><div class="core-bar-fill" style="width:' + pct + '%;background:' + color + '"></div></div>' +
|
||||||
|
'</div>';
|
||||||
}
|
}
|
||||||
if (barEl) {
|
html += '</div></div>';
|
||||||
barEl.style.width = core.usage_percent + '%';
|
|
||||||
barEl.style.backgroundColor = usageColor(core.usage_percent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overall CPU
|
|
||||||
document.getElementById('overall-' + srv.name).textContent = srv.overall_cpu;
|
|
||||||
|
|
||||||
// Memory
|
// Memory
|
||||||
const mem = srv.memory;
|
const mem = srv.memory;
|
||||||
const usedPct = (mem.used_mb - mem.cached_mb) / mem.total_mb * 100;
|
const usedPct = (mem.used_mb - mem.cached_mb) / mem.total_mb * 100;
|
||||||
const cachedPct = mem.cached_mb / mem.total_mb * 100;
|
const cachedPct = mem.cached_mb / mem.total_mb * 100;
|
||||||
const freePct = 100 - usedPct - cachedPct;
|
const freePct = 100 - usedPct - cachedPct;
|
||||||
document.getElementById('mem-' + srv.name).innerHTML =
|
html += '<div class="section"><div class="section-title">Memory</div>' +
|
||||||
|
'<div class="bar-container"><div class="mem-bar">' +
|
||||||
'<div class="mem-used" style="width:' + usedPct + '%">' + (usedPct > 5 ? formatMB(mem.used_mb - mem.cached_mb) : '') + '</div>' +
|
'<div class="mem-used" style="width:' + usedPct + '%">' + (usedPct > 5 ? formatMB(mem.used_mb - mem.cached_mb) : '') + '</div>' +
|
||||||
'<div class="mem-cached" style="width:' + cachedPct + '%">' + (cachedPct > 5 ? formatMB(mem.cached_mb) : '') + '</div>' +
|
'<div class="mem-cached" style="width:' + cachedPct + '%">' + (cachedPct > 5 ? formatMB(mem.cached_mb) : '') + '</div>' +
|
||||||
'<div class="mem-free" style="width:' + freePct + '%">' + (freePct > 5 ? formatMB(mem.available_mb) : '') + '</div>';
|
'<div class="mem-free" style="width:' + freePct + '%">' + (freePct > 5 ? formatMB(mem.available_mb) : '') + '</div>' +
|
||||||
document.getElementById('memd-' + srv.name).innerHTML =
|
'</div></div>' +
|
||||||
|
'<div class="mem-details">' +
|
||||||
'<span><span class="mem-dot" style="background:#da3633"></span>Used: ' + formatMB(mem.used_mb - mem.cached_mb) + '</span>' +
|
'<span><span class="mem-dot" style="background:#da3633"></span>Used: ' + formatMB(mem.used_mb - mem.cached_mb) + '</span>' +
|
||||||
'<span><span class="mem-dot" style="background:#d29922"></span>Cached: ' + formatMB(mem.cached_mb) + '</span>' +
|
'<span><span class="mem-dot" style="background:#d29922"></span>Cached: ' + formatMB(mem.cached_mb) + '</span>' +
|
||||||
'<span><span class="mem-dot" style="background:#238636"></span>Available: ' + formatMB(mem.available_mb) + '</span>' +
|
'<span><span class="mem-dot" style="background:#238636"></span>Available: ' + formatMB(mem.available_mb) + '</span>' +
|
||||||
'<span style="color:#484f58">Total: ' + formatMB(mem.total_mb) + '</span>' +
|
'<span style="color:#484f58">Total: ' + formatMB(mem.total_mb) + '</span>' +
|
||||||
'<span style="color:#484f58">' + mem.percent + '%</span>';
|
'<span style="color:#484f58">' + mem.percent + '%</span>' +
|
||||||
|
'</div></div>';
|
||||||
|
|
||||||
// Load
|
// Load
|
||||||
const load = srv.load;
|
const load = srv.load;
|
||||||
document.getElementById('load-' + srv.name).innerHTML =
|
html += '<div class="section"><div class="section-title">Load Average</div>' +
|
||||||
|
'<div class="load-row">' +
|
||||||
'<div class="load-item"><div class="load-label">1 min</div><div class="load-value" style="color:' + loadColor(load.load1, numCores) + '">' + load.load1.toFixed(2) + '</div></div>' +
|
'<div class="load-item"><div class="load-label">1 min</div><div class="load-value" style="color:' + loadColor(load.load1, numCores) + '">' + load.load1.toFixed(2) + '</div></div>' +
|
||||||
'<div class="load-item"><div class="load-label">5 min</div><div class="load-value" style="color:' + loadColor(load.load5, numCores) + '">' + load.load5.toFixed(2) + '</div></div>' +
|
'<div class="load-item"><div class="load-label">5 min</div><div class="load-value" style="color:' + loadColor(load.load5, numCores) + '">' + load.load5.toFixed(2) + '</div></div>' +
|
||||||
'<div class="load-item"><div class="load-label">15 min</div><div class="load-value" style="color:' + loadColor(load.load15, numCores) + '">' + load.load15.toFixed(2) + '</div></div>';
|
'<div class="load-item"><div class="load-label">15 min</div><div class="load-value" style="color:' + loadColor(load.load15, numCores) + '">' + load.load15.toFixed(2) + '</div></div>' +
|
||||||
|
'</div></div>';
|
||||||
|
|
||||||
// Uptime
|
// Uptime
|
||||||
document.getElementById('up-' + srv.name).textContent = srv.uptime.human;
|
html += '<div class="section"><div class="section-title">Uptime</div>' +
|
||||||
|
'<div class="uptime">' + srv.uptime.human + '</div></div>';
|
||||||
|
|
||||||
|
// VMs
|
||||||
|
const vms = srv.vms || [];
|
||||||
|
html += '<div class="section"><div class="section-title">Virtual Machines (' + vms.length + ')</div>';
|
||||||
|
if (vms.length === 0) {
|
||||||
|
html += '<div class="vm-none">No VMs found (virsh not available or no VMs defined)</div>';
|
||||||
|
} else {
|
||||||
|
// Sort: running first, then by name
|
||||||
|
const sorted = [...vms].sort((a, b) => {
|
||||||
|
if (a.state === 'running' && b.state !== 'running') return -1;
|
||||||
|
if (a.state !== 'running' && b.state === 'running') return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
html += '<table class="vm-table"><thead><tr>' +
|
||||||
|
'<th>Name</th><th>State</th><th>vCPUs</th><th>RAM</th><th>Autostart</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
for (const vm of sorted) {
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td>' + vm.name + '</td>' +
|
||||||
|
'<td><span class="vm-state ' + vmStateClass(vm.state) + '">' + vm.state + '</span></td>' +
|
||||||
|
'<td>' + vm.vcpus + '</td>' +
|
||||||
|
'<td>' + formatMB(vm.memory_mb) + '</td>' +
|
||||||
|
'<td>' + (vm.autostart ? 'yes' : 'no') + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!latestData) return;
|
||||||
|
const view = getView();
|
||||||
|
if (view === 'dashboard') {
|
||||||
|
appEl.innerHTML = renderDashboard(latestData.servers);
|
||||||
|
} else {
|
||||||
|
const srv = latestData.servers.find(s => s.name === view);
|
||||||
|
appEl.innerHTML = renderDetail(srv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', render);
|
||||||
|
|
||||||
let fails = 0;
|
let fails = 0;
|
||||||
async function poll() {
|
async function poll() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/servers');
|
const r = await fetch('/api/servers');
|
||||||
const data = await r.json();
|
latestData = await r.json();
|
||||||
for (const srv of data.servers) updateServer(srv);
|
render();
|
||||||
const online = data.servers.filter(s => s.status === 'online').length;
|
const online = latestData.servers.filter(s => s.status === 'online').length;
|
||||||
document.getElementById('subtitle').textContent = online + '/' + data.servers.length + ' servers online';
|
document.getElementById('subtitle').textContent = online + '/' + latestData.servers.length + ' servers online';
|
||||||
fails = 0;
|
fails = 0;
|
||||||
document.getElementById('status-dot').style.background = '#238636';
|
document.getElementById('status-dot').style.background = '#238636';
|
||||||
document.getElementById('status-text').textContent = 'live';
|
document.getElementById('status-text').textContent = 'live';
|
||||||
@@ -292,7 +388,7 @@ async function poll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
poll();
|
poll();
|
||||||
setInterval(poll, 1000);
|
setInterval(poll, 2000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user