[FIX] script todo: dashboard Textual — lag et gel sur gros parc corrigés
Le TUI de suivi laggait à 30 VM et se figeait quand l'I/O ralentissait (disque plein / logs effacés). Cause : à chaque tick (1 s) on lisait le fichier log ENTIER de CHAQUE VM pour le statut + on relisait tout le log sélectionné, en synchrone sur la boucle d'événements Textual. Corrections : - read_status ne lit que les 4 derniers Ko (le marqueur de sortie est sur la dernière ligne) : un log de 2 Mo passe de « tout lire » à 0,1 ms. - _load_selected_log lit de façon INCRÉMENTALE (seek à l'offset) au lieu de relire tout le fichier. - Les VM TERMINÉES ne sont plus relues (cache _final). - _tick_table (2 s) et _tick_log (1 s) séparés et PROTÉGÉS par try/except : une erreur I/O transitoire (disque plein, log supprimé) ne tue plus la boucle -> l'interface reste réactive aux touches. - RichLog borné (max_lines=5000). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
51a9e7b97c
commit
b7522bb92d
1 changed files with 63 additions and 28 deletions
|
|
@ -148,14 +148,23 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def read_status(log_path: str) -> tuple[str, int | None]:
|
def read_status(log_path: str) -> tuple[str, int | None]:
|
||||||
"""(état, code) d'un log : pending / running / done / failed."""
|
"""(état, code) d'un log : pending / running / done / failed. Ne lit que
|
||||||
|
la FIN du fichier (le marqueur de sortie est sur la dernière ligne) : lire
|
||||||
|
tout le log de 30 VM chaque seconde saturait la boucle d'événements du TUI
|
||||||
|
(lag, interface figée quand l'I/O ralentit)."""
|
||||||
try:
|
try:
|
||||||
data = Path(log_path).read_text(errors="replace")
|
size = os.path.getsize(log_path)
|
||||||
|
if size == 0:
|
||||||
|
return "pending", None
|
||||||
|
with open(log_path, "rb") as fh:
|
||||||
|
if size > 4096:
|
||||||
|
fh.seek(-4096, os.SEEK_END)
|
||||||
|
tail = fh.read().decode(errors="replace")
|
||||||
except OSError:
|
except OSError:
|
||||||
return "pending", None
|
return "pending", None
|
||||||
if not data.strip():
|
if not tail.strip():
|
||||||
return "pending", None
|
return "pending", None
|
||||||
for line in reversed(data.splitlines()):
|
for line in reversed(tail.splitlines()):
|
||||||
if EXIT_MARKER in line:
|
if EXIT_MARKER in line:
|
||||||
try:
|
try:
|
||||||
code = int(line.split()[-1])
|
code = int(line.split()[-1])
|
||||||
|
|
@ -203,6 +212,9 @@ def run_monitor(manifest_path: str) -> None:
|
||||||
self._offsets = {vm["name"]: 0 for vm in vms}
|
self._offsets = {vm["name"]: 0 for vm in vms}
|
||||||
self._selected = vms[0]["name"] if vms else None
|
self._selected = vms[0]["name"] if vms else None
|
||||||
self._follow = True
|
self._follow = True
|
||||||
|
# Statuts TERMINAUX mémorisés : une VM finie n'est plus relue
|
||||||
|
# (réduit fortement l'I/O sur un gros parc).
|
||||||
|
self._final = {}
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header(show_clock=True)
|
yield Header(show_clock=True)
|
||||||
|
|
@ -213,7 +225,10 @@ def run_monitor(manifest_path: str) -> None:
|
||||||
table.add_column("Durée", key="elapsed")
|
table.add_column("Durée", key="elapsed")
|
||||||
for vm in vms:
|
for vm in vms:
|
||||||
table.add_row(vm["name"], "⏳", "00:00", key=vm["name"])
|
table.add_row(vm["name"], "⏳", "00:00", key=vm["name"])
|
||||||
self._log = RichLog(id="log", highlight=False, markup=False)
|
# max_lines borne la mémoire/rendu (un install verbeux × 30 VM).
|
||||||
|
self._log = RichLog(
|
||||||
|
id="log", highlight=False, markup=False, max_lines=5000
|
||||||
|
)
|
||||||
with Horizontal():
|
with Horizontal():
|
||||||
yield table
|
yield table
|
||||||
yield self._log
|
yield self._log
|
||||||
|
|
@ -224,7 +239,10 @@ def run_monitor(manifest_path: str) -> None:
|
||||||
self.title = "ERPLibre — suivi d'installation"
|
self.title = "ERPLibre — suivi d'installation"
|
||||||
self._refresh_ssh()
|
self._refresh_ssh()
|
||||||
self._load_selected_log(reset=True)
|
self._load_selected_log(reset=True)
|
||||||
self.set_interval(1.0, self._tick)
|
# Table toutes les 2 s (30 lectures de fin de log), suivi du log
|
||||||
|
# sélectionné toutes les 1 s (une seule lecture incrémentale).
|
||||||
|
self.set_interval(2.0, self._tick_table)
|
||||||
|
self.set_interval(1.0, self._tick_log)
|
||||||
|
|
||||||
# -- helpers -------------------------------------------------------- #
|
# -- helpers -------------------------------------------------------- #
|
||||||
def _vm_by_name(self, name):
|
def _vm_by_name(self, name):
|
||||||
|
|
@ -244,39 +262,56 @@ def run_monitor(manifest_path: str) -> None:
|
||||||
vm = self._vm_by_name(self._selected)
|
vm = self._vm_by_name(self._selected)
|
||||||
if not vm:
|
if not vm:
|
||||||
return
|
return
|
||||||
|
name = vm["name"]
|
||||||
if reset:
|
if reset:
|
||||||
self._log.clear()
|
self._log.clear()
|
||||||
self._offsets[vm["name"]] = 0
|
self._offsets[name] = 0
|
||||||
|
# Lecture INCRÉMENTALE (seek à l'offset) : on ne relit pas tout le
|
||||||
|
# fichier à chaque rafraîchissement.
|
||||||
try:
|
try:
|
||||||
data = Path(vm["log"]).read_text(errors="replace")
|
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:
|
except OSError:
|
||||||
return
|
return
|
||||||
off = self._offsets[vm["name"]]
|
|
||||||
new = data[off:]
|
|
||||||
if new:
|
if new:
|
||||||
for line in new.splitlines():
|
for line in new.splitlines():
|
||||||
if EXIT_MARKER not in line:
|
if EXIT_MARKER not in line:
|
||||||
self._log.write(line)
|
self._log.write(line)
|
||||||
self._offsets[vm["name"]] = len(data)
|
|
||||||
|
|
||||||
def _tick(self):
|
def _tick_table(self):
|
||||||
table = self.query_one("#vms", DataTable)
|
# Protégé : une erreur I/O transitoire (disque plein, log effacé)
|
||||||
for vm in vms:
|
# ne doit pas tuer la boucle et figer l'interface.
|
||||||
state, code = read_status(vm["log"])
|
try:
|
||||||
elapsed = time.time() - started
|
table = self.query_one("#vms", DataTable)
|
||||||
mm, ss = divmod(int(elapsed), 60)
|
for vm in vms:
|
||||||
label = ICON[state]
|
name = vm["name"]
|
||||||
if state == "failed":
|
if name in self._final:
|
||||||
label = f"❌ ({code})"
|
continue # déjà terminé -> plus aucune lecture
|
||||||
elif state == "done":
|
state, code = read_status(vm["log"])
|
||||||
label = "✅"
|
label = ICON[state]
|
||||||
table.update_cell(vm["name"], "state", label)
|
if state == "failed":
|
||||||
if state in ("running", "pending"):
|
label = f"❌ ({code})"
|
||||||
table.update_cell(
|
elif state == "done":
|
||||||
vm["name"], "elapsed", f"{mm:02d}:{ss:02d}"
|
label = "✅"
|
||||||
)
|
table.update_cell(name, "state", label)
|
||||||
|
if state in ("running", "pending"):
|
||||||
|
mm, ss = divmod(int(time.time() - started), 60)
|
||||||
|
table.update_cell(
|
||||||
|
name, "elapsed", f"{mm:02d}:{ss:02d}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._final[name] = (state, code)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _tick_log(self):
|
||||||
if self._follow:
|
if self._follow:
|
||||||
self._load_selected_log()
|
try:
|
||||||
|
self._load_selected_log()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# -- events --------------------------------------------------------- #
|
# -- events --------------------------------------------------------- #
|
||||||
def on_data_table_row_highlighted(self, event) -> None:
|
def on_data_table_row_highlighted(self, event) -> None:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue