From 6f70da98d56a9448621770daa614ad3dda3f2cfe Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 29 Jul 2026 09:20:18 +0000 Subject: [PATCH] =?UTF-8?q?[IMP]=20script=20todo:=20dashboard=20=E2=80=94?= =?UTF-8?q?=20t=C3=A9l=C3=A9m=C3=A9trie=20CPU/disque,=20ETA=20historique,?= =?UTF-8?q?=20web=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajouts au suivi d'installation (dashboard Textual) : - Télémétrie hôte (barre dédiée, MAJ 2 s) : CPU % = charge / nb CPU, et disque du dossier des images (utilisé/total/libre) — utile pour anticiper le remplissage du disque (cas vécu). - Colonne « Disque » par VM : taille RÉELLE du qcow2 (st_blocks) -> on voit quelle VM grossit. - ETA « hypothèse » : les durées d'install sont MÉMORISÉES par architecture dans ~/.erplibre/qemu-install/stats.json ; l'ETA du parc = médiane historique (par arch, repli global) - temps écoulé, affichée au sous-titre. - Touche « w » : ouvre l'UI web de la VM (Odoo :8069) dans un navigateur CLI (browsh/carbonyl/w3m/links/elinks/lynx, le 1er trouvé ; sinon notifie quoi installer). Surtout utile quand l'install est terminée. Helpers module : load_stats/record_duration/eta_reference, _fmt_size/_fmt_secs, vm_disk_path/disk_actual_size, cli_browser. run_monitor(run_app=False) expose l'app pour test headless. Validé headless (run_test) : montage, ticks, enregistrement des durées, ETA, télémétrie, touches f/w — sans crash. Co-Authored-By: Claude Opus 4.8 (1M context) --- script/todo/qemu_install_monitor.py | 188 +++++++++++++++++++++++++--- 1 file changed, 173 insertions(+), 15 deletions(-) diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 54bb4ce..62de968 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -16,6 +16,7 @@ from __future__ import annotations import json import os import shlex +import shutil import subprocess import time from pathlib import Path @@ -174,11 +175,102 @@ def read_status(log_path: str) -> tuple[str, int | None]: return "running", None +# --------------------------------------------------------------------------- # +# Télémétrie, historique de durées (ETA), navigateur CLI +# --------------------------------------------------------------------------- # +def _stats_path() -> Path: + """Fichier d'historique persistant (durées d'installation par arch).""" + return session_dir() / "stats.json" + + +def load_stats() -> dict: + try: + return json.loads(_stats_path().read_text()) + except (OSError, ValueError): + return {} + + +def record_duration(arch: str, secs: float) -> None: + """Mémorise la durée d'une install (par architecture) pour estimer l'ETA + des prochaines. On garde les 20 dernières valeurs par arch.""" + data = load_stats() + durations = data.setdefault("durations", {}) + key = arch or "unknown" + lst = durations.setdefault(key, []) + lst.append(int(secs)) + durations[key] = lst[-20:] + try: + _stats_path().write_text(json.dumps(data)) + except OSError: + pass + + +def eta_reference(stats: dict, arch: str) -> int | None: + """Durée d'install de RÉFÉRENCE (médiane) pour cette arch, d'après + l'historique ; repli sur toutes archis confondues. None si aucun historique.""" + durations = stats.get("durations", {}) or {} + lst = durations.get(arch or "unknown") or [] + if not lst: + lst = [x for v in durations.values() for x in v] + if not lst: + return None + s = sorted(lst) + return s[len(s) // 2] + + +def _fmt_size(nbytes) -> str: + """Octets -> « 1.2G » / « 345M » / « 12K ».""" + if nbytes is None: + return "-" + for unit, div in (("T", 1 << 40), ("G", 1 << 30), ("M", 1 << 20)): + if nbytes >= div: + return f"{nbytes / div:.1f}{unit}" + return f"{nbytes // 1024}K" + + +def _fmt_secs(secs) -> str: + """Secondes -> « 45s » / « 12m » / « 1h05 ».""" + secs = int(secs) + if secs < 60: + return f"{secs}s" + if secs < 3600: + return f"{secs // 60}m" + return f"{secs // 3600}h{(secs % 3600) // 60:02d}" + + +def vm_disk_path(vm: dict) -> str: + """Chemin du qcow2 de la VM (défaut libvirt si non fourni).""" + return vm.get("disk") or f"/var/lib/libvirt/images/{vm['name']}.qcow2" + + +def disk_actual_size(path: str) -> int | None: + """Taille RÉELLEMENT occupée du qcow2 (creux) via st_blocks.""" + try: + st = os.stat(path) + return int(getattr(st, "st_blocks", 0)) * 512 + except OSError: + return None + + +# Navigateurs web en ligne de commande, par ordre de préférence (rendu JS +# d'abord — utile pour l'UI Odoo — puis navigateurs texte classiques). +CLI_BROWSERS = ("browsh", "carbonyl", "w3m", "links", "elinks", "lynx") + + +def cli_browser() -> str | None: + """Premier navigateur CLI disponible dans le PATH, sinon None.""" + for name in CLI_BROWSERS: + if shutil.which(name): + return name + return None + + # --------------------------------------------------------------------------- # # Dashboard Textual # --------------------------------------------------------------------------- # -def run_monitor(manifest_path: str) -> None: - """Ouvre le dashboard Textual sur un manifeste d'installation.""" +def run_monitor(manifest_path: str, run_app: bool = True): + """Ouvre le dashboard Textual sur un manifeste d'installation. `run_app` + à False renvoie l'instance sans la lancer (tests headless).""" from textual.app import App, ComposeResult from textual.containers import Horizontal from textual.widgets import DataTable, Footer, Header, RichLog, Static @@ -196,13 +288,15 @@ def run_monitor(manifest_path: str) -> None: class Monitor(App): CSS = """ - DataTable { width: 40; border: solid $accent; } + DataTable { width: 58; border: solid $accent; } RichLog { border: solid $accent; } + #telemetry { height: 1; color: $text-muted; } #sshbar { height: 2; color: $text-muted; } """ BINDINGS = [ ("q", "quit", "Quitter (détaché)"), ("s", "ssh", "SSH"), + ("w", "web", "Web (navigateur CLI)"), ("f", "follow", "Suivre"), ("c", "copy_log", "Copier log"), ] @@ -219,6 +313,9 @@ def run_monitor(manifest_path: str) -> None: # Cache des cellules AFFICHÉES : on n'appelle update_cell (donc on # ne re-render) que si la valeur CHANGE -> plus de churn de rendu. 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 "/" @staticmethod def _fmt(secs): @@ -239,8 +336,9 @@ def run_monitor(manifest_path: str) -> None: table.add_column("VM", key="vm") table.add_column("État", key="state") table.add_column("Durée", key="elapsed") + table.add_column("Disque", key="disk") for vm in vms: - table.add_row(vm["name"], "⏳", "00:00", key=vm["name"]) + table.add_row(vm["name"], "⏳", "--:--", "-", key=vm["name"]) # max_lines borne la mémoire/rendu (un install verbeux × 30 VM). self._log = RichLog( id="log", highlight=False, markup=False, max_lines=5000 @@ -248,6 +346,8 @@ def run_monitor(manifest_path: str) -> None: with Horizontal(): yield table yield self._log + # Barre de télémétrie hôte (CPU, disque, ETA parc). + yield Static("", id="telemetry") yield Static("", id="sshbar") yield Footer() @@ -272,8 +372,8 @@ def run_monitor(manifest_path: str) -> None: bar = self.query_one("#sshbar", Static) if vm: bar.update( - f" {vm['ssh']} (s = SSH · c = copier le log · " - "Maj+glisser = sélectionner)\n" + f" {vm['ssh']} (s = SSH · w = web :8069 · " + "c = copier le log · Maj+glisser = sélectionner)\n" f" Log : {vm['log']}" ) @@ -305,32 +405,66 @@ def run_monitor(manifest_path: str) -> None: try: table = self.query_one("#vms", DataTable) now = time.time() + remaining = [] # ETA restant par VM en cours for vm in vms: name = vm["name"] + # Disque réel du qcow2 (change lentement -> peu de churn). + self._set_cell( + table, name, "disk", + _fmt_size(disk_actual_size(vm_disk_path(vm))), + ) if name in self._final: - continue # déjà terminé -> plus aucune lecture + continue # déjà terminé -> plus de lecture de statut state, code = read_status(vm["log"]) if state in ("done", "failed"): - # Fige la durée à la complétion (plus de churn ensuite). + # Fige la durée + MÉMORISE pour l'ETA des prochaines. elapsed = now - started self._final[name] = (state, code, elapsed) + if state == "done": + record_duration(vm.get("arch"), elapsed) + self._stats = load_stats() lbl = "✅" if state == "done" else f"❌ ({code})" self._set_cell(table, name, "state", lbl) self._set_cell( table, name, "elapsed", self._fmt(elapsed) ) else: - # running/pending : icône stable -> _set_cell ne - # re-render que si elle change (pas de churn). La durée - # « live » est dans le sous-titre, pas répétée 16×. + # running/pending : icône stable -> pas de churn. ETA + # = référence historique (par arch) - temps écoulé. self._set_cell(table, name, "state", ICON[state]) - # Sous-titre : compteur de VM terminées + durée globale (UNE - # seule mise à jour, au lieu de 16 cellules « Durée »). + ref = eta_reference(self._stats, vm.get("arch")) + if ref is not None: + remaining.append(max(0, ref - (now - started))) + # Sous-titre : compteur de VM terminées + durée globale. done = len(self._final) + eta = "" + if remaining: + # ETA du parc = la VM en cours la plus longue à finir. + eta = f" · ETA ~{_fmt_secs(max(remaining))}" self.sub_title = ( f"{done}/{len(vms)} {t('completed')} · " - f"{self._fmt(now - started)}" + f"{self._fmt(now - started)}{eta}" ) + self._update_telemetry() + except Exception: + pass + + def _update_telemetry(self): + """Barre de télémétrie hôte : CPU % (charge/CPU) + disque du + dossier des images (là où les qcow2 grossissent).""" + try: + ncpu = os.cpu_count() or 1 + load1 = os.getloadavg()[0] + cpu_pct = min(999, int(load1 / ncpu * 100)) + du = shutil.disk_usage(self._disk_dir) + used_pct = int(du.used / du.total * 100) if du.total else 0 + txt = ( + f" ⚙ CPU {cpu_pct}% (charge {load1:.1f}/{ncpu}) " + f"💽 {self._disk_dir}: {_fmt_size(du.used)}/" + f"{_fmt_size(du.total)} ({used_pct}%) · " + f"libre {_fmt_size(du.free)}" + ) + self.query_one("#telemetry", Static).update(txt) except Exception: pass @@ -359,6 +493,27 @@ def run_monitor(manifest_path: str) -> None: with self.suspend(): os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || true") + def action_web(self) -> None: + """Ouvre l'UI web de la VM (Odoo :8069) dans un navigateur CLI + (browsh/carbonyl/w3m/links/elinks/lynx). Surtout utile une fois + l'installation TERMINÉE.""" + vm = self._vm_by_name(self._selected) + if not vm or not vm.get("ip"): + self.notify("Pas d'IP pour cette VM.", title="Web") + return + browser = cli_browser() + if not browser: + self.notify( + "Aucun navigateur CLI trouvé. Installez-en un : " + "w3m, lynx, links, elinks, browsh ou carbonyl.", + title="Web", + severity="warning", + ) + return + url = f"http://{vm['ip']}:8069" + with self.suspend(): + os.system(f"{browser} {shlex.quote(url)} || true") + def action_copy_log(self) -> None: """Copie le log complet de la VM sélectionnée dans le presse-papiers (OSC 52 ; marche aussi à travers SSH).""" @@ -375,4 +530,7 @@ def run_monitor(manifest_path: str) -> None: title="Presse-papiers", ) - Monitor().run() + app = Monitor() + if run_app: + app.run() + return app