diff --git a/app.py b/app.py index 502fe2e..259219a 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ import json import os +import subprocess import threading import time import urllib.request @@ -10,7 +11,7 @@ app = Flask(__name__) 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", "") servers = [] if SYSMON_SERVERS: @@ -18,13 +19,20 @@ if SYSMON_SERVERS: name, url = entry.strip().split(":", 1) 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 cpu_snapshot = {"cores": [], "overall": 0.0} cpu_lock = threading.Lock() +# VM cache +_vm_cache = {"data": [], "ts": 0} +_vm_lock = threading.Lock() +VM_CACHE_TTL = 10 + def parse_proc_stat(): - """Parse /proc/stat and return per-cpu jiffies as list of (id, user+nice+system, total).""" cores = [] overall = None with open(f"{PROC}/stat") as f: @@ -45,7 +53,6 @@ def parse_proc_stat(): def cpu_sampler(): - """Background thread: sample /proc/stat every 1s, compute deltas.""" global cpu_snapshot prev_cores, prev_overall = parse_proc_stat() prev_map = {cid: (busy, total) for cid, busy, total in prev_cores} @@ -79,7 +86,6 @@ def cpu_sampler(): def get_memory(): - """Parse /proc/meminfo, return dict with MB values.""" info = {} with open(f"{PROC}/meminfo") as f: for line in f: @@ -103,7 +109,6 @@ def get_memory(): def get_load(): - """Parse /proc/loadavg.""" with open(f"{PROC}/loadavg") as f: parts = f.read().split() return { @@ -114,7 +119,6 @@ def get_load(): def get_uptime(): - """Parse /proc/uptime, return human-readable string.""" with open(f"{PROC}/uptime") as f: secs = float(f.read().split()[0]) days = int(secs // 86400) @@ -129,22 +133,72 @@ def get_uptime(): 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(): - """Build stats dict from local /proc.""" with cpu_lock: snap = cpu_snapshot.copy() - return { + stats = { "cores": snap["cores"], "overall_cpu": snap["overall"], "memory": get_memory(), "load": get_load(), "uptime": get_uptime(), "num_cores": len(snap["cores"]), + "vms": get_vms(), } + return stats -def fetch_remote_stats(url, timeout=2): - """Fetch /api/stats from a remote agent. Returns dict or None on failure.""" +def fetch_remote_stats(url, timeout=3): try: req = urllib.request.Request(url.rstrip("/") + "/api/stats") with urllib.request.urlopen(req, timeout=timeout) as resp: @@ -160,6 +214,8 @@ def index(): @app.route("/api/stats") def stats(): + if IS_DASHBOARD: + return jsonify({"error": "dashboard-only mode, use /api/servers"}), 400 return jsonify(get_local_stats()) @@ -167,23 +223,18 @@ def stats(): def all_servers(): results = [] for srv in servers: - if srv["url"] == "local": - data = get_local_stats() + data = fetch_remote_stats(srv["url"]) + if data: data["name"] = srv["name"] data["status"] = "online" + data.setdefault("num_cores", len(data.get("cores", []))) results.append(data) else: - data = fetch_remote_stats(srv["url"]) - if data: - data["name"] = srv["name"] - data["status"] = "online" - data.setdefault("num_cores", len(data.get("cores", []))) - results.append(data) - else: - results.append({"name": srv["name"], "status": "unreachable"}) + results.append({"name": srv["name"], "status": "unreachable"}) return jsonify(servers=results) -# Start background sampler -t = threading.Thread(target=cpu_sampler, daemon=True) -t.start() +# Only start CPU sampler in agent mode (not dashboard-only) +if not IS_DASHBOARD: + t = threading.Thread(target=cpu_sampler, daemon=True) + t.start() diff --git a/deploy-agent.sh b/deploy-agent.sh index ebb786a..9ada836 100755 --- a/deploy-agent.sh +++ b/deploy-agent.sh @@ -2,8 +2,9 @@ set -e 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 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/ # Install dependencies and set up systemd service -ssh "$HOST" bash <<'REMOTE' +ssh "$HOST" PORT="$PORT" bash <<'REMOTE' set -e pip3 install --break-system-packages --quiet flask gunicorn 2>/dev/null || \ @@ -28,7 +29,7 @@ Type=simple User=kamaji WorkingDirectory=/home/kamaji/sysmon-agent 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 RestartSec=5 @@ -45,4 +46,4 @@ sudo systemctl status sysmon-agent --no-pager -l REMOTE echo "" -echo "Verify: curl http://$HOST:8083/api/stats" +echo "Verify: curl http://$HOST:$PORT/api/stats" diff --git a/run.sh b/run.sh index 541c300..2836855 100755 --- a/run.sh +++ b/run.sh @@ -15,8 +15,8 @@ podman run -d \ --name sysmon \ -p 8083:8083 \ --security-opt label=disable \ - -v /proc:/host/proc:ro \ - -e SYSMON_SERVERS="compute1:local,console:http://192.168.88.5:8083" \ + --add-host=host.containers.internal:host-gateway \ + -e SYSMON_SERVERS="compute1:http://host.containers.internal:8084,console:http://192.168.88.5:8083" \ --restart unless-stopped \ sysmon diff --git a/templates/index.html b/templates/index.html index 707c8b7..de714aa 100644 --- a/templates/index.html +++ b/templates/index.html @@ -11,7 +11,8 @@ font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; 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; } .section { margin-bottom: 24px; } .section-title { @@ -20,23 +21,60 @@ padding-bottom: 6px; } - /* Server sections */ - .server-section { - margin-bottom: 32px; padding: 16px; - border: 1px solid #21262d; border-radius: 8px; + /* Status indicator */ + .status { + position: fixed; top: 12px; right: 20px; font-size: 11px; color: #484f58; } - .server-header { - display: flex; align-items: center; gap: 10px; margin-bottom: 16px; + .status-dot { + 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; } - .server-badge.online { background: #238636; color: #fff; } - .server-badge.unreachable { background: #da3633; color: #fff; } - .server-unreachable { - color: #484f58; font-style: italic; padding: 20px; text-align: center; + .badge.online { background: #238636; color: #fff; } + .badge.unreachable { background: #da3633; color: #fff; } + .card-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } + .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 { display: grid; gap: 4px; } @@ -61,13 +99,6 @@ background: #161b22; border: 1px solid #21262d; border-radius: 6px; 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 > div { height: 100%; display: flex; align-items: center; justify-content: center; @@ -99,31 +130,48 @@ padding: 10px 16px; display: inline-block; font-size: 15px; } - /* Status indicator */ - .status { - position: fixed; top: 12px; right: 20px; font-size: 11px; color: #484f58; + /* VM table */ + .vm-table { + width: 100%; border-collapse: collapse; + background: #161b22; border: 1px solid #21262d; border-radius: 6px; + overflow: hidden; } - .status-dot { - display: inline-block; width: 6px; height: 6px; border-radius: 50%; - background: #238636; margin-right: 4px; vertical-align: middle; + .vm-table th { + text-align: left; padding: 8px 12px; font-size: 11px; color: #484f58; + 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) { body { padding: 12px; } - .server-section { padding: 10px; } + .card-grid { grid-template-columns: 1fr; } + .card-stats { grid-template-columns: 1fr 1fr; } }
-