[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
import asyncio
import json
import os
import shlex
@ -175,6 +176,18 @@ def read_status(log_path: str) -> tuple[str, int | 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
# --------------------------------------------------------------------------- #
@ -265,6 +278,44 @@ def cli_browser() -> str | 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:
"""{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
@ -391,7 +442,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# État libvirt (pause / effacée) : check LENT (appel virsh) toutes
# les 10 s ; le tableau applique le cache à chaque tick (2 s).
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 -------------------------------------------------------- #
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):
# 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)
if not vm:
return
@ -415,37 +469,62 @@ def run_monitor(manifest_path: str, run_app: bool = True):
if reset:
self._log.clear()
self._offsets[name] = 0
# Lecture INCRÉMENTALE (seek à l'offset) : on ne relit pas tout le
# fichier à chaque rafraîchissement.
try:
with open(vm["log"], "r", errors="replace") as fh:
fh.seek(self._offsets.get(name, 0))
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)
new, off = _read_new(vm["log"], self._offsets.get(name, 0))
self._offsets[name] = off
for line in new.splitlines():
if EXIT_MARKER not in line:
self._log.write(line)
def _tick_table(self):
# Protégé : une erreur I/O transitoire (disque plein, log effacé)
# ne doit pas tuer la boucle et figer l'interface.
def _collect_tele(self):
"""(THREAD) chaîne de télémétrie hôte : CPU % + disque des images."""
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:
table = self.query_one("#vms", DataTable)
now = time.time()
remaining = [] # ETA restant par VM en cours
remaining = []
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))),
)
self._set_cell(table, name, "disk", disks.get(name, "-"))
if name in self._final:
continue # déjà terminé -> plus de lecture de statut
# VM EFFACÉE pendant le suivi -> état terminal « effacé ».
continue
ds = self._domstate.get(name)
if ds == "gone":
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')}"
)
continue
state, code = read_status(vm["log"])
st = status.get(name)
if st is None:
continue
state, code = st
if state in ("done", "failed"):
# Fige la durée + MÉMORISE pour l'ETA des prochaines.
elapsed = now - started
self._final[name] = (state, code, elapsed)
if state == "done":
@ -467,67 +548,54 @@ def run_monitor(manifest_path: str, run_app: bool = True):
table, name, "elapsed", self._fmt(elapsed)
)
elif ds == "paused":
# VM EN PAUSE (virsh suspend) : l'install ne progresse
# pas -> on l'indique (non terminal, peut reprendre).
self._set_cell(
table, name, "state", f"{t('paused')}"
)
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])
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))}"
eta = (
f" · ETA ~{_fmt_secs(max(remaining))}" if remaining else ""
)
self.sub_title = (
f"{done}/{len(vms)} {t('completed')} · "
f"{self._fmt(now - started)}{eta}"
)
self._update_telemetry()
if tele:
self.query_one("#telemetry", Static).update(tele)
except Exception:
pass
def _update_telemetry(self):
"""Barre de télémétrie hôte : CPU % (charge/CPU) + disque du
dossier des images ( les qcow2 grossissent)."""
async def _tick_log(self):
if not self._follow:
return
vm = self._vm_by_name(self._selected)
if not vm:
return
name = vm["name"]
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)}"
new, off = await asyncio.to_thread(
_read_new, vm["log"], self._offsets.get(name, 0)
)
self.query_one("#telemetry", Static).update(txt)
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):
if self._follow:
try:
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, …).
async def _tick_domstate(self):
# Relevé LENT (subprocess virsh) DÉPORTÉ en thread : ne bloque plus
# la boucle. Une VM absente de « virsh list » est EFFACÉE (« gone »).
try:
states = virsh_domstates()
for vm in vms:
self._domstate[vm["name"]] = states.get(vm["name"], "gone")
states = await asyncio.to_thread(virsh_domstates)
except Exception:
pass
return
for vm in vms:
self._domstate[vm["name"]] = states.get(vm["name"], "gone")
# -- events --------------------------------------------------------- #
def on_data_table_row_highlighted(self, event) -> None:
@ -557,13 +625,36 @@ def run_monitor(manifest_path: str, run_app: bool = True):
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
# Aucun navigateur CLI : proposer de l'INSTALLER (commande
# adaptée à l'OS, affichée + validée avant exécution).
install = browser_install_command()
with self.suspend():
if not install:
print(
"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"
with self.suspend():
os.system(f"{browser} {shlex.quote(url)} || true")