SysDeck/bridge/glances.py

340 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""
SysDeck - Glances Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Aggregates system monitoring data from the Glances CLI
(https://github.com/nicolargo/glances) into a structured JSON document.
The bridge integrates the Glances built-in web UI. Glances ships a
webserver via
`glances -w` (default port 61208, 127.0.0.1). The bridge starts that
webserver as a background process; the JS panel iframes the running
web UI at http://127.0.0.1:61208 — full Glances web UI (all graphs,
all sensors, all top processes, all history) without SysDeck
re-implementing any of it.
Subcommands:
snapshot — full system snapshot
cpu — CPU metrics subset
memory — memory metrics subset
network — network metrics subset
start-web — start glances -w on 127.0.0.1:61208 (background)
writes the PID to /var/lib/sysdeck/glances/web.pid
stop-web — kill the background webserver (read PID file)
web-status — return {running, pid, port, url}
web-port — return the actual listening port (defaults to 61208)
Privilege model: the bridge runs glances via subprocess directly —
no `sudo` shell-out. The JS panel passes { superuser: 'try' } to
cockpit.spawn so the cockpit bridge prompts the operator via polkit
for the org.sysdeck.system.manage action (authorizes
/usr/bin/systemctl, /usr/bin/hostnamectl, etc., and by extension any
system-level subprocess the bridge runs).
Glances is GPL-3.0 licensed by Nicolargo. This bridge helper invokes
it as a separate process via subprocess — the suite (MIT) and Glances
(GPL-3.0) remain independent programs. No Glances code is bundled.
Usage:
python3 /usr/lib/sysdeck/bridge/glances.py snapshot
python3 /usr/lib/sysdeck/bridge/glances.py start-web
python3 /usr/lib/sysdeck/bridge/glances.py web-status
"""
import json
import os
import shutil
import signal
import subprocess
import sys
from pathlib import Path
from typing import Any
GLANCES_LICENSE = "GPL-3.0"
GLANCES_AUTHOR = "Nicolargo"
GLANCES_URL = "https://github.com/nicolargo/glances"
# Default Glances webserver port. The operator can override via the
# `--port` flag on start-web; this default matches `glances -w`'s own
# default.
GLANCES_WEB_HOST = "127.0.0.1"
GLANCES_WEB_PORT = 61208
# State directory for the background webserver's PID file. Created on
# first use; the cockpit superuser channel handles root perms.
STATE_DIR = Path("/var/lib/sysdeck/glances")
WEB_PID_FILE = STATE_DIR / "web.pid"
def _have(binary: str) -> bool:
"""True if binary is on PATH."""
return shutil.which(binary) is not None
def _ensure_state_dir() -> None:
"""Create the state dir. Best-effort; polkit handles root perms."""
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
except (PermissionError, OSError):
pass
def _read_pid() -> int | None:
"""Return the PID of the running glances webserver, or None."""
try:
return int(WEB_PID_FILE.read_text(encoding="utf-8").strip())
except (FileNotFoundError, ValueError, PermissionError, OSError):
return None
def _write_pid(pid: int | None) -> None:
"""Record the webserver PID (or clear it if pid is None)."""
try:
_ensure_state_dir()
if pid is None:
WEB_PID_FILE.unlink(missing_ok=True)
else:
WEB_PID_FILE.write_text(str(pid), encoding="utf-8")
except (PermissionError, OSError):
pass
def _is_pid_alive(pid: int) -> bool:
"""Return True if a process with the given PID exists.
Uses os.kill(pid, 0) — signal 0 is a no-op that returns successfully
if the process exists and the caller has permission to signal it,
or raises ProcessLookupError / PermissionError otherwise.
"""
try:
os.kill(pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
except OSError:
return False
GLANCES_INSTALL_HINT = (
"pip install glances # or: pacman -S glances / apt install glances / dnf install glances"
)
def run_glances(args: list[str], timeout: int = 30) -> tuple[str, str | None]:
"""Run glances, returning (stdout, failure_reason).
Step-down contract shared by the whole snapshot family: glances
missing, wedged, or erroring degrades to a reason string — the
caller renders {"available": false}, never a traceback.
"""
if not _have("glances"):
return "", "glances not installed"
try:
r = subprocess.run(
["glances", *args], capture_output=True, text=True, check=False,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return "", f"glances timed out after {timeout}s"
except OSError as exc:
return "", f"glances failed to start: {exc}"
if r.returncode != 0 and not r.stdout.strip():
return "", (r.stderr.strip() or f"glances exited {r.returncode}")[:200]
return r.stdout, None
def snapshot() -> dict[str, Any]:
"""Full system snapshot from glances JSON export.
Calls: glances --time 1 --quiet --export json --once
Returns the parsed JSON document, or {"available": false, ...}
when glances cannot answer.
"""
output, reason = run_glances(["--time", "1", "--quiet", "--export", "json", "--once"])
if reason:
return {"available": False, "reason": reason, "install": GLANCES_INSTALL_HINT}
lines = output.strip().splitlines()
if not lines:
return {"available": False, "reason": "glances produced no output"}
try:
return json.loads(lines[-1])
except json.JSONDecodeError:
return {"available": False, "reason": "glances output was not valid JSON"}
def _available_false(data: dict[str, Any]) -> bool:
return isinstance(data, dict) and data.get("available") is False
def cpu() -> dict[str, Any]:
"""CPU metrics subset from a glances snapshot."""
data = snapshot()
if _available_false(data):
return data
return data.get("cpu", {})
def memory() -> dict[str, Any]:
"""Memory metrics subset from a glances snapshot."""
data = snapshot()
if _available_false(data):
return data
return {
"mem": data.get("mem", {}),
"memswap": data.get("memswap", {}),
}
def network() -> dict[str, Any]:
"""Network interface metrics subset from a glances snapshot."""
data = snapshot()
if _available_false(data):
return data
return data.get("network", {})
# ── Web UI management ────────────────────────────────────────────────
#
# Glances ships a built-in webserver (`glances -w`) that serves a full
# web UI at http://127.0.0.1:61208 — the operator gets every chart,
# every sensor, every top process, and the history grapher without
# SysDeck re-implementing any of it. The bridge starts the webserver as
# a background process via subprocess.Popen, records the PID, and the
# JS panel iframes the URL.
def cmd_start_web(args: list[str]) -> dict[str, Any]:
"""Start the Glances built-in webserver (`glances -w`) in the background.
Optional args: [port] — overrides the default 61208.
The bridge runs `glances -w --bind 127.0.0.1 --port <port>` detached,
writes the child PID to /var/lib/sysdeck/glances/web.pid, and returns
immediately. The JS panel polls web-status to detect when the
webserver is up (typically <1s on a warm start).
"""
if not _have("glances"):
return {
"available": False,
"reason": "glances not installed",
"install": "pip install glances # or: pacman -S glances / apt install glances / dnf install glances",
}
port = GLANCES_WEB_PORT
if args:
try:
port = int(args[0])
except ValueError:
return {"error": f"port must be numeric, got {args[0]}"}
# If a PID is already on file and alive, don't start a second one.
existing_pid = _read_pid()
if existing_pid is not None and _is_pid_alive(existing_pid):
return {
"started": False,
"already_running": True,
"pid": existing_pid,
"port": port,
"url": f"http://{GLANCES_WEB_HOST}:{port}",
}
# Detach: open stdout/stderr to /dev/null, start in new session so
# the child survives the bridge process exiting, record the PID.
try:
_ensure_state_dir()
proc = subprocess.Popen(
["glances", "-w", "--bind", GLANCES_WEB_HOST, "--port", str(port)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
except (FileNotFoundError, OSError) as exc:
return {"started": False, "error": str(exc)}
_write_pid(proc.pid)
return {
"started": True,
"pid": proc.pid,
"port": port,
"url": f"http://{GLANCES_WEB_HOST}:{port}",
}
def cmd_stop_web(_args: list[str]) -> dict[str, Any]:
"""Stop the background Glances webserver."""
pid = _read_pid()
if pid is None:
return {"stopped": False, "reason": "no PID file — webserver not started"}
if not _is_pid_alive(pid):
_write_pid(None)
return {"stopped": True, "reason": "process was already dead (PID file cleared)"}
try:
# SIGTERM first — graceful shutdown. The glances webserver
# handles SIGTERM cleanly and exits within ~1s.
os.kill(pid, signal.SIGTERM)
_write_pid(None)
return {"stopped": True, "pid": pid}
except (ProcessLookupError, PermissionError, OSError) as exc:
return {"stopped": False, "pid": pid, "error": str(exc)}
def cmd_web_status(_args: list[str]) -> dict[str, Any]:
"""Return whether the Glances webserver is running + its URL."""
if not _have("glances"):
return {
"available": False,
"reason": "glances not installed",
"install": "pip install glances # or: pacman -S glances / apt install glances",
}
pid = _read_pid()
if pid is None:
return {
"available": True,
"running": False,
"url": f"http://{GLANCES_WEB_HOST}:{GLANCES_WEB_PORT}",
"hint": "Click Start Web UI to launch the built-in Glances webserver.",
}
if not _is_pid_alive(pid):
_write_pid(None)
return {
"available": True,
"running": False,
"url": f"http://{GLANCES_WEB_HOST}:{GLANCES_WEB_PORT}",
"hint": "Previous webserver process died — restart it.",
}
return {
"available": True,
"running": True,
"pid": pid,
"port": GLANCES_WEB_PORT,
"url": f"http://{GLANCES_WEB_HOST}:{GLANCES_WEB_PORT}",
}
COMMANDS = {
# v0.0.11 read-only snapshot subcommands (kept):
"snapshot": lambda _args: snapshot(),
"cpu": lambda _args: cpu(),
"memory": lambda _args: memory(),
"network": lambda _args: network(),
# v0.0.34 web UI integration:
"start-web": lambda args: cmd_start_web(args),
"stop-web": lambda args: cmd_stop_web(args),
"web-status": lambda _args: cmd_web_status([]),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))