[ADD] tui qemu: resume an installation already running

Installs run detached (setsid -f): closing the terminal does not stop
them, but it lost the only view onto them. With no way to resume, the
only way out was deleting the VMs and starting over.

Picking "Deploy" now looks at the latest run: if VMs there still lack an
exit marker, it offers to reopen its monitoring instead of starting
another. Time since the last write is shown, a dead run being otherwise
indistinguishable from a live one.

Only the latest run is examined: an old one left without a marker would
flag a phantom install forever. Dry-run creates nothing, so it does not
ask. "Reopen monitoring" moves to the Deployment section, where one
looks for it.

--- FR ---

Les installs partent détachées (setsid -f) : fermer le terminal ne les
arrête pas, mais faisait perdre la seule vue dessus. Sans moyen de
reprendre, la seule issue était d'effacer les VM et de recommencer.

Choisir « Déployer » regarde donc le dernier run : s'il lui reste des VM
sans marqueur de sortie, il propose de rouvrir son suivi plutôt que d'en
lancer un autre. Le silence depuis la dernière écriture est affiché, un
run mort n'étant pas distinguable autrement d'un run vivant.

Seul le dernier run est examiné : un run ancien laissé sans marqueur
signalerait éternellement une install fantôme. L'aperçu ne crée rien, il
ne pose pas la question. « Rouvrir le suivi » rejoint la section
Déploiement, où on le cherche.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-11 07:52:52 -04:00
parent 7650710295
commit b6de9b5506
3 changed files with 134 additions and 21 deletions

View file

@ -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.

View file

@ -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()

View file

@ -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…",