Encore du lag à 16 VM : update_cell était appelé pour CHAQUE ligne à chaque tick (état + « Durée »), or la Durée — dérivée d'un `started` global — est identique sur toutes les lignes et se réécrivait sans cesse -> re-render permanent de la DataTable. - _set_cell : n'appelle update_cell (donc ne re-render) que si la valeur CHANGE. Mesuré : 312 -> 18 update_cell sur 10 ticks × 16 VM. - La durée « live » passe dans le SOUS-TITRE (une seule mise à jour) ; la colonne Durée n'est écrite qu'à la complétion (durée finale figée). - Le NOMBRE de VM est affiché : titre « … (N VM) », sous-titre « k/N terminées · mm:ss ». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
378 lines
15 KiB
Python
378 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
"""Dashboard Textual : suivi des installations ERPLibre parallèles sur VM.
|
||
|
||
Principe « détachable » : chaque installation tourne dans un processus
|
||
DÉTACHÉ (setsid) qui écrit un fichier log et, à la fin, un marqueur
|
||
« __ERPLIBRE_EXIT__ <code> ». Ce module ne fait que VISUALISER ces fichiers :
|
||
quitter le dashboard n'arrête rien, on peut le rouvrir pour ré-attacher.
|
||
|
||
- launch_installs(...) : lance les process détachés + écrit un manifeste JSON.
|
||
- run_monitor(manifest_path) : ouvre le dashboard Textual sur un manifeste.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import shlex
|
||
import subprocess
|
||
import time
|
||
from pathlib import Path
|
||
|
||
try:
|
||
from script.todo.todo_i18n import t
|
||
except Exception: # pragma: no cover - repli si i18n indisponible
|
||
|
||
def t(key: str) -> str:
|
||
return key
|
||
|
||
|
||
EXIT_MARKER = "__ERPLIBRE_EXIT__"
|
||
SSH_OPTS = (
|
||
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
|
||
"-o ConnectTimeout=8"
|
||
)
|
||
|
||
|
||
def session_dir() -> Path:
|
||
"""Répertoire des logs/manifestes d'installation (créé au besoin)."""
|
||
base = Path(os.path.expanduser("~/.erplibre/qemu-install"))
|
||
base.mkdir(parents=True, exist_ok=True)
|
||
return base
|
||
|
||
|
||
def _launch_one(ip: str, remote_cmd: str, log_path: str) -> None:
|
||
"""Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise
|
||
la sortie puis écrit le marqueur de fin avec le code de sortie."""
|
||
# Sonde de disponibilité : on attend que sshd réponde ET que cloud-init
|
||
# soit TERMINÉ, via des connexions COURTES successives (jusqu'à ~20 min :
|
||
# une architecture ÉMULÉE, s390x/arm64 sur hôte x86, boote lentement).
|
||
# Au 1er boot, cloud-init régénère les clés d'hôte et REDÉMARRE sshd : une
|
||
# session SSH longue (ex. « cloud-init status --wait ») serait alors tuée
|
||
# (« Connection closed by remote host », exit 255 — cas Fedora). Chaque
|
||
# itération étant une connexion neuve, un redémarrage de sshd ne casse que
|
||
# la tentative en cours. On imprime toujours l'état (|| true) pour matcher
|
||
# sur le TEXTE, « status: running » n'ayant pas de code de sortie fiable.
|
||
ci_probe = (
|
||
"if command -v cloud-init >/dev/null 2>&1; then "
|
||
"cloud-init status 2>/dev/null || true; else echo nocloudinit; fi"
|
||
)
|
||
log_q = shlex.quote(log_path)
|
||
# On écrit un message d'attente + un battement toutes les ~30 s : sinon le
|
||
# log reste VIDE pendant tout le boot émulé et paraît « bloqué ».
|
||
msg_wait = t("Waiting for the VM to start (boot + cloud-init)")
|
||
msg_slow = t("(an emulated architecture can be slow; this is normal)")
|
||
msg_ready = t("VM ready - starting the ERPLibre install")
|
||
wrapper = (
|
||
f"echo {shlex.quote('== ' + msg_wait + ' ==')} >> {log_q}; "
|
||
f"echo {shlex.quote(' ' + msg_slow)} >> {log_q}; "
|
||
f"for i in $(seq 1 240); do "
|
||
f"st=$(ssh {SSH_OPTS} -o BatchMode=yes erplibre@{ip} "
|
||
f"{shlex.quote(ci_probe)} 2>/dev/null); "
|
||
f'case "$st" in '
|
||
f"*done*|*disabled*|*error*|*degraded*|*nocloudinit*) break;; "
|
||
f"esac; "
|
||
f'if [ $((i % 6)) -eq 0 ]; then echo " ... $((i*5))s" >> {log_q}; fi; '
|
||
f"sleep 5; done; "
|
||
f"echo {shlex.quote('== ' + msg_ready + ' ==')} >> {log_q}; "
|
||
f"ssh {SSH_OPTS} erplibre@{ip} {shlex.quote(remote_cmd)} "
|
||
f">> {log_q} 2>&1; "
|
||
f'echo "{EXIT_MARKER} $?" >> {log_q}'
|
||
)
|
||
# setsid -f : le process survit à la fermeture du menu / du dashboard.
|
||
subprocess.Popen(
|
||
["setsid", "-f", "bash", "-c", wrapper],
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
start_new_session=True,
|
||
)
|
||
|
||
|
||
def _log_header(vm: dict, branch: str, when: str) -> str:
|
||
"""En-tête du log : date, VM, distribution, version, architecture, branche.
|
||
Permet d'identifier l'installation d'un coup d'œil (et de ne jamais laisser
|
||
le log vide pendant l'attente du boot)."""
|
||
distro = vm.get("distro") or "?"
|
||
version = vm.get("version") or ""
|
||
arch = vm.get("arch") or "?"
|
||
bar = "=" * 64
|
||
return (
|
||
f"{bar}\n"
|
||
f" ERPLibre — {t('installation')}\n"
|
||
f" Date : {when}\n"
|
||
f" VM : {vm['name']}\n"
|
||
f" Distribution : {distro} {version}\n"
|
||
f" Architecture : {arch}\n"
|
||
f" Branche : {branch}\n"
|
||
f" IP : {vm['ip']}\n"
|
||
f"{bar}\n\n"
|
||
)
|
||
|
||
|
||
def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
|
||
"""vms : [{name, ip, distro?, version?, arch?}]. Lance chaque install
|
||
détachée, écrit un manifeste et retourne son chemin. remote_cmd : script
|
||
exécuté dans chaque VM."""
|
||
sdir = session_dir()
|
||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||
when = time.strftime("%Y-%m-%d %H:%M:%S")
|
||
logdir = sdir / stamp
|
||
logdir.mkdir(parents=True, exist_ok=True)
|
||
entries = []
|
||
for vm in vms:
|
||
log_path = str(logdir / f"{vm['name']}.log")
|
||
# En-tête d'emblée (date/distro/version/arch) : le log n'est jamais
|
||
# vide, l'utilisateur voit tout de suite QUOI s'installe.
|
||
Path(log_path).write_text(_log_header(vm, branch, when))
|
||
_launch_one(vm["ip"], remote_cmd, log_path)
|
||
entries.append(
|
||
{
|
||
"name": vm["name"],
|
||
"ip": vm["ip"],
|
||
"distro": vm.get("distro"),
|
||
"version": vm.get("version"),
|
||
"arch": vm.get("arch"),
|
||
"log": log_path,
|
||
"ssh": f"ssh erplibre@{vm['ip']}",
|
||
}
|
||
)
|
||
manifest = {
|
||
"branch": branch,
|
||
"started": time.time(),
|
||
"vms": entries,
|
||
}
|
||
manifest_path = str(logdir / "session.json")
|
||
Path(manifest_path).write_text(json.dumps(manifest, indent=2))
|
||
return manifest_path
|
||
|
||
|
||
def read_status(log_path: str) -> tuple[str, int | None]:
|
||
"""(é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:
|
||
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:
|
||
return "pending", None
|
||
if not tail.strip():
|
||
return "pending", None
|
||
for line in reversed(tail.splitlines()):
|
||
if EXIT_MARKER in line:
|
||
try:
|
||
code = int(line.split()[-1])
|
||
except ValueError:
|
||
code = 1
|
||
return ("done" if code == 0 else "failed"), code
|
||
return "running", None
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Dashboard Textual
|
||
# --------------------------------------------------------------------------- #
|
||
def run_monitor(manifest_path: str) -> None:
|
||
"""Ouvre le dashboard Textual sur un manifeste d'installation."""
|
||
from textual.app import App, ComposeResult
|
||
from textual.containers import Horizontal
|
||
from textual.widgets import DataTable, Footer, Header, RichLog, Static
|
||
|
||
manifest = json.loads(Path(manifest_path).read_text())
|
||
started = manifest.get("started", time.time())
|
||
vms = manifest["vms"]
|
||
|
||
ICON = {
|
||
"pending": "⏳",
|
||
"running": "⏳",
|
||
"done": "✅",
|
||
"failed": "❌",
|
||
}
|
||
|
||
class Monitor(App):
|
||
CSS = """
|
||
DataTable { width: 40; border: solid $accent; }
|
||
RichLog { border: solid $accent; }
|
||
#sshbar { height: 2; color: $text-muted; }
|
||
"""
|
||
BINDINGS = [
|
||
("q", "quit", "Quitter (détaché)"),
|
||
("s", "ssh", "SSH"),
|
||
("f", "follow", "Suivre"),
|
||
("c", "copy_log", "Copier log"),
|
||
]
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self._offsets = {vm["name"]: 0 for vm in vms}
|
||
self._selected = vms[0]["name"] if vms else None
|
||
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). Valeur = (état, code,
|
||
# durée à la complétion).
|
||
self._final = {}
|
||
# 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 = {}
|
||
|
||
@staticmethod
|
||
def _fmt(secs):
|
||
mm, ss = divmod(int(secs), 60)
|
||
return f"{mm:02d}:{ss:02d}"
|
||
|
||
def _set_cell(self, table, name, col, value):
|
||
key = (name, col)
|
||
if self._cells.get(key) == value:
|
||
return
|
||
self._cells[key] = value
|
||
table.update_cell(name, col, value)
|
||
|
||
def compose(self) -> ComposeResult:
|
||
yield Header(show_clock=True)
|
||
table = DataTable(id="vms", cursor_type="row")
|
||
# Clés de colonnes explicites : update_cell() les référence.
|
||
table.add_column("VM", key="vm")
|
||
table.add_column("État", key="state")
|
||
table.add_column("Durée", key="elapsed")
|
||
for vm in vms:
|
||
table.add_row(vm["name"], "⏳", "00:00", 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
|
||
)
|
||
with Horizontal():
|
||
yield table
|
||
yield self._log
|
||
yield Static("", id="sshbar")
|
||
yield Footer()
|
||
|
||
def on_mount(self) -> None:
|
||
# Le NOMBRE de VM figure dans le titre ; le sous-titre suit la
|
||
# progression (terminées / total + durée globale).
|
||
self.title = f"ERPLibre — {t('install monitoring')} ({len(vms)} VM)"
|
||
self.sub_title = f"0/{len(vms)} {t('completed')}"
|
||
self._refresh_ssh()
|
||
self._load_selected_log(reset=True)
|
||
# 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 -------------------------------------------------------- #
|
||
def _vm_by_name(self, name):
|
||
return next((v for v in vms if v["name"] == name), None)
|
||
|
||
def _refresh_ssh(self):
|
||
vm = self._vm_by_name(self._selected)
|
||
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" Log : {vm['log']}"
|
||
)
|
||
|
||
def _load_selected_log(self, reset=False):
|
||
vm = self._vm_by_name(self._selected)
|
||
if not vm:
|
||
return
|
||
name = vm["name"]
|
||
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)
|
||
|
||
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.
|
||
try:
|
||
table = self.query_one("#vms", DataTable)
|
||
now = time.time()
|
||
for vm in vms:
|
||
name = vm["name"]
|
||
if name in self._final:
|
||
continue # déjà terminé -> plus aucune lecture
|
||
state, code = read_status(vm["log"])
|
||
if state in ("done", "failed"):
|
||
# Fige la durée à la complétion (plus de churn ensuite).
|
||
elapsed = now - started
|
||
self._final[name] = (state, code, elapsed)
|
||
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×.
|
||
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 »).
|
||
done = len(self._final)
|
||
self.sub_title = (
|
||
f"{done}/{len(vms)} {t('completed')} · "
|
||
f"{self._fmt(now - started)}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _tick_log(self):
|
||
if self._follow:
|
||
try:
|
||
self._load_selected_log()
|
||
except Exception:
|
||
pass
|
||
|
||
# -- events --------------------------------------------------------- #
|
||
def on_data_table_row_highlighted(self, event) -> None:
|
||
name = event.row_key.value
|
||
if name and name != self._selected:
|
||
self._selected = name
|
||
self._refresh_ssh()
|
||
self._load_selected_log(reset=True)
|
||
|
||
def action_follow(self) -> None:
|
||
self._follow = not self._follow
|
||
|
||
def action_ssh(self) -> None:
|
||
vm = self._vm_by_name(self._selected)
|
||
if not vm:
|
||
return
|
||
with self.suspend():
|
||
os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || 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)."""
|
||
vm = self._vm_by_name(self._selected)
|
||
if not vm:
|
||
return
|
||
try:
|
||
text = Path(vm["log"]).read_text(errors="replace")
|
||
except OSError:
|
||
return
|
||
self.copy_to_clipboard(text)
|
||
self.notify(
|
||
f"Log de {vm['name']} copié ({len(text)} car.)",
|
||
title="Presse-papiers",
|
||
)
|
||
|
||
Monitor().run()
|