[ADD] qemu: statistics screen, with reset — and record failures too

The install history was only surfaced as a « ~5m avg (3) » suffix next to a
distro. Entry [12] now shows what that history actually contains: totals and
success rate, period covered, median/min/max and cumulated duration, then a
breakdown by distribution, version and architecture, plus the current VMs and
the disk they occupy. « [r] » erases the history after confirmation.

record_duration() was only called on success, so no success rate could ever be
computed. Failures are now recorded with ok=False. They are counted separately
and EXCLUDED from the averages and the ETA: how long a failed install ran says
nothing about how long a successful one takes. Entries written before the flag
existed have no « ok » key and are read as successes, which they were.

Aggregation lives in qemu_install_monitor.py as pure functions (stats_summary,
stats_by, all_runs, reset_stats), display in todo.py — the split the file
already follows.

Disk presets extended to 400G, 600G, 800G, 1T, 1.5T and 2T. The parser only
understood G, so « 1T » would have been rejected: sizes are now normalised
through _qemu_parse_disk (1 T = 1024 G, decimal comma accepted), since the rest
of the chain reasons in gigabytes.

Verified with a synthetic history of 9 runs including 2 failures: the rate,
the per-group failure counts and the reset all behave; a group with no success
shows « — » rather than a misleading « ~0s ».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-08-01 05:16:27 -04:00
parent 6d3a4c38cb
commit c1afa2f4d2
3 changed files with 352 additions and 45 deletions

View file

@ -260,7 +260,8 @@ def scan_log_errors(log_path: str) -> tuple[int, int]:
"""(nb_erreurs, nb_avertissements) dans un log d'installation, en
réutilisant la détection de la suite de tests ERPLibre : sous-chaîne
« error »/« warning » (insensible à la casse) moins les listes d'ignore.
Lit le fichier COMPLET (appelé une seule fois, à la complétion d'une VM)."""
Lit le fichier COMPLET (appelé une seule fois, à la complétion d'une VM).
"""
try:
text = Path(log_path).read_text(errors="replace")
except OSError:
@ -270,9 +271,7 @@ def scan_log_errors(log_path: str) -> tuple[int, int]:
low = line.lower()
if EXIT_MARKER in line:
continue
if "error" in low and not any(
ig in line for ig in _LST_IGNORE_ERROR
):
if "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR):
nerr += 1
if "warning" in low and not any(
ig in line for ig in _LST_IGNORE_WARNING
@ -311,7 +310,8 @@ def _read_tail(
`max_lines` lignes) et renvoie (texte, TAILLE TOTALE du fichier). Utilisé
au CHANGEMENT de VM : lire+réafficher le fichier ENTIER (offset 0) gelait
l'UI sur les gros logs (250 Ko / milliers de lignes). L'offset renvoyé =
taille totale -> le suivi incrémental (_tick_log) continue depuis la fin."""
taille totale -> le suivi incrémental (_tick_log) continue depuis la fin.
"""
try:
size = os.path.getsize(path)
with open(path, "rb") as fh:
@ -351,10 +351,14 @@ def load_stats() -> dict:
return {}
def record_duration(distro, version, arch, secs) -> None:
def record_duration(distro, version, arch, secs, ok=True) -> None:
"""Enregistre une install (distro + version + archi + durée + horodatage)
dans l'historique, pour l'ETA et les moyennes par archi/distro. Garde les
500 derniers runs."""
500 derniers runs.
`ok=False` conserve la trace d'un ÉCHEC : sans elle aucun taux de réussite
n'est calculable. Les échecs sont exclus des moyennes et de l'ETA (leur
durée ne dit rien du temps d'une install qui aboutit)."""
data = load_stats()
runs = data.setdefault("runs", [])
runs.append(
@ -364,6 +368,7 @@ def record_duration(distro, version, arch, secs) -> None:
"arch": arch or "?",
"seconds": int(secs),
"ts": int(time.time()),
"ok": bool(ok),
}
)
data["runs"] = runs[-500:]
@ -373,10 +378,82 @@ def record_duration(distro, version, arch, secs) -> None:
pass
def _runs(stats=None):
def reset_stats() -> int:
"""Vide l'historique. Renvoie le nombre de runs effacés."""
count = len(all_runs())
try:
_stats_path().write_text(json.dumps({"runs": []}))
except OSError:
pass
return count
def all_runs(stats=None):
"""Tous les runs, succès ET échecs."""
return (stats or load_stats()).get("runs", []) or []
def _runs(stats=None):
"""Runs RÉUSSIS seulement : base des moyennes et de l'ETA.
Les entrées écrites avant l'ajout du champ « ok » n'en ont pas ; elles
étaient forcément des succès (seuls ceux- étaient enregistrés).
"""
return [r for r in all_runs(stats) if r.get("ok", True)]
def stats_summary(stats=None):
"""Chiffres globaux de l'historique d'installation.
Renvoie un dict vide quand rien n'a encore été enregistré, pour que
l'appelant distingue « aucune donnée » de « zéro seconde »."""
runs = all_runs(stats)
if not runs:
return {}
ok = [r for r in runs if r.get("ok", True)]
secs = sorted(r["seconds"] for r in ok)
lst_ts = [r.get("ts", 0) for r in runs if r.get("ts")]
return {
"total": len(runs),
"ok": len(ok),
"failed": len(runs) - len(ok),
"first_ts": min(lst_ts) if lst_ts else 0,
"last_ts": max(lst_ts) if lst_ts else 0,
"median": secs[len(secs) // 2] if secs else 0,
"min": secs[0] if secs else 0,
"max": secs[-1] if secs else 0,
"total_secs": sum(secs),
}
def stats_by(field, stats=None):
"""Agrège par « distro », « arch » ou « distro version ».
Renvoie [(clé, nb_réussis, moyenne_secondes, nb_échecs)] trié du plus
utilisé au moins utilisé."""
dct = {}
for run in all_runs(stats):
if field == "version":
key = f"{run.get('distro', '?')} {run.get('version', '?')}"
else:
key = run.get(field, "?")
entry = dct.setdefault(key, {"secs": [], "failed": 0})
if run.get("ok", True):
entry["secs"].append(run["seconds"])
else:
entry["failed"] += 1
lst = [
(
key,
len(e["secs"]),
int(sum(e["secs"]) / len(e["secs"])) if e["secs"] else 0,
e["failed"],
)
for key, e in dct.items()
]
return sorted(lst, key=lambda row: (-(row[1] + row[3]), row[0]))
def eta_reference(stats, arch):
"""Durée d'install de RÉFÉRENCE (médiane) pour cette archi ; repli toutes
archis confondues. None si aucun historique."""
@ -650,7 +727,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
self._cells = {}
# Historique de durées (ETA) + dossier disque à surveiller.
self._stats = load_stats()
self._disk_dir = os.path.dirname(vm_disk_path(vms[0])) if vms else "/"
self._disk_dir = (
os.path.dirname(vm_disk_path(vms[0])) if vms else "/"
)
# État libvirt (running/paused/gone), rafraîchi à intervalle LENT.
self._domstate = {}
# Erreurs détectées dans le log à la complétion : {nom: (err, warn)}.
@ -698,7 +777,13 @@ def run_monitor(manifest_path: str, run_app: bool = True):
table.add_column("Disque", key="disk", width=8)
for i, vm in enumerate(vms, 1):
table.add_row(
str(i), vm["name"], "", "", "", "--:--", "-",
str(i),
vm["name"],
"",
"",
"",
"--:--",
"-",
key=vm["name"],
)
# max_lines borne la mémoire/rendu (un install verbeux × 30 VM).
@ -719,7 +804,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
def on_mount(self) -> None:
# Le NOMBRE de VM figure dans le titre ; le sous-titre suit la
# progression (terminées / total + durée globale).
self.title = f"ERPLibre — {t('install monitoring')} ({len(vms)} VM)"
self.title = (
f"ERPLibre — {t('install monitoring')} ({len(vms)} VM)"
)
self.sub_title = f"0/{len(vms)} {t('completed')}"
self._refresh_ssh()
self._load_selected_log(reset=True)
@ -808,9 +895,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
errors[name] = scan_log_errors(vm["log"])
# Odoo up ? On ne teste que celles pas encore confirmées up
# et non effacées (test TCP court sur :8069).
if name not in self._odoo_up and self._domstate.get(
name
) != "gone":
if (
name not in self._odoo_up
and self._domstate.get(name) != "gone"
):
if _port_open(vm.get("ip"), 8069):
odoo[name] = True
return disks, status, self._collect_tele(), errors, odoo
@ -836,7 +924,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
self._set_cell(table, name, "disk", disks.get(name, "-"))
# Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ».
self._set_cell(
table, name, "odoo",
table,
name,
"odoo",
"🟢" if name in self._odoo_up else "",
)
if name in self._final:
@ -855,14 +945,16 @@ def run_monitor(manifest_path: str, run_app: bool = True):
if state in ("done", "failed"):
elapsed = now - started
self._final[name] = (state, code, elapsed)
if state == "done":
record_duration(
vm.get("distro"),
vm.get("version"),
vm.get("arch"),
elapsed,
)
self._stats = load_stats()
# Les échecs sont enregistrés eux aussi (ok=False) :
# sans eux, aucun taux de réussite n'est calculable.
record_duration(
vm.get("distro"),
vm.get("version"),
vm.get("arch"),
elapsed,
ok=state == "done",
)
self._stats = load_stats()
lbl = "" if state == "done" else f"❌ ({code})"
self._set_cell(table, name, "state", lbl)
self._set_cell(
@ -871,7 +963,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# Colonne ⚠ (à gauche d'État) : erreurs détectées dans
# le log, y compris pour un « succès ».
self._set_cell(
table, name, "err",
table,
name,
"err",
self._err_label(self._errcount.get(name)),
)
elif ds == "paused":
@ -940,9 +1034,14 @@ def run_monitor(manifest_path: str, run_app: bool = True):
else:
running += 1
return {
"total": len(vms), "done": done, "fail": fail,
"deleted": deleted, "running": running, "paused": paused,
"err_vms": err_vms, "warn_vms": warn_vms,
"total": len(vms),
"done": done,
"fail": fail,
"deleted": deleted,
"running": running,
"paused": paused,
"err_vms": err_vms,
"warn_vms": warn_vms,
}
def _update_stats(self):
@ -1082,9 +1181,11 @@ def run_monitor(manifest_path: str, run_app: bool = True):
for i, b in enumerate(available, 1):
print(f" [{i}] {b}{' *' if i == 1 else ''}")
print(" [i] Installer un autre navigateur")
sel = input(
f"Choix (numéro, vide = {available[0]}) : "
).strip().lower()
sel = (
input(f"Choix (numéro, vide = {available[0]}) : ")
.strip()
.lower()
)
if sel == "i":
return self._install_cli_browser()
if not sel:
@ -1123,9 +1224,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
return None
printable = " ".join(cmd)
print(f"Commande : {printable}")
ans = input(
"Installer maintenant ? (o/N) : "
).strip().lower()
ans = input("Installer maintenant ? (o/N) : ").strip().lower()
if ans not in ("o", "oui", "y", "yes"):
return None
rc = os.system(printable)

View file

@ -1085,6 +1085,7 @@ class TODO:
"Reopen install monitoring (last run / history)"
)
},
{"prompt_description": t("Statistics (installs, durations, VMs)")},
{"section": t("Catalog")},
{"prompt_description": t("List available images and specs")},
]
@ -1121,6 +1122,8 @@ class TODO:
elif status == "11":
self._qemu_reopen_monitor()
elif status == "12":
self._qemu_stats()
elif status == "13":
self._qemu_list_images()
else:
cmd_no_found = True
@ -1137,6 +1140,134 @@ class TODO:
if cmd_no_found:
print(t("Command not found !"))
def _qemu_stats(self):
"""Statistiques d'utilisation de QEMU, et remise à zéro.
Tout vient de l'historique tenu par le moniteur d'installation
(.venv.erplibre/qemu_install_stats.json) et de l'état libvirt courant.
"""
try:
from script.todo import qemu_install_monitor as mon
except ImportError:
print(t("Install textual for the dashboard (pip)."))
return
while True:
summary = mon.stats_summary()
print(f"\n📊 {t('QEMU statistics')}")
if not summary:
print(f" {t('No installation recorded yet.')}")
else:
rate = 100 * summary["ok"] // max(summary["total"], 1)
print(f"\n── {t('Installations')} ──")
print(
f" {t('Total'):<18}: {summary['total']}"
f" ({summary['ok']} {t('succeeded')},"
f" {summary['failed']} {t('failed')}{rate} %)"
)
if summary["first_ts"]:
days = max(
1,
(summary["last_ts"] - summary["first_ts"]) // 86400,
)
print(
f" {t('Period'):<18}:"
f" {self._qemu_stamp(summary['first_ts'])}"
f"{self._qemu_stamp(summary['last_ts'])}"
f" ({days} {t('days')})"
)
print(
f" {t('Median duration'):<18}:"
f" {mon._fmt_secs(summary['median'])}"
f" ({t('min')} {mon._fmt_secs(summary['min'])} ·"
f" {t('max')} {mon._fmt_secs(summary['max'])})"
)
print(
f" {t('Cumulated time'):<18}:"
f" {mon._fmt_secs(summary['total_secs'])}"
)
for field, title in (
("distro", t("By distribution")),
("version", t("By version")),
("arch", t("By architecture")),
):
rows = mon.stats_by(field)
if not rows:
continue
print(f"\n── {title} ──")
for key, count, avg, failed in rows[:8]:
# Un groupe sans aucun succès n'a pas de moyenne : « — »
# plutôt qu'un « ~0s » trompeur.
moy = f"~{mon._fmt_secs(avg)}" if count else ""
fail = (
f"{failed} {self._plural(t('failure'), failed)}"
if failed
else ""
)
print(f" {key:<22} {count:>3} × {moy:<8}{fail}")
self._qemu_stats_vms(mon)
print(f"\n [r] {t('Reset the statistics')}")
print(f" [0] {t('Back')}")
answer = input(f"💬 {t('Your choice')} : ").strip().lower()
if answer in ("", "0"):
return
if answer == "r":
if not summary:
print(f" {t('Nothing to reset.')}")
continue
confirm = input(
f" {t('Erase')} {summary['total']}"
f" {t('recorded runs')}? (y/N): "
).strip()
if self._is_yes(confirm):
count = mon.reset_stats()
print(f"{count} {t('runs erased')}.")
else:
print(f" {t('Cancelled.')}")
@staticmethod
def _qemu_stamp(ts):
"""Horodatage court « 2026-08-01 »."""
try:
return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
except (OSError, OverflowError, ValueError):
return "?"
def _qemu_stats_vms(self, mon):
"""Machines virtuelles actuelles : nombre, états, place disque."""
try:
states = mon.virsh_domstates()
except Exception:
return
if not states:
return
running = sum(1 for s in states.values() if s == "running")
total_bytes = 0
counted = 0
for name in states:
try:
# vm_disk_path attend un dict ; le chemin par défaut de libvirt
# se déduit du seul nom.
size = mon.disk_actual_size(mon.vm_disk_path({"name": name}))
except Exception:
size = None
if size:
total_bytes += size
counted += 1
print(f"\n── {t('Virtual machines')} ──")
print(
f" {t('Defined'):<18}: {len(states)}"
f" ({running} {t('running')},"
f" {len(states) - running} {t('stopped')})"
)
if counted:
print(
f" {t('Disk used'):<18}:"
f" {mon._fmt_size(total_bytes)}"
f" ({counted} {self._plural(t('image'), counted)})"
)
def _qemu_download_image(self):
script_path = self._qemu_script_path()
distro = self._qemu_prompt_distro()
@ -3421,7 +3552,20 @@ class TODO:
# Suggestions proposées aux invites de taille. Les lettres démarrent à « a »
# pour ne JAMAIS entrer en conflit avec une valeur tapée directement : toute
# saisie commençant par un chiffre est lue comme la valeur elle-même.
_QEMU_DISK_PRESETS = ("20G", "40G", "60G", "80G", "120G", "200G")
_QEMU_DISK_PRESETS = (
"20G",
"40G",
"60G",
"80G",
"120G",
"200G",
"400G",
"600G",
"800G",
"1T",
"1.5T",
"2T",
)
# Jusqu'à 256 Go : les hôtes de virtualisation récents dépassent largement
# 32 Go, et l'invite est en Mo — l'équivalent en Go est donc affiché.
_QEMU_RAM_PRESETS = (
@ -3436,6 +3580,31 @@ class TODO:
262144,
)
@staticmethod
def _plural(word, count):
"""Accord simple : « échec » / « échecs ». Vaut pour fr et en."""
return word if abs(count) <= 1 else f"{word}s"
@staticmethod
def _qemu_parse_disk(value):
"""Normalise une taille de disque en « <n>G », ou None si invalide.
Accepte « 60 », « 60G », « 1T », « 1,5T ». Le suffixe T est converti
(1 T = 1024 G) : tout le reste de la chaîne nom de fichier qcow2,
argument --disk-size raisonne en gigaoctets.
"""
txt = value.strip().upper().replace(",", ".")
factor = 1
if txt.endswith("T"):
factor, txt = 1024, txt[:-1]
elif txt.endswith("G"):
txt = txt[:-1]
try:
gigs = int(float(txt) * factor)
except ValueError:
return None
return f"{gigs}G" if gigs > 0 else None
@staticmethod
def _qemu_ram_label(mb):
"""« 65536 (64G) » : l'invite est en Mo, on raisonne en Go."""
@ -3542,19 +3711,16 @@ class TODO:
).strip()
if new:
names[i] = new
dk = (
self._qemu_ask_value(
t("New disk size in G, blank = keep"),
sel[i][3],
self._QEMU_DISK_PRESETS,
)
.upper()
.rstrip("G")
dk = self._qemu_ask_value(
t("New disk size in G, blank = keep"),
sel[i][3],
self._QEMU_DISK_PRESETS,
)
if dk:
try:
sel[i][3] = f"{int(float(dk))}G"
except ValueError:
parsed = self._qemu_parse_disk(dk)
if parsed:
sel[i][3] = parsed
else:
print(f"{t('Invalid size.')}")
rm = self._qemu_ask_value(
t("New RAM in MB, blank = keep"),

View file

@ -2457,6 +2457,48 @@ TRANSLATIONS = {
"fr": "QEMU - Exemple dry-run (demo-vm, Ubuntu 24.04)",
"en": "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)",
},
# QEMU - statistics screen
"Statistics (installs, durations, VMs)": {
"fr": "📊 Statistiques (installations, durées, VM)",
"en": "📊 Statistics (installs, durations, VMs)",
},
"QEMU statistics": {"fr": "Statistiques QEMU", "en": "QEMU statistics"},
"No installation recorded yet.": {
"fr": "Aucune installation enregistrée pour l'instant.",
"en": "No installation recorded yet.",
},
"Installations": {"fr": "Installations", "en": "Installations"},
"Total": {"fr": "Total", "en": "Total"},
"succeeded": {"fr": "réussies", "en": "succeeded"},
"failed": {"fr": "échouées", "en": "failed"},
"Period": {"fr": "Période", "en": "Period"},
"days": {"fr": "jours", "en": "days"},
"Median duration": {"fr": "Durée médiane", "en": "Median duration"},
"min": {"fr": "min", "en": "min"},
"max": {"fr": "max", "en": "max"},
"Cumulated time": {"fr": "Temps cumulé", "en": "Cumulated time"},
"By distribution": {"fr": "Par distribution", "en": "By distribution"},
"By version": {"fr": "Par version", "en": "By version"},
"By architecture": {"fr": "Par architecture", "en": "By architecture"},
"Virtual machines": {
"fr": "Machines virtuelles",
"en": "Virtual machines",
},
"Defined": {"fr": "Définies", "en": "Defined"},
"running": {"fr": "en cours", "en": "running"},
"stopped": {"fr": "arrêtées", "en": "stopped"},
"Disk used": {"fr": "Disque utilisé", "en": "Disk used"},
"image": {"fr": "image", "en": "image"},
"failure": {"fr": "échec", "en": "failure"},
"Reset the statistics": {
"fr": "Réinitialiser les statistiques",
"en": "Reset the statistics",
},
"Nothing to reset.": {"fr": "Rien à effacer.", "en": "Nothing to reset."},
"Erase": {"fr": "Effacer", "en": "Erase"},
"recorded runs": {"fr": "runs enregistrés", "en": "recorded runs"},
"runs erased": {"fr": "runs effacés", "en": "runs erased"},
"Cancelled.": {"fr": "Annulé.", "en": "Cancelled."},
# Database migration - resume menu
"Migration in progress": {
"fr": "Migration en cours",