[ADD] suivi : les statistiques de chaque VM (écriture, RAM, disque)
Le tableau disait la durée et la taille du disque. Il ne disait pas si une VM TRAVAILLAIT : une installation figée et une qui compile s'y ressemblaient. Trois chiffres par VM, d'un seul appel « virsh domstats » pour tout le parc (0,03 s) : ce qu'elle écrit, sa RAM occupée/totale, son disque occupé/total. Le débit est une moyenne sur DIX secondes — le disque d'une installation travaille par rafales, et l'instantané n'y montrait que des 0 et des pics. Le ballon mémoire est réarmé au tour lent : sans période de collecte, libvirt rend le dernier rapport du pilote, vieux d'une demi-heure. Et cinq caractères récupérés sur trois colonnes trop larges font tenir la ligne en 150 colonnes. --- EN --- The table showed elapsed time and disk size. It did not show whether a VM was WORKING: a stalled install and a compiling one looked alike. Three numbers per VM, from a single "virsh domstats" call for the whole fleet (0.03 s): bytes written, RAM used/total, disk used/total. The write figure is a TEN-second average — an install's disk works in bursts, and the snapshot showed only zeros and spikes. The memory balloon is re-armed on the slow tick: with no collection period, libvirt hands back the driver's last report, half an hour old. And five characters reclaimed from three oversized columns keep the row inside 150. Assisted-by: Claude Opus 5
This commit is contained in:
parent
b352f6fbf1
commit
6a24687fe0
3 changed files with 723 additions and 11 deletions
|
|
@ -1059,6 +1059,245 @@ def virsh_domstates() -> dict:
|
|||
return states
|
||||
|
||||
|
||||
# Fenêtre du débit d'écriture. Dix secondes : le disque d'une VM en
|
||||
# installation travaille par rafales — « apt » décompresse, « poetry » compile,
|
||||
# puis plus rien pendant trois secondes. Un instantané de deux secondes affiche
|
||||
# donc tantôt 0, tantôt 200 Mo/s, et ne dit rien. Sur dix secondes, le chiffre
|
||||
# devient un débit qu'on peut comparer d'une VM à l'autre.
|
||||
WRITE_WINDOW = 10.0
|
||||
|
||||
# Âge maximal d'un relevé du ballon mémoire. Au-delà, la valeur est TAISÉE
|
||||
# plutôt que montrée : virtio-balloon ne publie ses compteurs que si une
|
||||
# période de collecte est armée, et une valeur figée depuis une demi-heure
|
||||
# ferait croire à une VM au repos alors qu'elle compile.
|
||||
BALLOON_MAX_AGE = 30.0
|
||||
|
||||
# Les compteurs du ballon sont en kibioctets ; ceux des blocs, en octets.
|
||||
_KIB = 1024
|
||||
|
||||
|
||||
def parse_domstats(text: str) -> dict:
|
||||
"""Sortie de « virsh domstats » -> {nom: relevé}.
|
||||
|
||||
Un relevé porte : ram_used / ram_total (octets), ram_at (horodatage du
|
||||
dernier rapport du ballon), wr_bytes (cumul écrit depuis le démarrage du
|
||||
processus QEMU), disk_used / disk_total (octets).
|
||||
|
||||
Le seed ISO est ÉCARTÉ des disques : monté en lecture seule, il n'est
|
||||
jamais écrit, et sa capacité (quelques mégaoctets) l'aurait fait passer
|
||||
pour le disque système sur une VM dont le qcow2 n'est pas encore alloué.
|
||||
"""
|
||||
out = {}
|
||||
nom = None
|
||||
brut = {}
|
||||
|
||||
def clore():
|
||||
if nom is None:
|
||||
return
|
||||
rec = {
|
||||
"ram_used": 0,
|
||||
"ram_total": 0,
|
||||
"ram_at": 0,
|
||||
"wr_bytes": 0,
|
||||
"disk_used": 0,
|
||||
"disk_total": 0,
|
||||
}
|
||||
dispo = brut.get("balloon.available")
|
||||
util = brut.get("balloon.usable")
|
||||
if dispo:
|
||||
rec["ram_total"] = dispo * _KIB
|
||||
if util is not None:
|
||||
rec["ram_used"] = max(0, (dispo - util)) * _KIB
|
||||
rec["ram_at"] = brut.get("balloon.last-update") or 0
|
||||
# Disques : on parcourt les index déclarés par block.count.
|
||||
meilleur = 0
|
||||
for i in range(int(brut.get("block.count") or 0)):
|
||||
chemin = brut.get(f"block.{i}.path.str") or ""
|
||||
if chemin.lower().endswith(".iso"):
|
||||
continue
|
||||
rec["wr_bytes"] += brut.get(f"block.{i}.wr.bytes") or 0
|
||||
cap = brut.get(f"block.{i}.capacity") or 0
|
||||
if cap >= meilleur:
|
||||
meilleur = cap
|
||||
rec["disk_total"] = cap
|
||||
rec["disk_used"] = brut.get(f"block.{i}.allocation") or 0
|
||||
out[nom] = rec
|
||||
|
||||
for ligne in (text or "").splitlines():
|
||||
ligne = ligne.strip()
|
||||
if ligne.startswith("Domain:"):
|
||||
clore()
|
||||
nom = ligne.split("'")[1] if "'" in ligne else None
|
||||
brut = {}
|
||||
continue
|
||||
if nom is None or "=" not in ligne:
|
||||
continue
|
||||
cle, _, val = ligne.partition("=")
|
||||
try:
|
||||
brut[cle] = int(val)
|
||||
except ValueError:
|
||||
# Les valeurs non numériques (chemins, noms de device) sont
|
||||
# gardées à part : « block.0.path » en est une, et c'est elle qui
|
||||
# démasque le seed ISO.
|
||||
brut[f"{cle}.str"] = val
|
||||
clore()
|
||||
return out
|
||||
|
||||
|
||||
class WriteWindow:
|
||||
"""Débit d'écriture moyen par VM, sur une fenêtre glissante."""
|
||||
|
||||
def __init__(self, window=WRITE_WINDOW):
|
||||
self.window = window
|
||||
self._hist = {}
|
||||
|
||||
def add(self, name, wr_bytes, now):
|
||||
hist = self._hist.setdefault(name, [])
|
||||
# Un compteur qui RECULE veut dire que le domaine a redémarré : le
|
||||
# processus QEMU est neuf, ses compteurs repartent de zéro. Sans ce
|
||||
# garde, le débit affiché serait négatif, puis énorme au relevé
|
||||
# suivant. Une installation redémarre la VM : le cas est la règle.
|
||||
if hist and wr_bytes < hist[-1][1]:
|
||||
hist.clear()
|
||||
hist.append((now, wr_bytes))
|
||||
limite = now - self.window
|
||||
while len(hist) > 2 and hist[1][0] < limite:
|
||||
hist.pop(0)
|
||||
|
||||
def rate(self, name):
|
||||
"""Octets/s, ou None tant que la fenêtre n'a pas de quoi conclure."""
|
||||
hist = self._hist.get(name) or []
|
||||
if len(hist) < 2:
|
||||
return None
|
||||
span = hist[-1][0] - hist[0][0]
|
||||
if span < 1.0:
|
||||
return None
|
||||
return max(0.0, (hist[-1][1] - hist[0][1]) / span)
|
||||
|
||||
def total(self, name):
|
||||
"""Écrit depuis le premier relevé de la fenêtre (octets)."""
|
||||
hist = self._hist.get(name) or []
|
||||
return hist[-1][1] if hist else None
|
||||
|
||||
|
||||
def fmt_rate(bps) -> str:
|
||||
"""Octets/s -> « 12.3M/s ». « - » tant qu'on ne sait pas."""
|
||||
return "-" if bps is None else f"{_fmt_size(int(bps))}/s"
|
||||
|
||||
|
||||
def _fmt_tight(nbytes) -> str:
|
||||
"""Comme _fmt_size, mais sans décimale au-delà de dix unités.
|
||||
|
||||
« 63G » plutôt que « 62.6G » : dans une colonne de tableau, ces deux
|
||||
caractères décident si « Disque » reste visible ou sort de l'écran, et la
|
||||
décimale n'apprend rien à côté d'un total de 65 Go.
|
||||
"""
|
||||
if nbytes is None:
|
||||
return "-"
|
||||
for unit, div in (("T", 1 << 40), ("G", 1 << 30), ("M", 1 << 20)):
|
||||
if nbytes >= div:
|
||||
val = nbytes / div
|
||||
return f"{val:.0f}{unit}" if val >= 10 else f"{val:.1f}{unit}"
|
||||
return f"{max(0, int(nbytes)) // 1024}K"
|
||||
|
||||
|
||||
def fmt_pair(used, total) -> str:
|
||||
"""« 1.1G/12G », « 63G/65G ». « - » si le total manque : « ?/12G »
|
||||
n'informe pas."""
|
||||
if not total:
|
||||
return "-"
|
||||
return f"{_fmt_tight(used)}/{_fmt_tight(total)}"
|
||||
|
||||
|
||||
def fmt_pct(used, total) -> str:
|
||||
return f" ({int(used / total * 100)}%)" if total else ""
|
||||
|
||||
|
||||
def ram_pair(rec, now, max_age=BALLOON_MAX_AGE) -> str:
|
||||
"""RAM utilisée/totale de la VM, ou « - » si le relevé est PÉRIMÉ."""
|
||||
if not rec or not rec.get("ram_total"):
|
||||
return "-"
|
||||
at = rec.get("ram_at") or 0
|
||||
if at and now - at > max_age:
|
||||
return "-"
|
||||
return fmt_pair(rec.get("ram_used"), rec["ram_total"])
|
||||
|
||||
|
||||
def vm_stats_line(name, rec, bps, now, ecrit=None) -> str:
|
||||
"""Section statistiques d'UNE VM, en une ligne dense.
|
||||
|
||||
Le tableau porte les mêmes chiffres en colonnes, pour tout le parc d'un
|
||||
coup d'œil ; cette ligne les détaille pour la VM sélectionnée — celle dont
|
||||
le journal et la commande SSH sont déjà affichés.
|
||||
"""
|
||||
if not rec:
|
||||
return f" 📊 {name} · {t('no statistics yet')}"
|
||||
bits = [f"✍ {fmt_rate(bps)} ({t('10s average')})"]
|
||||
if ecrit:
|
||||
bits.append(f"{t('total written')} {_fmt_size(ecrit)}")
|
||||
ram = ram_pair(rec, now)
|
||||
if ram != "-":
|
||||
bits.append(
|
||||
f"🧠 RAM {ram}{fmt_pct(rec['ram_used'], rec['ram_total'])}"
|
||||
)
|
||||
if rec.get("disk_total"):
|
||||
bits.append(
|
||||
f"💾 {t('disk')} "
|
||||
f"{fmt_pair(rec['disk_used'], rec['disk_total'])}"
|
||||
f"{fmt_pct(rec['disk_used'], rec['disk_total'])}"
|
||||
)
|
||||
return f" 📊 {name} · " + " · ".join(bits)
|
||||
|
||||
|
||||
def read_domstats() -> str:
|
||||
"""Sortie brute de « virsh domstats --balloon --block » (tout le parc).
|
||||
|
||||
UN appel pour toutes les VM — 0,03 s mesuré sur deux domaines. Le suivi
|
||||
relève toutes les deux secondes : une commande par VM y coûterait N
|
||||
processus à chaque tour."""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["sudo", "virsh", "domstats", "--balloon", "--block"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
env={**os.environ, "LC_ALL": "C", "LANG": "C"},
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return res.stdout if res.returncode == 0 else ""
|
||||
|
||||
|
||||
def arm_balloon(names) -> None:
|
||||
"""Arme la période de collecte du ballon (5 s) sur chaque VM.
|
||||
|
||||
Sans elle, « balloon.available » et « balloon.usable » restent FIGÉS sur le
|
||||
dernier rapport du pilote : mesuré sur une VM fraîche, 388 Mo annoncés
|
||||
contre 1,1 Go réellement occupés, avec un horodatage vieux d'une
|
||||
demi-heure. La période se perd quand le domaine redémarre — ce qu'une
|
||||
installation fait — donc on la réarme à intervalle lent.
|
||||
"""
|
||||
for name in names or ():
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"sudo",
|
||||
"virsh",
|
||||
"dommemstat",
|
||||
name,
|
||||
"--period",
|
||||
"5",
|
||||
"--live",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
env={**os.environ, "LC_ALL": "C", "LANG": "C"},
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Dashboard Textual
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
@ -1068,13 +1307,26 @@ def virsh_domstates() -> dict:
|
|||
# que ce qui était affiché au départ.
|
||||
COL_DEFAULT_WIDTHS = {
|
||||
"seq": 3,
|
||||
"vm": 22,
|
||||
"arch": 7,
|
||||
# 21 : un caractère de moins que l'ancien 22, et c'est lui qui fait tenir
|
||||
# la ligne entière sur un terminal de 150 colonnes. « + » l'élargit, et
|
||||
# c'est déjà la colonne visée par défaut.
|
||||
"vm": 21,
|
||||
# 5 : « amd64 », « s390x », « arm64 » font cinq caractères, et l'en-tête
|
||||
# « Arch » quatre. Les deux de plus ne servaient rien.
|
||||
"arch": 5,
|
||||
"err": 4,
|
||||
"state": 8,
|
||||
"odoo": 6,
|
||||
"elapsed": 7,
|
||||
"disk": 8,
|
||||
# 4 : une icône (🟢) ou un tiret, sous un en-tête de quatre lettres.
|
||||
"odoo": 4,
|
||||
# 6 : « 125:30 » est le pire cas d'une installation de deux heures.
|
||||
"elapsed": 6,
|
||||
# Section statistiques de la VM : ce qu'elle écrit, sa RAM, son disque.
|
||||
# « 12.3M/s » tient en 7 et « 1.1G/12G » en 9 : au-delà, « Disque » sortait
|
||||
# de l'écran sur un terminal de 150 colonnes, moitié prise par le journal.
|
||||
"wr": 7,
|
||||
# 10 et non 9 : sur une VM de 128 Go, « 1001M/128G » fait dix caractères.
|
||||
"ram": 10,
|
||||
"disk": 9,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1369,7 +1621,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
que devinable, et « max-width » laisse la table suivre l'élargissement
|
||||
des colonnes sans manger tout l'écran. */
|
||||
DataTable {
|
||||
width: auto; max-width: 60%; height: 1fr;
|
||||
width: auto; max-width: 66%; height: 1fr;
|
||||
overflow-x: auto; overflow-y: auto;
|
||||
scrollbar-size-horizontal: 1; scrollbar-size-vertical: 1;
|
||||
border: solid $accent;
|
||||
|
|
@ -1444,6 +1696,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
)
|
||||
# État libvirt (running/paused/gone), rafraîchi à intervalle LENT.
|
||||
self._domstate = {}
|
||||
# Statistiques par VM : dernier relevé libvirt, et la fenêtre
|
||||
# glissante qui en tire un débit d'écriture.
|
||||
self._vmstats = {}
|
||||
self._wrate = WriteWindow()
|
||||
# Erreurs détectées dans le log à la complétion : {nom: (err, warn)}.
|
||||
self._errcount = {}
|
||||
# Sommaire de stats déplié (clic) ou non.
|
||||
|
|
@ -1498,6 +1754,13 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
table.add_column(
|
||||
"Durée", key="elapsed", width=COL_DEFAULT_WIDTHS["elapsed"]
|
||||
)
|
||||
# Statistiques de la VM. « Écrit/s » est une MOYENNE sur dix
|
||||
# secondes : le disque d'une installation travaille par rafales,
|
||||
# et l'instantané n'y montrait que des 0 et des pics.
|
||||
table.add_column(
|
||||
"Écrit/s", key="wr", width=COL_DEFAULT_WIDTHS["wr"]
|
||||
)
|
||||
table.add_column("RAM", key="ram", width=COL_DEFAULT_WIDTHS["ram"])
|
||||
table.add_column(
|
||||
"Disque", key="disk", width=COL_DEFAULT_WIDTHS["disk"]
|
||||
)
|
||||
|
|
@ -1511,6 +1774,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
"—",
|
||||
"--:--",
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
key=vm["name"],
|
||||
)
|
||||
# max_lines borne la mémoire/rendu (un install verbeux × 30 VM).
|
||||
|
|
@ -1525,6 +1790,11 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
# Sommaire de stats en CHIFFRES (cliquable -> détail).
|
||||
yield Static("", id="stats")
|
||||
yield Static("", id="statsdetail")
|
||||
# Section statistiques de la VM SÉLECTIONNÉE : les mêmes chiffres
|
||||
# que ses colonnes, mais détaillés (pourcentages, cumul écrit).
|
||||
# Une ligne par VM du parc aurait chassé le pied de page dès cinq
|
||||
# machines ; la sélection suit déjà le journal et la barre SSH.
|
||||
yield Static("", id="vmstats")
|
||||
yield Static("", id="sshbar")
|
||||
yield Footer()
|
||||
|
||||
|
|
@ -1536,6 +1806,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
)
|
||||
self.sub_title = f"0/{len(vms)} {t('completed')}"
|
||||
self._refresh_ssh()
|
||||
self._refresh_vmstats()
|
||||
self._load_selected_log(reset=True)
|
||||
# Table toutes les 2 s (30 lectures de fin de log), suivi du log
|
||||
# sélectionné toutes les 1 s (une seule lecture incrémentale).
|
||||
|
|
@ -1551,6 +1822,25 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
def _vm_by_name(self, name):
|
||||
return next((v for v in vms if v["name"] == name), None)
|
||||
|
||||
def _refresh_vmstats(self):
|
||||
"""Ligne de statistiques de la VM sélectionnée."""
|
||||
name = self._selected
|
||||
if not name:
|
||||
return
|
||||
try:
|
||||
bar = self.query_one("#vmstats", Static)
|
||||
except Exception:
|
||||
return
|
||||
bar.update(
|
||||
vm_stats_line(
|
||||
name,
|
||||
self._vmstats.get(name),
|
||||
self._wrate.rate(name),
|
||||
time.time(),
|
||||
self._wrate.total(name),
|
||||
)
|
||||
)
|
||||
|
||||
def _refresh_ssh(self):
|
||||
vm = self._vm_by_name(self._selected)
|
||||
bar = self.query_one("#sshbar", Static)
|
||||
|
|
@ -1612,9 +1902,27 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
"""(THREAD) statut + taille disque de chaque VM + télémétrie. AUCUNE
|
||||
mise à jour d'UI ici : uniquement des I/O bloquantes déportées."""
|
||||
disks, status, errors, odoo = {}, {}, {}, {}
|
||||
# UN appel virsh pour tout le parc, dans ce thread : le débit se
|
||||
# calcule sur les relevés successifs, donc il faut échantillonner
|
||||
# à chaque tour (2 s) et non au rythme lent des états.
|
||||
stats = parse_domstats(read_domstats())
|
||||
now_s = time.time()
|
||||
for name, rec in stats.items():
|
||||
self._wrate.add(name, rec["wr_bytes"], now_s)
|
||||
self._vmstats = stats
|
||||
wr, ram = {}, {}
|
||||
for vm in vms:
|
||||
name = vm["name"]
|
||||
disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm)))
|
||||
rec = stats.get(name)
|
||||
wr[name] = fmt_rate(self._wrate.rate(name))
|
||||
ram[name] = ram_pair(rec, now_s)
|
||||
if rec and rec.get("disk_total"):
|
||||
disks[name] = fmt_pair(rec["disk_used"], rec["disk_total"])
|
||||
else:
|
||||
# Domaine pas encore défini (conversion de l'image, tout
|
||||
# début de l'installation) : le qcow2 existe déjà, et
|
||||
# st_blocks dit ce qu'il occupe. Sans total à annoncer.
|
||||
disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm)))
|
||||
if (
|
||||
name not in self._final
|
||||
and self._domstate.get(name) != "gone"
|
||||
|
|
@ -1634,16 +1942,22 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
):
|
||||
if _port_open(vm.get("ip"), 8069):
|
||||
odoo[name] = True
|
||||
return disks, status, self._collect_tele(), errors, odoo
|
||||
return disks, status, self._collect_tele(), errors, odoo, wr, ram
|
||||
|
||||
async def _tick_table(self):
|
||||
# I/O (lectures de logs, stat disque, /proc) DÉPORTÉES en thread ->
|
||||
# la boucle d'événements Textual reste fluide même sous forte
|
||||
# charge ou disque lent. Les mises à jour d'UI restent sur la boucle.
|
||||
try:
|
||||
disks, status, tele, errors, odoo = await asyncio.to_thread(
|
||||
self._collect_table
|
||||
)
|
||||
(
|
||||
disks,
|
||||
status,
|
||||
tele,
|
||||
errors,
|
||||
odoo,
|
||||
wr,
|
||||
ram,
|
||||
) = await asyncio.to_thread(self._collect_table)
|
||||
except Exception:
|
||||
return
|
||||
self._errcount.update(errors)
|
||||
|
|
@ -1655,6 +1969,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
for vm in vms:
|
||||
name = vm["name"]
|
||||
self._set_cell(table, name, "disk", disks.get(name, "-"))
|
||||
self._set_cell(table, name, "wr", wr.get(name, "-"))
|
||||
self._set_cell(table, name, "ram", ram.get(name, "-"))
|
||||
# Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ».
|
||||
self._set_cell(
|
||||
table,
|
||||
|
|
@ -1743,6 +2059,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
if tele:
|
||||
self.query_one("#telemetry", Static).update(tele)
|
||||
self._update_stats()
|
||||
# Les chiffres de la VM sélectionnée viennent d'être relevés :
|
||||
# sa section les redit ici, détaillés.
|
||||
self._refresh_vmstats()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -1849,6 +2168,14 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
return
|
||||
for vm in vms:
|
||||
self._domstate[vm["name"]] = states.get(vm["name"], "gone")
|
||||
# Réarmer la période du ballon sur les VM qui tournent : sans elle
|
||||
# la RAM affichée serait celle du dernier rapport du pilote, et une
|
||||
# installation redémarre la VM — ce qui remet la période à zéro.
|
||||
vivantes = [
|
||||
vm["name"] for vm in vms if states.get(vm["name"]) == "running"
|
||||
]
|
||||
if vivantes:
|
||||
await asyncio.to_thread(arm_balloon, vivantes)
|
||||
|
||||
# L'adresse est relue au même rythme. Le processus détaché suivait
|
||||
# déjà la VM quand son bail changeait, mais les VUES gardaient celle
|
||||
|
|
@ -1889,6 +2216,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
return
|
||||
self._selected = name
|
||||
self._refresh_ssh()
|
||||
self._refresh_vmstats()
|
||||
self._load_selected_log(reset=True)
|
||||
|
||||
def action_follow(self) -> None:
|
||||
|
|
|
|||
|
|
@ -3103,6 +3103,18 @@ TRANSLATIONS = {
|
|||
"fr": "Nœud de rendu",
|
||||
"en": "Render node",
|
||||
},
|
||||
"no statistics yet": {
|
||||
"fr": "pas encore de statistiques",
|
||||
"en": "no statistics yet",
|
||||
},
|
||||
"10s average": {
|
||||
"fr": "moyenne 10 s",
|
||||
"en": "10s average",
|
||||
},
|
||||
"total written": {
|
||||
"fr": "cumul écrit",
|
||||
"en": "total written",
|
||||
},
|
||||
"Screens": {
|
||||
"fr": "Écrans",
|
||||
"en": "Screens",
|
||||
|
|
|
|||
372
test/test_qemu_monitor_vmstats.py
Normal file
372
test/test_qemu_monitor_vmstats.py
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Statistiques par VM du suivi d'installation : écriture, RAM, disque.
|
||||
|
||||
Trois chiffres par VM, tirés d'UN appel « virsh domstats » pour tout le parc :
|
||||
ce qu'elle écrit (moyenne sur dix secondes), sa RAM occupée/totale, son disque
|
||||
occupé/total.
|
||||
|
||||
Ce que ces tests gardent, appris en les construisant contre des VM réelles :
|
||||
|
||||
- Le seed ISO doit être ÉCARTÉ des disques. Monté en lecture seule, il n'est
|
||||
jamais écrit, et sa capacité minuscule l'aurait fait passer pour le disque
|
||||
système d'une VM dont le qcow2 n'est pas encore alloué.
|
||||
- Un compteur d'écriture qui RECULE veut dire que le domaine a redémarré —
|
||||
ce qu'une installation fait. Sans garde, le débit devient négatif.
|
||||
- Le ballon mémoire ne publie ses compteurs que si une période de collecte est
|
||||
armée. Un relevé vieux d'une demi-heure doit être TAISÉ, pas affiché :
|
||||
mesuré, il annonçait 388 Mo pour une VM qui occupait 1,1 Go.
|
||||
- La ligne du tableau doit tenir : trois colonnes de plus, et « Disque »
|
||||
sortait de l'écran sur un terminal de 150 colonnes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from script.todo import qemu_install_monitor as mon
|
||||
from script.todo.qemu_install_monitor import (
|
||||
BALLOON_MAX_AGE,
|
||||
COL_DEFAULT_WIDTHS,
|
||||
WRITE_WINDOW,
|
||||
WriteWindow,
|
||||
_fmt_tight,
|
||||
fmt_pair,
|
||||
fmt_rate,
|
||||
parse_domstats,
|
||||
ram_pair,
|
||||
vm_stats_line,
|
||||
)
|
||||
|
||||
# Sortie RÉELLE de « virsh domstats --balloon --block » sur deux VM du parc :
|
||||
# une VM de migration en travail, et son seed ISO en second périphérique.
|
||||
DOMSTATS = """Domain: 'erplibre-ubuntu-2404-MIGRATION'
|
||||
balloon.current=12582912
|
||||
balloon.maximum=12582912
|
||||
balloon.swap_in=0
|
||||
balloon.unused=936072
|
||||
balloon.available=12242352
|
||||
balloon.usable=11064740
|
||||
balloon.last-update=1787392761
|
||||
balloon.rss=12623896
|
||||
block.count=2
|
||||
block.0.name=vda
|
||||
block.0.path=/var/lib/libvirt/images/erplibre-ubuntu-2404.qcow2
|
||||
block.0.rd.bytes=9664722944
|
||||
block.0.wr.reqs=2969857
|
||||
block.0.wr.bytes=165796498432
|
||||
block.0.allocation=67263266816
|
||||
block.0.capacity=69793218560
|
||||
block.0.physical=67265261568
|
||||
block.1.name=vdb
|
||||
block.1.path=/var/lib/libvirt/images/iso/erplibre-ubuntu-2404-seed.iso
|
||||
block.1.wr.bytes=4096
|
||||
block.1.allocation=380928
|
||||
block.1.capacity=380928
|
||||
Domain: 'erplibre-ubuntu-2604'
|
||||
balloon.current=12582912
|
||||
balloon.available=12237092
|
||||
balloon.usable=11054184
|
||||
balloon.last-update=1787392782
|
||||
block.count=1
|
||||
block.0.name=vda
|
||||
block.0.path=/var/lib/libvirt/images/erplibre-ubuntu-2604.qcow2
|
||||
block.0.wr.bytes=48117251072
|
||||
block.0.allocation=43900928000
|
||||
block.0.capacity=69793218560
|
||||
"""
|
||||
|
||||
MIGRATION = "erplibre-ubuntu-2404-MIGRATION"
|
||||
|
||||
|
||||
class TestLecture(unittest.TestCase):
|
||||
def test_it_reads_both_domains(self):
|
||||
st = parse_domstats(DOMSTATS)
|
||||
self.assertEqual({MIGRATION, "erplibre-ubuntu-2604"}, set(st))
|
||||
|
||||
def test_ram_is_available_minus_usable(self):
|
||||
"""Calibrée en son temps contre le « free » de deux invités : c'est
|
||||
« available - usable » qui suit ce qu'ils occupent, pas « unused »,
|
||||
qui compte le cache."""
|
||||
rec = parse_domstats(DOMSTATS)[MIGRATION]
|
||||
self.assertEqual(12242352 * 1024, rec["ram_total"])
|
||||
self.assertEqual((12242352 - 11064740) * 1024, rec["ram_used"])
|
||||
|
||||
def test_the_seed_iso_is_left_out_of_the_disk(self):
|
||||
"""Sa capacité (372 Ko) l'emporterait sur un qcow2 pas encore alloué,
|
||||
et le tableau annoncerait un disque plein à 100 %."""
|
||||
rec = parse_domstats(DOMSTATS)[MIGRATION]
|
||||
self.assertEqual(69793218560, rec["disk_total"])
|
||||
self.assertEqual(67263266816, rec["disk_used"])
|
||||
|
||||
def test_the_seed_iso_writes_are_left_out_too(self):
|
||||
"""4096 octets écrits sur un ISO en lecture seule : anecdotique ici,
|
||||
mais compter un périphérique qu'on n'affiche pas rend un débit qu'on
|
||||
ne peut relier à rien."""
|
||||
rec = parse_domstats(DOMSTATS)[MIGRATION]
|
||||
self.assertEqual(165796498432, rec["wr_bytes"])
|
||||
|
||||
def test_a_single_disk_vm_is_read_too(self):
|
||||
rec = parse_domstats(DOMSTATS)["erplibre-ubuntu-2604"]
|
||||
self.assertEqual(48117251072, rec["wr_bytes"])
|
||||
self.assertEqual(69793218560, rec["disk_total"])
|
||||
|
||||
def test_empty_or_broken_output_gives_nothing(self):
|
||||
for texte in ("", "erreur: pas de connexion", None):
|
||||
self.assertEqual({}, parse_domstats(texte))
|
||||
|
||||
def test_a_domain_without_balloon_stats_is_not_invented(self):
|
||||
"""Une VM éteinte, ou dont le pilote n'a rien publié : pas de RAM
|
||||
plutôt qu'un zéro qui passerait pour une mesure."""
|
||||
rec = parse_domstats("Domain: 'x'\n block.count=0\n")["x"]
|
||||
self.assertEqual(0, rec["ram_total"])
|
||||
self.assertEqual("-", ram_pair(rec, time.time()))
|
||||
|
||||
|
||||
class TestFenetreEcriture(unittest.TestCase):
|
||||
def test_two_samples_ten_seconds_apart_give_the_rate(self):
|
||||
w = WriteWindow(10.0)
|
||||
w.add("vm", 0, 1000.0)
|
||||
w.add("vm", 10 << 20, 1010.0)
|
||||
self.assertAlmostEqual(1 << 20, w.rate("vm"), delta=1024)
|
||||
|
||||
def test_one_sample_is_not_a_rate(self):
|
||||
"""Au premier tour, la colonne doit dire « - » et non « 0 » : un débit
|
||||
nul et un débit inconnu ne se ressemblent pas."""
|
||||
w = WriteWindow()
|
||||
w.add("vm", 12345, 1000.0)
|
||||
self.assertIsNone(w.rate("vm"))
|
||||
self.assertEqual("-", fmt_rate(w.rate("vm")))
|
||||
|
||||
def test_samples_too_close_together_are_not_a_rate(self):
|
||||
"""Deux relevés à 200 ms d'intervalle donnent un chiffre absurde ;
|
||||
mieux vaut attendre le tour suivant."""
|
||||
w = WriteWindow()
|
||||
w.add("vm", 0, 1000.0)
|
||||
w.add("vm", 1 << 20, 1000.2)
|
||||
self.assertIsNone(w.rate("vm"))
|
||||
|
||||
def test_the_window_keeps_about_ten_seconds(self):
|
||||
"""Sinon la moyenne porterait sur toute la durée de l'installation, et
|
||||
ne montrerait plus ce qui se passe MAINTENANT."""
|
||||
w = WriteWindow(10.0)
|
||||
# Une minute de relevés toutes les deux secondes, débit constant.
|
||||
for i in range(31):
|
||||
w.add("vm", i * (2 << 20), 1000.0 + 2 * i)
|
||||
span = w._hist["vm"][-1][0] - w._hist["vm"][0][0]
|
||||
self.assertLessEqual(span, 12.0)
|
||||
self.assertGreaterEqual(span, 10.0)
|
||||
self.assertAlmostEqual(1 << 20, w.rate("vm"), delta=1024)
|
||||
|
||||
def test_a_restart_resets_the_window_instead_of_going_negative(self):
|
||||
"""Le compteur appartient au processus QEMU : un redémarrage du domaine
|
||||
le remet à zéro, et une installation redémarre la VM."""
|
||||
w = WriteWindow(10.0)
|
||||
w.add("vm", 500 << 20, 1000.0)
|
||||
w.add("vm", 501 << 20, 1002.0)
|
||||
w.add("vm", 1 << 20, 1004.0) # domaine redémarré
|
||||
self.assertIsNone(w.rate("vm"))
|
||||
w.add("vm", 3 << 20, 1014.0)
|
||||
self.assertIsNotNone(w.rate("vm"))
|
||||
self.assertGreater(w.rate("vm"), 0)
|
||||
|
||||
def test_vms_do_not_mix(self):
|
||||
w = WriteWindow(10.0)
|
||||
w.add("a", 0, 1000.0)
|
||||
w.add("b", 0, 1000.0)
|
||||
w.add("a", 10 << 20, 1010.0)
|
||||
w.add("b", 1 << 20, 1010.0)
|
||||
self.assertAlmostEqual(1 << 20, w.rate("a"), delta=1024)
|
||||
self.assertAlmostEqual(1 << 20, w.rate("b") * 10, delta=10240)
|
||||
|
||||
def test_the_window_is_the_ten_seconds_announced(self):
|
||||
"""La colonne dit « moyenne 10 s » : la constante doit le tenir."""
|
||||
self.assertEqual(10.0, WRITE_WINDOW)
|
||||
|
||||
|
||||
class TestFraicheurRam(unittest.TestCase):
|
||||
def test_a_stale_balloon_report_is_hidden(self):
|
||||
rec = parse_domstats(DOMSTATS)[MIGRATION]
|
||||
vieux = rec["ram_at"] + BALLOON_MAX_AGE + 5
|
||||
self.assertEqual("-", ram_pair(rec, vieux))
|
||||
|
||||
def test_a_fresh_report_is_shown(self):
|
||||
rec = parse_domstats(DOMSTATS)[MIGRATION]
|
||||
self.assertEqual("1.1G/12G", ram_pair(rec, rec["ram_at"] + 2))
|
||||
|
||||
def test_a_report_without_a_date_is_still_shown(self):
|
||||
"""libvirt joint toujours « last-update » quand le ballon a parlé ;
|
||||
sans elle on ne sait pas juger, et taire la valeur perdrait la seule
|
||||
information disponible."""
|
||||
rec = dict(parse_domstats(DOMSTATS)[MIGRATION], ram_at=0)
|
||||
self.assertNotEqual("-", ram_pair(rec, time.time()))
|
||||
|
||||
|
||||
class TestFormats(unittest.TestCase):
|
||||
def test_the_decimal_goes_away_above_ten_units(self):
|
||||
"""« 63G » plutôt que « 62.6G » : ces deux caractères décident si
|
||||
« Disque » reste visible."""
|
||||
self.assertEqual("63G", _fmt_tight(67263266816))
|
||||
self.assertEqual("1.1G", _fmt_tight(1130479616))
|
||||
self.assertEqual("12G", _fmt_tight(12536168448))
|
||||
|
||||
def test_a_pair_without_a_total_says_nothing(self):
|
||||
self.assertEqual("-", fmt_pair(1024, 0))
|
||||
self.assertEqual("-", fmt_pair(None, None))
|
||||
|
||||
def test_the_rate_carries_its_unit(self):
|
||||
self.assertEqual("1.0M/s", fmt_rate(1 << 20))
|
||||
self.assertEqual("-", fmt_rate(None))
|
||||
|
||||
def test_the_pairs_fit_the_columns(self):
|
||||
"""Une valeur tronquée dans un tableau est un piège : on lit « 1001M/1 »
|
||||
et on croit une VM à 1 Go."""
|
||||
for used, total, cle in (
|
||||
(1001 << 20, 128 << 30, "ram"),
|
||||
(12 << 30, 128 << 30, "ram"),
|
||||
(999 << 30, 1 << 40, "disk"),
|
||||
(67263266816, 69793218560, "disk"),
|
||||
):
|
||||
self.assertLessEqual(
|
||||
len(fmt_pair(used, total)), COL_DEFAULT_WIDTHS[cle]
|
||||
)
|
||||
for bps in (0, 1 << 10, 12.3 * (1 << 20), 2 << 30):
|
||||
self.assertLessEqual(len(fmt_rate(bps)), COL_DEFAULT_WIDTHS["wr"])
|
||||
|
||||
|
||||
class TestSection(unittest.TestCase):
|
||||
def test_it_names_the_three_measures(self):
|
||||
rec = parse_domstats(DOMSTATS)[MIGRATION]
|
||||
ligne = vm_stats_line(
|
||||
MIGRATION, rec, 1 << 20, rec["ram_at"] + 1, rec["wr_bytes"]
|
||||
)
|
||||
self.assertIn(MIGRATION, ligne)
|
||||
self.assertIn("1.0M/s", ligne)
|
||||
self.assertIn("1.1G/12G", ligne)
|
||||
self.assertIn("63G/65G", ligne)
|
||||
|
||||
def test_it_shows_percentages(self):
|
||||
"""« 63G/65G » ne dit pas d'un coup d'œil que le disque est à 96 %."""
|
||||
rec = parse_domstats(DOMSTATS)[MIGRATION]
|
||||
ligne = vm_stats_line(MIGRATION, rec, 0, rec["ram_at"] + 1)
|
||||
self.assertIn("(96%)", ligne)
|
||||
self.assertIn("(9%)", ligne)
|
||||
|
||||
def test_a_vm_without_stats_says_so(self):
|
||||
ligne = vm_stats_line("vm-x", None, None, time.time())
|
||||
self.assertIn("vm-x", ligne)
|
||||
self.assertNotIn("RAM", ligne)
|
||||
|
||||
|
||||
class TestColonnes(unittest.TestCase):
|
||||
def test_the_three_columns_exist(self):
|
||||
for cle in ("wr", "ram", "disk"):
|
||||
self.assertIn(cle, COL_DEFAULT_WIDTHS)
|
||||
|
||||
def test_the_row_fits_a_150_column_terminal(self):
|
||||
"""La table est bornée à 66 % de l'écran, et le journal prend le reste.
|
||||
Trois colonnes de plus, et « Disque » sortait du cadre : le total est
|
||||
donc surveillé, padding de DataTable compris (2 par colonne)."""
|
||||
besoin = sum(COL_DEFAULT_WIDTHS.values()) + 2 * len(COL_DEFAULT_WIDTHS)
|
||||
self.assertLessEqual(besoin, int(150 * 0.66))
|
||||
|
||||
|
||||
class TestEcranMonte(unittest.IsolatedAsyncioTestCase):
|
||||
"""Le suivi monté pour de vrai : les colonnes portent-elles les chiffres ?
|
||||
|
||||
Aucun appel à libvirt : « read_domstats » rend la sortie enregistrée
|
||||
ci-dessus. Un test qui lance sudo ne tournerait ni en CI ni sur un poste
|
||||
sans le parc.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
base = Path(self.tmp.name)
|
||||
for nom in (MIGRATION, "erplibre-ubuntu-2604"):
|
||||
(base / f"{nom}.log").write_text("===> install\nligne\n")
|
||||
manifeste = {
|
||||
"branch": "develop",
|
||||
"started": time.time() - 300,
|
||||
"vms": [
|
||||
{
|
||||
"name": nom,
|
||||
"ip": "127.0.0.9",
|
||||
"arch": "amd64",
|
||||
"log": str(base / f"{nom}.log"),
|
||||
"ssh": f"ssh erplibre@{nom}",
|
||||
"disk": str(base / f"{nom}.qcow2"),
|
||||
}
|
||||
for nom in (MIGRATION, "erplibre-ubuntu-2604")
|
||||
],
|
||||
}
|
||||
self.manifest = base / "session.json"
|
||||
self.manifest.write_text(json.dumps(manifeste))
|
||||
# Le relevé du ballon doit paraître FRAIS, sinon la colonne RAM se
|
||||
# taira — ce qui est le comportement voulu, mais pas ce qu'on teste.
|
||||
self.stats = DOMSTATS.replace(
|
||||
"balloon.last-update=1787392761",
|
||||
f"balloon.last-update={int(time.time())}",
|
||||
).replace(
|
||||
"balloon.last-update=1787392782",
|
||||
f"balloon.last-update={int(time.time())}",
|
||||
)
|
||||
|
||||
async def _monte(self, taille=(150, 24)):
|
||||
from textual.widgets import DataTable, Static
|
||||
|
||||
with mock.patch.object(
|
||||
mon, "read_domstats", lambda: self.stats
|
||||
), mock.patch.object(
|
||||
mon, "arm_balloon", lambda names: None
|
||||
), mock.patch.object(
|
||||
mon,
|
||||
"virsh_domstates",
|
||||
lambda: {MIGRATION: "running", "erplibre-ubuntu-2604": "running"},
|
||||
), mock.patch.object(
|
||||
mon, "_port_open", lambda ip, port=8069, timeout=0.5: False
|
||||
):
|
||||
app = mon.run_monitor(str(self.manifest), run_app=False)
|
||||
async with app.run_test(size=taille) as pilot:
|
||||
await pilot.pause()
|
||||
# Le tick du tableau tourne toutes les deux secondes : on
|
||||
# l'appelle plutôt que d'attendre, et on antidate un premier
|
||||
# relevé pour que la fenêtre ait de quoi conclure.
|
||||
app._wrate.add(MIGRATION, 100 << 20, time.time() - 10)
|
||||
await app._tick_table()
|
||||
await pilot.pause()
|
||||
table = app.query_one("#vms", DataTable)
|
||||
cellules = {
|
||||
cle: str(table.get_cell(MIGRATION, cle))
|
||||
for cle in ("wr", "ram", "disk")
|
||||
}
|
||||
section = str(app.query_one("#vmstats", Static).content)
|
||||
besoin = table.virtual_size.width
|
||||
visible = table.size.width
|
||||
return cellules, section, besoin, visible
|
||||
|
||||
async def test_the_columns_carry_the_numbers(self):
|
||||
cellules, _section, _b, _v = await self._monte()
|
||||
self.assertEqual("1.1G/12G", cellules["ram"])
|
||||
self.assertEqual("63G/65G", cellules["disk"])
|
||||
self.assertTrue(cellules["wr"].endswith("/s"), cellules["wr"])
|
||||
|
||||
async def test_the_selected_vm_has_its_statistics_section(self):
|
||||
_c, section, _b, _v = await self._monte()
|
||||
self.assertIn(MIGRATION, section)
|
||||
self.assertIn("1.1G/12G", section)
|
||||
self.assertIn("63G/65G", section)
|
||||
|
||||
async def test_nothing_scrolls_out_of_a_150_column_terminal(self):
|
||||
"""Le vrai garde-fou de la largeur : mesuré sur la table montée, pas
|
||||
calculé à la main."""
|
||||
_c, _s, besoin, visible = await self._monte((150, 24))
|
||||
self.assertGreaterEqual(visible, besoin)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=1)
|
||||
Loading…
Reference in a new issue