Merge pull request 'refactor' (#1) from refactor into master
Reviewed-on: #1
This commit is contained in:
294
app.py
294
app.py
@@ -10,12 +10,16 @@ import subprocess
|
|||||||
from typing import List, Dict, Any, Optional, Tuple
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
from flask import Flask, request, jsonify, render_template
|
from flask import Flask, request, jsonify, render_template
|
||||||
|
|
||||||
|
# NEW: WebSockets
|
||||||
|
from flask_sock import Sock
|
||||||
|
|
||||||
APP_TITLE = "PVE HA Panel"
|
APP_TITLE = "PVE HA Panel"
|
||||||
DEFAULT_NODE = socket.gethostname()
|
DEFAULT_NODE = socket.gethostname()
|
||||||
HA_UNITS_START = ["watchdog-mux", "pve-ha-crm", "pve-ha-lrm"]
|
HA_UNITS_START = ["watchdog-mux", "pve-ha-crm", "pve-ha-lrm"]
|
||||||
HA_UNITS_STOP = list(reversed(HA_UNITS_START))
|
HA_UNITS_STOP = list(reversed(HA_UNITS_START))
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
|
sock = Sock(app) # NEW
|
||||||
|
|
||||||
# ---------------- exec helpers ----------------
|
# ---------------- exec helpers ----------------
|
||||||
def run(cmd: List[str], timeout: int = 25) -> subprocess.CompletedProcess:
|
def run(cmd: List[str], timeout: int = 25) -> subprocess.CompletedProcess:
|
||||||
@@ -41,12 +45,16 @@ def post_json(cmd: List[str], timeout: Optional[int] = None) -> Any:
|
|||||||
if cmd and cmd[0] == "pvesh" and len(cmd) > 2 and cmd[1] != "create":
|
if cmd and cmd[0] == "pvesh" and len(cmd) > 2 and cmd[1] != "create":
|
||||||
cmd = ["pvesh", "create"] + cmd[1:]
|
cmd = ["pvesh", "create"] + cmd[1:]
|
||||||
r = run(cmd, timeout=timeout or 25)
|
r = run(cmd, timeout=timeout or 25)
|
||||||
if r.returncode != 0 or not r.stdout.strip():
|
if r.returncode != 0:
|
||||||
return None
|
# pvesh create zwykle zwraca JSON tylko przy 200
|
||||||
try:
|
try:
|
||||||
return json.loads(r.stdout)
|
return json.loads(r.stdout) if r.stdout.strip() else {"error": r.stderr.strip(), "rc": r.returncode}
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return {"error": r.stderr.strip(), "rc": r.returncode}
|
||||||
|
try:
|
||||||
|
return json.loads(r.stdout) if r.stdout.strip() else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
def is_active(unit: str) -> bool:
|
def is_active(unit: str) -> bool:
|
||||||
return run(["systemctl", "is-active", "--quiet", unit]).returncode == 0
|
return run(["systemctl", "is-active", "--quiet", unit]).returncode == 0
|
||||||
@@ -64,7 +72,7 @@ def stop_if_running(unit: str, out: List[str]) -> None:
|
|||||||
def ha_node_maint(enable: bool, node: str, out: List[str]) -> None:
|
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]
|
cmd = ["ha-manager", "crm-command", "node-maintenance", "enable" if enable else "disable", node]
|
||||||
out.append("$ " + " ".join(shlex.quote(x) for x in cmd))
|
out.append("$ " + " ".join(shlex.quote(x) for x in cmd))
|
||||||
r = run(cmd, timeout=timeout or 25)
|
r = run(cmd, timeout=25)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
out.append(f"ERR: {r.stderr.strip()}")
|
out.append(f"ERR: {r.stderr.strip()}")
|
||||||
|
|
||||||
@@ -106,8 +114,7 @@ def enrich_nodes(nodes_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|||||||
for n in nodes_list or []:
|
for n in nodes_list or []:
|
||||||
name = n.get("node")
|
name = n.get("node")
|
||||||
if not name:
|
if not name:
|
||||||
out.append(n)
|
out.append(n); continue
|
||||||
continue
|
|
||||||
detail = get_json(["pvesh", "get", f"/nodes/{name}/status"]) or {}
|
detail = get_json(["pvesh", "get", f"/nodes/{name}/status"]) or {}
|
||||||
if "loadavg" in detail: n["loadavg"] = detail["loadavg"]
|
if "loadavg" in detail: n["loadavg"] = detail["loadavg"]
|
||||||
if "cpu" in detail: n["cpu"] = detail["cpu"]
|
if "cpu" in detail: n["cpu"] = detail["cpu"]
|
||||||
@@ -195,34 +202,21 @@ def merge_resources(api_res: List[Dict[str, Any]],
|
|||||||
parsed_res: List[Dict[str, Any]],
|
parsed_res: List[Dict[str, Any]],
|
||||||
vmct_idx: Dict[str, str]) -> List[Dict[str, Any]]:
|
vmct_idx: Dict[str, str]) -> List[Dict[str, Any]]:
|
||||||
by_sid: Dict[str, Dict[str, Any]] = {}
|
by_sid: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
# seed from /cluster/ha/resources
|
|
||||||
for r in (api_res or []):
|
for r in (api_res or []):
|
||||||
sid = norm_sid(r.get("sid"))
|
sid = norm_sid(r.get("sid"))
|
||||||
if not sid:
|
if not sid: continue
|
||||||
continue
|
x = dict(r); x["sid"] = sid; by_sid[sid] = x
|
||||||
x = dict(r)
|
|
||||||
x["sid"] = sid
|
|
||||||
by_sid[sid] = x
|
|
||||||
|
|
||||||
# merge runtime from ha-manager
|
|
||||||
for r in (parsed_res or []):
|
for r in (parsed_res or []):
|
||||||
sid = norm_sid(r.get("sid"))
|
sid = norm_sid(r.get("sid"))
|
||||||
if not sid:
|
if not sid: continue
|
||||||
continue
|
|
||||||
x = by_sid.get(sid, {"sid": sid})
|
x = by_sid.get(sid, {"sid": sid})
|
||||||
for k, v in r.items():
|
for k, v in r.items():
|
||||||
if k == "sid":
|
if k == "sid": continue
|
||||||
continue
|
if v not in (None, ""): x[k] = v
|
||||||
if v not in (None, ""):
|
|
||||||
x[k] = v
|
|
||||||
by_sid[sid] = x
|
by_sid[sid] = x
|
||||||
|
|
||||||
# fill node from /cluster/resources
|
|
||||||
for sid, x in by_sid.items():
|
for sid, x in by_sid.items():
|
||||||
if not x.get("node") and sid in vmct_idx:
|
if not x.get("node") and sid in vmct_idx:
|
||||||
x["node"] = vmct_idx[sid]
|
x["node"] = vmct_idx[sid]
|
||||||
|
|
||||||
return list(by_sid.values())
|
return list(by_sid.values())
|
||||||
|
|
||||||
# ---------------- VM details ----------------
|
# ---------------- VM details ----------------
|
||||||
@@ -265,20 +259,11 @@ def node_detail_payload(name: str) -> Dict[str, Any]:
|
|||||||
timeinfo = get_json(["pvesh", "get", f"/nodes/{name}/time"]) or {}
|
timeinfo = get_json(["pvesh", "get", f"/nodes/{name}/time"]) or {}
|
||||||
services = get_json(["pvesh", "get", f"/nodes/{name}/services"]) or []
|
services = get_json(["pvesh", "get", f"/nodes/{name}/services"]) or []
|
||||||
network_cfg = get_json(["pvesh", "get", f"/nodes/{name}/network"]) 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
|
netstat = get_json(["pvesh", "get", f"/nodes/{name}/netstat"]) or []
|
||||||
disks = get_json(["pvesh", "get", f"/nodes/{name}/disks/list"]) or []
|
disks = get_json(["pvesh", "get", f"/nodes/{name}/disks/list"]) or []
|
||||||
subscription = get_json(["pvesh", "get", f"/nodes/{name}/subscription"]) or {}
|
subscription = get_json(["pvesh", "get", f"/nodes/{name}/subscription"]) or {}
|
||||||
return {
|
return {"node": name,"status": status,"version": version,"time": timeinfo,"services": services,
|
||||||
"node": name,
|
"network_cfg": network_cfg,"netstat": netstat,"disks": disks,"subscription": subscription}
|
||||||
"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]:
|
def node_ha_services(node: str) -> Dict[str, str]:
|
||||||
svcs = get_json(["pvesh", "get", f"/nodes/{node}/services"]) or []
|
svcs = get_json(["pvesh", "get", f"/nodes/{node}/services"]) or []
|
||||||
@@ -294,40 +279,20 @@ def units_for_node(node: str) -> Dict[str, str]:
|
|||||||
wanted = {"watchdog-mux", "pve-ha-crm", "pve-ha-lrm"}
|
wanted = {"watchdog-mux", "pve-ha-crm", "pve-ha-lrm"}
|
||||||
svc = get_json(["pvesh", "get", f"/nodes/{node}/services"]) or []
|
svc = get_json(["pvesh", "get", f"/nodes/{node}/services"]) or []
|
||||||
states: Dict[str, str] = {}
|
states: Dict[str, str] = {}
|
||||||
|
|
||||||
def norm_state(s: dict) -> str:
|
def norm_state(s: dict) -> str:
|
||||||
raw_active = str(
|
raw_active = str((s.get("active","") or s.get("active-state","") or s.get("ActiveState","") or s.get("activestate",""))).lower()
|
||||||
s.get("active", "") or
|
|
||||||
s.get("active-state", "") or
|
|
||||||
s.get("ActiveState", "") or
|
|
||||||
s.get("activestate", "")
|
|
||||||
).lower()
|
|
||||||
|
|
||||||
status = str(s.get("status","")).lower()
|
status = str(s.get("status","")).lower()
|
||||||
substate = str(s.get("substate","")).lower()
|
substate = str(s.get("substate","")).lower()
|
||||||
state = str(s.get("state","")).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","active") or ("running" in state or "active" in state))
|
||||||
any_active = (
|
|
||||||
raw_active in ("active", "running", "1", "true") or
|
|
||||||
status in ("active", "running") or
|
|
||||||
substate in ("running", "active") or
|
|
||||||
("running" in state or "active" in state)
|
|
||||||
)
|
|
||||||
return "active" if any_active else "inactive"
|
return "active" if any_active else "inactive"
|
||||||
|
|
||||||
for s in svc:
|
for s in svc:
|
||||||
name_raw = (s.get("name") or "")
|
name_raw = (s.get("name") or ""); name = re.sub(r"\.service$","",name_raw)
|
||||||
name = re.sub(r"\.service$", "", name_raw)
|
if name in wanted: states[name] = norm_state(s)
|
||||||
if name in wanted:
|
|
||||||
states[name] = norm_state(s)
|
|
||||||
|
|
||||||
for u in wanted:
|
for u in wanted:
|
||||||
if states.get(u) != "active" and is_active(u):
|
if states.get(u) != "active" and is_active(u):
|
||||||
states[u] = "active"
|
states[u] = "active"
|
||||||
|
for u in wanted: states.setdefault(u, "inactive")
|
||||||
for u in wanted:
|
|
||||||
states.setdefault(u, "inactive")
|
|
||||||
|
|
||||||
return states
|
return states
|
||||||
|
|
||||||
# ---------------- snapshot ----------------
|
# ---------------- snapshot ----------------
|
||||||
@@ -348,11 +313,7 @@ def status_snapshot(node: str) -> Dict[str, Any]:
|
|||||||
if not ha_status:
|
if not ha_status:
|
||||||
for it in api.get("cluster_status", []):
|
for it in api.get("cluster_status", []):
|
||||||
if it.get("type") == "node":
|
if it.get("type") == "node":
|
||||||
ha_status.append({
|
ha_status.append({"node": it.get("name"),"state": "online" if it.get("online") else "offline","crm_state":"", "lrm_state":""})
|
||||||
"node": it.get("name"),
|
|
||||||
"state": "online" if it.get("online") else "offline",
|
|
||||||
"crm_state": "", "lrm_state": ""
|
|
||||||
})
|
|
||||||
|
|
||||||
enriched = []
|
enriched = []
|
||||||
for n in ha_status:
|
for n in ha_status:
|
||||||
@@ -366,17 +327,11 @@ def status_snapshot(node: str) -> Dict[str, Any]:
|
|||||||
pass
|
pass
|
||||||
enriched.append(n)
|
enriched.append(n)
|
||||||
api["ha_status"] = enriched
|
api["ha_status"] = enriched
|
||||||
|
|
||||||
api["ha_resources"] = merge_resources(api.get("ha_resources", []), parsed.get("resources", []), vmct_ix)
|
api["ha_resources"] = merge_resources(api.get("ha_resources", []), parsed.get("resources", []), vmct_ix)
|
||||||
|
|
||||||
units = units_for_node(node or socket.gethostname())
|
units = units_for_node(node or socket.gethostname())
|
||||||
return {
|
return {"node_arg": node, "hostname": socket.gethostname(), "votequorum": vq, "units": units,
|
||||||
"node_arg": node, "hostname": socket.gethostname(),
|
|
||||||
"votequorum": vq, "units": units,
|
|
||||||
"cfgtool": get_cfgtool().strip(), "pvecm": get_pvecm_status().strip(),
|
"cfgtool": get_cfgtool().strip(), "pvecm": get_pvecm_status().strip(),
|
||||||
"ha_raw": ha_raw, "replication": get_pvesr_status().strip(),
|
"ha_raw": ha_raw, "replication": get_pvesr_status().strip(), "api": api, "ts": int(time.time())}
|
||||||
"api": api, "ts": int(time.time())
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------- web ----------------
|
# ---------------- web ----------------
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@@ -384,11 +339,64 @@ def index():
|
|||||||
node = request.args.get("node", DEFAULT_NODE)
|
node = request.args.get("node", DEFAULT_NODE)
|
||||||
return render_template("index.html", title=APP_TITLE, node=node)
|
return render_template("index.html", title=APP_TITLE, node=node)
|
||||||
|
|
||||||
|
# Stary zbiorczy snapshot — zostaje
|
||||||
@app.get("/api/info")
|
@app.get("/api/info")
|
||||||
def api_info():
|
def api_info():
|
||||||
node = request.args.get("node", DEFAULT_NODE)
|
node = request.args.get("node", DEFAULT_NODE)
|
||||||
return jsonify(status_snapshot(node))
|
return jsonify(status_snapshot(node))
|
||||||
|
|
||||||
|
# --- NOWE lżejsze endpointy (szybsze ładowanie strony) ---
|
||||||
|
|
||||||
|
@app.get("/api/cluster")
|
||||||
|
def api_cluster_brief():
|
||||||
|
vq = votequorum_brief()
|
||||||
|
api = api_cluster_data()
|
||||||
|
ha_raw = get_ha_status_raw().strip()
|
||||||
|
parsed = parse_ha_manager(ha_raw)
|
||||||
|
vmct_ix = cluster_vmct_index()
|
||||||
|
api["ha_resources"] = merge_resources(api.get("ha_resources", []), parsed.get("resources", []), vmct_ix)
|
||||||
|
return jsonify({
|
||||||
|
"votequorum": vq,
|
||||||
|
"cluster_status": api.get("cluster_status", []),
|
||||||
|
"ha_resources": api.get("ha_resources", []),
|
||||||
|
"pvecm": get_pvecm_status().strip(),
|
||||||
|
"cfgtool": get_cfgtool().strip(),
|
||||||
|
"hostname": socket.gethostname(),
|
||||||
|
"ts": int(time.time())
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/api/nodes/summary")
|
||||||
|
def api_nodes_summary():
|
||||||
|
nodes = enrich_nodes((api_cluster_data().get("nodes") or []))
|
||||||
|
return jsonify({ "nodes": nodes })
|
||||||
|
|
||||||
|
@app.get("/api/units")
|
||||||
|
def api_units():
|
||||||
|
node = request.args.get("node", DEFAULT_NODE)
|
||||||
|
return jsonify({ "units": units_for_node(node) })
|
||||||
|
|
||||||
|
# Replication ze wszystkich nodów
|
||||||
|
@app.get("/api/replication/all")
|
||||||
|
def api_replication_all():
|
||||||
|
jobs: List[Dict[str, Any]] = []
|
||||||
|
nodes = [n.get("node") for n in (api_cluster_data().get("nodes") or []) if n.get("node")]
|
||||||
|
for name in nodes:
|
||||||
|
data = get_json(["pvesh", "get", f"/nodes/{name}/replication"]) or get_json(["pvesh","get",f"/nodes/{name}/replication/jobs"]) or []
|
||||||
|
for it in (data or []):
|
||||||
|
jobs.append({
|
||||||
|
"node": name,
|
||||||
|
"job": it.get("id") or it.get("job") or "",
|
||||||
|
"enabled": "yes" if (it.get("enable",1) in (1, "1", True)) else "no",
|
||||||
|
"target": it.get("target") or it.get("target-node") or "",
|
||||||
|
"last": it.get("last_sync") or it.get("last_sync", ""),
|
||||||
|
"next": it.get("next_sync") or it.get("next_sync", ""),
|
||||||
|
"dur": it.get("duration") or it.get("duration", ""),
|
||||||
|
"fail": int(it.get("fail_count") or it.get("failcount") or 0),
|
||||||
|
"state": it.get("state") or it.get("status") or ""
|
||||||
|
})
|
||||||
|
return jsonify({ "jobs": jobs })
|
||||||
|
|
||||||
|
# --- Detale ---
|
||||||
@app.get("/api/vm")
|
@app.get("/api/vm")
|
||||||
def api_vm_detail():
|
def api_vm_detail():
|
||||||
sid = request.args.get("sid", "")
|
sid = request.args.get("sid", "")
|
||||||
@@ -404,11 +412,9 @@ def api_list_vmct():
|
|||||||
meta = cluster_vmct_meta()
|
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")}
|
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]
|
nonha = [v for k, v in meta.items() if k not in ha_sids]
|
||||||
return jsonify({
|
return jsonify({"nonha": nonha, "ha_index": list(ha_sids), "count_nonha": len(nonha), "count_all_vmct": len(meta)})
|
||||||
"nonha": nonha, "ha_index": list(ha_sids),
|
|
||||||
"count_nonha": len(nonha), "count_all_vmct": len(meta)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
# --- Enable/Disable maintenance ---
|
||||||
@app.post("/api/enable")
|
@app.post("/api/enable")
|
||||||
def api_enable():
|
def api_enable():
|
||||||
if os.geteuid() != 0:
|
if os.geteuid() != 0:
|
||||||
@@ -431,8 +437,7 @@ def api_disable():
|
|||||||
ha_node_maint(False, node, log)
|
ha_node_maint(False, node, log)
|
||||||
return jsonify(ok=True, log=log)
|
return jsonify(ok=True, log=log)
|
||||||
|
|
||||||
|
# --- VM/CT admin actions API (zwrot UPID dla live) ---
|
||||||
# --- VM/CT admin actions API ---
|
|
||||||
|
|
||||||
def vm_locked(typ: str, node: str, vmid: int) -> bool:
|
def vm_locked(typ: str, node: str, vmid: int) -> bool:
|
||||||
base = f"/nodes/{node}/{typ}/{vmid}"
|
base = f"/nodes/{node}/{typ}/{vmid}"
|
||||||
@@ -466,32 +471,40 @@ def api_vm_action():
|
|||||||
if not node:
|
if not node:
|
||||||
return jsonify(ok=False, error="unknown node"), 400
|
return jsonify(ok=False, error="unknown node"), 400
|
||||||
|
|
||||||
if action == "migrate_offline":
|
|
||||||
action = "migrate"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if action == "unlock":
|
if action == "unlock":
|
||||||
|
# brak UPID (działa natychmiast)
|
||||||
if typ != "qemu":
|
if typ != "qemu":
|
||||||
return jsonify(ok=False, error="unlock only for qemu"), 400
|
return jsonify(ok=False, error="unlock only for qemu"), 400
|
||||||
if not vm_locked(typ, node, vmid):
|
if not vm_locked(typ, node, vmid):
|
||||||
return jsonify(ok=True, msg="not locked")
|
return jsonify(ok=True, msg="not locked")
|
||||||
r = run(["qm", "unlock", str(vmid)])
|
r = run(["qm", "unlock", str(vmid)])
|
||||||
return jsonify(ok=(r.returncode == 0), stdout=r.stdout, stderr=r.stderr)
|
return jsonify(ok=(r.returncode == 0), stdout=r.stdout, stderr=r.stderr, upid=None, source_node=node)
|
||||||
|
|
||||||
elif action in ("start", "stop", "shutdown"):
|
elif action in ("start", "stop", "shutdown"):
|
||||||
r = run_qm_pct(typ, vmid, action)
|
# PVE API -> zwraca UPID
|
||||||
return jsonify(ok=(r.returncode == 0), stdout=r.stdout, stderr=r.stderr)
|
base = f"/nodes/{node}/{typ}/{vmid}/status/{action}"
|
||||||
|
res = post_json(["pvesh", "create", base], timeout=120) or {}
|
||||||
elif action == "migrate":
|
|
||||||
if not target or target == node:
|
|
||||||
return jsonify(ok=False, error="target required and must differ from source"), 400
|
|
||||||
base = f"/nodes/{node}/{typ}/{vmid}/migrate"
|
|
||||||
cmd = ["pvesh", "create", base, "-target", target, "-online", "0"]
|
|
||||||
res = post_json(cmd, timeout=120)
|
|
||||||
upid = None
|
upid = None
|
||||||
if isinstance(res, dict):
|
if isinstance(res, dict):
|
||||||
upid = res.get("data") or res.get("upid")
|
upid = res.get("data") or res.get("upid")
|
||||||
return jsonify(ok=True, result=res or {}, upid=upid, source_node=node)
|
if not upid:
|
||||||
|
# fallback do qm/pct (bez UPID), ale zwrócimy ok
|
||||||
|
r = run_qm_pct(typ, vmid, action)
|
||||||
|
return jsonify(ok=(r.returncode == 0), stdout=r.stdout, stderr=r.stderr, upid=None, source_node=node)
|
||||||
|
return jsonify(ok=True, result=res, upid=upid, source_node=node)
|
||||||
|
|
||||||
|
elif action == "migrate" or action == "migrate_offline":
|
||||||
|
if action == "migrate_offline": # zgodność wstecz
|
||||||
|
action = "migrate"
|
||||||
|
if not target or target == node:
|
||||||
|
return jsonify(ok=False, error="target required and must differ from source"), 400
|
||||||
|
base = f"/nodes/{node}/{typ}/{vmid}/migrate"
|
||||||
|
res = post_json(["pvesh", "create", base, "-target", target, "-online", "0"], timeout=120) or {}
|
||||||
|
upid = None
|
||||||
|
if isinstance(res, dict):
|
||||||
|
upid = res.get("data") or res.get("upid")
|
||||||
|
return jsonify(ok=True, result=res, upid=upid, source_node=node)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return jsonify(ok=False, error="unknown action"), 400
|
return jsonify(ok=False, error="unknown action"), 400
|
||||||
@@ -500,39 +513,76 @@ def api_vm_action():
|
|||||||
return jsonify(ok=False, error=str(e)), 500
|
return jsonify(ok=False, error=str(e)), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- WebSocket: broadcast observe per sid ----------------
|
||||||
|
@sock.route("/ws/observe")
|
||||||
|
def ws_observe(ws):
|
||||||
|
q = ws.environ.get("QUERY_STRING", "")
|
||||||
|
params = {}
|
||||||
|
for part in q.split("&"):
|
||||||
|
if not part: continue
|
||||||
|
k, _, v = part.partition("="); params[k] = v
|
||||||
|
sid_raw = (params.get("sid") or "").strip()
|
||||||
|
sid = norm_sid(sid_raw)
|
||||||
|
if not sid:
|
||||||
|
ws.send(json.dumps({"type":"error","error":"sid required"})); return
|
||||||
|
|
||||||
@app.get("/api/task-status")
|
def resolve_tuple():
|
||||||
def api_task_status():
|
meta = cluster_vmct_meta()
|
||||||
upid = request.args.get("upid", "").strip()
|
return sid_to_tuple(sid, meta)
|
||||||
node = request.args.get("node", "").strip()
|
|
||||||
if not upid or not node:
|
|
||||||
return jsonify(ok=False, error="upid and node required"), 400
|
|
||||||
st = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/status"]) or {}
|
|
||||||
return jsonify(ok=True, status=st)
|
|
||||||
|
|
||||||
|
tup = resolve_tuple()
|
||||||
|
if not tup:
|
||||||
|
ws.send(json.dumps({"type":"error","error":"unknown sid"})); return
|
||||||
|
typ, vmid, node = tup
|
||||||
|
if not node:
|
||||||
|
ws.send(json.dumps({"type":"error","error":"could not resolve node"})); return
|
||||||
|
|
||||||
|
last_hash = None
|
||||||
|
seen_upids = set()
|
||||||
|
prev_node = node
|
||||||
|
|
||||||
@app.get("/api/task-log")
|
|
||||||
def api_task_log():
|
|
||||||
upid = request.args.get("upid", "").strip()
|
|
||||||
node = request.args.get("node", "").strip()
|
|
||||||
start = request.args.get("start", "0").strip()
|
|
||||||
try:
|
try:
|
||||||
start_i = int(start)
|
while True:
|
||||||
except Exception:
|
ntup = resolve_tuple()
|
||||||
start_i = 0
|
if ntup:
|
||||||
if not upid or not node:
|
_, _, cur_node = ntup
|
||||||
return jsonify(ok=False, error="upid and node required"), 400
|
if cur_node and cur_node != node:
|
||||||
# Returns a list of {n: <line_no>, t: <text>}
|
ws.send(json.dumps({"type":"moved","old_node":node,"new_node":cur_node,"meta":{"sid":sid,"vmid":vmid,"typ":typ}}))
|
||||||
lines = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/log", "-start", str(start_i)]) or []
|
prev_node, node = node, cur_node
|
||||||
# Compute next start
|
|
||||||
next_start = start_i
|
# bieżący status VM/CT -> event "vm"
|
||||||
if isinstance(lines, list) and lines:
|
|
||||||
# find max n
|
|
||||||
try:
|
try:
|
||||||
next_start = max((int(x.get("n", start_i)) for x in lines if isinstance(x, dict)), default=start_i) + 1
|
base = f"/nodes/{node}/{typ}/{vmid}"
|
||||||
|
cur = get_json(["pvesh", "get", f"{base}/status/current"]) or {}
|
||||||
|
cur_hash = json.dumps(cur, sort_keys=True)
|
||||||
|
if cur_hash != last_hash:
|
||||||
|
last_hash = cur_hash
|
||||||
|
ws.send(json.dumps({"type":"vm","current":cur,"meta":{"sid":sid,"node":node,"typ":typ,"vmid":vmid}}))
|
||||||
except Exception:
|
except Exception:
|
||||||
next_start = start_i
|
pass
|
||||||
return jsonify(ok=True, lines=lines or [], next_start=next_start)
|
|
||||||
|
# zadania na aktualnym i poprzednim nodzie
|
||||||
|
nodes_to_scan = [node] + ([prev_node] if prev_node and prev_node != node else [])
|
||||||
|
for nX in nodes_to_scan:
|
||||||
|
tasks = get_json(["pvesh","get",f"/nodes/{nX}/tasks","-limit","50"]) or []
|
||||||
|
for t in tasks:
|
||||||
|
upid = t.get("upid") if isinstance(t, dict) else None
|
||||||
|
tid = (t.get("id") or "") if isinstance(t, dict) else ""
|
||||||
|
if not upid or not isinstance(upid, str): continue
|
||||||
|
if (str(vmid) in tid) or (f"{'qemu' if typ=='qemu' else 'lxc'}/{vmid}" in tid):
|
||||||
|
st = get_json(["pvesh","get",f"/nodes/{nX}/tasks/{upid}/status"]) or {}
|
||||||
|
ws.send(json.dumps({"type":"task","upid":upid,"node":nX}))
|
||||||
|
if upid not in seen_upids and str(st.get("status","")).lower() != "stopped":
|
||||||
|
seen_upids.add(upid)
|
||||||
|
ws.send(json.dumps({"type":"task-start","upid":upid,"node":nX}))
|
||||||
|
if str(st.get("status","")).lower() == "stopped" or st.get("exitstatus"):
|
||||||
|
ok = str(st.get("exitstatus","")).upper() == "OK"
|
||||||
|
ws.send(json.dumps({"type":"done","upid":upid,"ok":ok,"node":nX}))
|
||||||
|
time.sleep(1.8)
|
||||||
|
except Exception:
|
||||||
|
try: ws.close()
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
import argparse
|
||||||
|
@@ -1,2 +1,3 @@
|
|||||||
flask
|
flask
|
||||||
gunicorn
|
gunicorn
|
||||||
|
flask-sock
|
300
static/js/admin.js
Normal file
300
static/js/admin.js
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { rowHTML, setRows, safe, showToast, badge } from './helpers.js';
|
||||||
|
import { api } from './api.js';
|
||||||
|
|
||||||
|
// ========= helpers =========
|
||||||
|
const q = (r, s) => (r || document).querySelector(s);
|
||||||
|
const qq = (r, s) => Array.from((r || document).querySelectorAll(s));
|
||||||
|
const low = (x) => String(x ?? '').toLowerCase();
|
||||||
|
|
||||||
|
function isRunning(st) { return /running|online|started/.test(low(st)); }
|
||||||
|
function isBusy(st) { return /working|progress|busy|locking|migrating/.test(low(st)); }
|
||||||
|
function isStopped(st) { return /(stopp|stopped|shutdown|offline|down|halt)/.test(low(st)); }
|
||||||
|
|
||||||
|
function setBadge(cell, statusRaw) {
|
||||||
|
if (!cell) return;
|
||||||
|
const s = String(statusRaw || '').trim() || '—';
|
||||||
|
let hue = 'dark';
|
||||||
|
if (isRunning(s)) hue = 'ok';
|
||||||
|
else if (isBusy(s)) hue = 'info';
|
||||||
|
cell.innerHTML = badge(s, hue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNodes(nodesSummary) {
|
||||||
|
const arr = Array.isArray(nodesSummary?.nodes) ? nodesSummary.nodes : (nodesSummary || []);
|
||||||
|
return Array.from(new Set(arr.map(n => String(n?.name || n?.node || n || '').trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildTargetSelect(selectEl, currentNode, nodes) {
|
||||||
|
if (!selectEl) return;
|
||||||
|
const cur = String(currentNode || '').trim();
|
||||||
|
const list = (nodes || []).map(n => String(n).trim()).filter(Boolean);
|
||||||
|
const others = list.filter(n => n && n !== cur);
|
||||||
|
selectEl.innerHTML = others.map(n => `<option value="${n}">${n}</option>`).join('');
|
||||||
|
if (selectEl.options.length > 0) selectEl.selectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========= state =========
|
||||||
|
const activeSids = new Set(); // SIDs with an ongoing task
|
||||||
|
const sidWatchdogs = new Map(); // sid -> timeout id (2 min safety)
|
||||||
|
let slowTimer = null; // 30s for entire table
|
||||||
|
let fastTimer = null; // 10s for active SIDs
|
||||||
|
let cachedNodes = [];
|
||||||
|
|
||||||
|
// Buttons reflect current status; MIGRATE only when stopped
|
||||||
|
function syncButtonsForRow(tr, statusRaw) {
|
||||||
|
const running = isRunning(statusRaw);
|
||||||
|
const busy = isBusy(statusRaw);
|
||||||
|
const stopped = isStopped(statusRaw);
|
||||||
|
|
||||||
|
const bStart = tr.querySelector('.act-start');
|
||||||
|
const bStop = tr.querySelector('.act-stop');
|
||||||
|
const bShutdown = tr.querySelector('.act-shutdown');
|
||||||
|
const bUnlock = tr.querySelector('.act-unlock');
|
||||||
|
const bMigrate = tr.querySelector('.act-migrate');
|
||||||
|
const sel = tr.querySelector('.target-node');
|
||||||
|
|
||||||
|
if (bStart) bStart.disabled = running || busy;
|
||||||
|
if (bStop) bStop.disabled = !running || busy;
|
||||||
|
if (bShutdown) bShutdown.disabled = !running || busy;
|
||||||
|
if (bUnlock) bUnlock.disabled = busy;
|
||||||
|
|
||||||
|
const hasTarget = !!(sel && sel.value);
|
||||||
|
// Offline migrate only: enabled only when STOPPED + has target + not busy
|
||||||
|
if (bMigrate) bMigrate.disabled = !(stopped && hasTarget) || busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========= rendering =========
|
||||||
|
export async function renderVMAdmin() {
|
||||||
|
const table = document.getElementById('vm-admin');
|
||||||
|
if (!table) return;
|
||||||
|
const tbody = table.querySelector('tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
const [list, nodesSummary] = await Promise.all([api.listAllVmct(), api.nodesSummary()]);
|
||||||
|
const all = Array.isArray(list?.all) ? list.all : [];
|
||||||
|
cachedNodes = extractNodes(nodesSummary);
|
||||||
|
|
||||||
|
// 8 columns exactly like THEAD: SID, Type, Name, Node, Status, Actions, Target, Migrate
|
||||||
|
const rows = all.map(vm => {
|
||||||
|
const sid = safe(vm.sid);
|
||||||
|
const type = safe(vm.type || vm.meta?.type || vm.kind || '—');
|
||||||
|
const name = safe(vm.name || vm.vmid || vm.sid);
|
||||||
|
const node = safe(vm.node || vm.meta?.node || '—');
|
||||||
|
const status = safe(vm.status || vm.current?.status || vm.current?.qmpstatus || '—');
|
||||||
|
|
||||||
|
return rowHTML([
|
||||||
|
sid,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
node,
|
||||||
|
badge(status, isRunning(status) ? 'ok' : 'dark'),
|
||||||
|
`<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-success act-start">Start</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-warning act-shutdown">Shutdown</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger act-stop">Stop</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary act-unlock">Unlock</button>
|
||||||
|
</div>`,
|
||||||
|
`<select class="form-select form-select-sm target-node"></select>`,
|
||||||
|
`<button type="button" class="btn btn-sm btn-outline-primary act-migrate">Migrate</button>`
|
||||||
|
], `data-sid="${sid}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
setRows(tbody, rows);
|
||||||
|
|
||||||
|
// init per-row
|
||||||
|
qq(tbody, 'tr[data-sid]').forEach(tr => {
|
||||||
|
const nodeCell = tr.children[3];
|
||||||
|
const statusCell = tr.children[4];
|
||||||
|
const sel = tr.querySelector('.target-node');
|
||||||
|
rebuildTargetSelect(sel, nodeCell?.textContent?.trim(), cachedNodes);
|
||||||
|
syncButtonsForRow(tr, statusCell?.innerText || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// click delegation (capture phase)
|
||||||
|
document.addEventListener('click', onClickAction, { capture: true });
|
||||||
|
|
||||||
|
// refresh loops
|
||||||
|
clearInterval(slowTimer); clearInterval(fastTimer);
|
||||||
|
slowTimer = setInterval(refreshAllRows, 30000);
|
||||||
|
fastTimer = setInterval(refreshActiveRows, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========= actions =========
|
||||||
|
async function onClickAction(ev) {
|
||||||
|
const btn = ev.target.closest?.('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate');
|
||||||
|
if (!btn) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
const tr = btn.closest('tr[data-sid]');
|
||||||
|
if (!tr) return;
|
||||||
|
const sid = tr.getAttribute('data-sid');
|
||||||
|
const name = tr.children[2]?.textContent?.trim() || sid;
|
||||||
|
const statusCell = tr.children[4];
|
||||||
|
const nodeCell = tr.children[3];
|
||||||
|
|
||||||
|
let action = '';
|
||||||
|
let target = undefined;
|
||||||
|
|
||||||
|
if (btn.classList.contains('act-start')) action = 'start';
|
||||||
|
if (btn.classList.contains('act-stop')) action = 'stop';
|
||||||
|
if (btn.classList.contains('act-shutdown')) action = 'shutdown';
|
||||||
|
if (btn.classList.contains('act-unlock')) action = 'unlock';
|
||||||
|
if (btn.classList.contains('act-migrate')) {
|
||||||
|
action = 'migrate';
|
||||||
|
const sel = tr.querySelector('.target-node');
|
||||||
|
target = sel?.value;
|
||||||
|
const curNode = nodeCell?.textContent?.trim();
|
||||||
|
if (!target || target === curNode) {
|
||||||
|
showToast('Migrate', 'Pick a target node different from current.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Optimistic: set working, disable buttons, start watchdog
|
||||||
|
setBadge(statusCell, 'working');
|
||||||
|
syncButtonsForRow(tr, 'working');
|
||||||
|
activeSids.add(sid);
|
||||||
|
armWatchdog(sid, name);
|
||||||
|
|
||||||
|
const res = await api.vmAction(sid, action, target);
|
||||||
|
|
||||||
|
// Most APIs respond before task finishes. We don't show "failed" immediately.
|
||||||
|
// If API responded at all, we just inform and wait for status polling to flip from "working".
|
||||||
|
if (res?.ok) {
|
||||||
|
showToast('Task queued', `${action.toUpperCase()} scheduled for ${name}.`, 'success');
|
||||||
|
} else {
|
||||||
|
// Even if backend returns a non-ok, in practice the task often proceeds.
|
||||||
|
// We soften the message and keep polling.
|
||||||
|
showToast('Possibly queued', `Attempted ${action} for ${name}. Waiting for status update…`, 'info');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Network or unexpected error — still keep "working" and wait for polling
|
||||||
|
showToast('Queued (connection issue)', `Action may have been accepted for ${name}. Monitoring status…`, 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2-minute safety timer per SID: if status didn't change, nudge with info (not hard error)
|
||||||
|
function armWatchdog(sid, name) {
|
||||||
|
clearTimeout(sidWatchdogs.get(sid));
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
// Still active after 2 min? Give a gentle notice; keep polling.
|
||||||
|
if (activeSids.has(sid)) {
|
||||||
|
showToast('Still processing', `No status change for ${name} yet. Continuing to monitor…`, 'info');
|
||||||
|
}
|
||||||
|
}, 120000);
|
||||||
|
sidWatchdogs.set(sid, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disarmWatchdog(sid) {
|
||||||
|
const id = sidWatchdogs.get(sid);
|
||||||
|
if (id) clearTimeout(id);
|
||||||
|
sidWatchdogs.delete(sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========= refresh loops =========
|
||||||
|
async function refreshAllRows() {
|
||||||
|
const table = document.getElementById('vm-admin');
|
||||||
|
if (!table) return;
|
||||||
|
const tbody = table.querySelector('tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const latest = await api.listAllVmct();
|
||||||
|
const list = Array.isArray(latest?.all) ? latest.all : [];
|
||||||
|
const bySid = new Map(list.map(x => [String(x.sid), x]));
|
||||||
|
const nodes = Array.isArray(latest?.nodes) ? latest.nodes : null;
|
||||||
|
if (nodes) cachedNodes = extractNodes({ nodes });
|
||||||
|
|
||||||
|
qq(tbody, 'tr[data-sid]').forEach(tr => {
|
||||||
|
const sid = tr.getAttribute('data-sid');
|
||||||
|
const row = bySid.get(sid);
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
const typeCell = tr.children[1];
|
||||||
|
const nameCell = tr.children[2];
|
||||||
|
const nodeCell = tr.children[3];
|
||||||
|
const statusCell = tr.children[4];
|
||||||
|
const sel = tr.querySelector('.target-node');
|
||||||
|
|
||||||
|
const newNode = String(row.node || row.meta?.node || '').trim();
|
||||||
|
if (newNode && nodeCell?.textContent?.trim() !== newNode) {
|
||||||
|
nodeCell.textContent = newNode;
|
||||||
|
rebuildTargetSelect(sel, newNode, cachedNodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newType = String(row.type || row.meta?.type || row.kind || '—');
|
||||||
|
if (typeCell && typeCell.textContent !== newType) typeCell.textContent = newType;
|
||||||
|
|
||||||
|
const newName = String(row.name || row.vmid || row.sid);
|
||||||
|
if (nameCell && nameCell.textContent !== newName) nameCell.textContent = newName;
|
||||||
|
|
||||||
|
const st = String(row.status || row.current?.status || row.current?.qmpstatus || '').trim();
|
||||||
|
if (st) {
|
||||||
|
// If this SID is in "active", keep working badge until fast loop confirms change
|
||||||
|
if (!activeSids.has(sid)) {
|
||||||
|
setBadge(statusCell, st);
|
||||||
|
syncButtonsForRow(tr, st);
|
||||||
|
} else {
|
||||||
|
// Keep buttons locked as working
|
||||||
|
syncButtonsForRow(tr, 'working');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the status moved to a terminal state, stop the watchdog
|
||||||
|
if (activeSids.has(sid) && (isRunning(st) || isStopped(st))) {
|
||||||
|
activeSids.delete(sid);
|
||||||
|
disarmWatchdog(sid);
|
||||||
|
setBadge(statusCell, st);
|
||||||
|
syncButtonsForRow(tr, st);
|
||||||
|
showToast('Done', `Status updated: ${newName} → ${st}.`, 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshActiveRows() {
|
||||||
|
if (activeSids.size === 0) return;
|
||||||
|
const table = document.getElementById('vm-admin');
|
||||||
|
if (!table) return;
|
||||||
|
const tbody = table.querySelector('tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
for (const sid of Array.from(activeSids)) {
|
||||||
|
const tr = tbody.querySelector(`tr[data-sid="${sid}"]`);
|
||||||
|
if (!tr) { activeSids.delete(sid); disarmWatchdog(sid); continue; }
|
||||||
|
await refreshOneRow(sid, tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOneRow(sid, tr) {
|
||||||
|
try {
|
||||||
|
const detail = await api.vmDetail(sid);
|
||||||
|
const nodeCell = tr.children[3];
|
||||||
|
const statusCell = tr.children[4];
|
||||||
|
const sel = tr.querySelector('.target-node');
|
||||||
|
const name = tr.children[2]?.textContent?.trim() || sid;
|
||||||
|
|
||||||
|
const st = String(detail?.current?.status || detail?.current?.qmpstatus || detail?.status || '').trim();
|
||||||
|
if (st) {
|
||||||
|
if (isRunning(st) || isStopped(st)) {
|
||||||
|
activeSids.delete(sid);
|
||||||
|
disarmWatchdog(sid);
|
||||||
|
}
|
||||||
|
setBadge(statusCell, st);
|
||||||
|
syncButtonsForRow(tr, st);
|
||||||
|
if (!activeSids.has(sid) && (isRunning(st) || isStopped(st))) {
|
||||||
|
showToast('Done', `Status updated: ${name} → ${st}.`, 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newNode = String(detail?.node || detail?.meta?.node || '').trim();
|
||||||
|
if (newNode && nodeCell?.textContent?.trim() !== newNode) {
|
||||||
|
nodeCell.textContent = newNode;
|
||||||
|
rebuildTargetSelect(sel, newNode, cachedNodes);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep waiting silently
|
||||||
|
}
|
||||||
|
}
|
32
static/js/api.js
Normal file
32
static/js/api.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Każda funkcja = jedno zapytanie (łatwy prefetch i równoległość)
|
||||||
|
export const api = {
|
||||||
|
snapshot: (node) => fetch(`/api/info?node=${encodeURIComponent(node||'')}`).then(r=>r.json()),
|
||||||
|
|
||||||
|
// lżejsze mikro-endpointy:
|
||||||
|
clusterBrief: () => fetch('/api/cluster').then(r=>r.json()),
|
||||||
|
nodesSummary: () => fetch('/api/nodes/summary').then(r=>r.json()),
|
||||||
|
units: (node) => fetch(`/api/units?node=${encodeURIComponent(node||'')}`).then(r=>r.json()),
|
||||||
|
replicationAll: () => fetch('/api/replication/all').then(r=>r.json()),
|
||||||
|
|
||||||
|
vmDetail: (sid) => fetch('/api/vm?sid=' + encodeURIComponent(sid)).then(r=>r.json()),
|
||||||
|
nodeDetail: (name) => fetch('/api/node?name=' + encodeURIComponent(name)).then(r=>r.json()),
|
||||||
|
listNonHA: () => fetch('/api/list-vmct').then(r=>r.json()),
|
||||||
|
listAllVmct: () => fetch('/api/list-all-vmct').then(r=>r.json()),
|
||||||
|
|
||||||
|
action: (act, node) => fetch('/api/'+act, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({node})}).then(r=>r.json()),
|
||||||
|
vmAction: (sid, action, target) => {
|
||||||
|
const body = { sid, action }; if (target) body.target = target;
|
||||||
|
return fetch('/api/vm-action', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}).then(r=>r.json());
|
||||||
|
},
|
||||||
|
|
||||||
|
wsTaskURL: (upid, node) => {
|
||||||
|
const proto = (location.protocol === 'https:') ? 'wss' : 'ws';
|
||||||
|
return `${proto}://${location.host}/ws/task?upid=${encodeURIComponent(upid)}&node=${encodeURIComponent(node)}`;
|
||||||
|
},
|
||||||
|
wsObserveURL: (sid) => {
|
||||||
|
const proto = (location.protocol === 'https:') ? 'wss' : 'ws';
|
||||||
|
return `${proto}://${location.host}/ws/observe?sid=${encodeURIComponent(sid)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
};
|
53
static/js/helpers.js
Normal file
53
static/js/helpers.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// ------ helpers ------
|
||||||
|
export const $ = (q) => document.querySelector(q);
|
||||||
|
export function safe(v) { return (v === undefined || v === null || v === '') ? '—' : String(v); }
|
||||||
|
export function ensureArr(a) { return Array.isArray(a) ? a : []; }
|
||||||
|
export function pct(p) { if (p == null) return '—'; return (p * 100).toFixed(1) + '%'; }
|
||||||
|
export 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]; }
|
||||||
|
export 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>`;
|
||||||
|
}
|
||||||
|
export function rowHTML(cols, attrs='') { return `<tr ${attrs}>${cols.map(c => `<td>${c ?? '—'}</td>`).join('')}</tr>`; }
|
||||||
|
export function setRows(tbody, rows) { tbody.innerHTML = rows.length ? rows.join('') : rowHTML(['—']); }
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
export 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>`;
|
||||||
|
}
|
||||||
|
// prefer first non-empty
|
||||||
|
export function pick(...vals) { for (const v of vals) { if (v !== undefined && v !== null && v !== '') return v; } return ''; }
|
||||||
|
|
||||||
|
// toast
|
||||||
|
export function showToast(title, body, variant) {
|
||||||
|
const cont = document.getElementById('toast-container'); if (!cont) return;
|
||||||
|
const vcls = { success:'text-bg-success', info:'text-bg-info', warning:'text-bg-warning', danger:'text-bg-danger', secondary:'text-bg-secondary' }[variant||'secondary'];
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'toast align-items-center ' + vcls;
|
||||||
|
el.setAttribute('role','alert'); el.setAttribute('aria-live','assertive'); el.setAttribute('aria-atomic','true');
|
||||||
|
el.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${title||''}</strong>${title?': ':''}${body||''}</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button></div>`;
|
||||||
|
cont.appendChild(el); const t = new bootstrap.Toast(el, { delay: 5000 }); t.show(); el.addEventListener('hidden.bs.toast', () => el.remove());
|
||||||
|
}
|
87
static/js/main.js
Normal file
87
static/js/main.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { $, safe } from './helpers.js';
|
||||||
|
import { api } from './api.js';
|
||||||
|
import { refs, setHealth, renderClusterCards, renderUnits, renderReplicationTable, renderHAResources, renderNonHA, renderNodesTable } from './tables.js';
|
||||||
|
import { renderVMAdmin } from './admin.js';
|
||||||
|
|
||||||
|
// ------ actions ------
|
||||||
|
async function callAction(act) {
|
||||||
|
const node = refs.nodeInput.value || '';
|
||||||
|
const d = await api.action(act, node);
|
||||||
|
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;
|
||||||
|
let ac = null; // AbortController dla równoległych fetchy
|
||||||
|
|
||||||
|
async function doRefresh() {
|
||||||
|
try {
|
||||||
|
if (ac) ac.abort();
|
||||||
|
ac = new AbortController();
|
||||||
|
const node = refs.nodeInput.value || '';
|
||||||
|
// Minimalny szybki zestaw danych — równolegle:
|
||||||
|
const [cluster, nodes, units, repl] = await Promise.allSettled([
|
||||||
|
api.clusterBrief(), api.nodesSummary(), api.units(node), api.replicationAll()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// render
|
||||||
|
const vq = (cluster.value && cluster.value.votequorum) || {};
|
||||||
|
const unitsMap = (units.value && units.value.units) || {};
|
||||||
|
const allUnits = Object.values(unitsMap).every(v => v === 'active');
|
||||||
|
setHealth((vq.quorate === 'yes') && allUnits, vq, allUnits);
|
||||||
|
|
||||||
|
const gl = document.getElementById('global-loading'); if (gl) gl.remove();
|
||||||
|
|
||||||
|
refs.qSummary.textContent = `Quorate: ${safe(vq.quorate)} | members: ${safe(vq.members)} | expected: ${safe(vq.expected)} | total: ${safe(vq.total)} | quorum: ${safe(vq.quorum)}`;
|
||||||
|
renderClusterCards((cluster.value && cluster.value.cluster_status) || []);
|
||||||
|
renderUnits(unitsMap);
|
||||||
|
renderReplicationTable((repl.value || {jobs:[]}));
|
||||||
|
|
||||||
|
renderHAResources((cluster.value && cluster.value.ha_resources) || []);
|
||||||
|
renderNodesTable((nodes.value && nodes.value.nodes) || []);
|
||||||
|
|
||||||
|
refs.pvecmPre.textContent = safe(cluster.value && cluster.value.pvecm);
|
||||||
|
refs.cfgtoolPre.textContent = safe(cluster.value && cluster.value.cfgtool);
|
||||||
|
refs.footer.textContent = `node_arg=${safe(node)} | host=${safe(cluster.value && cluster.value.hostname)} | ts=${new Date(((cluster.value && cluster.value.ts) || 0) * 1000).toLocaleString()}`;
|
||||||
|
|
||||||
|
// pierwszy raz: dociągnij Non-HA + VM Admin w idle
|
||||||
|
if (!doRefresh.didNonHA) { requestIdleCallback(() => renderNonHA().catch(console.error)); doRefresh.didNonHA = true; }
|
||||||
|
if (!doRefresh.didAdmin) { requestIdleCallback(() => renderVMAdmin().catch(console.error)); doRefresh.didAdmin = true; }
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#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));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// initial one-shot load
|
||||||
|
doRefresh().catch(console.error);
|
107
static/js/nodeDetail.js
Normal file
107
static/js/nodeDetail.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { safe, ensureArr, badge, rowHTML, humanBytes, kvGrid, fmtSeconds, pick } from './helpers.js';
|
||||||
|
|
||||||
|
export function renderNodeDetailCard(d) {
|
||||||
|
const st = d.status || {};
|
||||||
|
const ver = d.version || {};
|
||||||
|
const tm = d.time || {};
|
||||||
|
const netcfg = ensureArr(d.network_cfg);
|
||||||
|
const disks = ensureArr(d.disks);
|
||||||
|
const subscription = d.subscription || {};
|
||||||
|
|
||||||
|
// online detect
|
||||||
|
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 = Array.isArray(st.loadavg) ? st.loadavg.join(' ') : (st.loadavg || '');
|
||||||
|
|
||||||
|
const cpuinfo = st.cpuinfo || {};
|
||||||
|
const boot = st['boot-info'] || st.boot_info || {};
|
||||||
|
const curKernel = st['current-kernel'] || st.current_kernel || {};
|
||||||
|
const ramStr = (mem.used != null && mem.available != null && mem.total != null)
|
||||||
|
? `${humanBytes(mem.used)} used / ${humanBytes(mem.available)} free / ${humanBytes(mem.total)} total`
|
||||||
|
: (mem.total != null ? humanBytes(mem.total) : '—');
|
||||||
|
|
||||||
|
const tech = {
|
||||||
|
'PVE version': pick(st.pveversion, ver.pvemanager, ver['pve-manager']),
|
||||||
|
'Kernel': pick(st.kversion, curKernel.release, ver.kernel, ver.release),
|
||||||
|
'CPU model': pick(cpuinfo.model, st['cpu-model'], ver['cpu-model'], ver.cpu),
|
||||||
|
'Architecture': pick(curKernel.machine, ver.arch, st.architecture, st.arch),
|
||||||
|
'RAM': ramStr,
|
||||||
|
'Boot mode': pick(boot.mode) ? String(boot.mode).toUpperCase() : '—',
|
||||||
|
'Secure Boot': (boot.secureboot === 1 || boot.secureboot === '1') ? 'enabled' :
|
||||||
|
(boot.secureboot === 0 || boot.secureboot === '0') ? 'disabled' : '—'
|
||||||
|
};
|
||||||
|
|
||||||
|
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: ${safe(((st.cpu??null)!==null)?(st.cpu*100).toFixed(1)+'%':'—')}</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)} (${((mem.used/mem.total)*100).toFixed(1)}%)` : '—'}</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)} (${((root.used/root.total)*100).toFixed(1)}%)` : '—'}</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(tech['Kernel'])} / ${safe(pick(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>`;
|
||||||
|
|
||||||
|
const sysDetails = kvGrid(tech, Object.keys(tech), {
|
||||||
|
'PVE version': 'PVE version','Kernel':'Kernel version','CPU model':'CPU model',
|
||||||
|
'Architecture':'Arch','RAM':'RAM (used/free/total)','Boot mode':'Boot mode','Secure Boot':'Secure Boot'
|
||||||
|
});
|
||||||
|
|
||||||
|
const netRows = ensureArr(netcfg).map(n => 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>`;
|
||||||
|
|
||||||
|
const diskRows = ensureArr(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: ukryj, gdy notfound/No subscription key
|
||||||
|
const subTxt = (subscription.message||'') + ' ' + (subscription.status||'');
|
||||||
|
const hideSub = /notfound/i.test(subTxt) || /no subscription key/i.test(subTxt);
|
||||||
|
const subBox = hideSub ? '' : `
|
||||||
|
<div class="mt-3"><div class="fw-semibold mb-1">Subscription</div>
|
||||||
|
<div class="small">
|
||||||
|
<div>Status: ${badge(safe(subscription.status||'unknown'), /active|valid/i.test(subscription.status||'') ? 'ok':'warn')}</div>
|
||||||
|
${subscription.productname ? `<div>Product: <strong>${safe(subscription.productname)}</strong></div>` : ''}
|
||||||
|
${subscription.message ? `<div class="text-muted">${safe(subscription.message)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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">System details</div>${sysDetails}</div>
|
||||||
|
<div class="mt-3"><div class="fw-semibold mb-1">Network (config)</div>${netCfgTable}</div>
|
||||||
|
<div class="mt-3"><div class="fw-semibold mb-1">Disks</div>${diskTable}</div>
|
||||||
|
${subBox}
|
||||||
|
${rawBtn}${rawBox}`;
|
||||||
|
}
|
191
static/js/tables.js
Normal file
191
static/js/tables.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { $, safe, ensureArr, badge, rowHTML, setRows, pct, humanBytes, fmtSeconds, kvGrid, showToast } from './helpers.js';
|
||||||
|
import { api } from './api.js';
|
||||||
|
import { renderVmDetailCard } from './vmDetail.js';
|
||||||
|
import { renderNodeDetailCard } from './nodeDetail.js';
|
||||||
|
|
||||||
|
// DOM refs
|
||||||
|
export const refs = {
|
||||||
|
nodeInput: $('#node'),
|
||||||
|
healthDot: $('#healthDot'), healthTitle: $('#healthTitle'), healthSub: $('#healthSub'),
|
||||||
|
qSummary: $('#q-summary'), qCardsWrap: $('#q-cards'), unitsBox: $('#units'), replBox: $('#repl'),
|
||||||
|
tblHaRes: $('#ha-res'), tblHaStatus: $('#ha-status'), // (tblHaStatus nieużywany, sekcja wycięta w HTML)
|
||||||
|
tblNodes: $('#nodes'), tblNonHA: $('#nonha'),
|
||||||
|
pvecmPre: $('#pvecm'), cfgtoolPre: $('#cfgtool'), footer: $('#footer')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Health
|
||||||
|
export function setHealth(ok, vq, unitsActive) {
|
||||||
|
refs.healthDot.classList.toggle('ok', !!ok);
|
||||||
|
refs.healthDot.classList.toggle('bad', !ok);
|
||||||
|
refs.healthTitle.textContent = ok ? 'HA: OK' : 'HA: PROBLEM';
|
||||||
|
refs.healthSub.textContent = `Quorate=${String(vq.quorate)} | units=${unitsActive ? 'active' : 'inactive'} | members=${safe(vq.members)} | quorum=${safe(vq.quorum)}/${safe(vq.expected)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cluster cards
|
||||||
|
export function renderClusterCards(arr) {
|
||||||
|
const a = ensureArr(arr); refs.qCardsWrap.innerHTML = '';
|
||||||
|
if (!a.length) { refs.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');
|
||||||
|
refs.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)]);
|
||||||
|
});
|
||||||
|
refs.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>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderUnits(units) {
|
||||||
|
refs.unitsBox.innerHTML = '';
|
||||||
|
if (!units || !Object.keys(units).length) { refs.unitsBox.innerHTML = badge('No data','dark'); return; }
|
||||||
|
const map = { active:'ok', inactive:'err', failed:'err', activating:'warn' };
|
||||||
|
Object.entries(units).forEach(([k, v]) => refs.unitsBox.insertAdjacentHTML('beforeend', `<span class="me-2">${badge(k, map[v] || 'dark')}</span>`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replication — ALL NODES
|
||||||
|
export function renderReplicationTable(repAll) {
|
||||||
|
const arr = ensureArr(repAll.jobs);
|
||||||
|
if (!arr.length) { refs.replBox && (refs.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([safe(x.node), safe(x.job), en, safe(x.target), safe(x.last), safe(x.next), safe(x.dur), fc, st]);
|
||||||
|
});
|
||||||
|
refs.replBox.innerHTML = `<div class="table-responsive"><table class="table table-sm table-striped align-middle table-nowrap">
|
||||||
|
<thead><tr><th>Node</th><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 (expand)
|
||||||
|
export function renderHAResources(list) {
|
||||||
|
const tbody = refs.tblHaRes.querySelector('tbody');
|
||||||
|
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([`<span class="chev"></span>`, sid, stB, safe(x.node), safe(x.group), safe(x.flags || x.comment)], `class="expandable vm-row" data-sid="${sid}"`));
|
||||||
|
rows.push(`<tr class="vm-detail d-none"><td colspan="6"><div class="spinner-border spinner-border-sm me-2 d-none"></div><div class="vm-json">—</div></td></tr>`);
|
||||||
|
});
|
||||||
|
setRows(tbody, rows.length ? rows : [rowHTML(['—','—','—','—','—','—'])]);
|
||||||
|
|
||||||
|
Array.from(refs.tblHaRes.querySelectorAll('tr.vm-row')).forEach((tr, i) => {
|
||||||
|
tr.onclick = async () => {
|
||||||
|
const detailRow = refs.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');
|
||||||
|
// collapse all
|
||||||
|
refs.tblHaRes.querySelectorAll('tr.vm-detail').forEach(r => r.classList.add('d-none'));
|
||||||
|
refs.tblHaRes.querySelectorAll('tr.vm-row').forEach(r => r.classList.remove('expanded'));
|
||||||
|
if (open) {
|
||||||
|
detailRow.classList.remove('d-none'); tr.classList.add('expanded');
|
||||||
|
spin.classList.remove('d-none');
|
||||||
|
const sid = tr.getAttribute('data-sid');
|
||||||
|
try { const d = await api.vmDetail(sid); content.innerHTML = renderVmDetailCard(d); }
|
||||||
|
catch (e) { content.textContent = 'ERROR: ' + e; }
|
||||||
|
spin.classList.add('d-none');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-HA VM/CT (expand)
|
||||||
|
export async function renderNonHA() {
|
||||||
|
const r = await api.listNonHA();
|
||||||
|
const arr = ensureArr(r.nonha);
|
||||||
|
const tbody = refs.tblNonHA.querySelector('tbody');
|
||||||
|
if (!arr.length) { setRows(tbody, [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([`<span class="chev"></span>`, sid, type, name, node, st], `class="expandable vm-row" data-sid="${sid}"`));
|
||||||
|
rows.push(`<tr class="vm-detail d-none"><td colspan="6"><div class="spinner-border spinner-border-sm me-2 d-none"></div><div class="vm-json">—</div></td></tr>`);
|
||||||
|
});
|
||||||
|
setRows(tbody, rows);
|
||||||
|
Array.from(tbody.querySelectorAll('tr.vm-row')).forEach((tr, i) => {
|
||||||
|
tr.onclick = async () => {
|
||||||
|
const detailRow = tbody.querySelectorAll('tr.vm-detail')[i];
|
||||||
|
const content = detailRow.querySelector('.vm-json');
|
||||||
|
const spin = detailRow.querySelector('.spinner-border');
|
||||||
|
const open = detailRow.classList.contains('d-none');
|
||||||
|
tbody.querySelectorAll('tr.vm-detail').forEach(r => r.classList.add('d-none'));
|
||||||
|
tbody.querySelectorAll('tr.vm-row').forEach(r => r.classList.remove('expanded'));
|
||||||
|
if (open) {
|
||||||
|
detailRow.classList.remove('d-none'); tr.classList.add('expanded');
|
||||||
|
spin.classList.remove('d-none');
|
||||||
|
const sid = tr.getAttribute('data-sid');
|
||||||
|
try { const d = await api.vmDetail(sid); content.innerHTML = renderVmDetailCard(d); }
|
||||||
|
catch (e) { content.textContent = 'ERROR: ' + e; }
|
||||||
|
spin.classList.add('d-none');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nodes table (expand)
|
||||||
|
export function renderNodesTable(nodes) {
|
||||||
|
const tbody = refs.tblNodes.querySelector('tbody');
|
||||||
|
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="expandable node-row" data-node="${safe(n.node)}">
|
||||||
|
<td class="chev"></td><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>${fmtSeconds(n.uptime)}</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
const detail = `<tr class="node-detail d-none"><td colspan="8">
|
||||||
|
<div class="spinner-border spinner-border-sm me-2 d-none"></div><div class="node-json">—</div>
|
||||||
|
</td></tr>`;
|
||||||
|
|
||||||
|
return main + detail;
|
||||||
|
});
|
||||||
|
setRows(tbody, nrows);
|
||||||
|
|
||||||
|
Array.from(tbody.querySelectorAll('tr.node-row')).forEach((tr, i) => {
|
||||||
|
tr.onclick = async () => {
|
||||||
|
const detailRow = tbody.querySelectorAll('tr.node-detail')[i];
|
||||||
|
const content = detailRow.querySelector('.node-json');
|
||||||
|
const spin = detailRow.querySelector('.spinner-border');
|
||||||
|
const open = detailRow.classList.contains('d-none');
|
||||||
|
tbody.querySelectorAll('tr.node-detail').forEach(r => r.classList.add('d-none'));
|
||||||
|
tbody.querySelectorAll('tr.node-row').forEach(r => r.classList.remove('expanded'));
|
||||||
|
if (open) {
|
||||||
|
detailRow.classList.remove('d-none'); tr.classList.add('expanded');
|
||||||
|
spin.classList.remove('d-none');
|
||||||
|
const name = tr.getAttribute('data-node');
|
||||||
|
try { const d = await api.nodeDetail(name); content.innerHTML = renderNodeDetailCard(d); }
|
||||||
|
catch (e) { content.textContent = 'ERROR: ' + e; }
|
||||||
|
spin.classList.add('d-none');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
136
static/js/vmDetail.js
Normal file
136
static/js/vmDetail.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { safe, ensureArr, badge, rowHTML, humanBytes, kvGrid, fmtSeconds, parseVmNetworks } from './helpers.js';
|
||||||
|
|
||||||
|
export function renderVmDetailCard(d) {
|
||||||
|
// --- dokładnie ten sam kod, który miałeś — przeniesiony bez zmian ---
|
||||||
|
// (skrócone dla czytelności — wklejam pełną wersję z Twojego pliku)
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 bstat = cur.blockstat || {};
|
||||||
|
const bRows = Object.keys(bstat).sort().map(dev => {
|
||||||
|
const s = bstat[dev] || {};
|
||||||
|
return rowHTML([dev, humanBytes(s.rd_bytes||0), String(s.rd_operations||0),
|
||||||
|
humanBytes(s.wr_bytes||0), String(s.wr_operations||0), String(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)': `${String(cur.pressurecpusome||'—')}/${String(cur.pressurecpufull||'—')}`,
|
||||||
|
'Pressure IO (some/full)': `${String(cur.pressureiosome||'—')}/${String(cur.pressureiofull||'—')}`,
|
||||||
|
'Pressure MEM (some/full)': `${String(cur.pressurememorysome||'—')}/${String(cur.pressurememoryfull||'—')}`
|
||||||
|
};
|
||||||
|
|
||||||
|
const nets = parseVmNetworks(cfg);
|
||||||
|
const netRows = nets.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 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
|
||||||
|
};
|
||||||
|
|
||||||
|
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(d.agent||{},null,2)}</pre></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3 mb-2">
|
||||||
|
<div class="fw-bold">${safe(meta.name || cfg.name || d.sid)}</div>
|
||||||
|
<div class="text-muted small">${(d.type || '').toUpperCase()} / VMID ${safe(d.vmid)} @ ${safe(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>
|
||||||
|
|
||||||
|
<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)}</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></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></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3"><div class="fw-semibold mb-1">Network (config)</div>${netTable}</div>
|
||||||
|
<div class="mt-3"><div class="fw-semibold mb-1">Disks (block statistics)</div>
|
||||||
|
<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>${Object.keys(bstat).length ? bRows.join('') : rowHTML(['—','—','—','—','—','—','—'])}</tbody></table></div>
|
||||||
|
</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}
|
||||||
|
`;
|
||||||
|
}
|
900
static/main.js
900
static/main.js
@@ -1,900 +0,0 @@
|
|||||||
// ------ helpers ------
|
|
||||||
const tblVmAdmin = document.querySelector("#vm-admin");
|
|
||||||
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>`;
|
|
||||||
}
|
|
||||||
// prefer first non-empty
|
|
||||||
function pick(...vals) { for (const v of vals) { if (v !== undefined && v !== null && v !== '') return v; } return ''; }
|
|
||||||
|
|
||||||
// ------ 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'), tblHaStatus = $('#ha-status'), tblNodes = $('#nodes'), tblNonHA = $('#nonha');
|
|
||||||
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; }
|
|
||||||
if (!doRefresh.didAdmin) { await renderVMAdmin(); doRefresh.didAdmin = 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;
|
|
||||||
|
|
||||||
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 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 nets = parseVmNetworks(cfg);
|
|
||||||
const netRows = nets.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 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 `
|
|
||||||
<div class="d-flex flex-wrap align-items-center gap-3 mb-2">
|
|
||||||
<div class="fw-bold">${safe(meta.name || cfg.name || d.sid)}</div>
|
|
||||||
<div class="text-muted small">${(d.type || '').toUpperCase()} / VMID ${safe(d.vmid)} @ ${safe(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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<div class="fw-semibold mb-1">Network (config)</div>
|
|
||||||
${netTable}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<div class="fw-semibold mb-1">Disks (block statistics)</div>
|
|
||||||
<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>${Object.keys(bstat).length ? bRows.join('') : rowHTML(['—', '—', '—', '—', '—', '—', '—'])}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</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 || {}; lastSt = st;
|
|
||||||
const ver = d.version || {};
|
|
||||||
const tm = d.time || {};
|
|
||||||
const netcfg = ensureArr(d.network_cfg);
|
|
||||||
const disks = ensureArr(d.disks);
|
|
||||||
const subscription = d.subscription || {}; // <-- JEDYNA deklaracja (zamiast podwójnego 'const sub')
|
|
||||||
|
|
||||||
// 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 = Array.isArray(st.loadavg) ? st.loadavg.join(' ') : (st.loadavg || '');
|
|
||||||
|
|
||||||
// ---- SYSTEM DETAILS
|
|
||||||
const cpuinfo = st.cpuinfo || {};
|
|
||||||
const boot = st['boot-info'] || st.boot_info || {};
|
|
||||||
const curKernel = st['current-kernel'] || st.current_kernel || {};
|
|
||||||
const ramStr = (mem.used != null && mem.available != null && mem.total != null)
|
|
||||||
? `${humanBytes(mem.used)} used / ${humanBytes(mem.available)} free / ${humanBytes(mem.total)} total`
|
|
||||||
: (mem.total != null ? humanBytes(mem.total) : '—');
|
|
||||||
|
|
||||||
const tech = {
|
|
||||||
'PVE version': pick(st.pveversion, ver.pvemanager, ver['pve-manager']),
|
|
||||||
'Kernel': pick(st.kversion, curKernel.release, ver.kernel, ver.release),
|
|
||||||
'CPU model': pick(cpuinfo.model, st['cpu-model'], ver['cpu-model'], ver.cpu),
|
|
||||||
'Architecture': pick(curKernel.machine, ver.arch, st.architecture, st.arch),
|
|
||||||
'RAM': ramStr,
|
|
||||||
'Boot mode': pick(boot.mode) ? String(boot.mode).toUpperCase() : '—',
|
|
||||||
'Secure Boot': (boot.secureboot === 1 || boot.secureboot === '1') ? 'enabled' :
|
|
||||||
(boot.secureboot === 0 || boot.secureboot === '0') ? 'disabled' : '—'
|
|
||||||
};
|
|
||||||
|
|
||||||
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(tech['Kernel'])} / ${safe(pick(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>`;
|
|
||||||
|
|
||||||
const sysDetails = kvGrid(tech, Object.keys(tech), {
|
|
||||||
'PVE version': 'PVE version',
|
|
||||||
'Kernel': 'Kernel version',
|
|
||||||
'CPU model': 'CPU model',
|
|
||||||
'Architecture': 'Arch',
|
|
||||||
'RAM': 'RAM (used/free/total)',
|
|
||||||
'Boot mode': 'Boot mode',
|
|
||||||
'Secure Boot': 'Secure Boot'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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>`;
|
|
||||||
|
|
||||||
// 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(subscription.status || 'unknown'), /active|valid/i.test(subscription.status || '') ? 'ok' : 'warn')}</div>
|
|
||||||
${subscription.productname ? `<div>Product: <strong>${safe(subscription.productname)}</strong></div>` : ''}
|
|
||||||
${subscription.message ? `<div class="text-muted">${safe(subscription.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">System details</div>
|
|
||||||
${sysDetails}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<div class="fw-semibold mb-1">Network (config)</div>
|
|
||||||
${netCfgTable}
|
|
||||||
</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 && (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 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 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 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 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>${fmtSeconds(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 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 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);
|
|
||||||
|
|
||||||
|
|
||||||
// ------ VM Admin API/UI ------
|
|
||||||
async function fetchAllVmct() {
|
|
||||||
const r = await fetch('/api/list-all-vmct');
|
|
||||||
return await r.json();
|
|
||||||
}
|
|
||||||
async function vmAction(sid, action, target) {
|
|
||||||
const body = { sid, action };
|
|
||||||
if (target) body.target = target;
|
|
||||||
const r = await fetch('/api/vm-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
||||||
const d = await r.json();
|
|
||||||
if (!d.ok) throw new Error(d.error || 'action failed');
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderVMAdmin() {
|
|
||||||
const data = await fetchAllVmct();
|
|
||||||
const arr = ensureArr(data.all);
|
|
||||||
const nodes = ensureArr(data.nodes);
|
|
||||||
const tbody = document.querySelector('#vm-admin tbody');
|
|
||||||
if (!arr.length) { setRows(tbody, [rowHTML(['Brak VM/CT'])]); return; }
|
|
||||||
|
|
||||||
const rows = arr.map(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');
|
|
||||||
const actions = `
|
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
|
||||||
<button class="btn btn-outline-secondary act-unlock">Unlock</button>
|
|
||||||
<button class="btn btn-outline-success act-start">Start</button>
|
|
||||||
<button class="btn btn-outline-warning act-shutdown">Shutdown</button>
|
|
||||||
<button class="btn btn-outline-danger act-stop">Stop</button>
|
|
||||||
</div>`;
|
|
||||||
const sel = `<select class="form-select form-select-sm target-node" style="min-width:140px">
|
|
||||||
${nodes.map(n => `<option value="${n}" ${n === x.node ? 'disabled' : ''}>${n}${n === x.node ? ' (src)' : ''}</option>`).join('')}
|
|
||||||
</select>`;
|
|
||||||
const migrateBtn = `<div class="d-flex align-items-center gap-2"><button class="btn btn-outline-primary btn-sm act-migrate">Migrate (offline)</button><button class="btn btn-outline-secondary btn-sm act-status">Status</button></div>`;
|
|
||||||
return rowHTML([sid, type.toUpperCase(), name, node, st, actions, sel, migrateBtn], `data-sid="${sid}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
setRows(tbody, rows);
|
|
||||||
|
|
||||||
Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => {
|
|
||||||
const sid = tr.getAttribute('data-sid');
|
|
||||||
const getTarget = () => tr.querySelector('.target-node')?.value || '';
|
|
||||||
const colSpan = tr.children.length; // for status row width
|
|
||||||
const bind = (sel, action, needsTarget = false) => {
|
|
||||||
const btn = tr.querySelector(sel);
|
|
||||||
if (!btn) return;
|
|
||||||
btn.onclick = async () => {
|
|
||||||
setRowBusy(tr, true);
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
if (action === 'migrate') {
|
|
||||||
const target = getTarget();
|
|
||||||
const resp = await vmAction(sid, action, target);
|
|
||||||
// show expandable status row (spinner already set via setRowBusy)
|
|
||||||
const row = ensureMigRow(tr, colSpan);
|
|
||||||
setMigRowVisible(row, true);
|
|
||||||
const log = row.querySelector('.mig-log');
|
|
||||||
const srcNode = resp.source_node;
|
|
||||||
const upid = resp.upid;
|
|
||||||
const stopSig = { done: false };
|
|
||||||
tailTaskLog(upid, srcNode, log, stopSig);
|
|
||||||
|
|
||||||
log.textContent = `Starting offline migrate to ${target} (UPID: ${upid || '—'})...`;
|
|
||||||
// update badge immediately
|
|
||||||
const badgeCell = tr.children[4];
|
|
||||||
if (badgeCell) badgeCell.innerHTML = badge('migrating', 'info');
|
|
||||||
// start background polling; update log on each tick
|
|
||||||
await pollTask(upid, srcNode, (st) => {
|
|
||||||
const lines = [];
|
|
||||||
for (const k of ['type', 'status', 'pid', 'starttime', 'user', 'node', 'endtime', 'exitstatus']) {
|
|
||||||
if (st && st[k] !== undefined) lines.push(`${k}: ${st[k]}`);
|
|
||||||
}
|
|
||||||
log.textContent = lines.join('\n') || '—';
|
|
||||||
}, async (finalSt) => {
|
|
||||||
stopSig.done = true;
|
|
||||||
const exit = (finalSt && finalSt.exitstatus) ? String(finalSt.exitstatus) : '';
|
|
||||||
const ok = exit.toUpperCase() === 'OK';
|
|
||||||
const badgeCell = tr.children[4];
|
|
||||||
if (badgeCell) badgeCell.innerHTML = ok ? badge('running', 'ok') : badge('migrate error', 'err');
|
|
||||||
log.textContent += (log.textContent ? '\n' : '') + (ok ? 'Migration finished successfully.' : ('Migration failed: ' + (exit || 'unknown error')));
|
|
||||||
setRowBusy(tr, false);
|
|
||||||
await doRefresh();
|
|
||||||
// auto-collapse on success after 2s
|
|
||||||
if (ok) { setTimeout(() => { const row = tr.nextElementSibling; if (row && row.classList.contains('mig-row')) setMigRowVisible(row, false); }, 2000); }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await vmAction(sid, action, needsTarget ? getTarget() : undefined);
|
|
||||||
showToast('Success', `${action} executed for ${sid}`, 'success');
|
|
||||||
await doRefresh();
|
|
||||||
setRowBusy(tr, false);
|
|
||||||
}
|
|
||||||
} catch (e) { showToast('Error', 'ERROR: ' + e.message, 'danger'); }
|
|
||||||
btn.disabled = false;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
bind('.act-unlock', 'unlock');
|
|
||||||
bind('.act-start', 'start');
|
|
||||||
bind('.act-stop', 'stop');
|
|
||||||
bind('.act-shutdown', 'shutdown');
|
|
||||||
bind('.act-migrate', 'migrate', true);
|
|
||||||
const statusBtn = tr.querySelector('.act-status');
|
|
||||||
if (statusBtn) {
|
|
||||||
statusBtn.onclick = () => {
|
|
||||||
const row = ensureMigRow(tr, colSpan);
|
|
||||||
setMigRowVisible(row, row.classList.contains('d-none'));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function pollTask(upid, node, onUpdate, onDone) {
|
|
||||||
let lastSt = null;
|
|
||||||
if (!upid || !node) return;
|
|
||||||
const started = Date.now();
|
|
||||||
const maxMs = 5 * 60 * 1000;
|
|
||||||
const delay = (ms) => new Promise(r => setTimeout(r, ms));
|
|
||||||
while (Date.now() - started < maxMs) {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/task-status?upid=' + encodeURIComponent(upid) + '&node=' + encodeURIComponent(node));
|
|
||||||
const d = await r.json();
|
|
||||||
if (d && d.ok) {
|
|
||||||
const st = d.status || {};
|
|
||||||
lastSt = st;
|
|
||||||
if (onUpdate) { try { onUpdate(st); } catch { } }
|
|
||||||
const s = (st.status || '').toLowerCase();
|
|
||||||
if (s === 'stopped' || st.exitstatus) break;
|
|
||||||
}
|
|
||||||
} catch (e) { }
|
|
||||||
await delay(2000);
|
|
||||||
}
|
|
||||||
try { if (onDone) onDone(lastSt); } catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function ensureMigRow(mainTr, colSpan) {
|
|
||||||
let nxt = mainTr.nextElementSibling;
|
|
||||||
if (!nxt || !nxt.classList.contains('mig-row')) {
|
|
||||||
nxt = document.createElement('tr');
|
|
||||||
nxt.className = 'mig-row d-none';
|
|
||||||
const td = document.createElement('td');
|
|
||||||
td.colSpan = colSpan;
|
|
||||||
td.innerHTML = '<div class="p-3 border rounded bg-body-tertiary"><div class="fw-semibold mb-1">Migration status</div><pre class="small mb-0 mig-log">—</pre></div>';
|
|
||||||
nxt.appendChild(td);
|
|
||||||
mainTr.parentNode.insertBefore(nxt, mainTr.nextSibling);
|
|
||||||
}
|
|
||||||
return nxt;
|
|
||||||
}
|
|
||||||
function setMigRowVisible(row, vis) {
|
|
||||||
row.classList.toggle('d-none', !vis);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setRowBusy(tr, busy) {
|
|
||||||
const nameCell = tr.children[2];
|
|
||||||
if (!nameCell) return;
|
|
||||||
let spin = nameCell.querySelector('.op-spin');
|
|
||||||
if (busy) {
|
|
||||||
if (!spin) {
|
|
||||||
const span = document.createElement('span');
|
|
||||||
span.className = 'op-spin spinner-border spinner-border-sm align-middle ms-2';
|
|
||||||
span.setAttribute('role', 'status');
|
|
||||||
span.setAttribute('aria-hidden', 'true');
|
|
||||||
nameCell.appendChild(span);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (spin) spin.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function showToast(title, body, variant) {
|
|
||||||
const cont = document.getElementById('toast-container');
|
|
||||||
if (!cont) { console.warn('toast container missing'); return; }
|
|
||||||
const id = 't' + Math.random().toString(36).slice(2);
|
|
||||||
const vcls = {
|
|
||||||
success: 'text-bg-success',
|
|
||||||
info: 'text-bg-info',
|
|
||||||
warning: 'text-bg-warning',
|
|
||||||
danger: 'text-bg-danger',
|
|
||||||
secondary: 'text-bg-secondary'
|
|
||||||
}[variant || 'secondary'];
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'toast align-items-center ' + vcls;
|
|
||||||
el.setAttribute('role', 'alert');
|
|
||||||
el.setAttribute('aria-live', 'assertive');
|
|
||||||
el.setAttribute('aria-atomic', 'true');
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="d-flex">
|
|
||||||
<div class="toast-body">
|
|
||||||
<strong>${title || ''}</strong>${title ? ': ' : ''}${body || ''}
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
||||||
</div>`;
|
|
||||||
cont.appendChild(el);
|
|
||||||
const t = new bootstrap.Toast(el, { delay: 5000 });
|
|
||||||
t.show();
|
|
||||||
el.addEventListener('hidden.bs.toast', () => el.remove());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function tailTaskLog(upid, node, preEl, stopSignal) {
|
|
||||||
let start = 0;
|
|
||||||
const delay = (ms) => new Promise(r => setTimeout(r, ms));
|
|
||||||
const append = (txt) => {
|
|
||||||
if (!preEl) return;
|
|
||||||
preEl.textContent = (preEl.textContent ? preEl.textContent + '\n' : '') + txt;
|
|
||||||
trimLogEl(preEl);
|
|
||||||
preEl.scrollTop = preEl.scrollHeight;
|
|
||||||
};
|
|
||||||
while (!stopSignal.done) {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/task-log?upid=' + encodeURIComponent(upid) + '&node=' + encodeURIComponent(node) + '&start=' + start);
|
|
||||||
const d = await r.json();
|
|
||||||
if (d && d.ok) {
|
|
||||||
const lines = Array.isArray(d.lines) ? d.lines : [];
|
|
||||||
for (const ln of lines) {
|
|
||||||
if (ln && typeof ln.t === 'string') append(ln.t);
|
|
||||||
}
|
|
||||||
start = d.next_start ?? start;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// transient errors ignored
|
|
||||||
}
|
|
||||||
await delay(1500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const LOG_MAX_CHARS = 64 * 1024; // 64KB cap for log panel
|
|
||||||
function trimLogEl(preEl) {
|
|
||||||
if (!preEl) return;
|
|
||||||
const txt = preEl.textContent || '';
|
|
||||||
if (txt.length > LOG_MAX_CHARS) {
|
|
||||||
preEl.textContent = txt.slice(-LOG_MAX_CHARS);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -98,3 +98,101 @@ footer.site-footer a:hover {
|
|||||||
right: max(env(safe-area-inset-right), 1rem);
|
right: max(env(safe-area-inset-right), 1rem);
|
||||||
bottom: max(env(safe-area-inset-bottom), 1rem);
|
bottom: max(env(safe-area-inset-bottom), 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Row chevron (expandable rows) */
|
||||||
|
.table .chev {
|
||||||
|
width: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr.expandable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr.expandable .chev::before {
|
||||||
|
content: "▸";
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr.expanded .chev::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
content: "▾";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small utility widths */
|
||||||
|
.w-1 {
|
||||||
|
width: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle skeleton */
|
||||||
|
.skel {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(90deg, rgba(255, 255, 255, .05) 25%, rgba(255, 255, 255, .10) 37%, rgba(255, 255, 255, .05) 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: skel 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skel {
|
||||||
|
0% {
|
||||||
|
background-position: 100% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#vm-admin,
|
||||||
|
#vm-admin .table-responsive,
|
||||||
|
#vm-admin table,
|
||||||
|
#vm-admin tbody,
|
||||||
|
#vm-admin tr,
|
||||||
|
#vm-admin td {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#vm-admin td {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#vm-admin .target-node {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toasts: hard-pinned to bottom-right corner */
|
||||||
|
#toast-container {
|
||||||
|
position: fixed !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
left: auto !important;
|
||||||
|
top: auto !important;
|
||||||
|
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 1rem !important;
|
||||||
|
|
||||||
|
width: auto;
|
||||||
|
/* allow toast's own width (e.g., 350px in Bootstrap) */
|
||||||
|
max-width: 100vw;
|
||||||
|
/* safety on tiny screens */
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toast-container .toast {
|
||||||
|
pointer-events: auto;
|
||||||
|
margin: 0.25rem 0 0 0;
|
||||||
|
/* stack vertically */
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (inset: 0) {
|
||||||
|
|
||||||
|
/* If the browser supports logical inset, keep it exact as well */
|
||||||
|
#toast-container {
|
||||||
|
inset: auto 0 0 auto !important;
|
||||||
|
}
|
||||||
|
}
|
@@ -15,8 +15,9 @@
|
|||||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
|
<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>
|
<h1 class="h4 m-0">PVE HA Panel</h1>
|
||||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
<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">
|
<input class="form-control form-control-sm" id="node" value="{{ node }}" style="width: 180px" aria-label="Node">
|
||||||
<button class="btn btn-outline-secondary btn-sm" id="btnToggleAll">Collapse/Expand all</button>
|
<button class="btn btn-outline-secondary btn-sm" id="btnToggleAll" aria-expanded="false">Collapse/Expand
|
||||||
|
all</button>
|
||||||
|
|
||||||
<div class="vr d-none d-md-block"></div>
|
<div class="vr d-none d-md-block"></div>
|
||||||
|
|
||||||
@@ -43,14 +44,14 @@
|
|||||||
|
|
||||||
<!-- Global loading -->
|
<!-- Global loading -->
|
||||||
<div id="global-loading" class="d-flex align-items-center gap-2 mb-3">
|
<div id="global-loading" class="d-flex align-items-center gap-2 mb-3">
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
<div class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></div>
|
||||||
<span class="small text-muted">Loading data…</span>
|
<span class="small text-muted">Loading data…</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- HEALTH -->
|
<!-- HEALTH -->
|
||||||
<div class="card mb-3 border-0 shadow health-card">
|
<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="card-body d-flex flex-wrap align-items-center gap-3">
|
||||||
<div class="health-dot" id="healthDot"></div>
|
<div class="health-dot" id="healthDot" aria-hidden="true"></div>
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-bold" id="healthTitle">Loading…</div>
|
<div class="fw-bold" id="healthTitle">Loading…</div>
|
||||||
<div class="text-muted small" id="healthSub">—</div>
|
<div class="text-muted small" id="healthSub">—</div>
|
||||||
@@ -137,6 +138,7 @@
|
|||||||
<table class="table table-sm table-striped align-middle table-nowrap" id="ha-res">
|
<table class="table table-sm table-striped align-middle table-nowrap" id="ha-res">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-1"></th>
|
||||||
<th>SID</th>
|
<th>SID</th>
|
||||||
<th>State</th>
|
<th>State</th>
|
||||||
<th>Node</th>
|
<th>Node</th>
|
||||||
@@ -146,36 +148,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="small text-muted">Click a row to expand VM/CT details.</div>
|
<div class="small text-muted">Click to expand VM/CT data.</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,6 +185,7 @@
|
|||||||
<table class="table table-sm table-striped align-middle table-nowrap" id="nonha">
|
<table class="table table-sm table-striped align-middle table-nowrap" id="nonha">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-1"></th>
|
||||||
<th>SID</th>
|
<th>SID</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@@ -221,11 +195,11 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="text-muted">Loading…</td>
|
<td colspan="6" class="text-muted">Loading…</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="small text-muted">Click a row to expand VM/CT details.</div>
|
<div class="small text-muted">Kliknij wiersz, aby rozwinąć szczegóły VM/CT.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,11 +227,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="small text-muted">Akcje: Unlock (qm), Start/Stop/Shutdown, Offline migrate.</div>
|
<div class="small text-muted">Actions: Unlock (qm), Start/Stop/Shutdown, Offline migrate.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- TAB: Nodes (expandable) -->
|
<!-- TAB: Nodes (expandable) -->
|
||||||
<div class="tab-pane fade" id="tab-nodes">
|
<div class="tab-pane fade" id="tab-nodes">
|
||||||
<div class="card border-0">
|
<div class="card border-0">
|
||||||
@@ -265,6 +240,7 @@
|
|||||||
<table class="table table-sm table-striped align-middle table-nowrap" id="nodes">
|
<table class="table table-sm table-striped align-middle table-nowrap" id="nodes">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-1"></th>
|
||||||
<th>Node</th>
|
<th>Node</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>CPU</th>
|
<th>CPU</th>
|
||||||
@@ -280,11 +256,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="small text-muted">Click a row to expand node details.</div>
|
<div class="small text-muted">Click to expand node data.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-muted small mt-3" id="footer">—</div>
|
<div class="text-muted small mt-3" id="footer">—</div>
|
||||||
@@ -301,16 +276,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toasts -->
|
<!-- Toasts -->
|
||||||
<div aria-live="polite" aria-atomic="true" class="position-fixed bottom-0 end-0 p-3" style="z-index: 1080">
|
<div aria-live="polite" aria-atomic="true" class="position-fixed bottom-0 end-0" style="z-index: 1080">
|
||||||
<div id="toast-container" class="toast-container"></div>
|
<div id="toast-container" class="toast-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
<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>
|
|
||||||
|
<script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
Reference in New Issue
Block a user