[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:
parent
6d3a4c38cb
commit
c1afa2f4d2
3 changed files with 352 additions and 45 deletions
|
|
@ -260,7 +260,8 @@ def scan_log_errors(log_path: str) -> tuple[int, int]:
|
||||||
"""(nb_erreurs, nb_avertissements) dans un log d'installation, en
|
"""(nb_erreurs, nb_avertissements) dans un log d'installation, en
|
||||||
réutilisant la détection de la suite de tests ERPLibre : sous-chaîne
|
réutilisant la détection de la suite de tests ERPLibre : sous-chaîne
|
||||||
« error »/« warning » (insensible à la casse) moins les listes d'ignore.
|
« 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:
|
try:
|
||||||
text = Path(log_path).read_text(errors="replace")
|
text = Path(log_path).read_text(errors="replace")
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|
@ -270,9 +271,7 @@ def scan_log_errors(log_path: str) -> tuple[int, int]:
|
||||||
low = line.lower()
|
low = line.lower()
|
||||||
if EXIT_MARKER in line:
|
if EXIT_MARKER in line:
|
||||||
continue
|
continue
|
||||||
if "error" in low and not any(
|
if "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR):
|
||||||
ig in line for ig in _LST_IGNORE_ERROR
|
|
||||||
):
|
|
||||||
nerr += 1
|
nerr += 1
|
||||||
if "warning" in low and not any(
|
if "warning" in low and not any(
|
||||||
ig in line for ig in _LST_IGNORE_WARNING
|
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é
|
`max_lines` lignes) et renvoie (texte, TAILLE TOTALE du fichier). Utilisé
|
||||||
au CHANGEMENT de VM : lire+réafficher le fichier ENTIER (offset 0) gelait
|
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é =
|
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:
|
try:
|
||||||
size = os.path.getsize(path)
|
size = os.path.getsize(path)
|
||||||
with open(path, "rb") as fh:
|
with open(path, "rb") as fh:
|
||||||
|
|
@ -351,10 +351,14 @@ def load_stats() -> dict:
|
||||||
return {}
|
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)
|
"""Enregistre une install (distro + version + archi + durée + horodatage)
|
||||||
dans l'historique, pour l'ETA et les moyennes par archi/distro. Garde les
|
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()
|
data = load_stats()
|
||||||
runs = data.setdefault("runs", [])
|
runs = data.setdefault("runs", [])
|
||||||
runs.append(
|
runs.append(
|
||||||
|
|
@ -364,6 +368,7 @@ def record_duration(distro, version, arch, secs) -> None:
|
||||||
"arch": arch or "?",
|
"arch": arch or "?",
|
||||||
"seconds": int(secs),
|
"seconds": int(secs),
|
||||||
"ts": int(time.time()),
|
"ts": int(time.time()),
|
||||||
|
"ok": bool(ok),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
data["runs"] = runs[-500:]
|
data["runs"] = runs[-500:]
|
||||||
|
|
@ -373,10 +378,82 @@ def record_duration(distro, version, arch, secs) -> None:
|
||||||
pass
|
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 []
|
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-là é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):
|
def eta_reference(stats, arch):
|
||||||
"""Durée d'install de RÉFÉRENCE (médiane) pour cette archi ; repli toutes
|
"""Durée d'install de RÉFÉRENCE (médiane) pour cette archi ; repli toutes
|
||||||
archis confondues. None si aucun historique."""
|
archis confondues. None si aucun historique."""
|
||||||
|
|
@ -650,7 +727,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
self._cells = {}
|
self._cells = {}
|
||||||
# Historique de durées (ETA) + dossier disque à surveiller.
|
# Historique de durées (ETA) + dossier disque à surveiller.
|
||||||
self._stats = load_stats()
|
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.
|
# État libvirt (running/paused/gone), rafraîchi à intervalle LENT.
|
||||||
self._domstate = {}
|
self._domstate = {}
|
||||||
# Erreurs détectées dans le log à la complétion : {nom: (err, warn)}.
|
# 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)
|
table.add_column("Disque", key="disk", width=8)
|
||||||
for i, vm in enumerate(vms, 1):
|
for i, vm in enumerate(vms, 1):
|
||||||
table.add_row(
|
table.add_row(
|
||||||
str(i), vm["name"], "", "⏳", "—", "--:--", "-",
|
str(i),
|
||||||
|
vm["name"],
|
||||||
|
"",
|
||||||
|
"⏳",
|
||||||
|
"—",
|
||||||
|
"--:--",
|
||||||
|
"-",
|
||||||
key=vm["name"],
|
key=vm["name"],
|
||||||
)
|
)
|
||||||
# max_lines borne la mémoire/rendu (un install verbeux × 30 VM).
|
# 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:
|
def on_mount(self) -> None:
|
||||||
# Le NOMBRE de VM figure dans le titre ; le sous-titre suit la
|
# Le NOMBRE de VM figure dans le titre ; le sous-titre suit la
|
||||||
# progression (terminées / total + durée globale).
|
# 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.sub_title = f"0/{len(vms)} {t('completed')}"
|
||||||
self._refresh_ssh()
|
self._refresh_ssh()
|
||||||
self._load_selected_log(reset=True)
|
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"])
|
errors[name] = scan_log_errors(vm["log"])
|
||||||
# Odoo up ? On ne teste que celles pas encore confirmées up
|
# Odoo up ? On ne teste que celles pas encore confirmées up
|
||||||
# et non effacées (test TCP court sur :8069).
|
# et non effacées (test TCP court sur :8069).
|
||||||
if name not in self._odoo_up and self._domstate.get(
|
if (
|
||||||
name
|
name not in self._odoo_up
|
||||||
) != "gone":
|
and self._domstate.get(name) != "gone"
|
||||||
|
):
|
||||||
if _port_open(vm.get("ip"), 8069):
|
if _port_open(vm.get("ip"), 8069):
|
||||||
odoo[name] = True
|
odoo[name] = True
|
||||||
return disks, status, self._collect_tele(), errors, odoo
|
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, "-"))
|
self._set_cell(table, name, "disk", disks.get(name, "-"))
|
||||||
# Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ».
|
# Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ».
|
||||||
self._set_cell(
|
self._set_cell(
|
||||||
table, name, "odoo",
|
table,
|
||||||
|
name,
|
||||||
|
"odoo",
|
||||||
"🟢" if name in self._odoo_up else "—",
|
"🟢" if name in self._odoo_up else "—",
|
||||||
)
|
)
|
||||||
if name in self._final:
|
if name in self._final:
|
||||||
|
|
@ -855,14 +945,16 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
if state in ("done", "failed"):
|
if state in ("done", "failed"):
|
||||||
elapsed = now - started
|
elapsed = now - started
|
||||||
self._final[name] = (state, code, elapsed)
|
self._final[name] = (state, code, elapsed)
|
||||||
if state == "done":
|
# Les échecs sont enregistrés eux aussi (ok=False) :
|
||||||
record_duration(
|
# sans eux, aucun taux de réussite n'est calculable.
|
||||||
vm.get("distro"),
|
record_duration(
|
||||||
vm.get("version"),
|
vm.get("distro"),
|
||||||
vm.get("arch"),
|
vm.get("version"),
|
||||||
elapsed,
|
vm.get("arch"),
|
||||||
)
|
elapsed,
|
||||||
self._stats = load_stats()
|
ok=state == "done",
|
||||||
|
)
|
||||||
|
self._stats = load_stats()
|
||||||
lbl = "✅" if state == "done" else f"❌ ({code})"
|
lbl = "✅" if state == "done" else f"❌ ({code})"
|
||||||
self._set_cell(table, name, "state", lbl)
|
self._set_cell(table, name, "state", lbl)
|
||||||
self._set_cell(
|
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
|
# Colonne ⚠ (à gauche d'État) : erreurs détectées dans
|
||||||
# le log, y compris pour un « succès ».
|
# le log, y compris pour un « succès ».
|
||||||
self._set_cell(
|
self._set_cell(
|
||||||
table, name, "err",
|
table,
|
||||||
|
name,
|
||||||
|
"err",
|
||||||
self._err_label(self._errcount.get(name)),
|
self._err_label(self._errcount.get(name)),
|
||||||
)
|
)
|
||||||
elif ds == "paused":
|
elif ds == "paused":
|
||||||
|
|
@ -940,9 +1034,14 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
else:
|
else:
|
||||||
running += 1
|
running += 1
|
||||||
return {
|
return {
|
||||||
"total": len(vms), "done": done, "fail": fail,
|
"total": len(vms),
|
||||||
"deleted": deleted, "running": running, "paused": paused,
|
"done": done,
|
||||||
"err_vms": err_vms, "warn_vms": warn_vms,
|
"fail": fail,
|
||||||
|
"deleted": deleted,
|
||||||
|
"running": running,
|
||||||
|
"paused": paused,
|
||||||
|
"err_vms": err_vms,
|
||||||
|
"warn_vms": warn_vms,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _update_stats(self):
|
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):
|
for i, b in enumerate(available, 1):
|
||||||
print(f" [{i}] {b}{' *' if i == 1 else ''}")
|
print(f" [{i}] {b}{' *' if i == 1 else ''}")
|
||||||
print(" [i] Installer un autre navigateur")
|
print(" [i] Installer un autre navigateur")
|
||||||
sel = input(
|
sel = (
|
||||||
f"Choix (numéro, vide = {available[0]}) : "
|
input(f"Choix (numéro, vide = {available[0]}) : ")
|
||||||
).strip().lower()
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
if sel == "i":
|
if sel == "i":
|
||||||
return self._install_cli_browser()
|
return self._install_cli_browser()
|
||||||
if not sel:
|
if not sel:
|
||||||
|
|
@ -1123,9 +1224,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
return None
|
return None
|
||||||
printable = " ".join(cmd)
|
printable = " ".join(cmd)
|
||||||
print(f"Commande : {printable}")
|
print(f"Commande : {printable}")
|
||||||
ans = input(
|
ans = input("Installer maintenant ? (o/N) : ").strip().lower()
|
||||||
"Installer maintenant ? (o/N) : "
|
|
||||||
).strip().lower()
|
|
||||||
if ans not in ("o", "oui", "y", "yes"):
|
if ans not in ("o", "oui", "y", "yes"):
|
||||||
return None
|
return None
|
||||||
rc = os.system(printable)
|
rc = os.system(printable)
|
||||||
|
|
|
||||||
|
|
@ -1085,6 +1085,7 @@ class TODO:
|
||||||
"Reopen install monitoring (last run / history)"
|
"Reopen install monitoring (last run / history)"
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{"prompt_description": t("Statistics (installs, durations, VMs)")},
|
||||||
{"section": t("Catalog")},
|
{"section": t("Catalog")},
|
||||||
{"prompt_description": t("List available images and specs")},
|
{"prompt_description": t("List available images and specs")},
|
||||||
]
|
]
|
||||||
|
|
@ -1121,6 +1122,8 @@ class TODO:
|
||||||
elif status == "11":
|
elif status == "11":
|
||||||
self._qemu_reopen_monitor()
|
self._qemu_reopen_monitor()
|
||||||
elif status == "12":
|
elif status == "12":
|
||||||
|
self._qemu_stats()
|
||||||
|
elif status == "13":
|
||||||
self._qemu_list_images()
|
self._qemu_list_images()
|
||||||
else:
|
else:
|
||||||
cmd_no_found = True
|
cmd_no_found = True
|
||||||
|
|
@ -1137,6 +1140,134 @@ class TODO:
|
||||||
if cmd_no_found:
|
if cmd_no_found:
|
||||||
print(t("Command not 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):
|
def _qemu_download_image(self):
|
||||||
script_path = self._qemu_script_path()
|
script_path = self._qemu_script_path()
|
||||||
distro = self._qemu_prompt_distro()
|
distro = self._qemu_prompt_distro()
|
||||||
|
|
@ -3421,7 +3552,20 @@ class TODO:
|
||||||
# Suggestions proposées aux invites de taille. Les lettres démarrent à « a »
|
# 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
|
# 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.
|
# 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
|
# 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é.
|
# 32 Go, et l'invite est en Mo — l'équivalent en Go est donc affiché.
|
||||||
_QEMU_RAM_PRESETS = (
|
_QEMU_RAM_PRESETS = (
|
||||||
|
|
@ -3436,6 +3580,31 @@ class TODO:
|
||||||
262144,
|
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
|
@staticmethod
|
||||||
def _qemu_ram_label(mb):
|
def _qemu_ram_label(mb):
|
||||||
"""« 65536 (64G) » : l'invite est en Mo, on raisonne en Go."""
|
"""« 65536 (64G) » : l'invite est en Mo, on raisonne en Go."""
|
||||||
|
|
@ -3542,19 +3711,16 @@ class TODO:
|
||||||
).strip()
|
).strip()
|
||||||
if new:
|
if new:
|
||||||
names[i] = new
|
names[i] = new
|
||||||
dk = (
|
dk = self._qemu_ask_value(
|
||||||
self._qemu_ask_value(
|
t("New disk size in G, blank = keep"),
|
||||||
t("New disk size in G, blank = keep"),
|
sel[i][3],
|
||||||
sel[i][3],
|
self._QEMU_DISK_PRESETS,
|
||||||
self._QEMU_DISK_PRESETS,
|
|
||||||
)
|
|
||||||
.upper()
|
|
||||||
.rstrip("G")
|
|
||||||
)
|
)
|
||||||
if dk:
|
if dk:
|
||||||
try:
|
parsed = self._qemu_parse_disk(dk)
|
||||||
sel[i][3] = f"{int(float(dk))}G"
|
if parsed:
|
||||||
except ValueError:
|
sel[i][3] = parsed
|
||||||
|
else:
|
||||||
print(f" ⚠ {t('Invalid size.')}")
|
print(f" ⚠ {t('Invalid size.')}")
|
||||||
rm = self._qemu_ask_value(
|
rm = self._qemu_ask_value(
|
||||||
t("New RAM in MB, blank = keep"),
|
t("New RAM in MB, blank = keep"),
|
||||||
|
|
|
||||||
|
|
@ -2457,6 +2457,48 @@ TRANSLATIONS = {
|
||||||
"fr": "QEMU - Exemple dry-run (demo-vm, Ubuntu 24.04)",
|
"fr": "QEMU - Exemple dry-run (demo-vm, Ubuntu 24.04)",
|
||||||
"en": "QEMU - Sample 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
|
# Database migration - resume menu
|
||||||
"Migration in progress": {
|
"Migration in progress": {
|
||||||
"fr": "Migration en cours",
|
"fr": "Migration en cours",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue