[IMP] script todo: dashboard async (I/O en thread) + install navigateur CLI

- Ticks ASYNCHRONES : les I/O bloquantes (lecture des logs, stat disque,
  subprocess « virsh list », /proc) sont déportées en THREAD via
  asyncio.to_thread ; seules les mises à jour d'UI restent sur la boucle
  d'événements Textual -> plus de gel, même sous forte charge ou disque lent.
  _collect_table (thread) rassemble statut+disque+télémétrie, l'application
  au tableau se fait ensuite sur la boucle. _tick_log/_tick_domstate idem.
  Helper _read_new (lecture incrémentale) réutilisé.
- Touche « w » : si aucun navigateur CLI n'est présent, on PROPOSE de
  l'installer selon l'OS (apt Ubuntu/Debian, dnf Fedora, pacman Arch — nos 4
  systèmes) : la commande est AFFICHÉE puis exécutée après validation (o/N).

Validé headless : ticks async mettent l'UI à jour ; browser_install_command
renvoie la bonne commande selon l'OS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-07-29 10:09:06 +00:00
parent b542aa7dc2
commit bc0e06137f

View file

@ -13,6 +13,7 @@ quitter le dashboard n'arrête rien, on peut le rouvrir pour ré-attacher.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import os import os
import shlex import shlex
@ -175,6 +176,18 @@ def read_status(log_path: str) -> tuple[str, int | None]:
return "running", None return "running", None
def _read_new(path: str, offset: int) -> tuple[str, int]:
"""Lit le log à partir de `offset` (lecture incrémentale). Renvoie
(nouveau_texte, nouvel_offset). Bloquant -> à appeler dans un thread."""
try:
with open(path, "r", errors="replace") as fh:
fh.seek(offset)
data = fh.read()
return data, fh.tell()
except OSError:
return "", offset
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Télémétrie, historique de durées (ETA), navigateur CLI # Télémétrie, historique de durées (ETA), navigateur CLI
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@ -265,6 +278,44 @@ def cli_browser() -> str | None:
return None return None
def _os_id() -> str:
"""ID de la distribution hôte (/etc/os-release), ex. « ubuntu », « fedora »."""
try:
for line in open("/etc/os-release", encoding="utf-8"):
if line.startswith("ID="):
return line.split("=", 1)[1].strip().strip('"').lower()
except OSError:
pass
return ""
def browser_install_command() -> list | None:
"""Commande d'installation d'un navigateur CLI (w3m, léger et présent
partout) adaptée à l'OS hôte : apt (Ubuntu/Debian), dnf (Fedora), pacman
(Arch). None si le gestionnaire est inconnu."""
apt = ["sudo", "apt-get", "install", "-y", "w3m"]
dnf = ["sudo", "dnf", "install", "-y", "w3m"]
pac = ["sudo", "pacman", "-S", "--needed", "--noconfirm", "w3m"]
by_id = {
"ubuntu": apt,
"debian": apt,
"linuxmint": apt,
"fedora": dnf,
"arch": pac,
}
cmd = by_id.get(_os_id())
if cmd:
return cmd
# Repli : d'après le gestionnaire de paquets présent.
if shutil.which("apt-get"):
return apt
if shutil.which("dnf"):
return dnf
if shutil.which("pacman"):
return pac
return None
def virsh_domstates() -> dict: def virsh_domstates() -> dict:
"""{nom: état} de tous les domaines libvirt (« virsh list --all »). Sert à """{nom: état} de tous les domaines libvirt (« virsh list --all »). Sert à
détecter une VM EN PAUSE ou EFFACÉE pendant le suivi. Un seul appel virsh détecter une VM EN PAUSE ou EFFACÉE pendant le suivi. Un seul appel virsh
@ -391,7 +442,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# État libvirt (pause / effacée) : check LENT (appel virsh) toutes # État libvirt (pause / effacée) : check LENT (appel virsh) toutes
# les 10 s ; le tableau applique le cache à chaque tick (2 s). # les 10 s ; le tableau applique le cache à chaque tick (2 s).
self.set_interval(10.0, self._tick_domstate) self.set_interval(10.0, self._tick_domstate)
self._tick_domstate() # premier relevé immédiat # Premier relevé domstate immédiat (async -> via worker).
self.run_worker(self._tick_domstate(), exclusive=False)
# -- helpers -------------------------------------------------------- # # -- helpers -------------------------------------------------------- #
def _vm_by_name(self, name): def _vm_by_name(self, name):
@ -408,6 +460,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
) )
def _load_selected_log(self, reset=False): def _load_selected_log(self, reset=False):
# Lecture SYNCHRONE (mount / changement de ligne). Le suivi périodique
# passe par _tick_log (async, I/O en thread).
vm = self._vm_by_name(self._selected) vm = self._vm_by_name(self._selected)
if not vm: if not vm:
return return
@ -415,37 +469,62 @@ def run_monitor(manifest_path: str, run_app: bool = True):
if reset: if reset:
self._log.clear() self._log.clear()
self._offsets[name] = 0 self._offsets[name] = 0
# Lecture INCRÉMENTALE (seek à l'offset) : on ne relit pas tout le new, off = _read_new(vm["log"], self._offsets.get(name, 0))
# fichier à chaque rafraîchissement. self._offsets[name] = off
try: for line in new.splitlines():
with open(vm["log"], "r", errors="replace") as fh: if EXIT_MARKER not in line:
fh.seek(self._offsets.get(name, 0)) self._log.write(line)
new = fh.read()
self._offsets[name] = fh.tell()
except OSError:
return
if new:
for line in new.splitlines():
if EXIT_MARKER not in line:
self._log.write(line)
def _tick_table(self): def _collect_tele(self):
# Protégé : une erreur I/O transitoire (disque plein, log effacé) """(THREAD) chaîne de télémétrie hôte : CPU % + disque des images."""
# ne doit pas tuer la boucle et figer l'interface. try:
ncpu = os.cpu_count() or 1
load1 = os.getloadavg()[0]
du = shutil.disk_usage(self._disk_dir)
used_pct = int(du.used / du.total * 100) if du.total else 0
return (
f" ⚙ CPU {min(999, int(load1 / ncpu * 100))}% "
f"(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)}"
)
except Exception:
return ""
def _collect_table(self):
"""(THREAD) statut + taille disque de chaque VM + télémétrie. AUCUNE
mise à jour d'UI ici : uniquement des I/O bloquantes déportées."""
disks, status = {}, {}
for vm in vms:
name = vm["name"]
disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm)))
if (
name not in self._final
and self._domstate.get(name) != "gone"
):
status[name] = read_status(vm["log"])
return disks, status, self._collect_tele()
async def _tick_table(self):
# I/O (lectures de logs, stat disque, /proc) DÉPORTÉES en thread ->
# la boucle d'événements Textual reste fluide même sous forte
# charge ou disque lent. Les mises à jour d'UI restent sur la boucle.
try:
disks, status, tele = await asyncio.to_thread(
self._collect_table
)
except Exception:
return
try: try:
table = self.query_one("#vms", DataTable) table = self.query_one("#vms", DataTable)
now = time.time() now = time.time()
remaining = [] # ETA restant par VM en cours remaining = []
for vm in vms: for vm in vms:
name = vm["name"] name = vm["name"]
# Disque réel du qcow2 (change lentement -> peu de churn). self._set_cell(table, name, "disk", disks.get(name, "-"))
self._set_cell(
table, name, "disk",
_fmt_size(disk_actual_size(vm_disk_path(vm))),
)
if name in self._final: if name in self._final:
continue # déjà terminé -> plus de lecture de statut continue
# VM EFFACÉE pendant le suivi -> état terminal « effacé ».
ds = self._domstate.get(name) ds = self._domstate.get(name)
if ds == "gone": if ds == "gone":
self._final[name] = ("deleted", None, now - started) self._final[name] = ("deleted", None, now - started)
@ -453,9 +532,11 @@ def run_monitor(manifest_path: str, run_app: bool = True):
table, name, "state", f"{t('deleted')}" table, name, "state", f"{t('deleted')}"
) )
continue continue
state, code = read_status(vm["log"]) st = status.get(name)
if st is None:
continue
state, code = st
if state in ("done", "failed"): if state in ("done", "failed"):
# Fige la durée + MÉMORISE pour l'ETA des prochaines.
elapsed = now - started elapsed = now - started
self._final[name] = (state, code, elapsed) self._final[name] = (state, code, elapsed)
if state == "done": if state == "done":
@ -467,67 +548,54 @@ def run_monitor(manifest_path: str, run_app: bool = True):
table, name, "elapsed", self._fmt(elapsed) table, name, "elapsed", self._fmt(elapsed)
) )
elif ds == "paused": elif ds == "paused":
# VM EN PAUSE (virsh suspend) : l'install ne progresse
# pas -> on l'indique (non terminal, peut reprendre).
self._set_cell( self._set_cell(
table, name, "state", f"{t('paused')}" table, name, "state", f"{t('paused')}"
) )
else: else:
# 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]) self._set_cell(table, name, "state", ICON[state])
ref = eta_reference(self._stats, vm.get("arch")) ref = eta_reference(self._stats, vm.get("arch"))
if ref is not None: if ref is not None:
remaining.append(max(0, ref - (now - started))) remaining.append(max(0, ref - (now - started)))
# Sous-titre : compteur de VM terminées + durée globale.
done = len(self._final) done = len(self._final)
eta = "" eta = (
if remaining: f" · ETA ~{_fmt_secs(max(remaining))}" if remaining else ""
# ETA du parc = la VM en cours la plus longue à finir. )
eta = f" · ETA ~{_fmt_secs(max(remaining))}"
self.sub_title = ( self.sub_title = (
f"{done}/{len(vms)} {t('completed')} · " f"{done}/{len(vms)} {t('completed')} · "
f"{self._fmt(now - started)}{eta}" f"{self._fmt(now - started)}{eta}"
) )
self._update_telemetry() if tele:
self.query_one("#telemetry", Static).update(tele)
except Exception: except Exception:
pass pass
def _update_telemetry(self): async def _tick_log(self):
"""Barre de télémétrie hôte : CPU % (charge/CPU) + disque du if not self._follow:
dossier des images ( les qcow2 grossissent).""" return
vm = self._vm_by_name(self._selected)
if not vm:
return
name = vm["name"]
try: try:
ncpu = os.cpu_count() or 1 new, off = await asyncio.to_thread(
load1 = os.getloadavg()[0] _read_new, vm["log"], self._offsets.get(name, 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: except Exception:
pass return
self._offsets[name] = off
for line in new.splitlines():
if EXIT_MARKER not in line:
self._log.write(line)
def _tick_log(self): async def _tick_domstate(self):
if self._follow: # Relevé LENT (subprocess virsh) DÉPORTÉ en thread : ne bloque plus
try: # la boucle. Une VM absente de « virsh list » est EFFACÉE (« gone »).
self._load_selected_log()
except Exception:
pass
def _tick_domstate(self):
# Relevé LENT de l'état libvirt : une VM absente de « virsh list »
# est EFFACÉE (« gone ») ; sinon on garde son état (paused, …).
try: try:
states = virsh_domstates() states = await asyncio.to_thread(virsh_domstates)
for vm in vms:
self._domstate[vm["name"]] = states.get(vm["name"], "gone")
except Exception: except Exception:
pass return
for vm in vms:
self._domstate[vm["name"]] = states.get(vm["name"], "gone")
# -- events --------------------------------------------------------- # # -- events --------------------------------------------------------- #
def on_data_table_row_highlighted(self, event) -> None: def on_data_table_row_highlighted(self, event) -> None:
@ -557,13 +625,36 @@ def run_monitor(manifest_path: str, run_app: bool = True):
return return
browser = cli_browser() browser = cli_browser()
if not browser: if not browser:
self.notify( # Aucun navigateur CLI : proposer de l'INSTALLER (commande
"Aucun navigateur CLI trouvé. Installez-en un : " # adaptée à l'OS, affichée + validée avant exécution).
"w3m, lynx, links, elinks, browsh ou carbonyl.", install = browser_install_command()
title="Web", with self.suspend():
severity="warning", if not install:
) print(
return "Aucun navigateur CLI et gestionnaire de paquets "
"inconnu. Installez w3m/lynx/links/elinks/browsh."
)
input("Entrée pour revenir…")
else:
printable = " ".join(install)
print(
"Aucun navigateur CLI trouvé. Commande "
"d'installation proposée :\n"
f" {printable}"
)
ans = input(
"Installer maintenant ? (o/N) : "
).strip().lower()
if ans in ("o", "oui", "y", "yes"):
os.system(printable + " || true")
browser = cli_browser()
if not browser:
self.notify(
"Toujours aucun navigateur CLI disponible.",
title="Web",
severity="warning",
)
return
url = f"http://{vm['ip']}:8069" url = f"http://{vm['ip']}:8069"
with self.suspend(): with self.suspend():
os.system(f"{browser} {shlex.quote(url)} || true") os.system(f"{browser} {shlex.quote(url)} || true")