tools: live screen wall + contact sheet for the five lab guests
The lab went from one Windows guest to five and there was no way to see what they were all doing without issuing a QEMU screendump per guest by hand. - tools/vmwatch.py: always-on HTTP service serving an auto-refreshing wall of live guest screens. Runs on spicy as vmwatch.service; browse it at http://192.168.3.201:8140/. Click a tile for that guest full size. Guests are discovered from /etc/pve/qemu-server by matching sots-re, so clones appear and vanish on their own. A stopped, paused or unreachable guest gets a labelled placeholder tile carrying the monitor's own error, never a broken image or a 500. Python 3 stdlib only. - tools/vmwatch-install.sh: install/update/uninstall the unit on the host. - tools/vmshot.py: one-shot contact sheet, and --one <id> for a full-size grab. Pulls frames from the vmwatch service when it is up (0.5s) and falls back to ssh + qm monitor when it is not (4s). - guides/lab-screen-wall.md: how to use both, and why. Capture goes over each guest's QMP socket rather than forking qm: qm is a Perl program, and one fork per guest per tick cost ~90% of a host core and a 728 MB cgroup peak. Direct QMP is 0.33 CPU-seconds per 88s and 23 MB RSS. QEMU 11 here dumps PNG natively; the fallback PPM encoder was verified pixel-identical to QEMU's own on a real framebuffer. Read-only throughout: screendump does not perturb the guest (method-rule 19), so reading VM 140's screen is not an experiment and does not take its lock.
This commit is contained in:
parent
2e935b8491
commit
962cdb6981
4 changed files with 1204 additions and 0 deletions
63
guides/lab-screen-wall.md
Normal file
63
guides/lab-screen-wall.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Watching the lab guests
|
||||||
|
|
||||||
|
The lab is five Windows guests now. This is how to see all of them at once.
|
||||||
|
|
||||||
|
## The live wall
|
||||||
|
|
||||||
|
<http://192.168.3.201:8140/>
|
||||||
|
|
||||||
|
Leave the tab open. Tiles refresh every 5 s; click a tile for that guest full size.
|
||||||
|
Each tile carries the VM id, name, status and the capture timestamp. A guest that is
|
||||||
|
stopped, paused or unreachable shows a labelled `NO SIGNAL` tile with the monitor's
|
||||||
|
own error text, never a broken image.
|
||||||
|
|
||||||
|
Served by `vmwatch.service` on **spicy** (`/opt/vmwatch/vmwatch.py`, systemd, enabled
|
||||||
|
at boot). Source of truth is `tools/vmwatch.py` in this repo; redeploy with:
|
||||||
|
|
||||||
|
tools/vmwatch-install.sh spicy # install or update
|
||||||
|
tools/vmwatch-install.sh spicy --uninstall
|
||||||
|
|
||||||
|
Nothing was installed on spicy beyond that file and its unit — the service is Python 3
|
||||||
|
standard library only (spicy has no ImageMagick, no netpbm, no Pillow).
|
||||||
|
|
||||||
|
## The one-shot contact sheet
|
||||||
|
|
||||||
|
tools/vmshot.py # all guests -> dumps/vmshot/sheet-<ts>.png + latest.png
|
||||||
|
tools/vmshot.py --one 140 # one guest, full size
|
||||||
|
tools/vmshot.py 144 145 --cols 2
|
||||||
|
tools/vmshot.py --ssh # bypass the service, capture over SSH
|
||||||
|
|
||||||
|
`vmshot` pulls frames from the vmwatch service when it is reachable (~0.5 s, no extra
|
||||||
|
load on the guests) and falls back to SSH + `qm monitor` when it is not (~4 s). Output
|
||||||
|
lands in `dumps/vmshot/`, which is gitignored.
|
||||||
|
|
||||||
|
## Why this mechanism
|
||||||
|
|
||||||
|
Both tools capture with QEMU `screendump`, reached over each guest's
|
||||||
|
`/var/run/qemu-server/<id>.qmp` socket — the same socket and command `qm monitor` uses.
|
||||||
|
|
||||||
|
* It needs **no guest agent, no guest network and nothing installed in the guest**.
|
||||||
|
* It does **not perturb the guest**. That is the point (method-rule 19: an instrument
|
||||||
|
that perturbs the thing it measures has already cost this campaign once). Reading
|
||||||
|
VM 140's screen is therefore *not* an experiment and does **not** take 140's
|
||||||
|
exclusivity lock.
|
||||||
|
* The campaign board already records that `qm monitor` screendump is more reliable
|
||||||
|
than the in-guest click helper's `shot`.
|
||||||
|
|
||||||
|
## Two things worth knowing
|
||||||
|
|
||||||
|
**Do not fork `qm` in a loop.** The first version of vmwatch shelled out to
|
||||||
|
`qm monitor` once per guest per 5 s tick. `qm` is a Perl program: that cost **~90 % of
|
||||||
|
a host core and a 728 MB cgroup peak**. Talking to the QMP socket directly — same
|
||||||
|
socket, same command, no fork — brought it to **0.33 CPU-seconds per 88 s and 23 MB
|
||||||
|
RSS**, roughly a 300× reduction. The fleet list comes from `/etc/pve/qemu-server/*.conf`
|
||||||
|
plus `query-status` for the same reason. `qm` remains the fallback path only.
|
||||||
|
|
||||||
|
**QEMU 11 on spicy dumps PNG natively** (`screendump <file> -f png`), so no PPM
|
||||||
|
conversion is needed. `vmwatch.ppm_to_png` exists as a fallback for an older QEMU and
|
||||||
|
was checked against QEMU's own encoder on a real 1024×768 framebuffer: pixel-identical.
|
||||||
|
|
||||||
|
Guests are discovered dynamically by matching `sots-re` in the guest name, so clones
|
||||||
|
added or destroyed later appear and vanish on their own — nothing to edit.
|
||||||
|
|
||||||
|
Both tools are strictly read-only. Neither starts, stops, resets nor reconfigures a VM.
|
||||||
444
tools/vmshot.py
Executable file
444
tools/vmshot.py
Executable file
|
|
@ -0,0 +1,444 @@
|
||||||
|
#!/usr/bin/env -S uv run --quiet --with pillow python3
|
||||||
|
"""vmshot -- one-shot screenshot contact sheet for the SOTS lab Windows guests.
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
The lab went from one Windows guest (VM 140) to five (140, 141, 144, 145, 146).
|
||||||
|
There was no way to see what they were all doing without issuing a QEMU
|
||||||
|
``screendump`` per guest by hand. This grabs every guest in parallel and lays
|
||||||
|
them out on a single labelled contact sheet.
|
||||||
|
|
||||||
|
Mechanism
|
||||||
|
---------
|
||||||
|
``qm monitor <id> <<< "screendump <file> -f png"`` on the Proxmox host. This
|
||||||
|
reads the guest framebuffer straight out of QEMU:
|
||||||
|
|
||||||
|
* no guest agent, no guest network, nothing installed inside the guest;
|
||||||
|
* it does not perturb the guest at all -- relevant to method-rule 19, an
|
||||||
|
instrument that perturbs the thing it measures. Reading VM 140's screen is
|
||||||
|
therefore *not* an experiment and does not take VM 140's exclusivity lock;
|
||||||
|
* the campaign board already records that ``qm monitor`` screendump is more
|
||||||
|
reliable than the in-guest click helper's ``shot``.
|
||||||
|
|
||||||
|
spicy runs pve-manager 9.2 / QEMU 11, whose ``screendump`` takes ``-f png``
|
||||||
|
natively, so no PPM conversion and no netpbm/ImageMagick is needed on the host
|
||||||
|
(none is installed there, and this tool installs nothing). The PNGs are written
|
||||||
|
to a per-run temp dir on the host, streamed back inside a base64 tar in the same
|
||||||
|
SSH round trip, and the temp dir is removed before the SSH call returns -- so
|
||||||
|
nothing is left behind on spicy even if this script is killed.
|
||||||
|
|
||||||
|
Relationship to vmwatch
|
||||||
|
-----------------------
|
||||||
|
``tools/vmwatch.py`` is the live version of this: an always-on service on spicy
|
||||||
|
serving an auto-refreshing wall at http://192.168.3.201:8140/. When that
|
||||||
|
service is reachable this script pulls its already-captured frames over HTTP
|
||||||
|
instead of opening its own SSH session -- same frames, no extra load on the
|
||||||
|
guests, and it works from anywhere on the LAN without SSH. If the service is
|
||||||
|
down it falls back to the SSH + ``qm monitor`` path described below, so the CLI
|
||||||
|
never depends on the service being up.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
tools/vmshot.py # contact sheet of every sots-* guest
|
||||||
|
tools/vmshot.py --open # ... and open it in the default viewer
|
||||||
|
tools/vmshot.py 140 141 # only these ids
|
||||||
|
tools/vmshot.py --one 140 # full-size single guest, no sheet
|
||||||
|
tools/vmshot.py --cols 3 --width 900
|
||||||
|
|
||||||
|
Output lands in ``~/sots-re/dumps/vmshot/`` (``dumps/`` is gitignored).
|
||||||
|
|
||||||
|
A guest that is stopped, paused, or whose screendump fails gets a labelled
|
||||||
|
placeholder tile with the host-side error rather than aborting the sheet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
HOST = "spicy"
|
||||||
|
SERVICE = os.environ.get("VMWATCH_URL", "http://192.168.3.201:8140")
|
||||||
|
OUT_DIR = Path.home() / "sots-re" / "dumps" / "vmshot"
|
||||||
|
FLEET_PATTERN = re.compile(r"sots", re.I)
|
||||||
|
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||||
|
FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||||
|
|
||||||
|
# Tile chrome
|
||||||
|
LABEL_H = 34
|
||||||
|
PAD = 10
|
||||||
|
BG = (24, 26, 30)
|
||||||
|
LABEL_BG = (38, 42, 50)
|
||||||
|
LABEL_BG_BAD = (74, 34, 34)
|
||||||
|
FG = (232, 234, 238)
|
||||||
|
FG_DIM = (150, 156, 166)
|
||||||
|
BORDER = (60, 66, 78)
|
||||||
|
PLACEHOLDER_BG = (44, 30, 30)
|
||||||
|
|
||||||
|
|
||||||
|
def ssh(cmd: str, timeout: int = 90) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(
|
||||||
|
["ssh", "-o", "BatchMode=yes", HOST, cmd],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def service_state(timeout: float = 3.0) -> list[dict] | None:
|
||||||
|
"""Fleet state from a reachable vmwatch service, or None if it is not up."""
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"{SERVICE}/api/state", timeout=timeout) as r:
|
||||||
|
return json.load(r)
|
||||||
|
except (urllib.error.URLError, OSError, ValueError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def service_grab(ids: list[int], state: list[dict]) -> dict[int, dict]:
|
||||||
|
"""Pull already-captured frames from the vmwatch service. No guest load."""
|
||||||
|
by_id = {g["id"]: g for g in state}
|
||||||
|
out: dict[int, dict] = {}
|
||||||
|
for vid in ids:
|
||||||
|
g = by_id.get(vid)
|
||||||
|
if g is None:
|
||||||
|
out[vid] = {"png": None, "status": "absent", "log": "not known to vmwatch"}
|
||||||
|
continue
|
||||||
|
png = None
|
||||||
|
if g.get("ok"):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
f"{SERVICE}/shot/{vid}.png?t={g.get('ts', '')}", timeout=10
|
||||||
|
) as r:
|
||||||
|
png = r.read()
|
||||||
|
except (urllib.error.URLError, OSError):
|
||||||
|
png = None
|
||||||
|
out[vid] = {
|
||||||
|
"png": png,
|
||||||
|
"status": g.get("status", "?"),
|
||||||
|
"log": g.get("error", "") if not png else "",
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def discover_fleet() -> list[tuple[int, str, str]]:
|
||||||
|
"""Return [(vmid, name, status)] for every VM whose name matches sots-*."""
|
||||||
|
r = ssh("qm list", timeout=30)
|
||||||
|
if r.returncode != 0:
|
||||||
|
sys.exit(f"vmshot: `qm list` on {HOST} failed: {r.stderr.decode().strip()}")
|
||||||
|
fleet = []
|
||||||
|
for line in r.stdout.decode().splitlines()[1:]:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 3 or not parts[0].isdigit():
|
||||||
|
continue
|
||||||
|
vmid, name, status = int(parts[0]), parts[1], parts[2]
|
||||||
|
if FLEET_PATTERN.search(name):
|
||||||
|
fleet.append((vmid, name, status))
|
||||||
|
return sorted(fleet)
|
||||||
|
|
||||||
|
|
||||||
|
# Remote script: dump every requested guest in parallel, tar the results back,
|
||||||
|
# always clean up. Per-guest stderr is kept so a failure explains itself.
|
||||||
|
REMOTE = r"""
|
||||||
|
set -u
|
||||||
|
D=$(mktemp -d /tmp/vmshot.XXXXXX)
|
||||||
|
trap 'rm -rf "$D"' EXIT INT TERM
|
||||||
|
for id in %(ids)s; do
|
||||||
|
(
|
||||||
|
st=$(qm status "$id" 2>&1 | awk '{print $2}')
|
||||||
|
echo "$st" > "$D/$id.status"
|
||||||
|
timeout %(t)s qm monitor "$id" <<< "screendump $D/$id.png -f png" \
|
||||||
|
> "$D/$id.log" 2>&1
|
||||||
|
# qm monitor exits 0 even when the monitor command errors; the monitor
|
||||||
|
# echoes the failure into the log, so treat "no file" as the real test.
|
||||||
|
[ -s "$D/$id.png" ] || echo "no framebuffer written" >> "$D/$id.log"
|
||||||
|
) &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
tar -C "$D" -cf - . | base64 -w0
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def grab(ids: list[int], per_vm_timeout: int = 20) -> dict[int, dict]:
|
||||||
|
"""Screendump each id on the host; return {id: {'png': bytes|None, 'status', 'log'}}."""
|
||||||
|
remote = REMOTE % {"ids": " ".join(str(i) for i in ids), "t": per_vm_timeout}
|
||||||
|
r = ssh(f"bash -s <<'VMSHOT_EOF'\n{remote}\nVMSHOT_EOF", timeout=per_vm_timeout + 60)
|
||||||
|
if r.returncode != 0 and not r.stdout.strip():
|
||||||
|
sys.exit(f"vmshot: remote grab failed: {r.stderr.decode().strip()[:500]}")
|
||||||
|
|
||||||
|
out: dict[int, dict] = {i: {"png": None, "status": "?", "log": ""} for i in ids}
|
||||||
|
try:
|
||||||
|
blob = base64.b64decode(r.stdout.strip())
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:") as tf:
|
||||||
|
for m in tf.getmembers():
|
||||||
|
name = Path(m.name).name
|
||||||
|
if not m.isfile():
|
||||||
|
continue
|
||||||
|
stem, _, ext = name.rpartition(".")
|
||||||
|
if not stem.isdigit():
|
||||||
|
continue
|
||||||
|
vid = int(stem)
|
||||||
|
if vid not in out:
|
||||||
|
continue
|
||||||
|
data = tf.extractfile(m).read()
|
||||||
|
if ext == "png":
|
||||||
|
out[vid]["png"] = data
|
||||||
|
elif ext == "status":
|
||||||
|
out[vid]["status"] = data.decode(errors="replace").strip() or "?"
|
||||||
|
elif ext == "log":
|
||||||
|
out[vid]["log"] = data.decode(errors="replace").strip()
|
||||||
|
except Exception as e: # noqa: BLE001 - a mangled tar must not lose the whole sheet
|
||||||
|
for v in out.values():
|
||||||
|
v["log"] = v["log"] or f"could not unpack remote payload: {e}"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(FONT_BOLD if bold else FONT_PATH, size)
|
||||||
|
except OSError:
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_monitor_log(log: str) -> str:
|
||||||
|
"""Strip the monitor banner/prompt noise, keep the actual complaint."""
|
||||||
|
keep = []
|
||||||
|
for ln in log.splitlines():
|
||||||
|
ln = ln.replace("\x1b", "").strip()
|
||||||
|
ln = re.sub(r"^(QEMU \d[\w.]* monitor.*|qm> ?)", "", ln).strip()
|
||||||
|
if not ln or ln.startswith("Entering QEMU Monitor") or ln.startswith("Type 'help'"):
|
||||||
|
continue
|
||||||
|
keep.append(ln)
|
||||||
|
return "; ".join(keep)[:180]
|
||||||
|
|
||||||
|
|
||||||
|
def make_tile(vmid: int, name: str, info: dict, width: int, stamp: str) -> Image.Image:
|
||||||
|
png = info.get("png")
|
||||||
|
status = info.get("status", "?")
|
||||||
|
err = _clean_monitor_log(info.get("log", ""))
|
||||||
|
|
||||||
|
if png:
|
||||||
|
shot = Image.open(io.BytesIO(png)).convert("RGB")
|
||||||
|
native = f"{shot.width}x{shot.height}"
|
||||||
|
h = max(1, round(shot.height * width / shot.width))
|
||||||
|
shot = shot.resize((width, h), Image.LANCZOS)
|
||||||
|
detail = f"{status} {native} {stamp}"
|
||||||
|
bad = False
|
||||||
|
else:
|
||||||
|
h = round(width * 3 / 4)
|
||||||
|
shot = Image.new("RGB", (width, h), PLACEHOLDER_BG)
|
||||||
|
d = ImageDraw.Draw(shot)
|
||||||
|
msg = err or f"no screendump ({status})"
|
||||||
|
d.text(
|
||||||
|
(width // 2, h // 2 - 12),
|
||||||
|
"NO SIGNAL",
|
||||||
|
font=_font(max(18, width // 22), bold=True),
|
||||||
|
fill=(210, 120, 120),
|
||||||
|
anchor="mm",
|
||||||
|
)
|
||||||
|
f = _font(max(10, width // 60))
|
||||||
|
# crude wrap
|
||||||
|
line, lines = "", []
|
||||||
|
for word in msg.split():
|
||||||
|
if len(line) + len(word) + 1 > 52:
|
||||||
|
lines.append(line)
|
||||||
|
line = word
|
||||||
|
else:
|
||||||
|
line = f"{line} {word}".strip()
|
||||||
|
lines.append(line)
|
||||||
|
for i, ln in enumerate(lines[:4]):
|
||||||
|
d.text(
|
||||||
|
(width // 2, h // 2 + 18 + i * 15),
|
||||||
|
ln,
|
||||||
|
font=f,
|
||||||
|
fill=(190, 150, 150),
|
||||||
|
anchor="mm",
|
||||||
|
)
|
||||||
|
detail = f"{status} {stamp}"
|
||||||
|
bad = True
|
||||||
|
|
||||||
|
tile = Image.new("RGB", (width, h + LABEL_H), LABEL_BG_BAD if bad else LABEL_BG)
|
||||||
|
tile.paste(shot, (0, LABEL_H))
|
||||||
|
d = ImageDraw.Draw(tile)
|
||||||
|
f_id, f_name, f_detail = _font(16, bold=True), _font(14), _font(11)
|
||||||
|
y = LABEL_H // 2
|
||||||
|
|
||||||
|
d.text((8, y), str(vmid), font=f_id, fill=FG, anchor="lm")
|
||||||
|
name_x = 8 + round(d.textlength(str(vmid), font=f_id)) + 10
|
||||||
|
|
||||||
|
# A narrow tile must not let the two labels overlap: drop the timestamp
|
||||||
|
# first, then ellipsise the guest name to whatever room is left.
|
||||||
|
for candidate in (detail, detail.rsplit(" ", 1)[0], status):
|
||||||
|
detail_w = d.textlength(candidate, font=f_detail)
|
||||||
|
if name_x + d.textlength(name, font=f_name) + 12 + detail_w + 8 <= width:
|
||||||
|
detail = candidate
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
detail = status
|
||||||
|
detail_w = d.textlength(detail, font=f_detail)
|
||||||
|
|
||||||
|
room = width - 8 - detail_w - 12 - name_x
|
||||||
|
shown = name
|
||||||
|
while shown and d.textlength(shown + "…", font=f_name) > room:
|
||||||
|
shown = shown[:-1]
|
||||||
|
if shown != name:
|
||||||
|
shown = (shown + "…") if shown else ""
|
||||||
|
if room > 0 and shown:
|
||||||
|
d.text((name_x, y), shown, font=f_name, fill=FG, anchor="lm")
|
||||||
|
d.text((width - 8, y), detail, font=f_detail, fill=FG_DIM, anchor="rm")
|
||||||
|
d.rectangle([0, 0, width - 1, h + LABEL_H - 1], outline=BORDER)
|
||||||
|
return tile
|
||||||
|
|
||||||
|
|
||||||
|
def contact_sheet(fleet, shots, tile_w: int, cols: int, stamp: str) -> Image.Image:
|
||||||
|
tiles = [make_tile(vid, name, shots.get(vid, {}), tile_w, stamp) for vid, name, _ in fleet]
|
||||||
|
rows = (len(tiles) + cols - 1) // cols
|
||||||
|
row_h = [
|
||||||
|
max((t.height for t in tiles[r * cols : (r + 1) * cols]), default=0) for r in range(rows)
|
||||||
|
]
|
||||||
|
header = 40
|
||||||
|
W = PAD + cols * (tile_w + PAD)
|
||||||
|
H = header + PAD + sum(h + PAD for h in row_h)
|
||||||
|
sheet = Image.new("RGB", (W, H), BG)
|
||||||
|
d = ImageDraw.Draw(sheet)
|
||||||
|
live = sum(1 for v in shots.values() if v.get("png"))
|
||||||
|
d.text((PAD, header // 2), "SOTS lab guests", font=_font(18, bold=True), fill=FG, anchor="lm")
|
||||||
|
d.text(
|
||||||
|
(W - PAD, header // 2),
|
||||||
|
f"{live}/{len(fleet)} framebuffers captured {stamp} host={HOST}",
|
||||||
|
font=_font(12),
|
||||||
|
fill=FG_DIM,
|
||||||
|
anchor="rm",
|
||||||
|
)
|
||||||
|
y = header + PAD
|
||||||
|
for r in range(rows):
|
||||||
|
x = PAD
|
||||||
|
for t in tiles[r * cols : (r + 1) * cols]:
|
||||||
|
sheet.paste(t, (x, y))
|
||||||
|
x += tile_w + PAD
|
||||||
|
y += row_h[r] + PAD
|
||||||
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Contact sheet of the SOTS lab VM screens.")
|
||||||
|
ap.add_argument("ids", nargs="*", type=int, help="VM ids (default: every sots-* guest)")
|
||||||
|
ap.add_argument("--one", type=int, metavar="ID", help="save one guest full-size, no sheet")
|
||||||
|
ap.add_argument("--cols", type=int, default=3)
|
||||||
|
ap.add_argument("--width", type=int, default=760, help="tile width in px")
|
||||||
|
ap.add_argument("--out", type=Path, help="output png path")
|
||||||
|
ap.add_argument("--timeout", type=int, default=20, help="per-guest screendump timeout (s)")
|
||||||
|
ap.add_argument("--keep-raw", action="store_true", help="also save each guest's raw png")
|
||||||
|
ap.add_argument("--json", action="store_true", help="print a machine-readable result line")
|
||||||
|
ap.add_argument("--open", action="store_true", help="xdg-open the result")
|
||||||
|
ap.add_argument(
|
||||||
|
"--ssh",
|
||||||
|
action="store_true",
|
||||||
|
help="always capture over SSH, even if the vmwatch service is reachable",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
state = None if args.ssh else service_state()
|
||||||
|
if state is not None:
|
||||||
|
names = {g["id"]: g["name"] for g in state}
|
||||||
|
all_ids = [g["id"] for g in state]
|
||||||
|
source = SERVICE
|
||||||
|
else:
|
||||||
|
discovered = discover_fleet()
|
||||||
|
names = {v: n for v, n, _ in discovered}
|
||||||
|
all_ids = [v for v, _, _ in discovered]
|
||||||
|
source = f"ssh {HOST}"
|
||||||
|
|
||||||
|
if args.one is not None:
|
||||||
|
wanted = [args.one]
|
||||||
|
elif args.ids:
|
||||||
|
wanted = args.ids
|
||||||
|
else:
|
||||||
|
wanted = all_ids
|
||||||
|
if not wanted:
|
||||||
|
sys.exit(f"vmshot: no sots-* guests found via {source}")
|
||||||
|
fleet = [(v, names.get(v, f"vm{v}"), "") for v in wanted]
|
||||||
|
|
||||||
|
shots = service_grab(wanted, state) if state is not None else grab(wanted, args.timeout)
|
||||||
|
now = datetime.now()
|
||||||
|
stamp = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
tag = now.strftime("%Y%m%d-%H%M%S")
|
||||||
|
|
||||||
|
if args.keep_raw or args.one is not None:
|
||||||
|
for vid, info in shots.items():
|
||||||
|
if info.get("png"):
|
||||||
|
p = OUT_DIR / f"vm{vid}-{tag}.png"
|
||||||
|
p.write_bytes(info["png"])
|
||||||
|
|
||||||
|
if args.one is not None:
|
||||||
|
info = shots.get(args.one, {})
|
||||||
|
if not info.get("png"):
|
||||||
|
print(
|
||||||
|
f"vmshot: no framebuffer from {args.one}: "
|
||||||
|
f"{_clean_monitor_log(info.get('log', '')) or info.get('status', '?')}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
out = args.out or (OUT_DIR / f"vm{args.one}-{tag}.png")
|
||||||
|
out.write_bytes(info["png"])
|
||||||
|
else:
|
||||||
|
sheet = contact_sheet(fleet, shots, args.width, args.cols, stamp)
|
||||||
|
out = args.out or (OUT_DIR / f"sheet-{tag}.png")
|
||||||
|
sheet.save(out)
|
||||||
|
if args.out is None: # only the default location keeps a `latest` alias
|
||||||
|
(OUT_DIR / "latest.png").write_bytes(out.read_bytes())
|
||||||
|
|
||||||
|
live = sum(1 for v in shots.values() if v.get("png"))
|
||||||
|
if args.json:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"out": str(out),
|
||||||
|
"captured": live,
|
||||||
|
"requested": len(wanted),
|
||||||
|
"source": source,
|
||||||
|
"seconds": round(time.time() - t0, 1),
|
||||||
|
"guests": {
|
||||||
|
str(v): {
|
||||||
|
"name": names.get(v, ""),
|
||||||
|
"status": i.get("status"),
|
||||||
|
"ok": bool(i.get("png")),
|
||||||
|
"error": _clean_monitor_log(i.get("log", "")) or None,
|
||||||
|
}
|
||||||
|
for v, i in sorted(shots.items())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for v, i in sorted(shots.items()):
|
||||||
|
mark = "ok " if i.get("png") else "FAIL"
|
||||||
|
note = "" if i.get("png") else " " + (_clean_monitor_log(i.get("log", "")) or "?")
|
||||||
|
print(f" {mark} {v:<5} {names.get(v, ''):<20} {i.get('status', '?')}{note}")
|
||||||
|
print(
|
||||||
|
f"vmshot: {live}/{len(wanted)} captured in {time.time() - t0:.1f}s "
|
||||||
|
f"via {source} -> {out}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.open:
|
||||||
|
subprocess.run(["xdg-open", str(out)], check=False)
|
||||||
|
return 0 if live else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
60
tools/vmwatch-install.sh
Executable file
60
tools/vmwatch-install.sh
Executable file
|
|
@ -0,0 +1,60 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install / update the vmwatch fishtank service on the Proxmox host.
|
||||||
|
#
|
||||||
|
# What this puts on spicy (and nothing else -- no packages, no pip):
|
||||||
|
# /opt/vmwatch/vmwatch.py the service (Python 3 stdlib only)
|
||||||
|
# /etc/systemd/system/vmwatch.service
|
||||||
|
#
|
||||||
|
# Usage: tools/vmwatch-install.sh [host] (default host: spicy)
|
||||||
|
# tools/vmwatch-install.sh spicy --uninstall
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="${1:-spicy}"
|
||||||
|
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/vmwatch.py"
|
||||||
|
PORT="${VMWATCH_PORT:-8140}"
|
||||||
|
INTERVAL="${VMWATCH_INTERVAL:-5}"
|
||||||
|
|
||||||
|
if [[ "${2:-}" == "--uninstall" ]]; then
|
||||||
|
ssh "$HOST" 'systemctl disable --now vmwatch.service 2>/dev/null || true
|
||||||
|
rm -f /etc/systemd/system/vmwatch.service
|
||||||
|
rm -rf /opt/vmwatch
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo "vmwatch removed from $(hostname)"'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "installing vmwatch on $HOST (port $PORT, interval ${INTERVAL}s)"
|
||||||
|
ssh "$HOST" "mkdir -p /opt/vmwatch"
|
||||||
|
scp -q "$SRC" "$HOST:/opt/vmwatch/vmwatch.py"
|
||||||
|
ssh "$HOST" "chmod 0755 /opt/vmwatch/vmwatch.py"
|
||||||
|
|
||||||
|
ssh "$HOST" "cat > /etc/systemd/system/vmwatch.service" <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=vmwatch - live screen wall for the SOTS lab VMs
|
||||||
|
Documentation=file:///opt/vmwatch/vmwatch.py
|
||||||
|
After=network-online.target pve-guests.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/python3 /opt/vmwatch/vmwatch.py --port ${PORT} --interval ${INTERVAL}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
# read-only instrument: it only runs \`qm list\` and \`qm monitor ... screendump\`
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=no
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
# `enable --now` will not restart an already-running unit, so restart explicitly
|
||||||
|
ssh "$HOST" "systemctl daemon-reload \
|
||||||
|
&& systemctl enable vmwatch.service >/dev/null \
|
||||||
|
&& systemctl restart vmwatch.service && sleep 2 && systemctl is-active vmwatch.service"
|
||||||
|
IP=$(ssh "$HOST" "hostname -I | tr ' ' '\n' | grep -E '^192\.168\.' | head -1")
|
||||||
|
echo
|
||||||
|
echo "http://${IP}:${PORT}/"
|
||||||
637
tools/vmwatch.py
Executable file
637
tools/vmwatch.py
Executable file
|
|
@ -0,0 +1,637 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""vmwatch -- a fishtank for the SOTS lab Windows guests.
|
||||||
|
|
||||||
|
A small always-on HTTP service that serves an auto-refreshing page of live
|
||||||
|
guest screens. Leave the tab open and watch the lab.
|
||||||
|
|
||||||
|
http://192.168.3.201:8140/
|
||||||
|
|
||||||
|
Where it runs
|
||||||
|
-------------
|
||||||
|
On the Proxmox host **spicy** itself, because it needs `qm`. Installed as
|
||||||
|
``/opt/vmwatch/vmwatch.py`` with a systemd unit ``vmwatch.service``. The
|
||||||
|
canonical copy lives here in ``~/sots-re/tools/`` -- edit here, then reinstall
|
||||||
|
with ``tools/vmwatch-install.sh``.
|
||||||
|
|
||||||
|
Dependencies: **Python 3 standard library only.** Nothing is installed on
|
||||||
|
spicy beyond this file and its unit -- no pip, no ImageMagick, no netpbm (none
|
||||||
|
of which are present there).
|
||||||
|
|
||||||
|
Mechanism
|
||||||
|
---------
|
||||||
|
A QMP ``screendump`` sent to each guest's ``/var/run/qemu-server/<id>.qmp``
|
||||||
|
unix socket -- the same socket, and the same command, that
|
||||||
|
``qm monitor <id> <<< "screendump <file> -f png"`` uses, minus the Perl. That
|
||||||
|
detail is not cosmetic: forking ``qm`` once per guest per poll interval cost
|
||||||
|
about 90%% of a core on the host, which is not acceptable on a box that is also
|
||||||
|
running the guests. ``qm monitor`` remains the fallback when the socket is
|
||||||
|
absent. Either way this reads the guest framebuffer straight out of QEMU:
|
||||||
|
|
||||||
|
* no guest agent, no guest network, nothing running inside the guest;
|
||||||
|
* it does **not** perturb the guest -- relevant to method-rule 19, an
|
||||||
|
instrument that perturbs the thing it measures has already burned this
|
||||||
|
campaign once. Reading VM 140's screen is therefore not an experiment and
|
||||||
|
does not take VM 140's exclusivity lock;
|
||||||
|
* the campaign board already records that ``qm monitor`` screendump is more
|
||||||
|
reliable than the in-guest click helper's ``shot``.
|
||||||
|
|
||||||
|
spicy runs pve-manager 9.2 / QEMU 11, whose ``screendump`` accepts ``-f png``
|
||||||
|
natively. On an older QEMU without ``-f``, this falls back to a PPM dump and a
|
||||||
|
hand-rolled PPM->PNG encoder (zlib + a stored-filter IDAT), so no external
|
||||||
|
image tooling is needed either way. Temp files are read into memory and
|
||||||
|
unlinked immediately; nothing accumulates in /tmp.
|
||||||
|
|
||||||
|
Politeness
|
||||||
|
----------
|
||||||
|
A single background poller grabs every guest on a fixed interval and serves
|
||||||
|
every browser from that one in-memory copy, so N open tabs still cost one
|
||||||
|
screendump per guest per interval. The poller goes idle when no client has
|
||||||
|
asked for anything in IDLE_AFTER seconds, and wakes on the next request.
|
||||||
|
|
||||||
|
Routes
|
||||||
|
------
|
||||||
|
GET / the wall
|
||||||
|
GET /one/<id> one guest, full size
|
||||||
|
GET /api/state JSON: per-guest id, name, status, ok, error, age
|
||||||
|
GET /shot/<id>.png the latest cached framebuffer for that guest
|
||||||
|
GET /healthz plaintext ok
|
||||||
|
|
||||||
|
Read-only: this service never starts, stops, resets or reconfigures anything.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import zlib
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
FLEET_PATTERN = re.compile(r"sots-re", re.I)
|
||||||
|
DEFAULT_PORT = 8140
|
||||||
|
DEFAULT_INTERVAL = 5.0 # seconds between framebuffer grabs
|
||||||
|
FLEET_TTL = 20.0 # seconds between `qm list` refreshes
|
||||||
|
IDLE_AFTER = 120.0 # stop polling if nobody has looked for this long
|
||||||
|
DUMP_TIMEOUT = 15 # per-guest screendump timeout, seconds
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# host-side capture
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
CONF_DIR = "/etc/pve/qemu-server"
|
||||||
|
QMP_DIR = "/var/run/qemu-server"
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str], timeout: int, stdin: str | None = None) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(
|
||||||
|
cmd, input=stdin, capture_output=True, text=True, timeout=timeout, check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QmpError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Qmp:
|
||||||
|
"""Minimal QMP client over the per-VM unix socket PVE already exposes.
|
||||||
|
|
||||||
|
This is the same socket `qm monitor` uses, opened the same way (connect,
|
||||||
|
negotiate, one command, close). Going straight to it instead of shelling
|
||||||
|
out to `qm` matters: `qm` is a Perl program and forking one per guest per
|
||||||
|
poll interval cost ~90%% of a core on the host. Falls back to `qm monitor`
|
||||||
|
if the socket is missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, vmid: int, timeout: float = 10.0):
|
||||||
|
self.path = os.path.join(QMP_DIR, f"{vmid}.qmp")
|
||||||
|
self.timeout = timeout
|
||||||
|
self.sock: socket.socket | None = None
|
||||||
|
self.buf = b""
|
||||||
|
|
||||||
|
def __enter__(self) -> Qmp:
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(self.timeout)
|
||||||
|
s.connect(self.path)
|
||||||
|
self.sock = s
|
||||||
|
self._read_json() # greeting
|
||||||
|
self.execute("qmp_capabilities")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc) -> None:
|
||||||
|
if self.sock:
|
||||||
|
try:
|
||||||
|
self.sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _read_json(self) -> dict:
|
||||||
|
while True:
|
||||||
|
nl = self.buf.find(b"\n")
|
||||||
|
if nl >= 0:
|
||||||
|
line, self.buf = self.buf[:nl], self.buf[nl + 1 :]
|
||||||
|
if line.strip():
|
||||||
|
msg = json.loads(line)
|
||||||
|
if "event" in msg: # async event: not our reply
|
||||||
|
continue
|
||||||
|
return msg
|
||||||
|
continue
|
||||||
|
chunk = self.sock.recv(65536) # type: ignore[union-attr]
|
||||||
|
if not chunk:
|
||||||
|
raise QmpError("monitor closed the connection")
|
||||||
|
self.buf += chunk
|
||||||
|
|
||||||
|
def execute(self, cmd: str, **args) -> dict:
|
||||||
|
payload = {"execute": cmd}
|
||||||
|
if args:
|
||||||
|
payload["arguments"] = args
|
||||||
|
self.sock.sendall(json.dumps(payload).encode() + b"\n") # type: ignore[union-attr]
|
||||||
|
reply = self._read_json()
|
||||||
|
if "error" in reply:
|
||||||
|
raise QmpError(reply["error"].get("desc", json.dumps(reply["error"]))[:200])
|
||||||
|
return reply.get("return", {})
|
||||||
|
|
||||||
|
|
||||||
|
def _conf_name(vmid: int) -> str | None:
|
||||||
|
"""Guest name from its PVE config, ignoring the [snapshot] sections."""
|
||||||
|
try:
|
||||||
|
with open(os.path.join(CONF_DIR, f"{vmid}.conf"), encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
if line.startswith("["): # snapshot section begins
|
||||||
|
break
|
||||||
|
if line.startswith("name:"):
|
||||||
|
return line.split(":", 1)[1].strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def list_fleet() -> list[dict]:
|
||||||
|
"""[{id, name, status}] for every VM whose name matches sots-re.
|
||||||
|
|
||||||
|
Read from /etc/pve/qemu-server + the QMP sockets rather than `qm list`,
|
||||||
|
which is another Perl fork. Falls back to `qm list` if that is not
|
||||||
|
readable (e.g. running somewhere that is not a PVE node).
|
||||||
|
"""
|
||||||
|
fleet = []
|
||||||
|
try:
|
||||||
|
entries = os.listdir(CONF_DIR)
|
||||||
|
except OSError:
|
||||||
|
entries = []
|
||||||
|
for entry in entries:
|
||||||
|
stem, _, ext = entry.rpartition(".")
|
||||||
|
if ext != "conf" or not stem.isdigit():
|
||||||
|
continue
|
||||||
|
vmid = int(stem)
|
||||||
|
name = _conf_name(vmid)
|
||||||
|
if not name or not FLEET_PATTERN.search(name):
|
||||||
|
continue
|
||||||
|
status = "stopped"
|
||||||
|
if os.path.exists(os.path.join(QMP_DIR, f"{vmid}.qmp")):
|
||||||
|
try:
|
||||||
|
with Qmp(vmid, timeout=5) as q:
|
||||||
|
status = q.execute("query-status").get("status", "running")
|
||||||
|
except (OSError, QmpError, ValueError):
|
||||||
|
status = "unreachable"
|
||||||
|
fleet.append({"id": vmid, "name": name, "status": status})
|
||||||
|
if fleet:
|
||||||
|
return sorted(fleet, key=lambda g: g["id"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = _run(["qm", "list"], timeout=20)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
return []
|
||||||
|
for line in r.stdout.splitlines()[1:]:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 3 or not parts[0].isdigit():
|
||||||
|
continue
|
||||||
|
if FLEET_PATTERN.search(parts[1]):
|
||||||
|
fleet.append({"id": int(parts[0]), "name": parts[1], "status": parts[2]})
|
||||||
|
return sorted(fleet, key=lambda g: g["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def ppm_to_png(data: bytes) -> bytes:
|
||||||
|
"""Minimal binary-P6 PPM -> PNG. Only used if QEMU lacks screendump -f png."""
|
||||||
|
if not data.startswith(b"P6"):
|
||||||
|
raise ValueError("not a binary P6 PPM")
|
||||||
|
# header: P6, then width height maxval, whitespace separated, # comments allowed
|
||||||
|
fields, pos = [], 2
|
||||||
|
while len(fields) < 3:
|
||||||
|
while pos < len(data) and data[pos : pos + 1].isspace():
|
||||||
|
pos += 1
|
||||||
|
if data[pos : pos + 1] == b"#":
|
||||||
|
while pos < len(data) and data[pos] != 0x0A:
|
||||||
|
pos += 1
|
||||||
|
continue
|
||||||
|
start = pos
|
||||||
|
while pos < len(data) and not data[pos : pos + 1].isspace():
|
||||||
|
pos += 1
|
||||||
|
fields.append(int(data[start:pos]))
|
||||||
|
pos += 1 # single whitespace byte after maxval
|
||||||
|
w, h, maxval = fields
|
||||||
|
if maxval != 255:
|
||||||
|
raise ValueError(f"unsupported PPM maxval {maxval}")
|
||||||
|
px = data[pos : pos + w * h * 3]
|
||||||
|
stride = w * 3
|
||||||
|
raw = bytearray()
|
||||||
|
for y in range(h):
|
||||||
|
raw.append(0) # filter type 0 (None)
|
||||||
|
raw += px[y * stride : (y + 1) * stride]
|
||||||
|
|
||||||
|
def chunk(tag: bytes, payload: bytes) -> bytes:
|
||||||
|
return (
|
||||||
|
struct.pack(">I", len(payload))
|
||||||
|
+ tag
|
||||||
|
+ payload
|
||||||
|
+ struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
b"\x89PNG\r\n\x1a\n"
|
||||||
|
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||||
|
+ chunk(b"IDAT", zlib.compress(bytes(raw), 6))
|
||||||
|
+ chunk(b"IEND", b"")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def screendump(vmid: int, png_native: bool = True) -> tuple[bytes | None, str]:
|
||||||
|
"""Grab one guest framebuffer. Returns (png_bytes, error_message); never raises."""
|
||||||
|
suffix = ".png" if png_native else ".ppm"
|
||||||
|
fd, path = tempfile.mkstemp(prefix=f"vmwatch-{vmid}-", suffix=suffix, dir="/tmp")
|
||||||
|
os.close(fd)
|
||||||
|
os.unlink(path) # QEMU creates it itself; we only wanted a unique name
|
||||||
|
err = ""
|
||||||
|
try:
|
||||||
|
if os.path.exists(os.path.join(QMP_DIR, f"{vmid}.qmp")):
|
||||||
|
args = {"filename": path}
|
||||||
|
if png_native:
|
||||||
|
args["format"] = "png"
|
||||||
|
with Qmp(vmid, timeout=DUMP_TIMEOUT) as q:
|
||||||
|
q.execute("screendump", **args)
|
||||||
|
else:
|
||||||
|
# not running, or not a PVE node: fall back to the CLI monitor
|
||||||
|
cmd = f"screendump {path}" + (" -f png" if png_native else "")
|
||||||
|
r = _run(["qm", "monitor", str(vmid)], timeout=DUMP_TIMEOUT, stdin=cmd + "\n")
|
||||||
|
# `qm monitor` exits 0 even when the monitor command itself errors;
|
||||||
|
# it echoes the failure to stdout, so "no file" is the real test.
|
||||||
|
err = _clean_monitor_text(r.stdout + r.stderr)
|
||||||
|
except FileNotFoundError:
|
||||||
|
_unlink(path)
|
||||||
|
return None, "guest is not running (no monitor socket)"
|
||||||
|
except (socket.timeout, subprocess.TimeoutExpired):
|
||||||
|
_unlink(path)
|
||||||
|
return None, "screendump timed out"
|
||||||
|
except (QmpError, OSError, ValueError) as e:
|
||||||
|
_unlink(path)
|
||||||
|
return None, str(e) or f"{type(e).__name__}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
blob = fh.read()
|
||||||
|
except OSError:
|
||||||
|
blob = b""
|
||||||
|
finally:
|
||||||
|
_unlink(path)
|
||||||
|
|
||||||
|
if not blob:
|
||||||
|
return None, err or "no framebuffer written"
|
||||||
|
if png_native and blob.startswith(b"\x89PNG"):
|
||||||
|
return blob, ""
|
||||||
|
try:
|
||||||
|
return ppm_to_png(blob), ""
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return None, f"could not decode framebuffer: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def _unlink(path: str) -> None:
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_monitor_text(text: str) -> str:
|
||||||
|
keep = []
|
||||||
|
for ln in text.replace("\x1b", "").splitlines():
|
||||||
|
ln = re.sub(r"^(QEMU \d[\w.]* monitor.*|qm> ?)", "", ln.strip()).strip()
|
||||||
|
if not ln or ln.startswith(("Entering QEMU Monitor", "Type 'help'", "screendump ")):
|
||||||
|
continue
|
||||||
|
keep.append(ln)
|
||||||
|
return "; ".join(keep)[:200]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# poller
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Tank:
|
||||||
|
def __init__(self, interval: float):
|
||||||
|
self.interval = interval
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.shots: dict[int, dict] = {} # id -> {png, error, ts}
|
||||||
|
self.fleet: list[dict] = []
|
||||||
|
self.fleet_ts = 0.0
|
||||||
|
self.last_client = time.time()
|
||||||
|
self.png_native = True
|
||||||
|
self.cycles = 0
|
||||||
|
self.started = time.time()
|
||||||
|
self.wake = threading.Event()
|
||||||
|
|
||||||
|
def touch(self) -> None:
|
||||||
|
idle = time.time() - self.last_client > IDLE_AFTER
|
||||||
|
self.last_client = time.time()
|
||||||
|
if idle:
|
||||||
|
self.wake.set() # first look after a nap: refresh right away
|
||||||
|
|
||||||
|
def get_fleet(self) -> list[dict]:
|
||||||
|
with self.lock:
|
||||||
|
fresh = time.time() - self.fleet_ts < FLEET_TTL and self.fleet
|
||||||
|
if fresh:
|
||||||
|
return list(self.fleet)
|
||||||
|
fleet = list_fleet()
|
||||||
|
with self.lock:
|
||||||
|
if fleet or not self.fleet:
|
||||||
|
self.fleet, self.fleet_ts = fleet, time.time()
|
||||||
|
return list(self.fleet)
|
||||||
|
|
||||||
|
def cycle(self) -> None:
|
||||||
|
fleet = self.get_fleet()
|
||||||
|
if not fleet:
|
||||||
|
return
|
||||||
|
with ThreadPoolExecutor(max_workers=max(1, len(fleet))) as pool:
|
||||||
|
results = list(pool.map(lambda g: (g["id"], screendump(g["id"], self.png_native)), fleet))
|
||||||
|
now = time.time()
|
||||||
|
with self.lock:
|
||||||
|
for vmid, (png, err) in results:
|
||||||
|
prev = self.shots.get(vmid, {})
|
||||||
|
self.shots[vmid] = {
|
||||||
|
"png": png if png else prev.get("png"),
|
||||||
|
"fresh": bool(png),
|
||||||
|
"error": err,
|
||||||
|
"ts": now if png else prev.get("ts", 0.0),
|
||||||
|
}
|
||||||
|
live = {g["id"] for g in fleet}
|
||||||
|
for gone in set(self.shots) - live:
|
||||||
|
del self.shots[gone]
|
||||||
|
self.cycles += 1
|
||||||
|
# one-time downgrade if this QEMU has no `-f png`
|
||||||
|
if self.png_native and all(not p for _, (p, _) in results):
|
||||||
|
errs = " ".join(e for _, (_, e) in results)
|
||||||
|
if "format" in errs.lower() or "invalid" in errs.lower() or "-f" in errs:
|
||||||
|
self.png_native = False
|
||||||
|
print("vmwatch: QEMU lacks screendump -f png; falling back to PPM", flush=True)
|
||||||
|
|
||||||
|
def loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
if time.time() - self.last_client <= IDLE_AFTER:
|
||||||
|
try:
|
||||||
|
self.cycle()
|
||||||
|
except Exception as e: # noqa: BLE001 - the poller must never die
|
||||||
|
print(f"vmwatch: cycle error: {e}", file=sys.stderr, flush=True)
|
||||||
|
self.wake.wait(self.interval)
|
||||||
|
else:
|
||||||
|
self.wake.wait(2.0)
|
||||||
|
self.wake.clear()
|
||||||
|
|
||||||
|
def state(self) -> list[dict]:
|
||||||
|
now = time.time()
|
||||||
|
out = []
|
||||||
|
with self.lock:
|
||||||
|
shots = dict(self.shots)
|
||||||
|
for g in self.get_fleet():
|
||||||
|
s = shots.get(g["id"], {})
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
**g,
|
||||||
|
"ok": bool(s.get("png")),
|
||||||
|
"fresh": bool(s.get("fresh")),
|
||||||
|
"error": s.get("error") or "",
|
||||||
|
"age": round(now - s["ts"], 1) if s.get("ts") else None,
|
||||||
|
"ts": time.strftime("%H:%M:%S", time.localtime(s["ts"])) if s.get("ts") else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def png(self, vmid: int) -> bytes | None:
|
||||||
|
with self.lock:
|
||||||
|
return (self.shots.get(vmid) or {}).get("png")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# page
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PAGE = """<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>%(title)s</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%%3E%%3Crect width='16' height='16' rx='2' fill='%%232a2f39'/%%3E%%3Crect x='2' y='3' width='12' height='8' rx='1' fill='%%2368c07a'/%%3E%%3Crect x='6' y='12' width='4' height='1.5' fill='%%2398a0ad'/%%3E%%3C/svg%%3E">
|
||||||
|
<style>
|
||||||
|
:root{--bg:#16181c;--tile:#22262e;--tile2:#2a2f39;--line:#3a4150;--fg:#e8eaee;
|
||||||
|
--dim:#98a0ad;--bad:#4a2222;--badfg:#e08a8a;--ok:#68c07a}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--fg);
|
||||||
|
font:13px/1.4 "Segoe UI",system-ui,-apple-system,sans-serif}
|
||||||
|
header{display:flex;align-items:center;gap:12px;padding:8px 14px;
|
||||||
|
background:#1c1f25;border-bottom:1px solid var(--line);
|
||||||
|
position:sticky;top:0;z-index:5}
|
||||||
|
header h1{font-size:14px;font-weight:600;margin:0;letter-spacing:.2px}
|
||||||
|
header .sp{flex:1}
|
||||||
|
header .meta{color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums}
|
||||||
|
.dot{width:7px;height:7px;border-radius:50%%;background:var(--ok);display:inline-block;
|
||||||
|
margin-right:5px;transition:opacity .3s}
|
||||||
|
.dot.blink{opacity:.25}
|
||||||
|
main{display:grid;gap:10px;padding:10px;
|
||||||
|
grid-template-columns:repeat(auto-fill,minmax(%(minw)spx,1fr))}
|
||||||
|
main.one{grid-template-columns:1fr;padding:10px}
|
||||||
|
.tile{background:var(--tile);border:1px solid var(--line);border-radius:5px;
|
||||||
|
overflow:hidden;text-decoration:none;color:inherit;display:block}
|
||||||
|
.tile.bad{background:var(--bad);border-color:#6a3030}
|
||||||
|
.bar{display:flex;align-items:center;gap:8px;padding:5px 9px;background:var(--tile2);
|
||||||
|
border-bottom:1px solid var(--line)}
|
||||||
|
.tile.bad .bar{background:#3a1e1e;border-bottom-color:#6a3030}
|
||||||
|
.id{font-weight:700;font-size:13px;font-variant-numeric:tabular-nums}
|
||||||
|
.nm{color:var(--fg);font-size:12px}
|
||||||
|
.st{margin-left:auto;color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums;
|
||||||
|
white-space:nowrap}
|
||||||
|
.shot{display:block;width:100%%;aspect-ratio:4/3;object-fit:contain;background:#000}
|
||||||
|
.ph{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;
|
||||||
|
aspect-ratio:4/3;background:#2c1e1e;color:var(--badfg);text-align:center;padding:14px}
|
||||||
|
.ph b{font-size:15px;letter-spacing:1.5px}
|
||||||
|
.ph span{font-size:11px;color:#b89696;max-width:34em;word-break:break-word}
|
||||||
|
a.back{color:var(--dim);text-decoration:none;font-size:12px}
|
||||||
|
a.back:hover{color:var(--fg)}
|
||||||
|
</style></head><body>
|
||||||
|
<header>
|
||||||
|
<span class="dot" id="dot"></span>
|
||||||
|
<h1>%(title)s</h1>
|
||||||
|
%(nav)s
|
||||||
|
<span class="sp"></span>
|
||||||
|
<span class="meta" id="meta">connecting...</span>
|
||||||
|
</header>
|
||||||
|
<main id="wall" class="%(cls)s"></main>
|
||||||
|
<script>
|
||||||
|
const ONLY = %(only)s, INTERVAL = %(interval)s;
|
||||||
|
const wall = document.getElementById('wall'), meta = document.getElementById('meta'),
|
||||||
|
dot = document.getElementById('dot');
|
||||||
|
const esc = s => String(s).replace(/[&<>"]/g, c =>
|
||||||
|
({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
|
|
||||||
|
function render(guests){
|
||||||
|
const want = guests.map(g => String(g.id)).join(',');
|
||||||
|
if (wall.dataset.keys !== want){ wall.innerHTML = ''; wall.dataset.keys = want;
|
||||||
|
for (const g of guests){
|
||||||
|
const el = document.createElement(ONLY ? 'div' : 'a');
|
||||||
|
el.className = 'tile'; el.id = 'g' + g.id;
|
||||||
|
if (!ONLY) el.href = '/one/' + g.id;
|
||||||
|
el.innerHTML = '<div class="bar"><span class="id"></span><span class="nm"></span>'
|
||||||
|
+ '<span class="st"></span></div><div class="body"></div>';
|
||||||
|
wall.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const g of guests){
|
||||||
|
const el = document.getElementById('g' + g.id); if (!el) continue;
|
||||||
|
el.classList.toggle('bad', !g.ok);
|
||||||
|
el.querySelector('.id').textContent = g.id;
|
||||||
|
el.querySelector('.nm').textContent = g.name;
|
||||||
|
el.querySelector('.st').textContent =
|
||||||
|
g.status + (g.ok ? ' ' + g.ts + (g.fresh ? '' : ' (stale)') : ' no signal');
|
||||||
|
const body = el.querySelector('.body');
|
||||||
|
if (g.ok){
|
||||||
|
let img = body.querySelector('img');
|
||||||
|
if (!img){ body.innerHTML = ''; img = document.createElement('img');
|
||||||
|
img.className = 'shot'; img.alt = 'VM ' + g.id;
|
||||||
|
// an undecodable frame must degrade to the placeholder, not a broken icon
|
||||||
|
img.onerror = () => { el.classList.add('bad'); body.innerHTML =
|
||||||
|
'<div class="ph"><b>NO SIGNAL</b><span>frame did not decode</span></div>'; };
|
||||||
|
body.appendChild(img); }
|
||||||
|
// cache-buster keyed to capture time: no refetch unless the frame changed
|
||||||
|
const next = '/shot/' + g.id + '.png?t=' + encodeURIComponent(g.ts);
|
||||||
|
if (img.getAttribute('src') !== next) img.src = next;
|
||||||
|
} else {
|
||||||
|
body.innerHTML = '<div class="ph"><b>NO SIGNAL</b><span>'
|
||||||
|
+ esc(g.error || g.status) + '</span></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick(){
|
||||||
|
try{
|
||||||
|
const r = await fetch('/api/state', {cache:'no-store'});
|
||||||
|
let guests = await r.json();
|
||||||
|
if (ONLY) guests = guests.filter(g => g.id == ONLY);
|
||||||
|
render(guests);
|
||||||
|
const live = guests.filter(g => g.ok).length;
|
||||||
|
meta.textContent = live + '/' + guests.length + ' framebuffers · '
|
||||||
|
+ new Date().toLocaleTimeString() + ' · every ' + INTERVAL + 's · spicy';
|
||||||
|
dot.style.background = 'var(--ok)';
|
||||||
|
} catch(e){
|
||||||
|
meta.textContent = 'lost contact with vmwatch — retrying';
|
||||||
|
dot.style.background = '#c05a5a';
|
||||||
|
}
|
||||||
|
dot.classList.add('blink'); setTimeout(() => dot.classList.remove('blink'), 300);
|
||||||
|
}
|
||||||
|
tick(); setInterval(tick, INTERVAL * 1000);
|
||||||
|
</script></body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "vmwatch"
|
||||||
|
tank: Tank = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
def log_message(self, fmt, *a): # quieter than the default one-line-per-image
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _send(self, code, ctype, body: bytes, cache: str = "no-store"):
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", ctype)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", cache)
|
||||||
|
self.end_headers()
|
||||||
|
try:
|
||||||
|
self.wfile.write(body)
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _page(self, only: int | None):
|
||||||
|
title = f"SOTS guest {only}" if only else "SOTS lab guests"
|
||||||
|
nav = '<a class="back" href="/">← all guests</a>' if only else ""
|
||||||
|
body = PAGE % {
|
||||||
|
"title": html.escape(title),
|
||||||
|
"nav": nav,
|
||||||
|
"cls": "one" if only else "",
|
||||||
|
"only": only if only else "null",
|
||||||
|
"interval": self.tank.interval,
|
||||||
|
"minw": 900 if only else 460,
|
||||||
|
}
|
||||||
|
self._send(HTTPStatus.OK, "text/html; charset=utf-8", body.encode())
|
||||||
|
|
||||||
|
def do_GET(self): # noqa: N802
|
||||||
|
path = self.path.split("?", 1)[0]
|
||||||
|
self.tank.touch()
|
||||||
|
|
||||||
|
if path == "/":
|
||||||
|
return self._page(None)
|
||||||
|
if m := re.fullmatch(r"/one/(\d+)/?", path):
|
||||||
|
return self._page(int(m.group(1)))
|
||||||
|
if path == "/api/state":
|
||||||
|
return self._send(
|
||||||
|
HTTPStatus.OK, "application/json", json.dumps(self.tank.state()).encode()
|
||||||
|
)
|
||||||
|
if m := re.fullmatch(r"/shot/(\d+)\.png", path):
|
||||||
|
png = self.tank.png(int(m.group(1)))
|
||||||
|
if png:
|
||||||
|
# immutable: the URL carries the capture timestamp
|
||||||
|
return self._send(HTTPStatus.OK, "image/png", png, "max-age=30")
|
||||||
|
return self._send(HTTPStatus.NOT_FOUND, "text/plain", b"no framebuffer\n")
|
||||||
|
if path == "/healthz":
|
||||||
|
t = self.tank
|
||||||
|
up = int(time.time() - t.started)
|
||||||
|
return self._send(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
"text/plain",
|
||||||
|
f"ok guests={len(t.get_fleet())} cycles={t.cycles} uptime={up}s\n".encode(),
|
||||||
|
)
|
||||||
|
return self._send(HTTPStatus.NOT_FOUND, "text/plain", b"not found\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||||
|
ap.add_argument("--bind", default="0.0.0.0")
|
||||||
|
ap.add_argument("--interval", type=float, default=DEFAULT_INTERVAL)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
tank = Tank(args.interval)
|
||||||
|
Handler.tank = tank
|
||||||
|
threading.Thread(target=tank.loop, daemon=True, name="poller").start()
|
||||||
|
srv = ThreadingHTTPServer((args.bind, args.port), Handler)
|
||||||
|
srv.daemon_threads = True
|
||||||
|
print(
|
||||||
|
f"vmwatch: http://{args.bind}:{args.port}/ interval={args.interval}s "
|
||||||
|
f"guests={[g['id'] for g in tank.get_fleet()]}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
srv.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Loading…
Add table
Reference in a new issue