[FIX] script todo: suivi install — navigation fluide (tail + debounce)

Cause des GROS ralentissements en changeant de VM / lisant les logs :
- on_data_table_row_highlighted rechargeait le log à CHAQUE mouvement du
  curseur (donc en rafale quand on tient la flèche) ;
- _load_selected_log(reset) lisait le fichier ENTIER (offset 0) et écrivait
  TOUTES les lignes dans le RichLog, SYNCHRONE sur la boucle d'événements
  -> un log de 250 Ko / plusieurs milliers de lignes gelait l'UI, × chaque
  pas de navigation.

Corrections :
- _read_tail : au changement de VM on ne lit que la FIN du log (128 Ko /
  1000 lignes max) et on cale l'offset sur la taille totale -> le suivi
  incrémental continue depuis la fin. Affichage quasi instantané.
- Debounce (0,25 s) : RowHighlighted mémorise seulement la cible ; on ne
  charge qu'une fois le curseur stabilisé, et uniquement la DERNIÈRE VM.

Validé headless : 2 flèches rapides -> 1 seul chargement (VM finale),
1000 lignes affichées au lieu de 8000.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-07-30 01:31:00 -04:00
parent fd3226a053
commit b99db0ea66

View file

@ -292,6 +292,32 @@ def _read_new(path: str, offset: int) -> tuple[str, int]:
return "", offset
def _read_tail(
path: str, max_bytes: int = 131072, max_lines: int = 1000
) -> tuple[str, int]:
"""Lit uniquement la FIN du log (dernier `max_bytes`, tronqué à
`max_lines` lignes) et renvoie (texte, TAILLE TOTALE du fichier). Utilisé
au CHANGEMENT de VM : lire+réafficher le fichier ENTIER (offset 0) gelait
l'UI sur les gros logs (250 Ko / milliers de lignes). L'offset renvoyé =
taille totale -> le suivi incrémental (_tick_log) continue depuis la fin."""
try:
size = os.path.getsize(path)
with open(path, "rb") as fh:
if size > max_bytes:
fh.seek(-max_bytes, os.SEEK_END)
raw = fh.read()
text = raw.decode(errors="replace")
# Si on a coupé au milieu d'une ligne, jeter la 1re ligne partielle.
if size > max_bytes and "\n" in text:
text = text.split("\n", 1)[1]
lines = text.splitlines()
if len(lines) > max_lines:
lines = lines[-max_lines:]
return "\n".join(lines), size
except OSError:
return "", 0
# --------------------------------------------------------------------------- #
# Télémétrie, historique de durées (ETA), navigateur CLI
# --------------------------------------------------------------------------- #
@ -619,6 +645,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
self._errcount = {}
# Sommaire de stats déplié (clic) ou non.
self._stats_open = False
# Debounce du changement de VM : la sélection défile vite au
# clavier ; on ne recharge le log qu'une fois le curseur STABILISÉ.
self._pending_sel = None
self._sel_timer = None
@staticmethod
def _fmt(secs):
@ -697,15 +727,22 @@ 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).
# Au reset (changement de VM), on ne lit QUE LA FIN du log
# (_read_tail) et on cale l'offset sur la taille totale : le suivi
# incrémental (_tick_log) continue depuis la fin. Lire+réafficher
# le fichier ENTIER gelait l'UI sur les gros logs.
vm = self._vm_by_name(self._selected)
if not vm:
return
name = vm["name"]
if reset:
self._log.clear()
self._offsets[name] = 0
text, size = _read_tail(vm["log"])
self._offsets[name] = size
for line in text.splitlines():
if EXIT_MARKER not in line:
self._log.write(line)
return
new, off = _read_new(vm["log"], self._offsets.get(name, 0))
self._offsets[name] = off
for line in new.splitlines():
@ -926,11 +963,26 @@ def run_monitor(manifest_path: str, run_app: bool = True):
# -- events --------------------------------------------------------- #
def on_data_table_row_highlighted(self, event) -> None:
# DEBOUNCE : RowHighlighted se déclenche à CHAQUE mouvement du
# curseur. Recharger le log à chaque pas (surtout en maintenant la
# flèche) enchaînait les rechargements -> gros lag. On diffère de
# 0,25 s et on ne charge que la DERNIÈRE VM sélectionnée.
name = event.row_key.value
if name and name != self._selected:
self._selected = name
self._refresh_ssh()
self._load_selected_log(reset=True)
if not name or name == self._selected:
return
self._pending_sel = name
if self._sel_timer is not None:
self._sel_timer.stop()
self._sel_timer = self.set_timer(0.25, self._apply_pending_sel)
def _apply_pending_sel(self) -> None:
name = self._pending_sel
self._sel_timer = None
if not name or name == self._selected:
return
self._selected = name
self._refresh_ssh()
self._load_selected_log(reset=True)
def action_follow(self) -> None:
self._follow = not self._follow