[IMP] script todo: dashboard — télémétrie CPU/disque, ETA historique, web CLI
Ajouts au suivi d'installation (dashboard Textual) : - Télémétrie hôte (barre dédiée, MAJ 2 s) : CPU % = charge / nb CPU, et disque du dossier des images (utilisé/total/libre) — utile pour anticiper le remplissage du disque (cas vécu). - Colonne « Disque » par VM : taille RÉELLE du qcow2 (st_blocks) -> on voit quelle VM grossit. - ETA « hypothèse » : les durées d'install sont MÉMORISÉES par architecture dans ~/.erplibre/qemu-install/stats.json ; l'ETA du parc = médiane historique (par arch, repli global) - temps écoulé, affichée au sous-titre. - Touche « w » : ouvre l'UI web de la VM (Odoo :8069) dans un navigateur CLI (browsh/carbonyl/w3m/links/elinks/lynx, le 1er trouvé ; sinon notifie quoi installer). Surtout utile quand l'install est terminée. Helpers module : load_stats/record_duration/eta_reference, _fmt_size/_fmt_secs, vm_disk_path/disk_actual_size, cli_browser. run_monitor(run_app=False) expose l'app pour test headless. Validé headless (run_test) : montage, ticks, enregistrement des durées, ETA, télémétrie, touches f/w — sans crash. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fdbec6d19d
commit
6f70da98d5
1 changed files with 173 additions and 15 deletions
|
|
@ -16,6 +16,7 @@ from __future__ import annotations
|
|||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
|
@ -174,11 +175,102 @@ def read_status(log_path: str) -> tuple[str, int | None]:
|
|||
return "running", None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Télémétrie, historique de durées (ETA), navigateur CLI
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _stats_path() -> Path:
|
||||
"""Fichier d'historique persistant (durées d'installation par arch)."""
|
||||
return session_dir() / "stats.json"
|
||||
|
||||
|
||||
def load_stats() -> dict:
|
||||
try:
|
||||
return json.loads(_stats_path().read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def record_duration(arch: str, secs: float) -> None:
|
||||
"""Mémorise la durée d'une install (par architecture) pour estimer l'ETA
|
||||
des prochaines. On garde les 20 dernières valeurs par arch."""
|
||||
data = load_stats()
|
||||
durations = data.setdefault("durations", {})
|
||||
key = arch or "unknown"
|
||||
lst = durations.setdefault(key, [])
|
||||
lst.append(int(secs))
|
||||
durations[key] = lst[-20:]
|
||||
try:
|
||||
_stats_path().write_text(json.dumps(data))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def eta_reference(stats: dict, arch: str) -> int | None:
|
||||
"""Durée d'install de RÉFÉRENCE (médiane) pour cette arch, d'après
|
||||
l'historique ; repli sur toutes archis confondues. None si aucun historique."""
|
||||
durations = stats.get("durations", {}) or {}
|
||||
lst = durations.get(arch or "unknown") or []
|
||||
if not lst:
|
||||
lst = [x for v in durations.values() for x in v]
|
||||
if not lst:
|
||||
return None
|
||||
s = sorted(lst)
|
||||
return s[len(s) // 2]
|
||||
|
||||
|
||||
def _fmt_size(nbytes) -> str:
|
||||
"""Octets -> « 1.2G » / « 345M » / « 12K »."""
|
||||
if nbytes is None:
|
||||
return "-"
|
||||
for unit, div in (("T", 1 << 40), ("G", 1 << 30), ("M", 1 << 20)):
|
||||
if nbytes >= div:
|
||||
return f"{nbytes / div:.1f}{unit}"
|
||||
return f"{nbytes // 1024}K"
|
||||
|
||||
|
||||
def _fmt_secs(secs) -> str:
|
||||
"""Secondes -> « 45s » / « 12m » / « 1h05 »."""
|
||||
secs = int(secs)
|
||||
if secs < 60:
|
||||
return f"{secs}s"
|
||||
if secs < 3600:
|
||||
return f"{secs // 60}m"
|
||||
return f"{secs // 3600}h{(secs % 3600) // 60:02d}"
|
||||
|
||||
|
||||
def vm_disk_path(vm: dict) -> str:
|
||||
"""Chemin du qcow2 de la VM (défaut libvirt si non fourni)."""
|
||||
return vm.get("disk") or f"/var/lib/libvirt/images/{vm['name']}.qcow2"
|
||||
|
||||
|
||||
def disk_actual_size(path: str) -> int | None:
|
||||
"""Taille RÉELLEMENT occupée du qcow2 (creux) via st_blocks."""
|
||||
try:
|
||||
st = os.stat(path)
|
||||
return int(getattr(st, "st_blocks", 0)) * 512
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
# Navigateurs web en ligne de commande, par ordre de préférence (rendu JS
|
||||
# d'abord — utile pour l'UI Odoo — puis navigateurs texte classiques).
|
||||
CLI_BROWSERS = ("browsh", "carbonyl", "w3m", "links", "elinks", "lynx")
|
||||
|
||||
|
||||
def cli_browser() -> str | None:
|
||||
"""Premier navigateur CLI disponible dans le PATH, sinon None."""
|
||||
for name in CLI_BROWSERS:
|
||||
if shutil.which(name):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Dashboard Textual
|
||||
# --------------------------------------------------------------------------- #
|
||||
def run_monitor(manifest_path: str) -> None:
|
||||
"""Ouvre le dashboard Textual sur un manifeste d'installation."""
|
||||
def run_monitor(manifest_path: str, run_app: bool = True):
|
||||
"""Ouvre le dashboard Textual sur un manifeste d'installation. `run_app`
|
||||
à False renvoie l'instance sans la lancer (tests headless)."""
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.widgets import DataTable, Footer, Header, RichLog, Static
|
||||
|
|
@ -196,13 +288,15 @@ def run_monitor(manifest_path: str) -> None:
|
|||
|
||||
class Monitor(App):
|
||||
CSS = """
|
||||
DataTable { width: 40; border: solid $accent; }
|
||||
DataTable { width: 58; border: solid $accent; }
|
||||
RichLog { border: solid $accent; }
|
||||
#telemetry { height: 1; color: $text-muted; }
|
||||
#sshbar { height: 2; color: $text-muted; }
|
||||
"""
|
||||
BINDINGS = [
|
||||
("q", "quit", "Quitter (détaché)"),
|
||||
("s", "ssh", "SSH"),
|
||||
("w", "web", "Web (navigateur CLI)"),
|
||||
("f", "follow", "Suivre"),
|
||||
("c", "copy_log", "Copier log"),
|
||||
]
|
||||
|
|
@ -219,6 +313,9 @@ def run_monitor(manifest_path: str) -> None:
|
|||
# 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 = {}
|
||||
# Historique de durées (ETA) + dossier disque à surveiller.
|
||||
self._stats = load_stats()
|
||||
self._disk_dir = os.path.dirname(vm_disk_path(vms[0])) if vms else "/"
|
||||
|
||||
@staticmethod
|
||||
def _fmt(secs):
|
||||
|
|
@ -239,8 +336,9 @@ def run_monitor(manifest_path: str) -> None:
|
|||
table.add_column("VM", key="vm")
|
||||
table.add_column("État", key="state")
|
||||
table.add_column("Durée", key="elapsed")
|
||||
table.add_column("Disque", key="disk")
|
||||
for vm in vms:
|
||||
table.add_row(vm["name"], "⏳", "00:00", key=vm["name"])
|
||||
table.add_row(vm["name"], "⏳", "--:--", "-", 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
|
||||
|
|
@ -248,6 +346,8 @@ def run_monitor(manifest_path: str) -> None:
|
|||
with Horizontal():
|
||||
yield table
|
||||
yield self._log
|
||||
# Barre de télémétrie hôte (CPU, disque, ETA parc).
|
||||
yield Static("", id="telemetry")
|
||||
yield Static("", id="sshbar")
|
||||
yield Footer()
|
||||
|
||||
|
|
@ -272,8 +372,8 @@ def run_monitor(manifest_path: str) -> None:
|
|||
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" {vm['ssh']} (s = SSH · w = web :8069 · "
|
||||
"c = copier le log · Maj+glisser = sélectionner)\n"
|
||||
f" Log : {vm['log']}"
|
||||
)
|
||||
|
||||
|
|
@ -305,32 +405,66 @@ def run_monitor(manifest_path: str) -> None:
|
|||
try:
|
||||
table = self.query_one("#vms", DataTable)
|
||||
now = time.time()
|
||||
remaining = [] # ETA restant par VM en cours
|
||||
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))),
|
||||
)
|
||||
if name in self._final:
|
||||
continue # déjà terminé -> plus aucune lecture
|
||||
continue # déjà terminé -> plus de lecture de statut
|
||||
state, code = read_status(vm["log"])
|
||||
if state in ("done", "failed"):
|
||||
# Fige la durée à la complétion (plus de churn ensuite).
|
||||
# Fige la durée + MÉMORISE pour l'ETA des prochaines.
|
||||
elapsed = now - started
|
||||
self._final[name] = (state, code, elapsed)
|
||||
if state == "done":
|
||||
record_duration(vm.get("arch"), elapsed)
|
||||
self._stats = load_stats()
|
||||
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×.
|
||||
# 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])
|
||||
# Sous-titre : compteur de VM terminées + durée globale (UNE
|
||||
# seule mise à jour, au lieu de 16 cellules « Durée »).
|
||||
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))}"
|
||||
self.sub_title = (
|
||||
f"{done}/{len(vms)} {t('completed')} · "
|
||||
f"{self._fmt(now - started)}"
|
||||
f"{self._fmt(now - started)}{eta}"
|
||||
)
|
||||
self._update_telemetry()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _update_telemetry(self):
|
||||
"""Barre de télémétrie hôte : CPU % (charge/CPU) + disque du
|
||||
dossier des images (là où les qcow2 grossissent)."""
|
||||
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)}"
|
||||
)
|
||||
self.query_one("#telemetry", Static).update(txt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -359,6 +493,27 @@ def run_monitor(manifest_path: str) -> None:
|
|||
with self.suspend():
|
||||
os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || true")
|
||||
|
||||
def action_web(self) -> None:
|
||||
"""Ouvre l'UI web de la VM (Odoo :8069) dans un navigateur CLI
|
||||
(browsh/carbonyl/w3m/links/elinks/lynx). Surtout utile une fois
|
||||
l'installation TERMINÉE."""
|
||||
vm = self._vm_by_name(self._selected)
|
||||
if not vm or not vm.get("ip"):
|
||||
self.notify("Pas d'IP pour cette VM.", title="Web")
|
||||
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
|
||||
url = f"http://{vm['ip']}:8069"
|
||||
with self.suspend():
|
||||
os.system(f"{browser} {shlex.quote(url)} || 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)."""
|
||||
|
|
@ -375,4 +530,7 @@ def run_monitor(manifest_path: str) -> None:
|
|||
title="Presse-papiers",
|
||||
)
|
||||
|
||||
Monitor().run()
|
||||
app = Monitor()
|
||||
if run_app:
|
||||
app.run()
|
||||
return app
|
||||
|
|
|
|||
Loading…
Reference in a new issue