diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 5fe2e4c..f0e16fa 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -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-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): """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) diff --git a/script/todo/todo.py b/script/todo/todo.py index 9fbfc74..47cbd15 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -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 « 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"), diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 314a4f1..4f72ef1 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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",