push
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
venv
|
||||
env
|
42
README.md
Normal file
42
README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 1) katalog + venv
|
||||
sudo mkdir -p /opt/pve-ha-web
|
||||
sudo chown -R $USER:$USER /opt/pve-ha-web
|
||||
cd /opt/pve-ha-web
|
||||
|
||||
# 2) pliki aplikacji (app.py, templates/, static/, requirements.txt) — skopiuj tu
|
||||
# …gdy już je masz w katalogu…
|
||||
|
||||
# 3) virtualenv + deps
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
deactivate
|
||||
|
||||
# 4) systemd unit
|
||||
sudo tee /etc/systemd/system/pve-ha-web.service >/dev/null <<'UNIT'
|
||||
[Unit]
|
||||
Description=PVE HA Web Panel
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/pve-ha-web
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
ExecStart=/opt/pve-ha-web/venv/bin/gunicorn -w 2 -b 0.0.0.0:8000 app:app
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
# 5) start + autostart
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now pve-ha-web
|
||||
|
||||
# 6) sprawdzenie
|
||||
systemctl status pve-ha-web
|
||||
ss -ltnp | grep :8000
|
BIN
__pycache__/app.cpython-313.pyc
Normal file
BIN
__pycache__/app.cpython-313.pyc
Normal file
Binary file not shown.
442
app.py
Normal file
442
app.py
Normal file
@@ -0,0 +1,442 @@
|
||||
#!/usr/bin/env python3
|
||||
# /opt/pve-ha-web/app.py
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import json
|
||||
import time
|
||||
import subprocess
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from flask import Flask, request, jsonify, render_template
|
||||
|
||||
APP_TITLE = "PVE HA Panel"
|
||||
DEFAULT_NODE = socket.gethostname()
|
||||
HA_UNITS_START = ["watchdog-mux", "pve-ha-crm", "pve-ha-lrm"]
|
||||
HA_UNITS_STOP = list(reversed(HA_UNITS_START))
|
||||
|
||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||
|
||||
# ---------------- exec helpers ----------------
|
||||
def run(cmd: List[str], timeout: int = 25) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, check=False, text=True, capture_output=True, timeout=timeout)
|
||||
|
||||
def get_text(cmd: List[str]) -> str:
|
||||
r = run(cmd)
|
||||
return r.stdout if r.returncode == 0 else ""
|
||||
|
||||
def get_json(cmd: List[str]) -> Any:
|
||||
if cmd and cmd[0] == "pvesh" and "--output-format" not in cmd:
|
||||
cmd = cmd + ["--output-format", "json"]
|
||||
r = run(cmd)
|
||||
if r.returncode != 0 or not r.stdout.strip():
|
||||
return None
|
||||
try:
|
||||
return json.loads(r.stdout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def post_json(cmd: List[str]) -> Any:
|
||||
# force "create" for POST-like agent calls
|
||||
if cmd and cmd[0] == "pvesh" and len(cmd) > 2 and cmd[1] != "create":
|
||||
cmd = ["pvesh", "create"] + cmd[1:]
|
||||
r = run(cmd)
|
||||
if r.returncode != 0 or not r.stdout.strip():
|
||||
return None
|
||||
try:
|
||||
return json.loads(r.stdout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def is_active(unit: str) -> bool:
|
||||
return run(["systemctl", "is-active", "--quiet", unit]).returncode == 0
|
||||
|
||||
def start_if_needed(unit: str, out: List[str]) -> None:
|
||||
if not is_active(unit):
|
||||
out.append(f"+ start {unit}")
|
||||
run(["systemctl", "start", unit])
|
||||
|
||||
def stop_if_running(unit: str, out: List[str]) -> None:
|
||||
if is_active(unit):
|
||||
out.append(f"- stop {unit}")
|
||||
run(["systemctl", "stop", unit])
|
||||
|
||||
def ha_node_maint(enable: bool, node: str, out: List[str]) -> None:
|
||||
cmd = ["ha-manager", "crm-command", "node-maintenance", "enable" if enable else "disable", node]
|
||||
out.append("$ " + " ".join(shlex.quote(x) for x in cmd))
|
||||
r = run(cmd)
|
||||
if r.returncode != 0:
|
||||
out.append(f"ERR: {r.stderr.strip()}")
|
||||
|
||||
# ---------------- collectors ----------------
|
||||
def get_pvecm_status() -> str: return get_text(["pvecm", "status"])
|
||||
def get_quorumtool(short: bool = True) -> str: return get_text(["corosync-quorumtool", "-s" if short else "-l"])
|
||||
def get_cfgtool() -> str: return get_text(["corosync-cfgtool", "-s"])
|
||||
def get_ha_status_raw() -> str: return get_text(["ha-manager", "status"])
|
||||
def get_pvesr_status() -> str: return get_text(["pvesr", "status"])
|
||||
|
||||
def votequorum_brief() -> Dict[str, Any]:
|
||||
out = get_quorumtool(True)
|
||||
rv: Dict[str, Any] = {}
|
||||
rx = {
|
||||
"expected": r"Expected votes:\s*(\d+)",
|
||||
"total": r"Total votes:\s*(\d+)",
|
||||
"quorum": r"Quorum:\s*(\d+)",
|
||||
"quorate": r"Quorate:\s*(Yes|No)"
|
||||
}
|
||||
for k, rgx in rx.items():
|
||||
m = re.search(rgx, out, re.I)
|
||||
rv[k] = (None if not m else (m.group(1).lower() if k == "quorate" else int(m.group(1))))
|
||||
out_l = get_quorumtool(False)
|
||||
lines = [ln for ln in out_l.splitlines() if re.match(r"^\s*\d+\s+\d+\s+\S+", ln)]
|
||||
rv["members"] = len(lines) if lines else None
|
||||
return rv
|
||||
|
||||
def api_cluster_data() -> Dict[str, Any]:
|
||||
return {
|
||||
"cluster_status": get_json(["pvesh", "get", "/cluster/status"]) or [],
|
||||
"ha_status": get_json(["pvesh", "get", "/cluster/ha/status"]) or [],
|
||||
"ha_resources": get_json(["pvesh", "get", "/cluster/ha/resources"]) or [],
|
||||
"ha_groups": get_json(["pvesh", "get", "/cluster/ha/groups"]) or [],
|
||||
"nodes": get_json(["pvesh", "get", "/nodes"]) or [],
|
||||
}
|
||||
|
||||
def enrich_nodes(nodes_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for n in nodes_list or []:
|
||||
name = n.get("node")
|
||||
if not name:
|
||||
out.append(n)
|
||||
continue
|
||||
detail = get_json(["pvesh", "get", f"/nodes/{name}/status"]) or {}
|
||||
if "loadavg" in detail: n["loadavg"] = detail["loadavg"]
|
||||
if "cpu" in detail: n["cpu"] = detail["cpu"]
|
||||
if "memory" in detail:
|
||||
n["mem"] = detail["memory"].get("used"); n["maxmem"] = detail["memory"].get("total")
|
||||
if "rootfs" in detail:
|
||||
n["rootfs"] = detail["rootfs"].get("used"); n["maxrootfs"] = detail["rootfs"].get("total")
|
||||
out.append(n)
|
||||
return out
|
||||
|
||||
# ---------------- ha-manager parser ----------------
|
||||
def parse_ha_manager(text: str) -> Dict[str, Any]:
|
||||
nodes: Dict[str, Dict[str, str]] = {}
|
||||
resources: Dict[str, Dict[str, str]] = {}
|
||||
current_node: Optional[str] = None
|
||||
for line in (text or "").splitlines():
|
||||
s = line.strip()
|
||||
m = re.match(r"node:\s+(\S+)\s+\(([^)]+)\)", s)
|
||||
if m:
|
||||
current_node = m.group(1)
|
||||
nodes.setdefault(current_node, {"node": current_node, "state": m.group(2)})
|
||||
continue
|
||||
m = re.match(r"(lrm|crm)\s+status:\s+(\S+)", s)
|
||||
if m and current_node:
|
||||
nodes[current_node]["lrm" if m.group(1)=="lrm" else "crm"] = m.group(2)
|
||||
continue
|
||||
m = re.match(r"service:\s+(\S+)\s+on\s+(\S+)\s+\(([^)]+)\)", s)
|
||||
if m:
|
||||
sid, node, flags = m.group(1), m.group(2), m.group(3)
|
||||
rec = {"sid": sid, "node": node, "flags": flags}
|
||||
rec["state"] = "started" if "started" in flags else ("stopped" if "stopped" in flags else rec.get("state"))
|
||||
resources[sid] = rec
|
||||
continue
|
||||
m = re.match(r"service:\s+(\S+)\s+\(([^)]*)\)\s+on\s+(\S+)", s)
|
||||
if m:
|
||||
sid, flags, node = m.group(1), m.group(2), m.group(3)
|
||||
rec = {"sid": sid, "node": node, "flags": flags}
|
||||
rec["state"] = "started" if "started" in flags else ("stopped" if "stopped" in flags else rec.get("state"))
|
||||
resources[sid] = rec
|
||||
continue
|
||||
m = re.match(r"service\s+(\S+):\s+(\S+)\s+on\s+(\S+)", s)
|
||||
if m:
|
||||
sid, st, node = m.group(1), m.group(2), m.group(3)
|
||||
resources[sid] = {"sid": sid, "state": st, "node": node}
|
||||
continue
|
||||
return {"nodes": list(nodes.values()), "resources": list(resources.values())}
|
||||
|
||||
# ---------------- SID utils / indexes ----------------
|
||||
def norm_sid(s: Optional[str]) -> Optional[str]:
|
||||
if not s: return None
|
||||
s = str(s)
|
||||
m = re.match(r"^(vm|ct):(\d+)$", s)
|
||||
if m: return f"{m.group(1)}:{m.group(2)}"
|
||||
m = re.match(r"^(qemu|lxc)/(\d+)$", s)
|
||||
if m: return ("vm" if m.group(1) == "qemu" else "ct") + f":{m.group(2)}"
|
||||
return s
|
||||
|
||||
def cluster_vmct_index() -> Dict[str, str]:
|
||||
items = get_json(["pvesh", "get", "/cluster/resources"]) or []
|
||||
idx: Dict[str, str] = {}
|
||||
for it in items:
|
||||
t = it.get("type")
|
||||
if t not in ("qemu", "lxc"): continue
|
||||
vmid = it.get("vmid"); node = it.get("node")
|
||||
sid = (("vm" if t=="qemu" else "ct") + f":{vmid}") if vmid is not None else norm_sid(it.get("id"))
|
||||
if sid and node: idx[sid] = node
|
||||
return idx
|
||||
|
||||
def cluster_vmct_meta() -> Dict[str, Dict[str, Any]]:
|
||||
items = get_json(["pvesh", "get", "/cluster/resources"]) or []
|
||||
meta: Dict[str, Dict[str, Any]] = {}
|
||||
for it in items:
|
||||
t = it.get("type")
|
||||
if t not in ("qemu", "lxc"): continue
|
||||
sid = norm_sid(it.get("id")) or (("vm" if t == "qemu" else "ct") + f":{it.get('vmid')}")
|
||||
if sid:
|
||||
meta[sid] = {
|
||||
"sid": sid, "type": t, "vmid": it.get("vmid"),
|
||||
"node": it.get("node"), "name": it.get("name"),
|
||||
"status": it.get("status"), "hastate": it.get("hastate")
|
||||
}
|
||||
return meta
|
||||
|
||||
def merge_resources(api_res: List[Dict[str, Any]],
|
||||
parsed_res: List[Dict[str, Any]],
|
||||
vmct_idx: Dict[str, str]) -> List[Dict[str, Any]]:
|
||||
by_sid: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# seed from /cluster/ha/resources
|
||||
for r in (api_res or []):
|
||||
sid = norm_sid(r.get("sid"))
|
||||
if not sid:
|
||||
continue
|
||||
x = dict(r)
|
||||
x["sid"] = sid
|
||||
by_sid[sid] = x
|
||||
|
||||
# merge runtime from ha-manager
|
||||
for r in (parsed_res or []):
|
||||
sid = norm_sid(r.get("sid"))
|
||||
if not sid:
|
||||
continue
|
||||
x = by_sid.get(sid, {"sid": sid})
|
||||
for k, v in r.items():
|
||||
if k == "sid":
|
||||
continue
|
||||
if v not in (None, ""):
|
||||
x[k] = v
|
||||
by_sid[sid] = x
|
||||
|
||||
# fill node from /cluster/resources
|
||||
for sid, x in by_sid.items():
|
||||
if not x.get("node") and sid in vmct_idx:
|
||||
x["node"] = vmct_idx[sid]
|
||||
|
||||
return list(by_sid.values())
|
||||
|
||||
# ---------------- VM details ----------------
|
||||
def sid_to_tuple(sid: str, meta: Dict[str, Dict[str, Any]]) -> Optional[Tuple[str, int, str]]:
|
||||
sid_n = norm_sid(sid)
|
||||
if not sid_n: return None
|
||||
m = re.match(r"^(vm|ct):(\d+)$", sid_n)
|
||||
if not m: return None
|
||||
typ = "qemu" if m.group(1) == "vm" else "lxc"
|
||||
vmid = int(m.group(2))
|
||||
node = (meta.get(sid_n) or {}).get("node")
|
||||
return (typ, vmid, node)
|
||||
|
||||
def vm_detail_payload(sid: str) -> Dict[str, Any]:
|
||||
meta = cluster_vmct_meta()
|
||||
tup = sid_to_tuple(sid, meta)
|
||||
if not tup: return {"sid": sid, "error": "bad sid"}
|
||||
typ, vmid, node = tup
|
||||
if not node: return {"sid": sid, "error": "unknown node"}
|
||||
base = f"/nodes/{node}/{typ}/{vmid}"
|
||||
current = get_json(["pvesh", "get", f"{base}/status/current"]) or {}
|
||||
config = get_json(["pvesh", "get", f"{base}/config"]) or {}
|
||||
agent_info = None; agent_os = None; agent_ifaces = None
|
||||
if typ == "qemu":
|
||||
agent_info = get_json(["pvesh", "get", f"{base}/agent/info"])
|
||||
agent_os = post_json(["pvesh", "create", f"{base}/agent/get-osinfo"]) or None
|
||||
agent_ifaces = post_json(["pvesh", "create", f"{base}/agent/network-get-interfaces"]) or None
|
||||
return {
|
||||
"sid": norm_sid(sid), "node": node, "type": typ, "vmid": vmid,
|
||||
"meta": meta.get(norm_sid(sid), {}),
|
||||
"current": current, "config": config,
|
||||
"agent": {"info": agent_info, "osinfo": agent_os, "ifaces": agent_ifaces} if typ=="qemu" else None
|
||||
}
|
||||
|
||||
# ---------------- Node details ----------------
|
||||
def node_detail_payload(name: str) -> Dict[str, Any]:
|
||||
if not name: return {"error": "no node"}
|
||||
status = get_json(["pvesh", "get", f"/nodes/{name}/status"]) or {}
|
||||
version = get_json(["pvesh", "get", f"/nodes/{name}/version"]) or {}
|
||||
timeinfo = get_json(["pvesh", "get", f"/nodes/{name}/time"]) or {}
|
||||
services = get_json(["pvesh", "get", f"/nodes/{name}/services"]) or []
|
||||
network_cfg = get_json(["pvesh", "get", f"/nodes/{name}/network"]) or []
|
||||
netstat = get_json(["pvesh", "get", f"/nodes/{name}/netstat"]) or [] # may be empty on some versions
|
||||
disks = get_json(["pvesh", "get", f"/nodes/{name}/disks/list"]) or []
|
||||
subscription = get_json(["pvesh", "get", f"/nodes/{name}/subscription"]) or {}
|
||||
return {
|
||||
"node": name,
|
||||
"status": status,
|
||||
"version": version,
|
||||
"time": timeinfo,
|
||||
"services": services,
|
||||
"network_cfg": network_cfg,
|
||||
"netstat": netstat,
|
||||
"disks": disks,
|
||||
"subscription": subscription
|
||||
}
|
||||
|
||||
def node_ha_services(node: str) -> Dict[str, str]:
|
||||
svcs = get_json(["pvesh", "get", f"/nodes/{node}/services"]) or []
|
||||
def one(name: str) -> str:
|
||||
for s in svcs:
|
||||
if s.get("name") == name:
|
||||
st = s.get("state") or s.get("active") or ""
|
||||
return "active" if str(st).lower() in ("running", "active") else (st or "inactive")
|
||||
return ""
|
||||
return {"crm_state": one("pve-ha-crm"), "lrm_state": one("pve-ha-lrm")}
|
||||
|
||||
def units_for_node(node: str) -> Dict[str, str]:
|
||||
"""
|
||||
Preferuj /nodes/<node>/services. Normalizuj nazwy (bez .service)
|
||||
i mapuj różne pola na 'active'/'inactive'.
|
||||
"""
|
||||
wanted = {"watchdog-mux", "pve-ha-crm", "pve-ha-lrm"}
|
||||
svc = get_json(["pvesh", "get", f"/nodes/{node}/services"]) or []
|
||||
states: Dict[str, str] = {}
|
||||
|
||||
def norm_state(s: dict) -> str:
|
||||
# Proxmox bywa: {"state":"enabled","active":"active"} albo {"active":1} albo {"status":"running"} itp.
|
||||
raw_active = str(s.get("active", "")).lower()
|
||||
status = str(s.get("status", "")).lower()
|
||||
substate = str(s.get("substate", "")).lower()
|
||||
state = str(s.get("state", "")).lower()
|
||||
any_active = (
|
||||
raw_active in ("active", "running", "1", "true") or
|
||||
status in ("active", "running") or
|
||||
substate in ("running") or
|
||||
("running" in state)
|
||||
)
|
||||
return "active" if any_active else "inactive"
|
||||
|
||||
for s in svc:
|
||||
name_raw = (s.get("name") or "")
|
||||
name = re.sub(r"\.service$", "", name_raw)
|
||||
if name in wanted:
|
||||
states[name] = norm_state(s)
|
||||
|
||||
# fallback lokalny tylko jeśli API nic nie zwróciło
|
||||
if not states:
|
||||
for u in wanted:
|
||||
states[u] = "active" if is_active(u) else "inactive"
|
||||
|
||||
# zawsze zwróć pełny zestaw
|
||||
for u in wanted:
|
||||
states.setdefault(u, "inactive")
|
||||
|
||||
return states
|
||||
|
||||
# ---------------- snapshot ----------------
|
||||
def status_snapshot(node: str) -> Dict[str, Any]:
|
||||
vq = votequorum_brief()
|
||||
api = api_cluster_data()
|
||||
api["nodes"] = enrich_nodes(api.get("nodes", []))
|
||||
ha_raw = get_ha_status_raw().strip()
|
||||
parsed = parse_ha_manager(ha_raw)
|
||||
vmct_ix = cluster_vmct_index()
|
||||
|
||||
ha_status = api.get("ha_status") or []
|
||||
if not ha_status and parsed.get("nodes"):
|
||||
ha_status = [{
|
||||
"node": n.get("node"), "state": n.get("state"),
|
||||
"crm_state": n.get("crm", ""), "lrm_state": n.get("lrm", "")
|
||||
} for n in parsed["nodes"]]
|
||||
if not ha_status:
|
||||
for it in api.get("cluster_status", []):
|
||||
if it.get("type") == "node":
|
||||
ha_status.append({
|
||||
"node": it.get("name"),
|
||||
"state": "online" if it.get("online") else "offline",
|
||||
"crm_state": "", "lrm_state": ""
|
||||
})
|
||||
|
||||
enriched = []
|
||||
for n in ha_status:
|
||||
node_name = n.get("node"); crm = n.get("crm_state") or ""; lrm = n.get("lrm_state") or ""
|
||||
if node_name and (not crm or not lrm):
|
||||
try:
|
||||
svc = node_ha_services(node_name)
|
||||
if not crm: n["crm_state"] = svc.get("crm_state", "")
|
||||
if not lrm: n["lrm_state"] = svc.get("lrm_state", "")
|
||||
except Exception:
|
||||
pass
|
||||
enriched.append(n)
|
||||
api["ha_status"] = enriched
|
||||
|
||||
api["ha_resources"] = merge_resources(api.get("ha_resources", []), parsed.get("resources", []), vmct_ix)
|
||||
|
||||
units = units_for_node(node or socket.gethostname())
|
||||
return {
|
||||
"node_arg": node, "hostname": socket.gethostname(),
|
||||
"votequorum": vq, "units": units,
|
||||
"cfgtool": get_cfgtool().strip(), "pvecm": get_pvecm_status().strip(),
|
||||
"ha_raw": ha_raw, "replication": get_pvesr_status().strip(),
|
||||
"api": api, "ts": int(time.time())
|
||||
}
|
||||
|
||||
# ---------------- web ----------------
|
||||
@app.get("/")
|
||||
def index():
|
||||
node = request.args.get("node", DEFAULT_NODE)
|
||||
return render_template("index.html", title=APP_TITLE, node=node)
|
||||
|
||||
@app.get("/api/info")
|
||||
def api_info():
|
||||
node = request.args.get("node", DEFAULT_NODE)
|
||||
return jsonify(status_snapshot(node))
|
||||
|
||||
@app.get("/api/vm")
|
||||
def api_vm_detail():
|
||||
sid = request.args.get("sid", "")
|
||||
return jsonify(vm_detail_payload(sid))
|
||||
|
||||
@app.get("/api/node")
|
||||
def api_node_detail():
|
||||
name = request.args.get("name", "")
|
||||
return jsonify(node_detail_payload(name))
|
||||
|
||||
@app.get("/api/list-vmct")
|
||||
def api_list_vmct():
|
||||
meta = cluster_vmct_meta()
|
||||
ha_sids = {norm_sid(r.get("sid")) for r in (api_cluster_data().get("ha_resources") or []) if r.get("sid")}
|
||||
nonha = [v for k, v in meta.items() if k not in ha_sids]
|
||||
return jsonify({
|
||||
"nonha": nonha, "ha_index": list(ha_sids),
|
||||
"count_nonha": len(nonha), "count_all_vmct": len(meta)
|
||||
})
|
||||
|
||||
@app.post("/api/enable")
|
||||
def api_enable():
|
||||
if os.geteuid() != 0:
|
||||
return jsonify(ok=False, error="run as root"), 403
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
node = data.get("node") or DEFAULT_NODE
|
||||
log: List[str] = []
|
||||
ha_node_maint(True, node, log)
|
||||
for u in HA_UNITS_STOP: stop_if_running(u, log)
|
||||
return jsonify(ok=True, log=log)
|
||||
|
||||
@app.post("/api/disable")
|
||||
def api_disable():
|
||||
if os.geteuid() != 0:
|
||||
return jsonify(ok=False, error="run as root"), 403
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
node = data.get("node") or DEFAULT_NODE
|
||||
log: List[str] = []
|
||||
for u in HA_UNITS_START: start_if_needed(u, log)
|
||||
ha_node_maint(False, node, log)
|
||||
return jsonify(ok=True, log=log)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--bind", default="127.0.0.1:8088", help="addr:port")
|
||||
p.add_argument("--node", default=DEFAULT_NODE, help="default node")
|
||||
args = p.parse_args()
|
||||
DEFAULT_NODE = args.node
|
||||
host, port = args.bind.split(":")
|
||||
app.run(host=host, port=int(port), debug=False, threaded=True)
|
18
pve-ha-web.service
Normal file
18
pve-ha-web.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=PVE HA Web Panel
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/pve-ha-web
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
# Jeżeli chcesz zdefiniować domyślny node:
|
||||
# Environment="DEFAULT_NODE=pve1"
|
||||
ExecStart=/opt/pve-ha-web/venv/bin/gunicorn -w 2 -b 0.0.0.0:8000 app:app
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask
|
||||
gunicorn
|
697
static/main.js
Normal file
697
static/main.js
Normal file
@@ -0,0 +1,697 @@
|
||||
// ------ helpers ------
|
||||
const $ = (q)=>document.querySelector(q);
|
||||
function safe(v){ return (v===undefined||v===null||v==='') ? '—' : String(v); }
|
||||
function ensureArr(a){ return Array.isArray(a)?a:[]; }
|
||||
function pct(p){ if(p==null) return '—'; return (p*100).toFixed(1)+'%'; }
|
||||
function humanBytes(n){ if(n==null) return '—'; const u=['B','KiB','MiB','GiB','TiB','PiB']; let i=0,x=+n; while(x>=1024&&i<u.length-1){x/=1024;i++;} return x.toFixed(1)+' '+u[i]; }
|
||||
function badge(txt, kind){
|
||||
const cls={ok:'bg-success-subtle text-success-emphasis',warn:'bg-warning-subtle text-warning-emphasis',
|
||||
err:'bg-danger-subtle text-danger-emphasis',info:'bg-info-subtle text-info-emphasis',
|
||||
dark:'bg-secondary-subtle text-secondary-emphasis'}[kind||'dark'];
|
||||
return `<span class="badge rounded-pill ${cls}">${safe(txt)}</span>`;
|
||||
}
|
||||
function rowHTML(cols, attrs=''){ return `<tr ${attrs}>${cols.map(c=>`<td>${c??'—'}</td>`).join('')}</tr>`; }
|
||||
function setRows(tbody, rows){ tbody.innerHTML = rows.length ? rows.join('') : rowHTML(['—']); }
|
||||
function fmtSeconds(s){
|
||||
if(s==null) return '—';
|
||||
s = Math.floor(s);
|
||||
const d = Math.floor(s/86400); s%=86400;
|
||||
const h = Math.floor(s/3600); s%=3600;
|
||||
const m = Math.floor(s/60); s%=60;
|
||||
const parts = [h.toString().padStart(2,'0'), m.toString().padStart(2,'0'), s.toString().padStart(2,'0')].join(':');
|
||||
return d>0 ? `${d}d ${parts}` : parts;
|
||||
}
|
||||
function parseNetConf(val){
|
||||
const out={}; if(!val) return out;
|
||||
val.split(',').forEach(kv=>{
|
||||
const [k,v] = kv.split('=');
|
||||
if(k && v!==undefined) out[k.trim()]=v.trim();
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function parseVmNetworks(config){
|
||||
const nets=[];
|
||||
for(const [k,v] of Object.entries(config||{})){
|
||||
const m = k.match(/^net(\d+)$/);
|
||||
if(m){ nets.push({idx:+m[1], raw:v, ...parseNetConf(v)}); }
|
||||
}
|
||||
nets.sort((a,b)=>a.idx-b.idx);
|
||||
return nets;
|
||||
}
|
||||
function kvGrid(obj, keys, titleMap={}){
|
||||
return `<div class="row row-cols-1 row-cols-md-2 g-2">
|
||||
${keys.map(k=>`
|
||||
<div class="col">
|
||||
<div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">${titleMap[k]||k}</div>
|
||||
<div class="fw-semibold">${safe(obj[k])}</div>
|
||||
</div></div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ------ DOM refs ------
|
||||
const nodeInput=$('#node'), btnEnable=$('#btnEnable'), btnDisable=$('#btnDisable'), btnToggleAll=$('#btnToggleAll');
|
||||
const btnRefresh=$('#btnRefresh'), btnAuto=$('#btnAuto'), selInterval=$('#selInterval');
|
||||
const healthDot=$('#healthDot'), healthTitle=$('#healthTitle'), healthSub=$('#healthSub');
|
||||
const qSummary=$('#q-summary'), qCardsWrap=$('#q-cards'), unitsBox=$('#units'), replBox=$('#repl');
|
||||
const tblHaRes=$('#ha-res tbody'), tblHaStatus=$('#ha-status tbody'), tblNodes=$('#nodes tbody'), tblNonHA=$('#nonha tbody');
|
||||
const pvecmPre=$('#pvecm'), cfgtoolPre=$('#cfgtool'), footer=$('#footer');
|
||||
|
||||
// ------ actions ------
|
||||
async function callAction(act){
|
||||
const node = nodeInput.value || '';
|
||||
const r = await fetch('/api/'+act,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({node})});
|
||||
const d = await r.json(); alert(d.ok?'OK':('ERROR: '+(d.error||'unknown')));
|
||||
}
|
||||
btnEnable.onclick=()=>callAction('enable');
|
||||
btnDisable.onclick=()=>callAction('disable');
|
||||
btnToggleAll.onclick=()=>{
|
||||
document.querySelectorAll('.accordion-collapse').forEach(el=>{
|
||||
const bs = bootstrap.Collapse.getOrCreateInstance(el,{toggle:false});
|
||||
el.classList.contains('show')?bs.hide():bs.show();
|
||||
});
|
||||
};
|
||||
|
||||
// ------ refresh control ------
|
||||
let REF_TIMER = null;
|
||||
async function fetchSnapshot(){
|
||||
const r = await fetch('/api/info?node='+encodeURIComponent(nodeInput.value||''));
|
||||
return await r.json();
|
||||
}
|
||||
async function doRefresh(){
|
||||
const d = await fetchSnapshot();
|
||||
renderSnap(d);
|
||||
if (!doRefresh.didNonHA){ await renderNonHA(); doRefresh.didNonHA=true; }
|
||||
}
|
||||
btnRefresh.onclick = doRefresh;
|
||||
btnAuto.onclick = ()=>{
|
||||
if(REF_TIMER){
|
||||
clearInterval(REF_TIMER); REF_TIMER=null;
|
||||
btnAuto.textContent='OFF';
|
||||
btnAuto.classList.remove('btn-success'); btnAuto.classList.add('btn-outline-success');
|
||||
selInterval.disabled = true;
|
||||
}else{
|
||||
const iv = parseInt(selInterval.value||'30000',10);
|
||||
REF_TIMER = setInterval(doRefresh, iv);
|
||||
btnAuto.textContent='ON';
|
||||
btnAuto.classList.remove('btn-outline-success'); btnAuto.classList.add('btn-success');
|
||||
selInterval.disabled = false;
|
||||
}
|
||||
};
|
||||
selInterval.onchange = ()=>{
|
||||
if(REF_TIMER){
|
||||
clearInterval(REF_TIMER);
|
||||
REF_TIMER = setInterval(doRefresh, parseInt(selInterval.value||'30000',10));
|
||||
}
|
||||
};
|
||||
|
||||
// ------ VM detail API ------
|
||||
async function fetchVmDetail(sid){
|
||||
const r = await fetch('/api/vm?sid='+encodeURIComponent(sid));
|
||||
return await r.json();
|
||||
}
|
||||
// ------ Node detail API ------
|
||||
async function fetchNodeDetail(name){
|
||||
const r = await fetch('/api/node?name='+encodeURIComponent(name));
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
// ------ VM detail card ------
|
||||
function renderVmDetailCard(d){
|
||||
const meta = d.meta || {};
|
||||
const cur = d.current || {};
|
||||
const cfg = d.config || {};
|
||||
const ag = d.agent || {};
|
||||
const agInfo= ag.info || null;
|
||||
const agOS = ag.osinfo && ag.osinfo.result ? ag.osinfo.result : null;
|
||||
const agIfs = ag.ifaces && ag.ifaces.result ? ag.ifaces.result : null;
|
||||
|
||||
const statusBadge = /running|online|started/i.test(meta.status||cur.status||'')
|
||||
? badge(meta.status || cur.status || 'running','ok')
|
||||
: badge(meta.status || cur.status || 'stopped','err');
|
||||
|
||||
const maxmem = cur.maxmem ?? (cfg.memory? Number(cfg.memory)*1024*1024 : null);
|
||||
const used = cur.mem ?? null;
|
||||
const free = (maxmem!=null && used!=null) ? Math.max(0, maxmem-used) : null;
|
||||
const balloonEnabled = (cfg.balloon !== undefined) ? (Number(cfg.balloon)!==0) : (cur.balloon !== undefined && Number(cur.balloon)!==0);
|
||||
const binfo = cur.ballooninfo || null;
|
||||
|
||||
const nets = parseVmNetworks(cfg);
|
||||
|
||||
let guestName = agOS && (agOS.name || agOS.pretty_name) || (agInfo && agInfo.version) || '';
|
||||
let guestIPs = [];
|
||||
if (Array.isArray(agIfs)){
|
||||
agIfs.forEach(i=>{
|
||||
(i['ip-addresses']||[]).forEach(ip=>{
|
||||
const a = ip['ip-address']; if (a && !a.startsWith('fe80')) guestIPs.push(a);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const nicStats = cur.nics || {};
|
||||
const nicRows = Object.keys(nicStats).sort().map(ifn=>{
|
||||
const ns = nicStats[ifn]||{};
|
||||
return rowHTML([ifn, humanBytes(ns.netin||0), humanBytes(ns.netout||0)]);
|
||||
});
|
||||
|
||||
const bstat = cur.blockstat || {};
|
||||
const bRows = Object.keys(bstat).sort().map(dev=>{
|
||||
const s=bstat[dev]||{};
|
||||
return rowHTML([
|
||||
dev, humanBytes(s.rd_bytes||0), safe(s.rd_operations||0),
|
||||
humanBytes(s.wr_bytes||0), safe(s.wr_operations||0),
|
||||
safe(s.flush_operations||0), humanBytes(s.wr_highest_offset||0)
|
||||
]);
|
||||
});
|
||||
|
||||
const ha = cur.ha || {};
|
||||
const haBadge = ha.state ? (/started/i.test(ha.state)?badge(ha.state,'ok'):badge(ha.state,'warn')) : badge('—','dark');
|
||||
|
||||
const sysCards = {
|
||||
'QMP status': cur.qmpstatus,
|
||||
'QEMU': cur['running-qemu'],
|
||||
'Machine': cur['running-machine'],
|
||||
'PID': cur.pid,
|
||||
'Pressure CPU (some/full)': `${safe(cur.pressurecpusome)}/${safe(cur.pressurecpufull)}`,
|
||||
'Pressure IO (some/full)': `${safe(cur.pressureiosome)}/${safe(cur.pressureiofull)}`,
|
||||
'Pressure MEM (some/full)': `${safe(cur.pressurememorysome)}/${safe(cur.pressurememoryfull)}`
|
||||
};
|
||||
|
||||
const head = `
|
||||
<div class="d-flex flex-wrap align-items-center gap-3 mb-2">
|
||||
<div class="fw-bold">${meta.name || cfg.name || d.sid}</div>
|
||||
<div class="text-muted small">${(d.type||'').toUpperCase()} / VMID ${d.vmid} @ ${d.node}</div>
|
||||
<div class="vr"></div>
|
||||
<div>${statusBadge}</div>
|
||||
${meta.hastate ? `<div class="vr"></div><div class="small">HA: ${badge(meta.hastate, /started/i.test(meta.hastate)?'ok':'warn')}</div>` : ''}
|
||||
${ha.state ? `<div class="vr"></div><div class="small">HA runtime: ${haBadge}</div>` : ''}
|
||||
</div>`;
|
||||
|
||||
const quick = `
|
||||
<div class="row row-cols-2 row-cols-md-4 g-2">
|
||||
<div class="col"><div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">CPU</div><div class="fw-semibold">${cur.cpu !== undefined ? (cur.cpu*100).toFixed(1)+'%' : '—'}</div>
|
||||
<div class="small text-muted">vCPUs: ${safe(cur.cpus ?? cfg.cores ?? cfg.sockets)}</div>
|
||||
</div></div></div>
|
||||
<div class="col"><div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">Memory (used/free/total)</div>
|
||||
<div class="fw-semibold">${
|
||||
(used!=null && maxmem!=null)
|
||||
? `${humanBytes(used)} / ${humanBytes(free)} / ${humanBytes(maxmem)}`
|
||||
: '—'
|
||||
}</div>
|
||||
<div class="small mt-1">Ballooning: ${balloonEnabled ? badge('enabled','ok') : badge('disabled','err')}</div>
|
||||
${binfo ? `<div class="small text-muted mt-1">Balloon actual: ${humanBytes(binfo.actual)} | guest free: ${humanBytes(binfo.free_mem)} | guest total: ${humanBytes(binfo.total_mem)}</div>` : ''}
|
||||
</div></div></div>
|
||||
<div class="col"><div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">Disk (used/total)</div>
|
||||
<div class="fw-semibold">${(cur.disk!=null && cur.maxdisk!=null) ? `${humanBytes(cur.disk)} / ${humanBytes(cur.maxdisk)}` : '—'}</div>
|
||||
<div class="small text-muted mt-1">R: ${humanBytes(cur.diskread||0)} | W: ${humanBytes(cur.diskwrite||0)}</div>
|
||||
</div></div></div>
|
||||
<div class="col"><div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">Uptime</div><div class="fw-semibold">${fmtSeconds(cur.uptime)}</div>
|
||||
<div class="small text-muted mt-1">Tags: ${safe(cfg.tags||meta.tags||'—')}</div>
|
||||
</div></div></div>
|
||||
</div>`;
|
||||
|
||||
const netRows = (parseVmNetworks(cfg)).map(n=>{
|
||||
const br = n.bridge || n.br || '—';
|
||||
const mdl = n.model || n.type || (n.raw?.split(',')[0]?.split('=')[0]) || 'virtio';
|
||||
const mac = n.hwaddr || n.mac || (n.raw?.match(/([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}/)?.[0] || '—');
|
||||
const vlan = n.tag || n.vlan || '—';
|
||||
const fw = (n.firewall==='1') ? badge('on','warn') : badge('off','dark');
|
||||
return rowHTML([`net${n.idx}`, mdl, br, vlan, mac, fw]);
|
||||
});
|
||||
const netTable = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>IF</th><th>Model</th><th>Bridge</th><th>VLAN</th><th>MAC</th><th>FW</th></tr></thead>
|
||||
<tbody>${netRows.length?netRows.join(''):rowHTML(['—','—','—','—','—','—'])}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const nicLive = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>TAP</th><th>RX</th><th>TX</th></tr></thead>
|
||||
<tbody>${nicRows.length?nicRows.join(''):rowHTML(['—','—','—'])}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const blkTable = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>Device</th><th>Read bytes</th><th>Read ops</th><th>Write bytes</th><th>Write ops</th><th>Flush ops</th><th>Highest offset</th></tr></thead>
|
||||
<tbody>${bRows.length?bRows.join(''):rowHTML(['—','—','—','—','—','—','—'])}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const agentSummary = (agInfo||agOS||guestIPs.length)
|
||||
? `<div class="small">
|
||||
${agOS ? `<div>Guest OS: <strong>${safe(guestName)}</strong></div>`:''}
|
||||
${guestIPs.length ? `<div>Guest IPs: ${guestIPs.map(ip=>badge(ip,'info')).join(' ')}</div>`:''}
|
||||
${agInfo ? `<div>Agent: ${badge('present','ok')}</div>`:`<div>Agent: ${badge('not available','err')}</div>`}
|
||||
</div>`
|
||||
: '<div class="text-muted small">No guest agent data</div>';
|
||||
|
||||
const cfgFacts = {
|
||||
'BIOS': cfg.bios, 'UEFI/EFI disk': cfg.efidisk0 ? 'yes' : 'no',
|
||||
'CPU type': cfg.cpu, 'Sockets': cfg.sockets, 'Cores': cfg.cores, 'NUMA': cfg.numa,
|
||||
'On boot': cfg.onboot ? 'yes' : 'no', 'OS type': cfg.ostype, 'SCSI hw': cfg.scsihw
|
||||
};
|
||||
|
||||
// Collapsible raw JSON
|
||||
const rawId = `raw-${d.type}-${d.vmid}`;
|
||||
const rawBtn = `
|
||||
<button class="btn btn-sm btn-outline-secondary mt-3" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#${rawId}">
|
||||
Show raw JSON
|
||||
</button>`;
|
||||
const rawBox = `
|
||||
<div id="${rawId}" class="collapse mt-2">
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#rt-${d.vmid}" type="button">Runtime</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#cfg-${d.vmid}" type="button">Config</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#agt-${d.vmid}" type="button">Agent</button></li>
|
||||
</ul>
|
||||
<div class="tab-content border-top pt-3">
|
||||
<div class="tab-pane fade show active" id="rt-${d.vmid}"><pre class="small mb-0">${JSON.stringify(cur, null, 2)}</pre></div>
|
||||
<div class="tab-pane fade" id="cfg-${d.vmid}"><pre class="small mb-0">${JSON.stringify(cfg, null, 2)}</pre></div>
|
||||
<div class="tab-pane fade" id="agt-${d.vmid}"><pre class="small mb-0">${JSON.stringify(ag, null, 2)}</pre></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
${head}
|
||||
${quick}
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="fw-semibold mb-1">Network (config)</div>
|
||||
${netTable}
|
||||
<div class="fw-semibold mt-3 mb-1">Network (live)</div>
|
||||
${nicLive}
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="fw-semibold mb-1">Disks (block statistics)</div>
|
||||
${blkTable}
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="fw-semibold mb-1">System / QEMU</div>
|
||||
${kvGrid(sysCards, Object.keys(sysCards))}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 mb-1"><div class="fw-semibold">Config facts</div>${kvGrid(cfgFacts, Object.keys(cfgFacts))}</div>
|
||||
|
||||
<div class="mt-2">${agentSummary}</div>
|
||||
|
||||
${rawBtn}
|
||||
${rawBox}
|
||||
`;
|
||||
}
|
||||
|
||||
// ------ Node detail card ------
|
||||
function renderNodeDetailCard(d){
|
||||
const st = d.status || {};
|
||||
const ver = d.version || {};
|
||||
const tm = d.time || {};
|
||||
const svcs = ensureArr(d.services);
|
||||
const netcfg = ensureArr(d.network_cfg);
|
||||
const netstat = ensureArr(d.netstat);
|
||||
const disks = ensureArr(d.disks);
|
||||
const sub = d.subscription || {};
|
||||
|
||||
// robust online detection
|
||||
const isOn = /online|running/i.test(st.status||'') ||
|
||||
/online/i.test(st.hastate||'') ||
|
||||
(st.uptime>0) ||
|
||||
(st.cpu!=null && st.maxcpu!=null) ||
|
||||
(st.memory && st.memory.total>0);
|
||||
const statusTxt = isOn ? 'online' : (st.status||'offline');
|
||||
const sB = isOn ? badge(statusTxt,'ok') : badge(statusTxt,'err');
|
||||
|
||||
const mem = st.memory || {};
|
||||
const root = st.rootfs || {};
|
||||
const load = st.loadavg || '';
|
||||
|
||||
const top = `
|
||||
<div class="d-flex flex-wrap align-items-center gap-3 mb-2">
|
||||
<div class="fw-bold">${safe(d.node)}</div>
|
||||
<div class="vr"></div>
|
||||
<div>${sB}</div>
|
||||
<div class="vr"></div>
|
||||
<div class="small text-muted">CPU: ${pct(st.cpu)}</div>
|
||||
<div class="small text-muted">Load: ${safe(load)}</div>
|
||||
<div class="small text-muted">Uptime: ${fmtSeconds(st.uptime)}</div>
|
||||
</div>`;
|
||||
|
||||
const memCard = `
|
||||
<div class="row row-cols-2 row-cols-md-4 g-2">
|
||||
<div class="col"><div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">Memory</div>
|
||||
<div class="fw-semibold">${
|
||||
(mem.used!=null && mem.total!=null) ? `${humanBytes(mem.used)} / ${humanBytes(mem.total)} (${pct(mem.used/mem.total)})` : '—'
|
||||
}</div>
|
||||
</div></div></div>
|
||||
<div class="col"><div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">RootFS</div>
|
||||
<div class="fw-semibold">${
|
||||
(root.used!=null && root.total!=null) ? `${humanBytes(root.used)} / ${humanBytes(root.total)} (${pct(root.used/root.total)})` : '—'
|
||||
}</div>
|
||||
</div></div></div>
|
||||
<div class="col"><div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">Kernel / QEMU</div>
|
||||
<div class="fw-semibold">${safe(ver.kernel || (ver['release']||''))} / ${safe(ver.qemu||ver['running-qemu']||'')}</div>
|
||||
</div></div></div>
|
||||
<div class="col"><div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">Time</div>
|
||||
<div class="fw-semibold">${safe(tm.localtime)} ${tm.timezone?`(${tm.timezone})`:''}</div>
|
||||
</div></div></div>
|
||||
</div>`;
|
||||
|
||||
// --- NORMALIZE service names + build badge
|
||||
const svcMap = {};
|
||||
svcs.forEach(s => {
|
||||
const raw = (s && s.name) ? s.name : '';
|
||||
const key = raw.replace(/\.service$/,''); // normalize
|
||||
const st = (s && (s.state || s.active)) || '';
|
||||
svcMap[key] = st;
|
||||
});
|
||||
function svcBadge(name){
|
||||
const st = String(svcMap[name]||'').toLowerCase();
|
||||
return (/running|active/.test(st)) ? badge('active','ok') :
|
||||
(st ? badge(st,'err') : badge('inactive','err'));
|
||||
}
|
||||
const svcTable = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>Service</th><th>State</th><th>Service</th><th>State</th><th>Service</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>watchdog-mux</strong></td><td>${svcBadge('watchdog-mux')}</td>
|
||||
<td><strong>pve-ha-crm</strong></td><td>${svcBadge('pve-ha-crm')}</td>
|
||||
<td><strong>pve-ha-lrm</strong></td><td>${svcBadge('pve-ha-lrm')}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
// Network config
|
||||
const netRows = netcfg.map(n=>{
|
||||
return rowHTML([safe(n.iface||n.ifname), safe(n.type), safe(n.method||n.autostart), safe(n.bridge_ports||n.address||'—'), safe(n.cidr||n.netmask||'—'), safe(n.comments||'')]);
|
||||
});
|
||||
const netCfgTable = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>IF</th><th>Type</th><th>Method</th><th>Ports/Address</th><th>Netmask/CIDR</th><th>Comment</th></tr></thead>
|
||||
<tbody>${netRows.length?netRows.join(''):rowHTML(['—','—','—','—','—','—'])}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
// Netstat live
|
||||
const nsRows = netstat.map(x=> rowHTML([safe(x.dev||x.iface), humanBytes(x.rx_bytes||x['receive-bytes']||0), humanBytes(x.tx_bytes||x['transmit-bytes']||0)]));
|
||||
const netLiveTable = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>IF</th><th>RX</th><th>TX</th></tr></thead>
|
||||
<tbody>${nsRows.length?nsRows.join(''):rowHTML(['—','—','—'])}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
// Disks
|
||||
const diskRows = disks.map(dv=> rowHTML([safe(dv.devpath||dv.kname||dv.dev), safe(dv.model), safe(dv.size?humanBytes(dv.size):'—'), safe(dv.health||dv.wearout||'—'), safe(dv.serial||'—')]));
|
||||
const diskTable = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>Device</th><th>Model</th><th>Size</th><th>Health</th><th>Serial</th></tr></thead>
|
||||
<tbody>${diskRows.length?diskRows.join(''):rowHTML(['—','—','—','—','—'])}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
// Subscription
|
||||
const subBox = `
|
||||
<div class="small">
|
||||
<div>Status: ${badge(safe(sub.status||'unknown'), /active|valid/i.test(sub.status||'')?'ok':'warn')}</div>
|
||||
${sub.productname?`<div>Product: <strong>${safe(sub.productname)}</strong></div>`:''}
|
||||
${sub.message?`<div class="text-muted">${safe(sub.message)}</div>`:''}
|
||||
</div>`;
|
||||
|
||||
// Collapsible raw JSON
|
||||
const rawId = `raw-node-${safe(d.node)}`;
|
||||
const rawBtn = `
|
||||
<button class="btn btn-sm btn-outline-secondary mt-3" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#${rawId}">
|
||||
Show raw JSON
|
||||
</button>`;
|
||||
const rawBox = `
|
||||
<div id="${rawId}" class="collapse mt-2">
|
||||
<pre class="small mb-0">${JSON.stringify(d, null, 2)}</pre>
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
${top}
|
||||
${memCard}
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="fw-semibold mb-1">HA services</div>
|
||||
${svcTable}
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="fw-semibold mb-1">Network (config)</div>
|
||||
${netCfgTable}
|
||||
<div class="fw-semibold mt-3 mb-1">Network (live)</div>
|
||||
${netLiveTable}
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="fw-semibold mb-1">Disks</div>
|
||||
${diskTable}
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="fw-semibold mb-1">Subscription</div>
|
||||
${subBox}
|
||||
</div>
|
||||
|
||||
${rawBtn}
|
||||
${rawBox}
|
||||
`;
|
||||
}
|
||||
|
||||
// ------ Sections ------
|
||||
function setHealth(ok, vq, unitsActive){
|
||||
healthDot.classList.toggle('ok',!!ok);
|
||||
healthDot.classList.toggle('bad',!ok);
|
||||
healthTitle.textContent = ok ? 'HA: OK' : 'HA: PROBLEM';
|
||||
healthSub.textContent = `Quorate=${String(vq.quorate)} | units=${unitsActive?'active':'inactive'} | members=${safe(vq.members)} | quorum=${safe(vq.quorum)}/${safe(vq.expected)}`;
|
||||
}
|
||||
|
||||
function renderClusterCards(arr){
|
||||
const a = ensureArr(arr); qCardsWrap.innerHTML='';
|
||||
if(!a.length){ qCardsWrap.innerHTML = badge('No data','dark'); return; }
|
||||
const cluster = a.find(x=>x.type==='cluster')||{};
|
||||
const qB = cluster.quorate ? badge('Quorate: yes','ok') : badge('Quorate: no','err');
|
||||
qCardsWrap.insertAdjacentHTML('beforeend', `
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm"><div class="card-body d-flex flex-wrap align-items-center gap-3">
|
||||
<div class="fw-bold">${safe(cluster.name)}</div>
|
||||
<div class="text-muted small">id: ${safe(cluster.id)}</div><div class="vr"></div>
|
||||
<div>${qB}</div><div class="vr"></div>
|
||||
<div class="small">nodes: <strong>${safe(cluster.nodes)}</strong></div>
|
||||
<div class="small">version: <strong>${safe(cluster.version)}</strong></div>
|
||||
</div></div>
|
||||
</div>`);
|
||||
const nodes = a.filter(x=>x.type==='node');
|
||||
const rows = nodes.map(n=>{
|
||||
const online = n.online ? badge('online','ok') : badge('offline','err');
|
||||
const local = n.local ? ' '+badge('local','info') : '';
|
||||
return rowHTML([safe(n.name), online+local, safe(n.ip), safe(n.nodeid), safe(n.level)]);
|
||||
});
|
||||
qCardsWrap.insertAdjacentHTML('beforeend', `
|
||||
<div class="col-12"><div class="card border-0"><div class="card-body table-responsive pt-2">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>Node</th><th>Status</th><th>IP</th><th>NodeID</th><th>Level</th></tr></thead>
|
||||
<tbody>${rows.join('')}</tbody>
|
||||
</table>
|
||||
</div></div></div>`);
|
||||
}
|
||||
|
||||
function renderUnits(units){
|
||||
unitsBox.innerHTML='';
|
||||
if(!units || !Object.keys(units).length){ unitsBox.innerHTML=badge('No data','dark'); return; }
|
||||
const map={active:'ok',inactive:'err',failed:'err',activating:'warn'};
|
||||
Object.entries(units).forEach(([k,v])=> unitsBox.insertAdjacentHTML('beforeend', `<span class="me-2">${badge(k,map[v]||'dark')}</span>`));
|
||||
}
|
||||
|
||||
function parsePveSr(text){
|
||||
const lines=(text||'').split('\n').map(s=>s.trim()).filter(Boolean), out=[];
|
||||
for(const ln of lines){
|
||||
if(ln.startsWith('JobID')) continue;
|
||||
const m=ln.match(/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+)$/);
|
||||
if(m) out.push({job:m[1],enabled:m[2],target:m[3],last:m[4],next:m[5],dur:m[6],fail:+m[7],state:m[8]});
|
||||
} return out;
|
||||
}
|
||||
function renderReplication(text){
|
||||
const arr=parsePveSr(text);
|
||||
if(!arr.length){ replBox.innerHTML='<span class="text-muted small">No replication jobs</span>'; return; }
|
||||
const rows=arr.map(x=>{
|
||||
const en=/^yes$/i.test(x.enabled)?badge('Yes','ok'):badge('No','err');
|
||||
const st=/^ok$/i.test(x.state)?badge(x.state,'ok'):badge(x.state,'err');
|
||||
const fc=x.fail>0?badge(String(x.fail),'err'):badge(String(x.fail),'ok');
|
||||
return rowHTML([x.job,en,x.target,x.last,x.next,x.dur,fc,st]);
|
||||
});
|
||||
replBox.innerHTML=`<div class="table-responsive"><table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>JobID</th><th>Enabled</th><th>Target</th><th>LastSync</th><th>NextSync</th><th>Duration</th><th>FailCount</th><th>State</th></tr></thead>
|
||||
<tbody>${rows.join('')}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
// HA Resources table (expandable rows)
|
||||
function renderHAResources(list){
|
||||
const arr=ensureArr(list); const rows=[];
|
||||
arr.forEach(x=>{
|
||||
const st = x.state||'—';
|
||||
const stB = /start/i.test(st)?badge(st,'ok'):(/stop/i.test(st)?badge(st,'err'):badge(st,'dark'));
|
||||
const sid = safe(x.sid);
|
||||
rows.push(rowHTML([sid, stB, safe(x.node), safe(x.group), safe(x.flags||x.comment)], `class="vm-row" data-sid="${sid}"`));
|
||||
rows.push(`<tr class="vm-detail d-none"><td colspan="5"><div class="spinner-border spinner-border-sm me-2 d-none"></div><div class="vm-json">—</div></td></tr>`);
|
||||
});
|
||||
setRows(tblHaRes, rows.length?rows:[rowHTML(['—','—','—','—','—'])]);
|
||||
Array.from(document.querySelectorAll('#ha-res tbody tr.vm-row')).forEach((tr,i)=>{
|
||||
tr.onclick = async ()=>{
|
||||
const detailRow = tblHaRes.querySelectorAll('tr.vm-detail')[i];
|
||||
const content = detailRow.querySelector('.vm-json');
|
||||
const spin = detailRow.querySelector('.spinner-border');
|
||||
const open = detailRow.classList.contains('d-none');
|
||||
document.querySelectorAll('#ha-res tbody tr.vm-detail').forEach(r=>r.classList.add('d-none'));
|
||||
if(open){
|
||||
detailRow.classList.remove('d-none'); spin.classList.remove('d-none');
|
||||
const sid = tr.getAttribute('data-sid');
|
||||
try { const d = await fetchVmDetail(sid); content.innerHTML = renderVmDetailCard(d); }
|
||||
catch(e){ content.textContent='ERROR: '+e; }
|
||||
spin.classList.add('d-none');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderHAStatus(list){
|
||||
const st=ensureArr(list);
|
||||
if(!st.length){ setRows(tblHaStatus, [rowHTML(['—','—','—','—'])]); return; }
|
||||
const rows=st.map(n=>{
|
||||
const sB=/active|online/i.test(n.state||'')?badge(n.state,'ok'):badge(safe(n.state||'—'),'warn');
|
||||
const crmB=/active/i.test(n.crm_state||'')?badge(n.crm_state,'ok'):badge(safe(n.crm_state||'—'),'err');
|
||||
const lrmB=/active/i.test(n.lrm_state||'')?badge(n.lrm_state,'ok'):badge(safe(n.lrm_state||'—'),'err');
|
||||
return rowHTML([safe(n.node), sB, crmB, lrmB]);
|
||||
});
|
||||
setRows(tblHaStatus, rows);
|
||||
}
|
||||
|
||||
// Non-HA VM/CT table
|
||||
async function renderNonHA(){
|
||||
const r = await fetch('/api/list-vmct');
|
||||
const d = await r.json();
|
||||
const arr = ensureArr(d.nonha);
|
||||
if(!arr.length){ setRows(tblNonHA,[rowHTML(['No non-HA VMs/CTs'])]); return; }
|
||||
const rows=[];
|
||||
arr.forEach(x=>{
|
||||
const sid=safe(x.sid), type=safe(x.type), name=safe(x.name), node=safe(x.node);
|
||||
const st=/running/i.test(x.status||'')?badge(x.status,'ok'):badge(x.status,'dark');
|
||||
rows.push(rowHTML([sid,type,name,node,st], `class="vm-row" data-sid="${sid}`+"\""));
|
||||
rows.push(`<tr class="vm-detail d-none"><td colspan="5"><div class="spinner-border spinner-border-sm me-2 d-none"></div><div class="vm-json">—</div></td></tr>`);
|
||||
});
|
||||
setRows(tblNonHA, rows);
|
||||
Array.from(document.querySelectorAll('#nonha tbody tr.vm-row')).forEach((tr,i)=>{
|
||||
tr.onclick = async ()=>{
|
||||
const detailRow = tblNonHA.querySelectorAll('tr.vm-detail')[i];
|
||||
const content = detailRow.querySelector('.vm-json');
|
||||
const spin = detailRow.querySelector('.spinner-border');
|
||||
const open = detailRow.classList.contains('d-none');
|
||||
document.querySelectorAll('#nonha tbody tr.vm-detail').forEach(r=>r.classList.add('d-none'));
|
||||
if(open){
|
||||
detailRow.classList.remove('d-none'); spin.classList.remove('d-none');
|
||||
const sid = tr.getAttribute('data-sid');
|
||||
try { const d = await fetchVmDetail(sid); content.innerHTML = renderVmDetailCard(d); }
|
||||
catch(e){ content.textContent='ERROR: '+e; }
|
||||
spin.classList.add('d-none');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Nodes table (expandable) + sticky first column + robust online detect
|
||||
function renderNodesTable(nodes){
|
||||
const nrows = ensureArr(nodes).map(n=>{
|
||||
const isOn = /online|running/i.test(n.status||'') ||
|
||||
/online/i.test(n.hastate||'') ||
|
||||
(n.uptime>0) ||
|
||||
(n.cpu!=null && n.maxcpu!=null) ||
|
||||
(n.mem!=null && n.maxmem!=null);
|
||||
const statusTxt = isOn ? 'online' : (n.status||'offline');
|
||||
const sB = isOn ? badge(statusTxt,'ok') : badge(statusTxt,'err');
|
||||
|
||||
const mem=(n.mem!=null&&n.maxmem)?`${humanBytes(n.mem)} / ${humanBytes(n.maxmem)} (${pct(n.mem/n.maxmem)})`:'—';
|
||||
const rfs=(n.rootfs!=null&&n.maxrootfs)?`${humanBytes(n.rootfs)} / ${humanBytes(n.maxrootfs)} (${pct(n.rootfs/n.maxrootfs)})`:'—';
|
||||
const load=(n.loadavg!=null)?String(n.loadavg):'—';
|
||||
const cpu=(n.cpu!=null)?pct(n.cpu):'—';
|
||||
|
||||
const main = `<tr class="node-row" data-node="${safe(n.node)}">
|
||||
<td class="sticky-col fw-semibold">${safe(n.node)}</td>
|
||||
<td>${sB}</td><td>${cpu}</td><td>${load}</td><td>${mem}</td><td>${rfs}</td><td>${safe(n.uptime)}</td>
|
||||
</tr>`;
|
||||
|
||||
const detail = `<tr class="node-detail d-none">
|
||||
<td colspan="7">
|
||||
<div class="spinner-border spinner-border-sm me-2 d-none"></div>
|
||||
<div class="node-json">—</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
return main + detail;
|
||||
});
|
||||
setRows(tblNodes, nrows);
|
||||
|
||||
Array.from(document.querySelectorAll('#nodes tbody tr.node-row')).forEach((tr,i)=>{
|
||||
tr.onclick = async ()=>{
|
||||
const detailRow = tblNodes.querySelectorAll('tr.node-detail')[i];
|
||||
const content = detailRow.querySelector('.node-json');
|
||||
const spin = detailRow.querySelector('.spinner-border');
|
||||
const open = detailRow.classList.contains('d-none');
|
||||
document.querySelectorAll('#nodes tbody tr.node-detail').forEach(r=>r.classList.add('d-none'));
|
||||
if(open){
|
||||
detailRow.classList.remove('d-none'); spin.classList.remove('d-none');
|
||||
const name = tr.getAttribute('data-node');
|
||||
try { const d = await fetchNodeDetail(name); content.innerHTML = renderNodeDetailCard(d); }
|
||||
catch(e){ content.textContent='ERROR: '+e; }
|
||||
spin.classList.add('d-none');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ------ main render ------
|
||||
function renderSnap(d){
|
||||
const vq=d.votequorum||{}; const units=d.units||{}; const allUnits=Object.values(units).every(v=>v==='active');
|
||||
const ok = (vq.quorate==='yes') && allUnits;
|
||||
setHealth(ok, vq, allUnits);
|
||||
|
||||
const gl = document.getElementById('global-loading'); if (gl) gl.remove();
|
||||
|
||||
qSummary.textContent = `Quorate: ${safe(vq.quorate)} | members: ${safe(vq.members)} | expected: ${safe(vq.expected)} | total: ${safe(vq.total)} | quorum: ${safe(vq.quorum)}`;
|
||||
renderClusterCards(d.api && d.api.cluster_status);
|
||||
renderUnits(units);
|
||||
renderReplication(d.replication);
|
||||
renderHAResources(d.api && d.api.ha_resources);
|
||||
renderHAStatus(d.api && d.api.ha_status);
|
||||
renderNodesTable(d.api && d.api.nodes);
|
||||
|
||||
pvecmPre.textContent = safe(d.pvecm);
|
||||
cfgtoolPre.textContent = safe(d.cfgtool);
|
||||
|
||||
footer.textContent = `node_arg=${safe(d.node_arg)} | host=${safe(d.hostname)} | ts=${new Date((d.ts||0)*1000).toLocaleString()}`;
|
||||
}
|
||||
|
||||
// initial one-shot load (auto refresh OFF by default)
|
||||
doRefresh().catch(console.error);
|
28
static/styles.css
Normal file
28
static/styles.css
Normal file
@@ -0,0 +1,28 @@
|
||||
/* Dark theme */
|
||||
body { background-color: #0f1115; }
|
||||
.card.health-card { background: #101520; }
|
||||
.health-dot { width: 12px; height: 12px; border-radius: 50%; background: #dc3545; }
|
||||
.health-dot.ok { background: #28a745; }
|
||||
.health-dot.bad { background: #dc3545; }
|
||||
|
||||
/* Tables */
|
||||
.table td, .table th { vertical-align: middle; }
|
||||
|
||||
/* Dividers */
|
||||
.vr { width:1px; min-height:1rem; background: rgba(255,255,255,.15); }
|
||||
|
||||
/* --- horizontal scroll & nowrap for wide tables --- */
|
||||
.table-responsive { overflow-x: auto; }
|
||||
.table-nowrap { white-space: nowrap; }
|
||||
@media (min-width: 992px){
|
||||
.table-nowrap-lg-normal { white-space: normal; }
|
||||
}
|
||||
|
||||
/* sticky first column (for wide tables) */
|
||||
.sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: var(--bs-body-bg);
|
||||
box-shadow: 1px 0 0 rgba(255,255,255,.08);
|
||||
}
|
206
templates/index.html
Normal file
206
templates/index.html
Normal file
@@ -0,0 +1,206 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>PVE HA Panel</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='styles.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container py-4">
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
|
||||
<h1 class="h4 m-0">PVE HA Panel</h1>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<input class="form-control form-control-sm" id="node" value="{{ node }}" style="width: 180px">
|
||||
<button class="btn btn-outline-secondary btn-sm" id="btnToggleAll">Collapse/Expand all</button>
|
||||
|
||||
<div class="vr d-none d-md-block"></div>
|
||||
|
||||
<button class="btn btn-primary btn-sm" id="btnRefresh">Refresh now</button>
|
||||
<div class="input-group input-group-sm" style="width:220px">
|
||||
<span class="input-group-text">Auto-refresh</span>
|
||||
<select class="form-select" id="selInterval" disabled>
|
||||
<option value="10000">10 s</option>
|
||||
<option value="20000">20 s</option>
|
||||
<option value="30000" selected>30 s</option>
|
||||
<option value="45000">45 s</option>
|
||||
<option value="60000">60 s</option>
|
||||
<option value="120000">120 s</option>
|
||||
</select>
|
||||
<button class="btn btn-outline-success" id="btnAuto">OFF</button>
|
||||
</div>
|
||||
|
||||
<div class="vr d-none d-md-block"></div>
|
||||
|
||||
<button type="button" class="btn btn-success btn-sm" id="btnEnable">Enable maintenance</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" id="btnDisable">Disable maintenance</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global loading -->
|
||||
<div id="global-loading" class="d-flex align-items-center gap-2 mb-3">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
<span class="small text-muted">Loading data…</span>
|
||||
</div>
|
||||
|
||||
<!-- HEALTH -->
|
||||
<div class="card mb-3 border-0 shadow health-card">
|
||||
<div class="card-body d-flex flex-wrap align-items-center gap-3">
|
||||
<div class="health-dot" id="healthDot"></div>
|
||||
<div>
|
||||
<div class="fw-bold" id="healthTitle">Loading…</div>
|
||||
<div class="text-muted small" id="healthSub">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3" id="mainTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-ha" type="button">HA</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-nonha" type="button">VM/CT (non-HA)</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-nodes" type="button">Nodes</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- TAB: HA -->
|
||||
<div class="tab-pane fade show active" id="tab-ha">
|
||||
<div class="accordion" id="acc">
|
||||
|
||||
<!-- Cluster / Quorum -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="h-q">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-q">
|
||||
Cluster / Quorum
|
||||
</button>
|
||||
</h2>
|
||||
<div id="c-q" class="accordion-collapse collapse" data-bs-parent="#acc">
|
||||
<div class="accordion-body">
|
||||
<div id="q-summary" class="mb-3 small">Loading…</div>
|
||||
<div class="row g-3" id="q-cards"><div class="text-muted small">Loading…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Systemd -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="h-sd">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-sd">
|
||||
Systemd (HA)
|
||||
</button>
|
||||
</h2>
|
||||
<div id="c-sd" class="accordion-collapse collapse" data-bs-parent="#acc">
|
||||
<div class="accordion-body">
|
||||
<div id="units" class="d-flex flex-wrap gap-2"><span class="text-muted small">Loading…</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replication -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="h-repl">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-repl">
|
||||
Replication (pvesr)
|
||||
</button>
|
||||
</h2>
|
||||
<div id="c-repl" class="accordion-collapse collapse" data-bs-parent="#acc">
|
||||
<div class="accordion-body"><div id="repl"><span class="text-muted small">Loading…</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HA resources -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="h-res">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-res">
|
||||
HA — resources
|
||||
</button>
|
||||
</h2>
|
||||
<div id="c-res" class="accordion-collapse collapse" data-bs-parent="#acc">
|
||||
<div class="accordion-body table-responsive">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap" id="ha-res">
|
||||
<thead><tr><th>SID</th><th>State</th><th>Node</th><th>Group</th><th>Flags/Comment</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="small text-muted">Click a row to expand VM/CT details.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HA status -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="h-hastatus">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-hastatus">
|
||||
HA — status
|
||||
</button>
|
||||
</h2>
|
||||
<div id="c-hastatus" class="accordion-collapse collapse" data-bs-parent="#acc">
|
||||
<div class="accordion-body table-responsive">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap" id="ha-status">
|
||||
<thead><tr><th>Node</th><th>Status</th><th>CRM</th><th>LRM</th></tr></thead>
|
||||
<tbody><tr><td colspan="4" class="text-muted">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="h-raw">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-raw">
|
||||
Raw: pvecm / cfgtool
|
||||
</button>
|
||||
</h2>
|
||||
<div id="c-raw" class="accordion-collapse collapse" data-bs-parent="#acc">
|
||||
<div class="accordion-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6"><pre id="pvecm" class="mb-0 small"></pre></div>
|
||||
<div class="col-lg-6"><pre id="cfgtool" class="mb-0 small"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Non-HA -->
|
||||
<div class="tab-pane fade" id="tab-nonha">
|
||||
<div class="card border-0">
|
||||
<div class="card-body table-responsive">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap" id="nonha">
|
||||
<thead><tr><th>SID</th><th>Type</th><th>Name</th><th>Node</th><th>Status</th></tr></thead>
|
||||
<tbody><tr><td colspan="5" class="text-muted">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
<div class="small text-muted">Click a row to expand VM/CT details.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Nodes (expandable) -->
|
||||
<div class="tab-pane fade" id="tab-nodes">
|
||||
<div class="card border-0">
|
||||
<div class="card-body table-responsive">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap" id="nodes">
|
||||
<thead><tr><th>Node</th><th>Status</th><th>CPU</th><th>Load</th><th>Mem</th><th>RootFS</th><th>Uptime</th></tr></thead>
|
||||
<tbody><tr><td colspan="7" class="text-muted">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
<div class="small text-muted">Click a row to expand node details.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mt-3" id="footer">—</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='main.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
Reference in New Issue
Block a user