diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 7b68617..d25ee20 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -261,6 +261,46 @@ def read_status(log_path: str) -> tuple[str, int | None]: return "running", None +def run_progress(run: dict) -> dict: + """Avancement d'un run : combien de VM tournent encore, et depuis quand + plus rien n'a été écrit. `idle` sert à distinguer une install vivante d'un + run laissé pour mort — le marqueur de sortie manque dans les deux cas.""" + active = final = 0 + latest = 0.0 + for vm in run.get("vms", []): + log = vm.get("log") or "" + state, _code = read_status(log) + if state in ("done", "failed"): + final += 1 + else: + active += 1 + try: + latest = max(latest, os.path.getmtime(log)) + except OSError: + pass + return { + "active": active, + "final": final, + "total": active + final, + "idle": (time.time() - latest) if latest else None, + } + + +def active_run(): + """Le run le PLUS RÉCENT s'il a encore des VM en cours, sinon None. + + Les installs tournent détachées (`setsid -f`) : fermer le terminal laisse + le travail se poursuivre mais fait perdre la seule vue dessus. On ne + regarde que le dernier run — un run ancien resté sans marqueur de sortie + signalerait éternellement une install fantôme.""" + runs = list_install_runs() + if not runs: + return None + run = dict(runs[0]) + run.update(run_progress(run)) + return run if run["total"] and run["active"] else None + + # Listes d'ignore reprises de script/test/run_parallel_test.py (erreurs/ # avertissements connus et bénins) : on réutilise la MÊME logique de détection # que la suite de tests ERPLibre pour analyser les logs d'installation. diff --git a/script/todo/todo.py b/script/todo/todo.py index f55798b..c4e7d7f 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1243,6 +1243,11 @@ class TODO: ) }, {"prompt_description": t("Download a cloud image only")}, + { + "prompt_description": t( + "Reopen install monitoring (last run / history)" + ) + }, {"section": t("Manage")}, {"prompt_description": t("List VMs (virsh list --all)")}, {"prompt_description": t("Show a VM IP address")}, @@ -1255,11 +1260,6 @@ class TODO: "Test a VM (open Odoo in a CLI browser)" ) }, - { - "prompt_description": t( - "Reopen install monitoring (last run / history)" - ) - }, {"prompt_description": t("Statistics (installs, durations, VMs)")}, { "prompt_description": t( @@ -1286,21 +1286,21 @@ class TODO: elif status == "3": self._qemu_download_image() elif status == "4": - self._qemu_list_vms(ask_advanced=True) - elif status == "5": - self._qemu_show_ip() - elif status == "6": - self._qemu_console() - elif status == "7": - self._qemu_resize_disk() - elif status == "8": - self._qemu_delete_vm() - elif status == "9": - self._qemu_cleanup() - elif status == "10": - self._qemu_test_vm() - elif status == "11": self._qemu_reopen_monitor() + elif status == "5": + self._qemu_list_vms(ask_advanced=True) + elif status == "6": + self._qemu_show_ip() + elif status == "7": + self._qemu_console() + elif status == "8": + self._qemu_resize_disk() + elif status == "9": + self._qemu_delete_vm() + elif status == "10": + self._qemu_cleanup() + elif status == "11": + self._qemu_test_vm() elif status == "12": self._qemu_stats() elif status == "13": @@ -2367,16 +2367,62 @@ class TODO: except ValueError: print(t("Invalid selection.")) return + self._qemu_open_monitor(run["manifest"]) + + def _qemu_open_monitor(self, manifest): + """Ouvre le dashboard sur un manifeste, en installant Textual au + besoin. Deux entrées y mènent — l'historique et la reprise proposée + avant un déploiement — d'où une seule définition.""" + from script.todo import qemu_install_monitor as mon + try: - mon.run_monitor(run["manifest"]) + mon.run_monitor(manifest) except ImportError: from script.todo import textual_setup if textual_setup.ensure(): - mon.run_monitor(run["manifest"]) + mon.run_monitor(manifest) except Exception as exc: print(f"{t('Command failed: ')}{exc}") + def _qemu_active_install(self): + """Propose de reprendre le suivi quand une installation tourne encore. + + Les installs partent détachées (`setsid -f`) : fermer le terminal ne + les arrête pas, mais faisait perdre la seule vue dessus, et la seule + issue connue était de tout effacer pour recommencer. + + True si l'on ne doit PAS enchaîner sur un déploiement.""" + try: + from script.todo import qemu_install_monitor as mon + + run = mon.active_run() + except Exception: + return False + if not run: + return False + names = ", ".join(v.get("name", "?") for v in run["vms"]) + print( + f"\n⏳ {t('An install is still running:')} {run['label']} — " + f"{run['active']}/{run['total']} {t('VM(s) in progress')}" + ) + print(f" {names}") + if run.get("idle") is not None: + # Un silence prolongé trahit un run mort dont le marqueur de sortie + # ne viendra jamais : l'utilisateur tranche mieux que nous. + print( + f" {t('Last activity:')} {mon._fmt_secs(int(run['idle']))}" + ) + print(f"\n [1] {t('Reopen that monitoring')} *") + print(f" [2] {t('Deploy anyway (new run)')}") + print(f" [0] {t('Back')}") + sel = input(t("Choice (number, blank = reopen): ")).strip() + if sel == "2": + return False + if sel != "0": + self._qemu_open_monitor(run["manifest"]) + return True + def _qemu_choose_cli_browser(self): """Offre la LISTE des navigateurs CLI installés, plus une option pour en INSTALLER un autre, et renvoie celui choisi, sinon None.""" @@ -5170,6 +5216,9 @@ class TODO: last = self._qemu_last_run_line() if last: print(last) + # Un aperçu ne crée rien : il n'a pas à interroger sur un run en cours. + if not dry_run and self._qemu_active_install(): + return self._qemu_check_libvirt_group() self._qemu_check_kvm() diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 41dd0ab..498841c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1973,6 +1973,30 @@ TRANSLATIONS = { "fr": "RAM (Mo)", "en": "RAM (MB)", }, + "An install is still running:": { + "fr": "Une installation est encore en cours :", + "en": "An install is still running:", + }, + "VM(s) in progress": { + "fr": "VM en cours", + "en": "VM(s) in progress", + }, + "Last activity:": { + "fr": "Dernière activité il y a :", + "en": "Last activity:", + }, + "Reopen that monitoring": { + "fr": "Rouvrir ce suivi", + "en": "Reopen that monitoring", + }, + "Deploy anyway (new run)": { + "fr": "Déployer quand même (nouveau run)", + "en": "Deploy anyway (new run)", + }, + "Choice (number, blank = reopen): ": { + "fr": "Choix (numéro, vide = rouvrir) : ", + "en": "Choice (number, blank = reopen): ", + }, "free value…": { "fr": "valeur libre…", "en": "free value…",