diff --git a/pve_snapshot/README.md b/pve_snapshot/README.md new file mode 100644 index 0000000..e469f4e --- /dev/null +++ b/pve_snapshot/README.md @@ -0,0 +1,18 @@ +# Sul nodo Proxmox (raccoglie + genera HTML) +```bash +python3 pve_inventory.py # dashboard HTML +python3 pve_inventory.py --json # HTML + JSON +python3 pve_inventory.py --json-only --stdout # solo JSON a stdout +python3 pve_inventory.py --open # genera e apre nel browser +``` + +# Da un JSON già esistente (su qualsiasi PC) +```bash +python3 pve_inventory.py --from snapshot.json +python3 pve_inventory.py --from snapshot.json -o report.html --open +``` + +# Pipeline automatica via cron +```bash +0 6 * * * python3 /root/pve_inventory.py --json -o /var/www/html/pve.html +``` \ No newline at end of file diff --git a/pve_snapshot/pve_inventory.py b/pve_snapshot/pve_inventory.py new file mode 100644 index 0000000..9c3e798 --- /dev/null +++ b/pve_snapshot/pve_inventory.py @@ -0,0 +1,910 @@ +#!/usr/bin/env python3 +""" +PVE Inventory — Snapshot + Dashboard in un unico script +========================================================= +Raccoglie dati dall'ambiente Proxmox e genera una dashboard HTML interattiva. +Può anche leggere un JSON esistente e generare solo la dashboard. + +Modalità: + 1) Su nodo PVE (raccolta + dashboard): + python3 pve_inventory.py # → HTML dashboard + python3 pve_inventory.py --json # → salva anche il JSON + python3 pve_inventory.py --open # → genera e apre nel browser + + 2) Da JSON esistente (solo dashboard): + python3 pve_inventory.py --from snapshot.json + python3 pve_inventory.py --from snapshot.json -o report.html + + 3) Solo JSON (senza dashboard): + python3 pve_inventory.py --json-only + python3 pve_inventory.py --json-only --stdout + +Requisiti: + - Python 3.6+ (nessuna dipendenza esterna) + - Se eseguito su PVE: accesso root e pvesh disponibile +""" + +import json +import subprocess +import sys +import os +import re +import socket +import argparse +import webbrowser +import html as html_mod +from datetime import datetime +from pathlib import Path + + +# ═══════════════════════════════════════════════════════════════════════════ +# PARTE 1: RACCOLTA DATI (pvesh) +# ═══════════════════════════════════════════════════════════════════════════ + +def check_pvesh(): + """Verifica che pvesh sia disponibile e funzionante.""" + try: + r = subprocess.run( + ["pvesh", "get", "/version", "--output-format", "json"], + capture_output=True, text=True, timeout=10 + ) + return r.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def api_get(path): + """Chiama le API Proxmox via pvesh.""" + try: + r = subprocess.run( + ["pvesh", "get", path, "--output-format", "json"], + capture_output=True, text=True, timeout=30 + ) + if r.returncode == 0 and r.stdout.strip(): + return json.loads(r.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e: + print(f" [WARN] API {path}: {e}", file=sys.stderr) + return None + + +def bytes_human(n): + if n is None or n == 0: + return "0 B" + for u in ("B", "KB", "MB", "GB", "TB", "PB"): + if abs(n) < 1024: + return f"{n:.1f} {u}" + n /= 1024 + return f"{n:.1f} EB" + + +def pct(used, total): + return round(used / total * 100, 1) if total else 0 + + +# ── Collectors ──────────────────────────────────────────────────────────── + +def collect_version(): + log("versione PVE") + d = api_get("/version") + if d: + return {"version": d.get("version", "N/A"), "release": d.get("release", ""), "repoid": d.get("repoid", "")} + v = _run("pveversion") + return {"version": v or "N/A", "release": "", "repoid": ""} + + +def collect_nodes(): + log("nodi") + nodes = api_get("/nodes") or [] + result = [] + for node in nodes: + name = node.get("node", "unknown") + log(f" nodo {name}") + info = { + "name": name, + "status": node.get("status", "unknown"), + "uptime_seconds": node.get("uptime", 0), + "cpu_count": node.get("maxcpu", 0), + "cpu_usage_pct": round(node.get("cpu", 0) * 100, 1), + "memory_total": node.get("maxmem", 0), + "memory_used": node.get("mem", 0), + "memory_total_human": bytes_human(node.get("maxmem", 0)), + "memory_used_human": bytes_human(node.get("mem", 0)), + "memory_usage_pct": pct(node.get("mem", 0), node.get("maxmem", 0)), + "kernel": "", "cpu_model": "", + } + up = node.get("uptime", 0) + info["uptime_human"] = f"{up // 86400}d {(up % 86400) // 3600}h {(up % 3600) // 60}m" + + st = api_get(f"/nodes/{name}/status") + if st: + info["kernel"] = st.get("kversion", "") or st.get("kernel", "") + ci = st.get("cpuinfo", {}) + if ci: + info["cpu_model"] = ci.get("model", "") + info["cpu_sockets"] = ci.get("sockets", 1) + info["cpu_cores_per_socket"] = ci.get("cores", 0) + info["cpu_threads"] = ci.get("cpus", 0) + + dns = api_get(f"/nodes/{name}/dns") + if dns: + info["dns_search"] = dns.get("search", "") + servers = [dns.get(f"dns{i}", "") for i in range(1, 4)] + info["dns_servers"] = ", ".join(s for s in servers if s) + + ti = api_get(f"/nodes/{name}/time") + if ti: + info["timezone"] = ti.get("timezone", "") + + result.append(info) + return result + + +def collect_vms(node_name): + log(f" VM QEMU ({node_name})") + vms = api_get(f"/nodes/{node_name}/qemu") or [] + result = [] + for vm in sorted(vms, key=lambda x: x.get("vmid", 0)): + vmid = vm.get("vmid") + info = { + "vmid": vmid, "name": vm.get("name", ""), "status": vm.get("status", "unknown"), + "cpu_cores": vm.get("cpus", 0), + "cpu_usage_pct": round(vm.get("cpu", 0) * 100, 1), + "memory_max": vm.get("maxmem", 0), "memory_used": vm.get("mem", 0), + "memory_max_human": bytes_human(vm.get("maxmem", 0)), + "memory_used_human": bytes_human(vm.get("mem", 0)), + "disk_max": vm.get("maxdisk", 0), + "disk_max_human": bytes_human(vm.get("maxdisk", 0)), + "uptime_seconds": vm.get("uptime", 0), + "tags": vm.get("tags", ""), "template": bool(vm.get("template", 0)), + "qemu_agent": False, "disks": [], "networks": [], + "boot_order": "", "machine_type": "", "bios": "", "os_type": "", + "snapshots": [], "description": "", "scsihw": "", + } + cfg = api_get(f"/nodes/{node_name}/qemu/{vmid}/config") + if cfg: + info["bios"] = cfg.get("bios", "seabios") + info["machine_type"] = cfg.get("machine", "") + info["os_type"] = cfg.get("ostype", "") + info["boot_order"] = cfg.get("boot", "") + info["qemu_agent"] = bool(cfg.get("agent", 0)) + info["description"] = cfg.get("description", "") + info["scsihw"] = cfg.get("scsihw", "") + for k, v in cfg.items(): + if any(k.startswith(p) for p in ("scsi", "virtio", "ide", "sata", "efidisk", "tpmstate")): + if isinstance(v, str) and ":" in v: + info["disks"].append({"device": k, "config": v}) + if k.startswith("net") and isinstance(v, str): + info["networks"].append({"device": k, "config": v}) + + snaps = api_get(f"/nodes/{node_name}/qemu/{vmid}/snapshot") or [] + info["snapshots"] = [ + {"name": s.get("name", ""), "description": s.get("description", ""), "snaptime": s.get("snaptime", 0)} + for s in snaps if s.get("name") != "current" + ] + result.append(info) + return result + + +def collect_lxc(node_name): + log(f" LXC ({node_name})") + cts = api_get(f"/nodes/{node_name}/lxc") or [] + result = [] + for ct in sorted(cts, key=lambda x: x.get("vmid", 0)): + vmid = ct.get("vmid") + info = { + "vmid": vmid, "name": ct.get("name", ""), "status": ct.get("status", "unknown"), + "cpu_cores": ct.get("cpus", 0), + "cpu_usage_pct": round(ct.get("cpu", 0) * 100, 1), + "memory_max": ct.get("maxmem", 0), "memory_used": ct.get("mem", 0), + "memory_max_human": bytes_human(ct.get("maxmem", 0)), + "memory_used_human": bytes_human(ct.get("mem", 0)), + "disk_max": ct.get("maxdisk", 0), "disk_used": ct.get("disk", 0), + "disk_max_human": bytes_human(ct.get("maxdisk", 0)), + "disk_used_human": bytes_human(ct.get("disk", 0)), + "swap_max": ct.get("maxswap", 0), "swap_used": ct.get("swap", 0), + "uptime_seconds": ct.get("uptime", 0), + "tags": ct.get("tags", ""), "template": bool(ct.get("template", 0)), + "type": "lxc", "mountpoints": [], "networks": [], "snapshots": [], + } + cfg = api_get(f"/nodes/{node_name}/lxc/{vmid}/config") + if cfg: + info["hostname"] = cfg.get("hostname", "") + info["ostype"] = cfg.get("ostype", "") + info["unprivileged"] = bool(cfg.get("unprivileged", 0)) + info["description"] = cfg.get("description", "") + info["rootfs"] = cfg.get("rootfs", "") + info["arch"] = cfg.get("arch", "amd64") + info["onboot"] = bool(cfg.get("onboot", 0)) + info["startup"] = cfg.get("startup", "") + info["features"] = cfg.get("features", "") + for k, v in cfg.items(): + if k.startswith("mp") and isinstance(v, str): + info["mountpoints"].append({"device": k, "config": v}) + if k.startswith("net") and isinstance(v, str): + info["networks"].append({"device": k, "config": v}) + + snaps = api_get(f"/nodes/{node_name}/lxc/{vmid}/snapshot") or [] + info["snapshots"] = [ + {"name": s.get("name", ""), "description": s.get("description", ""), "snaptime": s.get("snaptime", 0)} + for s in snaps if s.get("name") != "current" + ] + result.append(info) + return result + + +def collect_storage(node_name): + log(f" storage ({node_name})") + sts = api_get(f"/nodes/{node_name}/storage") or [] + return [{ + "storage": s.get("storage", ""), "type": s.get("type", ""), + "status": "active" if s.get("active", 0) else "inactive", + "enabled": bool(s.get("enabled", 1)), "shared": bool(s.get("shared", 0)), + "content": s.get("content", ""), + "total": s.get("total", 0), "used": s.get("used", 0), "avail": s.get("avail", 0), + "total_human": bytes_human(s.get("total", 0)), + "used_human": bytes_human(s.get("used", 0)), + "avail_human": bytes_human(s.get("avail", 0)), + "usage_pct": pct(s.get("used", 0), s.get("total", 0)), + } for s in sts] + + +def collect_network(node_name): + log(f" rete ({node_name})") + nets = api_get(f"/nodes/{node_name}/network") or [] + result = [] + for n in nets: + info = {k: n.get(k, "") for k in ( + "iface", "type", "method", "method6", "address", "netmask", + "gateway", "cidr", "address6", "bridge_ports", "bridge_stp", + "bridge_fd", "comments" + )} + info["active"] = bool(n.get("active", 0)) + info["autostart"] = bool(n.get("autostart", 0)) + info["bridge_vlan_aware"] = bool(n.get("bridge-vlan-aware", 0)) + info["bond_slaves"] = n.get("slaves", "") + info["bond_mode"] = n.get("bond_mode", "") + info = {k: v for k, v in info.items() if v not in ("", None, False, 0)} + info.setdefault("iface", "") + info.setdefault("type", "") + result.append(info) + return result + + +def collect_cluster(): + log("cluster") + status = api_get("/cluster/status") + resources = api_get("/cluster/resources") + s = {"total_vms": 0, "running_vms": 0, "total_lxc": 0, "running_lxc": 0, + "total_cpu": 0, "total_memory": 0, "total_memory_human": ""} + if resources: + for r in resources: + t = r.get("type", "") + if t == "qemu": + s["total_vms"] += 1 + if r.get("status") == "running": s["running_vms"] += 1 + elif t == "lxc": + s["total_lxc"] += 1 + if r.get("status") == "running": s["running_lxc"] += 1 + elif t == "node": + s["total_cpu"] += r.get("maxcpu", 0) + s["total_memory"] += r.get("maxmem", 0) + s["total_memory_human"] = bytes_human(s["total_memory"]) + return {"status": status or [], "resource_summary": s} + + +def collect_simple(path, label): + log(label) + return api_get(path) or [] + + +def collect_firewall(): + log("firewall") + return {"options": api_get("/cluster/firewall/options") or {}, + "rules": api_get("/cluster/firewall/rules") or []} + + +def collect_ha(): + log("HA") + return {"resources": api_get("/cluster/ha/resources") or [], + "groups": api_get("/cluster/ha/groups") or [], + "status": api_get("/cluster/ha/status/current") or []} + + +def collect_sdn(): + log("SDN") + return {"vnets": api_get("/cluster/sdn/vnets") or [], + "zones": api_get("/cluster/sdn/zones") or []} + + +def gather_all(): + """Raccoglie tutti i dati dal nodo PVE locale.""" + ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + hn = socket.gethostname() + + data = { + "metadata": {"timestamp": ts, "hostname": hn, "generator": "pve_inventory.py", "format_version": "1.0"}, + "version": collect_version(), + "cluster": collect_cluster(), + "nodes": [], "guests": {"qemu": [], "lxc": []}, + "storage": [], "network": [], + "pools": collect_simple("/pools", "pool"), + "backup_jobs": collect_simple("/cluster/backup", "backup jobs"), + "firewall": collect_firewall(), + "ha": collect_ha(), + "replication": collect_simple("/cluster/replication", "replication"), + "sdn": collect_sdn(), + } + + nodes = collect_nodes() + data["nodes"] = nodes + + for node in nodes: + nn = node["name"] + data["guests"]["qemu"].extend(collect_vms(nn)) + data["guests"]["lxc"].extend(collect_lxc(nn)) + if nn == hn or not data["storage"]: + data["storage"] = collect_storage(nn) + data["network"] = collect_network(nn) + + return data + + +def _run(cmd, timeout=15): + try: + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) + return r.stdout.strip() if r.returncode == 0 else None + except subprocess.TimeoutExpired: + return None + + +def log(msg): + print(f" 📡 {msg}...", file=sys.stderr) + + +# ═══════════════════════════════════════════════════════════════════════════ +# PARTE 2: GENERAZIONE DASHBOARD HTML +# ═══════════════════════════════════════════════════════════════════════════ + +def esc(text): + return html_mod.escape(str(text)) if text else "" + +def _ip_from_desc(desc): + if not desc: return None + m = re.search(r'(?:ipv4|IPv4|IP)[:\s]*(\d+\.\d+\.\d+\.\d+)', desc, re.IGNORECASE) + if m: return m.group(1) + m = re.search(r'(\d+\.\d+\.\d+\.\d+)', desc) + if m and not m.group(1).startswith(("192.168.", "10.", "172.")): + return m.group(1) + return None + +def _lxc_ip(networks): + for n in networks: + m = re.search(r'ip=(\d+\.\d+\.\d+\.\d+)', n.get("config", "")) + if m: return m.group(1) + return None + +def _lxc_port(desc): + if not desc: return None + m = re.search(r'[Pp]orta[:\s]*(\d+)', desc) + return m.group(1) if m else None + +def _bridges(networks): + brs = [] + for n in networks: + m = re.search(r'bridge=(\w+)', n.get("config", "")) + if m: brs.append(m.group(1)) + return brs + +def _clean_desc(desc): + if not desc: return "" + t = re.sub(r'<[^>]+>', ' ', desc) + return re.sub(r'\s+', ' ', t).strip()[:120] + +def _snaptime(ts): + if not ts: return "" + try: return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") + except (ValueError, OSError): return str(ts) + + +# ── CSS ─────────────────────────────────────────────────────────────────── + +CSS = """:root{--bg-deep:#06080c;--bg-surface:#0c1018;--bg-card:#111822;--bg-card-hover:#161f2e;--border:#1c2738;--border-bright:#2a3a52;--text-primary:#e2e8f0;--text-secondary:#8892a4;--text-dim:#4a5568;--accent-blue:#3b82f6;--accent-blue-dim:#1e3a5f;--accent-green:#22c55e;--accent-green-dim:#0d3320;--accent-amber:#f59e0b;--accent-amber-dim:#422006;--accent-red:#ef4444;--accent-red-dim:#3b1111;--accent-purple:#a855f7;--accent-purple-dim:#2e1065;--accent-cyan:#06b6d4;--accent-cyan-dim:#083344;--glow-blue:0 0 20px rgba(59,130,246,0.15);--radius:12px;--radius-sm:8px} +*{margin:0;padding:0;box-sizing:border-box} +body{font-family:'Outfit',sans-serif;background:var(--bg-deep);color:var(--text-primary);min-height:100vh;overflow-x:hidden} +body::before{content:'';position:fixed;inset:0;background-image:linear-gradient(rgba(59,130,246,0.03) 1px,transparent 1px),linear-gradient(90deg,rgba(59,130,246,0.03) 1px,transparent 1px);background-size:60px 60px;pointer-events:none;z-index:0} +body::after{content:'';position:fixed;top:-200px;left:50%;transform:translateX(-50%);width:800px;height:600px;background:radial-gradient(ellipse,rgba(59,130,246,0.06) 0%,transparent 70%);pointer-events:none;z-index:0} +.container{position:relative;z-index:1;max-width:1440px;margin:0 auto;padding:40px 30px} +.header{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:50px;padding-bottom:30px;border-bottom:1px solid var(--border)} +.header-left h1{font-size:2.2em;font-weight:800;letter-spacing:-0.03em;background:linear-gradient(135deg,#e2e8f0 0%,#3b82f6 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:6px} +.header-left .subtitle{font-family:'JetBrains Mono',monospace;font-size:0.85em;color:var(--text-dim)} +.header-right{text-align:right;display:flex;flex-direction:column;gap:6px} +.header-badge{display:inline-flex;align-items:center;gap:6px;font-family:'JetBrains Mono',monospace;font-size:0.75em;color:var(--text-secondary);background:var(--bg-card);border:1px solid var(--border);padding:5px 12px;border-radius:20px} +.header-badge .dot{width:6px;height:6px;border-radius:50%;background:var(--accent-green);box-shadow:0 0 8px var(--accent-green);animation:pulse 2s infinite} +@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}} +.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin-bottom:50px} +.summary-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:22px;position:relative;overflow:hidden;transition:all 0.3s ease} +.summary-card:hover{border-color:var(--border-bright);transform:translateY(-2px);box-shadow:var(--glow-blue)} +.summary-card .icon{font-size:1.6em;margin-bottom:12px;display:block} +.summary-card .label{font-size:0.72em;text-transform:uppercase;letter-spacing:0.12em;color:var(--text-dim);font-weight:600;margin-bottom:6px} +.summary-card .value{font-size:2em;font-weight:800;letter-spacing:-0.02em;line-height:1} +.summary-card .detail{font-size:0.8em;color:var(--text-secondary);margin-top:4px;font-family:'JetBrains Mono',monospace} +.summary-card .accent-line{position:absolute;top:0;left:0;right:0;height:2px} +.section{margin-bottom:50px} +.section-header{display:flex;align-items:center;gap:12px;margin-bottom:20px} +.section-header h2{font-size:1.3em;font-weight:700} +.section-header .count{font-family:'JetBrains Mono',monospace;font-size:0.75em;color:var(--accent-blue);background:var(--accent-blue-dim);padding:3px 10px;border-radius:12px} +.tab-nav{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid var(--border);overflow-x:auto} +.tab-btn{font-family:'Outfit',sans-serif;font-size:0.88em;font-weight:500;color:var(--text-dim);background:none;border:none;padding:10px 18px;cursor:pointer;border-bottom:2px solid transparent;transition:all 0.2s;white-space:nowrap} +.tab-btn:hover{color:var(--text-secondary)} +.tab-btn.active{color:var(--accent-blue);border-bottom-color:var(--accent-blue)} +.tab-content{display:none}.tab-content.active{display:block} +.guest-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px} +.guest-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;transition:all 0.25s ease} +.guest-card:hover{border-color:var(--border-bright);background:var(--bg-card-hover)} +.guest-card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px} +.guest-card-header .name-group{display:flex;align-items:center;gap:10px} +.guest-card-header .vmid{font-family:'JetBrains Mono',monospace;font-size:0.75em;color:var(--text-dim);background:var(--bg-deep);padding:3px 8px;border-radius:6px;border:1px solid var(--border)} +.guest-card-header .name{font-weight:600;font-size:1.05em} +.status-pill{display:inline-flex;align-items:center;gap:5px;font-family:'JetBrains Mono',monospace;font-size:0.7em;padding:3px 10px;border-radius:12px;font-weight:500} +.status-pill.running{color:var(--accent-green);background:var(--accent-green-dim)} +.status-pill.stopped{color:var(--accent-red);background:var(--accent-red-dim)} +.status-pill .sdot{width:5px;height:5px;border-radius:50%;background:currentColor} +.guest-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:14px} +.stat-box{background:var(--bg-deep);border-radius:var(--radius-sm);padding:10px;text-align:center} +.stat-box .stat-label{font-size:0.65em;text-transform:uppercase;letter-spacing:0.1em;color:var(--text-dim);margin-bottom:4px;font-weight:600} +.stat-box .stat-value{font-family:'JetBrains Mono',monospace;font-size:0.9em;font-weight:600} +.guest-meta{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px} +.meta-tag{font-family:'JetBrains Mono',monospace;font-size:0.65em;padding:2px 8px;border-radius:6px;border:1px solid var(--border);color:var(--text-secondary);background:var(--bg-deep)} +.meta-tag.bios{color:var(--accent-purple);border-color:var(--accent-purple-dim)} +.meta-tag.net{color:var(--accent-cyan);border-color:var(--accent-cyan-dim)} +.meta-tag.tag{color:var(--accent-amber);border-color:var(--accent-amber-dim)} +.meta-tag.unpriv{color:var(--accent-green);border-color:var(--accent-green-dim)} +.meta-tag.priv{color:var(--accent-amber);border-color:var(--accent-amber-dim)} +.meta-tag.warn{color:var(--accent-red)} +.guest-description{font-size:0.78em;color:var(--text-dim);margin-top:10px;padding-top:10px;border-top:1px solid var(--border);font-family:'JetBrains Mono',monospace;line-height:1.5;word-break:break-word} +.guest-disks{margin-top:10px;padding-top:10px;border-top:1px solid var(--border)} +.disk-item{display:flex;align-items:center;gap:8px;font-family:'JetBrains Mono',monospace;font-size:0.72em;color:var(--text-secondary);padding:3px 0} +.disk-item .disk-dev{color:var(--accent-blue);min-width:65px;font-weight:500} +.disk-item .disk-conf{color:var(--text-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.snap-list{margin-top:10px;padding-top:10px;border-top:1px solid var(--border)} +.snap-item{font-family:'JetBrains Mono',monospace;font-size:0.72em;color:var(--text-dim);padding:2px 0;display:flex;gap:8px;align-items:center} +.snap-item .snap-icon{color:var(--accent-amber)}.snap-item .snap-name{color:var(--text-secondary)} +.topology{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:30px;overflow-x:auto} +.topo-container{display:flex;flex-direction:column;gap:30px} +.topo-bridge-label{display:inline-flex;align-items:center;gap:8px;font-family:'JetBrains Mono',monospace;font-size:0.85em;font-weight:600;padding:8px 16px;border-radius:var(--radius-sm);margin-bottom:14px} +.topo-bridge-label.br-public{background:linear-gradient(135deg,var(--accent-blue-dim),transparent);color:var(--accent-blue);border:1px solid var(--accent-blue-dim)} +.topo-bridge-label.br-internal{background:linear-gradient(135deg,var(--accent-green-dim),transparent);color:var(--accent-green);border:1px solid var(--accent-green-dim)} +.topo-bridge-label.br-other{background:linear-gradient(135deg,var(--accent-purple-dim),transparent);color:var(--accent-purple);border:1px solid var(--accent-purple-dim)} +.topo-bridge-info{font-family:'JetBrains Mono',monospace;font-size:0.7em;color:var(--text-dim);margin-left:10px} +.topo-guests{display:flex;flex-wrap:wrap;gap:10px;padding-left:20px;border-left:2px solid var(--border);margin-left:12px} +.topo-node{font-family:'JetBrains Mono',monospace;font-size:0.75em;padding:6px 12px;border-radius:var(--radius-sm);background:var(--bg-deep);border:1px solid var(--border);color:var(--text-secondary);display:flex;align-items:center;gap:6px;transition:all 0.2s} +.topo-node:hover{border-color:var(--border-bright);background:var(--bg-card)} +.topo-node .ticon{font-size:1.1em} +.topo-node.vm .ticon{color:var(--accent-blue)}.topo-node.lxc .ticon{color:var(--accent-green)} +.topo-node .tip{color:var(--text-dim)} +.storage-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:14px} +.storage-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:22px;transition:all 0.25s ease} +.storage-card:hover{border-color:var(--border-bright)} +.storage-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px} +.storage-header .sname{font-weight:600;font-size:1.05em} +.storage-header .stype{font-family:'JetBrains Mono',monospace;font-size:0.72em;color:var(--accent-purple);background:var(--accent-purple-dim);padding:3px 10px;border-radius:10px} +.storage-bar{width:100%;height:10px;background:var(--bg-deep);border-radius:5px;overflow:hidden;margin:12px 0} +.storage-bar-fill{height:100%;border-radius:5px;background:linear-gradient(90deg,var(--accent-blue),var(--accent-cyan))} +.storage-bar-fill.warn{background:linear-gradient(90deg,var(--accent-amber),#f97316)} +.storage-bar-fill.crit{background:linear-gradient(90deg,var(--accent-red),#f97316)} +.storage-stats{display:flex;justify-content:space-between;font-family:'JetBrains Mono',monospace;font-size:0.78em;color:var(--text-secondary);margin-top:8px} +.storage-content{display:flex;flex-wrap:wrap;gap:5px;margin-top:10px} +.content-tag{font-family:'JetBrains Mono',monospace;font-size:0.65em;padding:2px 8px;border-radius:6px;background:var(--bg-deep);border:1px solid var(--border);color:var(--text-dim)} +.net-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px} +.net-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;transition:all 0.25s ease} +.net-card:hover{border-color:var(--border-bright)} +.net-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px} +.net-card-header .iface-name{font-family:'JetBrains Mono',monospace;font-weight:700;font-size:1.1em;color:var(--accent-cyan)} +.net-card-header .iface-type{font-size:0.72em;padding:3px 10px;border-radius:10px;background:var(--accent-cyan-dim);color:var(--accent-cyan);font-family:'JetBrains Mono',monospace} +.net-detail{font-family:'JetBrains Mono',monospace;font-size:0.78em;color:var(--text-secondary);padding:3px 0;display:flex;gap:8px} +.net-detail .ndl{color:var(--text-dim);min-width:80px} +.backup-table{width:100%;border-collapse:collapse;font-size:0.88em} +.backup-table th{text-align:left;font-size:0.72em;text-transform:uppercase;letter-spacing:0.1em;color:var(--text-dim);font-weight:600;padding:10px 14px;border-bottom:1px solid var(--border);background:var(--bg-card)} +.backup-table td{padding:12px 14px;border-bottom:1px solid var(--border);font-family:'JetBrains Mono',monospace;font-size:0.88em;color:var(--text-secondary)} +.backup-table tr:hover td{background:var(--bg-card-hover)} +.enabled-yes{color:var(--accent-green)}.enabled-no{color:var(--accent-red)} +.firewall-box{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:22px} +.fw-status{display:flex;align-items:center;gap:10px;margin-bottom:14px;font-weight:600} +.fw-rule{font-family:'JetBrains Mono',monospace;font-size:0.82em;color:var(--text-secondary);padding:10px;background:var(--bg-deep);border-radius:var(--radius-sm);margin-top:8px;border-left:3px solid var(--accent-amber)} +.fw-rule.disabled{opacity:0.5} +.warning-box{margin-top:18px;padding:14px;background:var(--accent-amber-dim);border:1px solid var(--accent-amber);border-radius:var(--radius-sm);font-size:0.85em;line-height:1.6} +@keyframes fadeUp{from{opacity:0;transform:translateY(15px)}to{opacity:1;transform:translateY(0)}} +.animate-in{animation:fadeUp 0.5s ease forwards;opacity:0} +.delay-1{animation-delay:0.05s}.delay-2{animation-delay:0.1s}.delay-3{animation-delay:0.15s} +.delay-4{animation-delay:0.2s}.delay-5{animation-delay:0.25s}.delay-6{animation-delay:0.3s} +@media(max-width:768px){.container{padding:20px 16px}.header{flex-direction:column;gap:16px}.header-right{text-align:left}.header-left h1{font-size:1.6em}.guest-grid,.storage-grid,.net-grid{grid-template-columns:1fr}.summary-grid{grid-template-columns:repeat(2,1fr)}} +""" + + +# ── HTML section generators ────────────────────────────────────────────── + +def _header(d): + m = d.get("metadata", {}) + v = d.get("version", {}) + n = d["nodes"][0] if d.get("nodes") else {} + hn = esc(m.get("hostname", "unknown")) + ts = esc(m.get("timestamp", "")).replace("_", " ") + kr = esc(n.get("kernel", "N/A")).split(" ")[1] if " " in n.get("kernel", "") else esc(n.get("kernel", "")) + return f"""
| Schedule | VM/LXC | Nomi | Compressione | Modo | Storage | Stato | ' + '
|---|---|---|---|---|---|---|
| {esc(j.get("schedule",""))} | {esc(vids)} | ' + f'{esc(names)} | ' + f'{esc(j.get("compress",""))} | {esc(j.get("mode",""))} | ' + f'{esc(j.get("storage",""))} | ' + f'{"✅ attivo" if en else "❌ disattivo"} |
Nessuna regola configurata.
') + for r in rules: + dis = not r.get("enable", 1) + parts = [r.get("action", ""), r.get("type", "")] + if r.get("proto"): parts.append(r["proto"]) + if r.get("dport"): parts.append(f'dport:{r["dport"]}') + if r.get("source"): parts.append(f'src:{r["source"]}') + label = "[DISABILITATA] " if dis else "" + h.append(f'