2026-07-28 00:27:25 -04:00
|
|
|
|
#!/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
|
|
|
|
|
|
|
2026-07-29 06:09:06 -04:00
|
|
|
|
import asyncio
|
2026-07-28 00:27:25 -04:00
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import shlex
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
import shutil
|
2026-07-30 01:52:55 -04:00
|
|
|
|
import socket
|
2026-07-28 00:27:25 -04:00
|
|
|
|
import subprocess
|
|
|
|
|
|
import time
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
2026-07-28 04:15:46 -04:00
|
|
|
|
try:
|
|
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
|
|
except Exception: # pragma: no cover - repli si i18n indisponible
|
|
|
|
|
|
|
|
|
|
|
|
def t(key: str) -> str:
|
|
|
|
|
|
return key
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 00:27:25 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 00:22:37 -04:00
|
|
|
|
def list_install_runs() -> list:
|
|
|
|
|
|
"""Runs d'installation passés (chacun a un session.json), TRIÉS du plus
|
|
|
|
|
|
récent au plus ancien. Chaque entrée : dict {label, manifest, mtime,
|
|
|
|
|
|
vms, branch}. Permet de ROUVRIR le suivi d'un run (le dashboard s'étant
|
|
|
|
|
|
fermé sur un bug, on reprend l'analyse)."""
|
|
|
|
|
|
runs = []
|
|
|
|
|
|
for d in sorted(session_dir().glob("*/"), reverse=True):
|
|
|
|
|
|
manifest = d / "session.json"
|
|
|
|
|
|
if not manifest.is_file():
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = json.loads(manifest.read_text())
|
|
|
|
|
|
mtime = manifest.stat().st_mtime
|
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
|
continue
|
|
|
|
|
|
runs.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"label": d.name.rstrip("/"),
|
|
|
|
|
|
"manifest": str(manifest),
|
|
|
|
|
|
"mtime": mtime,
|
|
|
|
|
|
"vms": data.get("vms", []),
|
|
|
|
|
|
"branch": data.get("branch", ""),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
runs.sort(key=lambda r: r["mtime"], reverse=True)
|
|
|
|
|
|
return runs
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 00:27:25 -04:00
|
|
|
|
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."""
|
2026-07-28 03:19:17 -04:00
|
|
|
|
# Sonde de disponibilité : on attend que sshd réponde ET que cloud-init
|
2026-07-28 04:15:46 -04:00
|
|
|
|
# soit TERMINÉ, via des connexions COURTES successives (jusqu'à ~20 min :
|
|
|
|
|
|
# une architecture ÉMULÉE, s390x/arm64 sur hôte x86, boote lentement).
|
2026-07-28 03:19:17 -04:00
|
|
|
|
# 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"
|
|
|
|
|
|
)
|
2026-07-28 04:15:46 -04:00
|
|
|
|
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")
|
2026-07-28 00:27:25 -04:00
|
|
|
|
wrapper = (
|
2026-07-28 04:15:46 -04:00
|
|
|
|
f"echo {shlex.quote('== ' + msg_wait + ' ==')} >> {log_q}; "
|
|
|
|
|
|
f"echo {shlex.quote(' ' + msg_slow)} >> {log_q}; "
|
|
|
|
|
|
f"for i in $(seq 1 240); do "
|
2026-07-28 03:19:17 -04:00
|
|
|
|
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;; "
|
2026-07-28 04:15:46 -04:00
|
|
|
|
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}; "
|
2026-07-28 00:27:25 -04:00
|
|
|
|
f"ssh {SSH_OPTS} erplibre@{ip} {shlex.quote(remote_cmd)} "
|
2026-07-28 04:15:46 -04:00
|
|
|
|
f">> {log_q} 2>&1; "
|
|
|
|
|
|
f'echo "{EXIT_MARKER} $?" >> {log_q}'
|
2026-07-28 00:27:25 -04:00
|
|
|
|
)
|
|
|
|
|
|
# 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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 04:57:36 -04:00
|
|
|
|
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"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 00:27:25 -04:00
|
|
|
|
def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
|
2026-07-28 04:57:36 -04:00
|
|
|
|
"""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."""
|
2026-07-28 00:27:25 -04:00
|
|
|
|
sdir = session_dir()
|
|
|
|
|
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
2026-07-28 04:57:36 -04:00
|
|
|
|
when = time.strftime("%Y-%m-%d %H:%M:%S")
|
2026-07-28 00:27:25 -04:00
|
|
|
|
logdir = sdir / stamp
|
|
|
|
|
|
logdir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
entries = []
|
|
|
|
|
|
for vm in vms:
|
|
|
|
|
|
log_path = str(logdir / f"{vm['name']}.log")
|
2026-07-28 04:57:36 -04:00
|
|
|
|
# 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))
|
2026-07-28 00:27:25 -04:00
|
|
|
|
_launch_one(vm["ip"], remote_cmd, log_path)
|
|
|
|
|
|
entries.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": vm["name"],
|
|
|
|
|
|
"ip": vm["ip"],
|
2026-07-28 04:57:36 -04:00
|
|
|
|
"distro": vm.get("distro"),
|
|
|
|
|
|
"version": vm.get("version"),
|
|
|
|
|
|
"arch": vm.get("arch"),
|
2026-07-28 00:27:25 -04:00
|
|
|
|
"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]:
|
2026-07-29 05:01:39 -04:00
|
|
|
|
"""(é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)."""
|
2026-07-28 00:27:25 -04:00
|
|
|
|
try:
|
2026-07-29 05:01:39 -04:00
|
|
|
|
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")
|
2026-07-28 00:27:25 -04:00
|
|
|
|
except OSError:
|
|
|
|
|
|
return "pending", None
|
2026-07-29 05:01:39 -04:00
|
|
|
|
if not tail.strip():
|
2026-07-28 00:27:25 -04:00
|
|
|
|
return "pending", None
|
2026-07-29 05:01:39 -04:00
|
|
|
|
for line in reversed(tail.splitlines()):
|
2026-07-28 00:27:25 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 07:40:43 -04:00
|
|
|
|
# Listes d'ignore reprises de script/test/run_parallel_test.py (erreurs/
|
|
|
|
|
|
# avertissements connus et bénins) : on réutilise la MÊME logique de détection
|
|
|
|
|
|
# que la suite de tests ERPLibre pour analyser les logs d'installation.
|
|
|
|
|
|
_LST_IGNORE_WARNING = (
|
|
|
|
|
|
"have the same label:",
|
|
|
|
|
|
"odoo.addons.code_generator.extractor_module_file: Ignore next error about"
|
|
|
|
|
|
" ALTER TABLE DROP CONSTRAINT.",
|
|
|
|
|
|
)
|
|
|
|
|
|
_LST_IGNORE_ERROR = (
|
|
|
|
|
|
"fetchmail_notify_error_to_sender",
|
|
|
|
|
|
'odoo.sql_db: bad query: ALTER TABLE "db_backup" DROP CONSTRAINT'
|
|
|
|
|
|
' "db_backup_db_backup_name_unique"',
|
|
|
|
|
|
'ERROR: constraint "db_backup_db_backup_name_unique" of relation'
|
|
|
|
|
|
' "db_backup" does not exist',
|
|
|
|
|
|
'odoo.sql_db: bad query: ALTER TABLE "db_backup" DROP CONSTRAINT'
|
|
|
|
|
|
' "db_backup_db_backup_days_to_keep_positive"',
|
|
|
|
|
|
'ERROR: constraint "db_backup_db_backup_days_to_keep_positive" of relation'
|
|
|
|
|
|
' "db_backup" does not exist',
|
|
|
|
|
|
"odoo.addons.code_generator.extractor_module_file: Ignore next error about"
|
|
|
|
|
|
" ALTER TABLE DROP CONSTRAINT.",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 22:16:35 -04:00
|
|
|
|
def scan_log_error_lines(log_path: str, cap: int = 500) -> tuple[list, list]:
|
|
|
|
|
|
"""(lignes_erreur, lignes_avertissement) d'un log, même détection que
|
|
|
|
|
|
scan_log_errors mais on RETIENT les lignes (bornées à `cap`) pour les
|
|
|
|
|
|
afficher. Chaque ligne est préfixée de son numéro (1-indexé)."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
text = Path(log_path).read_text(errors="replace")
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
return [], []
|
|
|
|
|
|
errs, warns = [], []
|
|
|
|
|
|
for i, line in enumerate(text.splitlines(), 1):
|
|
|
|
|
|
if EXIT_MARKER in line:
|
|
|
|
|
|
continue
|
|
|
|
|
|
low = line.lower()
|
|
|
|
|
|
if (
|
|
|
|
|
|
"error" in low
|
|
|
|
|
|
and not any(ig in line for ig in _LST_IGNORE_ERROR)
|
|
|
|
|
|
and len(errs) < cap
|
|
|
|
|
|
):
|
|
|
|
|
|
errs.append(f"{i}: {line}")
|
|
|
|
|
|
if (
|
|
|
|
|
|
"warning" in low
|
|
|
|
|
|
and not any(ig in line for ig in _LST_IGNORE_WARNING)
|
|
|
|
|
|
and len(warns) < cap
|
|
|
|
|
|
):
|
|
|
|
|
|
warns.append(f"{i}: {line}")
|
|
|
|
|
|
return errs, warns
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 07:40:43 -04:00
|
|
|
|
def scan_log_errors(log_path: str) -> tuple[int, int]:
|
|
|
|
|
|
"""(nb_erreurs, nb_avertissements) dans un log d'installation, en
|
|
|
|
|
|
réutilisant la détection de la suite de tests ERPLibre : sous-chaîne
|
|
|
|
|
|
« error »/« warning » (insensible à la casse) moins les listes d'ignore.
|
|
|
|
|
|
Lit le fichier COMPLET (appelé une seule fois, à la complétion d'une VM)."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
text = Path(log_path).read_text(errors="replace")
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
return 0, 0
|
|
|
|
|
|
nerr = nwarn = 0
|
|
|
|
|
|
for line in text.splitlines():
|
|
|
|
|
|
low = line.lower()
|
|
|
|
|
|
if EXIT_MARKER in line:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if "error" in low and not any(
|
|
|
|
|
|
ig in line for ig in _LST_IGNORE_ERROR
|
|
|
|
|
|
):
|
|
|
|
|
|
nerr += 1
|
|
|
|
|
|
if "warning" in low and not any(
|
|
|
|
|
|
ig in line for ig in _LST_IGNORE_WARNING
|
|
|
|
|
|
):
|
|
|
|
|
|
nwarn += 1
|
|
|
|
|
|
return nerr, nwarn
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 01:52:55 -04:00
|
|
|
|
def _port_open(ip: str, port: int = 8069, timeout: float = 0.5) -> bool:
|
|
|
|
|
|
"""Vrai si un TCP connect réussit (l'UI web Odoo écoute sur :8069)."""
|
|
|
|
|
|
if not ip:
|
|
|
|
|
|
return False
|
|
|
|
|
|
try:
|
|
|
|
|
|
with socket.create_connection((ip, port), timeout=timeout):
|
|
|
|
|
|
return True
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 06:09:06 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 01:31:00 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# Télémétrie, historique de durées (ETA), navigateur CLI
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _stats_path() -> Path:
|
2026-07-29 06:46:21 -04:00
|
|
|
|
"""Fichier d'historique persistant des installations. DÉDIÉ dans
|
|
|
|
|
|
.venv.erplibre du dépôt (repli sur ~/.erplibre si le venv est absent)."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
venv = Path(__file__).resolve().parents[2] / ".venv.erplibre"
|
|
|
|
|
|
venv.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
return venv / "qemu_install_stats.json"
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
return session_dir() / "stats.json"
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_stats() -> dict:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return json.loads(_stats_path().read_text())
|
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 06:46:21 -04:00
|
|
|
|
def record_duration(distro, version, arch, secs) -> None:
|
|
|
|
|
|
"""Enregistre une install (distro + version + archi + durée + horodatage)
|
|
|
|
|
|
dans l'historique, pour l'ETA et les moyennes par archi/distro. Garde les
|
|
|
|
|
|
500 derniers runs."""
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
data = load_stats()
|
2026-07-29 06:46:21 -04:00
|
|
|
|
runs = data.setdefault("runs", [])
|
|
|
|
|
|
runs.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"distro": distro or "?",
|
|
|
|
|
|
"version": version or "?",
|
|
|
|
|
|
"arch": arch or "?",
|
|
|
|
|
|
"seconds": int(secs),
|
|
|
|
|
|
"ts": int(time.time()),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
data["runs"] = runs[-500:]
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
try:
|
2026-07-29 06:46:21 -04:00
|
|
|
|
_stats_path().write_text(json.dumps(data, ensure_ascii=False))
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 06:46:21 -04:00
|
|
|
|
def _runs(stats=None):
|
|
|
|
|
|
return (stats or load_stats()).get("runs", []) or []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def eta_reference(stats, arch):
|
|
|
|
|
|
"""Durée d'install de RÉFÉRENCE (médiane) pour cette archi ; repli toutes
|
|
|
|
|
|
archis confondues. None si aucun historique."""
|
|
|
|
|
|
runs = _runs(stats)
|
|
|
|
|
|
secs = [r["seconds"] for r in runs if r.get("arch") == arch]
|
|
|
|
|
|
if not secs:
|
|
|
|
|
|
secs = [r["seconds"] for r in runs]
|
|
|
|
|
|
if not secs:
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
return None
|
2026-07-29 06:46:21 -04:00
|
|
|
|
s = sorted(secs)
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
return s[len(s) // 2]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 06:46:21 -04:00
|
|
|
|
def _avg(field, value, stats=None):
|
|
|
|
|
|
secs = [r["seconds"] for r in _runs(stats) if r.get(field) == value]
|
|
|
|
|
|
return (sum(secs) / len(secs)) if secs else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def avg_by_arch(arch, stats=None):
|
|
|
|
|
|
"""(moyenne_secondes, nb_runs) pour cette archi, ou (None, 0)."""
|
|
|
|
|
|
secs = [r["seconds"] for r in _runs(stats) if r.get("arch") == arch]
|
|
|
|
|
|
return (sum(secs) / len(secs), len(secs)) if secs else (None, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def avg_by_distro(distro, stats=None):
|
|
|
|
|
|
"""(moyenne_secondes, nb_runs) pour cette distro, ou (None, 0)."""
|
|
|
|
|
|
secs = [r["seconds"] for r in _runs(stats) if r.get("distro") == distro]
|
|
|
|
|
|
return (sum(secs) / len(secs), len(secs)) if secs else (None, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 07:29:53 -04:00
|
|
|
|
def avg_by_version(distro, version, stats=None):
|
|
|
|
|
|
"""(moyenne_secondes, nb_runs) pour cette (distro, version), ou (None, 0)."""
|
|
|
|
|
|
secs = [
|
|
|
|
|
|
r["seconds"]
|
|
|
|
|
|
for r in _runs(stats)
|
|
|
|
|
|
if r.get("distro") == distro and r.get("version") == version
|
|
|
|
|
|
]
|
|
|
|
|
|
return (sum(secs) / len(secs), len(secs)) if secs else (None, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 06:46:21 -04:00
|
|
|
|
def last_run(stats=None):
|
|
|
|
|
|
"""Dernier run enregistré (dict) ou None."""
|
|
|
|
|
|
runs = _runs(stats)
|
|
|
|
|
|
return runs[-1] if runs else None
|
|
|
|
|
|
|
|
|
|
|
|
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 06:09:06 -04:00
|
|
|
|
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 ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 06:51:54 -04:00
|
|
|
|
# Navigateurs CLI installables via apt/dnf/pacman (nom de paquet = binaire).
|
|
|
|
|
|
# browsh/carbonyl ne sont pas dans les dépôts standard -> non proposés ici.
|
|
|
|
|
|
INSTALLABLE_BROWSERS = (
|
|
|
|
|
|
("w3m", "w3m — léger, rend un peu de HTML"),
|
|
|
|
|
|
("lynx", "lynx — navigateur texte"),
|
|
|
|
|
|
("links", "links — texte / graphique"),
|
|
|
|
|
|
("elinks", "elinks — texte, onglets"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def browser_install_command(browser="w3m") -> list | None:
|
|
|
|
|
|
"""Commande d'installation du navigateur CLI `browser` adaptée à l'OS hôte :
|
|
|
|
|
|
apt (Ubuntu/Debian), dnf (Fedora), pacman (Arch). None si gestionnaire
|
|
|
|
|
|
inconnu."""
|
|
|
|
|
|
apt = ["sudo", "apt-get", "install", "-y", browser]
|
|
|
|
|
|
dnf = ["sudo", "dnf", "install", "-y", browser]
|
|
|
|
|
|
pac = ["sudo", "pacman", "-S", "--needed", "--noconfirm", browser]
|
2026-07-29 06:09:06 -04:00
|
|
|
|
by_id = {
|
|
|
|
|
|
"ubuntu": apt,
|
|
|
|
|
|
"debian": apt,
|
|
|
|
|
|
"linuxmint": apt,
|
|
|
|
|
|
"fedora": dnf,
|
|
|
|
|
|
"arch": pac,
|
|
|
|
|
|
}
|
|
|
|
|
|
cmd = by_id.get(_os_id())
|
|
|
|
|
|
if cmd:
|
|
|
|
|
|
return cmd
|
|
|
|
|
|
if shutil.which("apt-get"):
|
|
|
|
|
|
return apt
|
|
|
|
|
|
if shutil.which("dnf"):
|
|
|
|
|
|
return dnf
|
|
|
|
|
|
if shutil.which("pacman"):
|
|
|
|
|
|
return pac
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 05:30:16 -04:00
|
|
|
|
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
|
|
|
|
|
|
pour tout le parc (à interroger à intervalle LENT)."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
res = subprocess.run(
|
|
|
|
|
|
["sudo", "virsh", "list", "--all"],
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
timeout=15,
|
2026-07-30 01:04:07 -04:00
|
|
|
|
# LC_ALL=C : sortie en ANGLAIS (« running »/« paused »/« shut off »
|
|
|
|
|
|
# + en-tête « Id Name State ») quelle que soit la locale de l'hôte.
|
|
|
|
|
|
env={**os.environ, "LC_ALL": "C", "LANG": "C"},
|
2026-07-29 05:30:16 -04:00
|
|
|
|
)
|
|
|
|
|
|
except (OSError, subprocess.SubprocessError):
|
|
|
|
|
|
return {}
|
|
|
|
|
|
states = {}
|
|
|
|
|
|
for line in res.stdout.splitlines():
|
|
|
|
|
|
parts = line.split()
|
|
|
|
|
|
# Ignore l'en-tête (« Id Name State ») et le séparateur (« ---- »,
|
|
|
|
|
|
# un seul token). Une VM éteinte a « - » en Id : à NE PAS ignorer.
|
|
|
|
|
|
if len(parts) < 3 or parts[0] == "Id":
|
|
|
|
|
|
continue
|
|
|
|
|
|
states[parts[1]] = " ".join(parts[2:])
|
|
|
|
|
|
return states
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 00:27:25 -04:00
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# Dashboard Textual
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
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)."""
|
2026-07-28 00:27:25 -04:00
|
|
|
|
from textual.app import App, ComposeResult
|
2026-07-29 22:16:35 -04:00
|
|
|
|
from textual.containers import Horizontal, Vertical
|
|
|
|
|
|
from textual.screen import ModalScreen
|
2026-07-28 00:27:25 -04:00
|
|
|
|
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"]
|
|
|
|
|
|
|
2026-07-29 22:16:35 -04:00
|
|
|
|
class ErrorLinesScreen(ModalScreen):
|
|
|
|
|
|
"""Petite fenêtre modale : liste les LIGNES d'erreurs/avertissements
|
|
|
|
|
|
d'une VM pour les lire (défilable). Échap / q pour fermer."""
|
|
|
|
|
|
|
|
|
|
|
|
BINDINGS = [
|
|
|
|
|
|
("escape", "dismiss", "Fermer"),
|
|
|
|
|
|
("q", "dismiss", "Fermer"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, vm_name, errs, warns):
|
|
|
|
|
|
super().__init__()
|
|
|
|
|
|
self._vm = vm_name
|
|
|
|
|
|
self._errs = errs
|
|
|
|
|
|
self._warns = warns
|
|
|
|
|
|
|
|
|
|
|
|
def compose(self) -> ComposeResult:
|
|
|
|
|
|
with Vertical(id="errbox"):
|
|
|
|
|
|
yield Static(
|
|
|
|
|
|
f" {self._vm} — ⚠ {len(self._errs)} "
|
|
|
|
|
|
f"{t('errors')} · ⚡ {len(self._warns)} {t('warnings')}"
|
|
|
|
|
|
f" ({t('Esc to close')})",
|
|
|
|
|
|
id="errtitle",
|
|
|
|
|
|
)
|
|
|
|
|
|
yield RichLog(
|
|
|
|
|
|
id="errlog", highlight=False, markup=False, wrap=True
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def on_mount(self) -> None:
|
|
|
|
|
|
log = self.query_one("#errlog", RichLog)
|
|
|
|
|
|
if self._errs:
|
|
|
|
|
|
log.write(f"── {t('errors').capitalize()} ──")
|
|
|
|
|
|
for line in self._errs:
|
|
|
|
|
|
log.write(line)
|
|
|
|
|
|
if self._warns:
|
|
|
|
|
|
log.write(f"── {t('warnings').capitalize()} ──")
|
|
|
|
|
|
for line in self._warns:
|
|
|
|
|
|
log.write(line)
|
|
|
|
|
|
if not self._errs and not self._warns:
|
|
|
|
|
|
log.write(t("No error detected."))
|
|
|
|
|
|
|
|
|
|
|
|
def action_dismiss(self) -> None:
|
|
|
|
|
|
self.dismiss()
|
|
|
|
|
|
|
2026-07-28 00:27:25 -04:00
|
|
|
|
ICON = {
|
|
|
|
|
|
"pending": "⏳",
|
|
|
|
|
|
"running": "⏳",
|
|
|
|
|
|
"done": "✅",
|
|
|
|
|
|
"failed": "❌",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class Monitor(App):
|
|
|
|
|
|
CSS = """
|
[IMP] qemu: table lisible/scrollable, guest-exec autorisé, service Odoo, choix navigateur
Suivi d'installation (qemu_install_monitor.py) :
- Table VM/État LISIBLE : largeurs de colonnes fixes (VM 26, ⚠ 4, État 12,
Durée 7, Disque 8) -> l'État n'est plus tronqué à 3-4 caractères
(« ❌ effacée », « ⏸ en pause » lisibles) ; table défilable (overflow-x,
height 1fr) pour les longs noms et les gros parcs.
- « w » web : offre désormais la LISTE des navigateurs CLI installés
(_choose_browser) pour choisir lequel utiliser, + option [i] installer.
Déploiement (deploy_qemu.py) :
- guest-exec AUTORISÉ : on vide la liste de blocage de qemu-ga
(block-rpcs/blacklist vides — pas allow-rpcs qui est une liste BLANCHE et
casserait les autres RPC), + neutralise /etc/sysconfig/qemu-ga (Fedora).
Installation (todo.py) :
- Profils AVEC Odoo (install_odoo*) UNIQUEMENT : Odoo est enregistré comme
service systemd (erplibre.service, inspiré de script/systemd/
install_daemon.sh) puis enable --now. Pas pour ERPLibre seul / mobile /
Déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 08:06:29 -04:00
|
|
|
|
DataTable { width: 74; height: 1fr; overflow-x: auto; border: solid $accent; }
|
2026-07-28 00:27:25 -04:00
|
|
|
|
RichLog { border: solid $accent; }
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
#telemetry { height: 1; color: $text-muted; }
|
2026-07-29 07:40:43 -04:00
|
|
|
|
#stats { height: 1; color: $accent; }
|
|
|
|
|
|
#statsdetail { display: none; height: auto; color: $text-muted; }
|
2026-07-28 02:10:58 -04:00
|
|
|
|
#sshbar { height: 2; color: $text-muted; }
|
2026-07-29 22:16:35 -04:00
|
|
|
|
ErrorLinesScreen { align: center middle; }
|
|
|
|
|
|
#errbox {
|
|
|
|
|
|
width: 80%; height: 70%;
|
|
|
|
|
|
border: thick $accent; background: $surface;
|
|
|
|
|
|
}
|
|
|
|
|
|
#errtitle { height: 1; color: $accent; text-style: bold; }
|
|
|
|
|
|
#errlog { height: 1fr; border: solid $accent; }
|
2026-07-28 00:27:25 -04:00
|
|
|
|
"""
|
|
|
|
|
|
BINDINGS = [
|
|
|
|
|
|
("q", "quit", "Quitter (détaché)"),
|
|
|
|
|
|
("s", "ssh", "SSH"),
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
("w", "web", "Web (navigateur CLI)"),
|
2026-07-28 00:27:25 -04:00
|
|
|
|
("f", "follow", "Suivre"),
|
2026-07-28 00:40:18 -04:00
|
|
|
|
("c", "copy_log", "Copier log"),
|
2026-07-29 22:16:35 -04:00
|
|
|
|
("d", "details", "Détails erreurs"),
|
2026-07-29 07:40:43 -04:00
|
|
|
|
("p", "pause_all", "Pause tout"),
|
|
|
|
|
|
("o", "resume_all", "Reprendre tout"),
|
2026-07-28 00:27:25 -04:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-29 05:01:39 -04:00
|
|
|
|
# Statuts TERMINAUX mémorisés : une VM finie n'est plus relue
|
2026-07-29 05:13:56 -04:00
|
|
|
|
# (réduit fortement l'I/O sur un gros parc). Valeur = (état, code,
|
|
|
|
|
|
# durée à la complétion).
|
2026-07-29 05:01:39 -04:00
|
|
|
|
self._final = {}
|
2026-07-29 05:13:56 -04:00
|
|
|
|
# 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 = {}
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
# 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 "/"
|
2026-07-29 05:30:16 -04:00
|
|
|
|
# État libvirt (running/paused/gone), rafraîchi à intervalle LENT.
|
|
|
|
|
|
self._domstate = {}
|
2026-07-29 07:40:43 -04:00
|
|
|
|
# Erreurs détectées dans le log à la complétion : {nom: (err, warn)}.
|
|
|
|
|
|
self._errcount = {}
|
|
|
|
|
|
# Sommaire de stats déplié (clic) ou non.
|
|
|
|
|
|
self._stats_open = False
|
2026-07-30 01:52:55 -04:00
|
|
|
|
# VM dont l'UI Odoo (:8069) répond déjà : une fois détectée « up »,
|
|
|
|
|
|
# on ne re-teste plus (Odoo ne redescend pas en cours d'install).
|
|
|
|
|
|
self._odoo_up = set()
|
2026-07-30 01:31:00 -04:00
|
|
|
|
# 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
|
2026-07-29 05:13:56 -04:00
|
|
|
|
|
|
|
|
|
|
@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)
|
2026-07-28 00:27:25 -04:00
|
|
|
|
|
|
|
|
|
|
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.
|
2026-07-29 07:40:43 -04:00
|
|
|
|
# La colonne « ⚠ » (erreurs détectées) est à GAUCHE d'« État ».
|
[IMP] qemu: table lisible/scrollable, guest-exec autorisé, service Odoo, choix navigateur
Suivi d'installation (qemu_install_monitor.py) :
- Table VM/État LISIBLE : largeurs de colonnes fixes (VM 26, ⚠ 4, État 12,
Durée 7, Disque 8) -> l'État n'est plus tronqué à 3-4 caractères
(« ❌ effacée », « ⏸ en pause » lisibles) ; table défilable (overflow-x,
height 1fr) pour les longs noms et les gros parcs.
- « w » web : offre désormais la LISTE des navigateurs CLI installés
(_choose_browser) pour choisir lequel utiliser, + option [i] installer.
Déploiement (deploy_qemu.py) :
- guest-exec AUTORISÉ : on vide la liste de blocage de qemu-ga
(block-rpcs/blacklist vides — pas allow-rpcs qui est une liste BLANCHE et
casserait les autres RPC), + neutralise /etc/sysconfig/qemu-ga (Fedora).
Installation (todo.py) :
- Profils AVEC Odoo (install_odoo*) UNIQUEMENT : Odoo est enregistré comme
service systemd (erplibre.service, inspiré de script/systemd/
install_daemon.sh) puis enable --now. Pas pour ERPLibre seul / mobile /
Déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 08:06:29 -04:00
|
|
|
|
# Largeurs FIXES pour les colonnes courtes -> l'État n'est plus
|
|
|
|
|
|
# tronqué (« ❌ effacée », « ⏸ en pause » lisibles) ; la table
|
|
|
|
|
|
# défile horizontalement (overflow-x) pour les noms de VM longs.
|
|
|
|
|
|
table.add_column("VM", key="vm", width=26)
|
|
|
|
|
|
table.add_column("⚠", key="err", width=4)
|
|
|
|
|
|
table.add_column("État", key="state", width=12)
|
2026-07-30 01:52:55 -04:00
|
|
|
|
# « Odoo » : l'UI web répond-elle sur :8069 ? (🟢 up / — down)
|
|
|
|
|
|
table.add_column("Odoo", key="odoo", width=6)
|
[IMP] qemu: table lisible/scrollable, guest-exec autorisé, service Odoo, choix navigateur
Suivi d'installation (qemu_install_monitor.py) :
- Table VM/État LISIBLE : largeurs de colonnes fixes (VM 26, ⚠ 4, État 12,
Durée 7, Disque 8) -> l'État n'est plus tronqué à 3-4 caractères
(« ❌ effacée », « ⏸ en pause » lisibles) ; table défilable (overflow-x,
height 1fr) pour les longs noms et les gros parcs.
- « w » web : offre désormais la LISTE des navigateurs CLI installés
(_choose_browser) pour choisir lequel utiliser, + option [i] installer.
Déploiement (deploy_qemu.py) :
- guest-exec AUTORISÉ : on vide la liste de blocage de qemu-ga
(block-rpcs/blacklist vides — pas allow-rpcs qui est une liste BLANCHE et
casserait les autres RPC), + neutralise /etc/sysconfig/qemu-ga (Fedora).
Installation (todo.py) :
- Profils AVEC Odoo (install_odoo*) UNIQUEMENT : Odoo est enregistré comme
service systemd (erplibre.service, inspiré de script/systemd/
install_daemon.sh) puis enable --now. Pas pour ERPLibre seul / mobile /
Déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 08:06:29 -04:00
|
|
|
|
table.add_column("Durée", key="elapsed", width=7)
|
|
|
|
|
|
table.add_column("Disque", key="disk", width=8)
|
2026-07-28 00:27:25 -04:00
|
|
|
|
for vm in vms:
|
2026-07-29 07:40:43 -04:00
|
|
|
|
table.add_row(
|
2026-07-30 01:52:55 -04:00
|
|
|
|
vm["name"], "", "⏳", "—", "--:--", "-", key=vm["name"]
|
2026-07-29 07:40:43 -04:00
|
|
|
|
)
|
2026-07-29 05:01:39 -04:00
|
|
|
|
# max_lines borne la mémoire/rendu (un install verbeux × 30 VM).
|
|
|
|
|
|
self._log = RichLog(
|
|
|
|
|
|
id="log", highlight=False, markup=False, max_lines=5000
|
|
|
|
|
|
)
|
2026-07-28 00:27:25 -04:00
|
|
|
|
with Horizontal():
|
|
|
|
|
|
yield table
|
|
|
|
|
|
yield self._log
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
# Barre de télémétrie hôte (CPU, disque, ETA parc).
|
|
|
|
|
|
yield Static("", id="telemetry")
|
2026-07-29 07:40:43 -04:00
|
|
|
|
# Sommaire de stats en CHIFFRES (cliquable -> détail).
|
|
|
|
|
|
yield Static("", id="stats")
|
|
|
|
|
|
yield Static("", id="statsdetail")
|
2026-07-28 00:27:25 -04:00
|
|
|
|
yield Static("", id="sshbar")
|
|
|
|
|
|
yield Footer()
|
|
|
|
|
|
|
|
|
|
|
|
def on_mount(self) -> None:
|
2026-07-29 05:13:56 -04:00
|
|
|
|
# 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')}"
|
2026-07-28 00:27:25 -04:00
|
|
|
|
self._refresh_ssh()
|
|
|
|
|
|
self._load_selected_log(reset=True)
|
2026-07-30 01:28:06 -04:00
|
|
|
|
# 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).
|
2026-07-29 05:01:39 -04:00
|
|
|
|
self.set_interval(2.0, self._tick_table)
|
|
|
|
|
|
self.set_interval(1.0, self._tick_log)
|
2026-07-30 01:28:06 -04:00
|
|
|
|
# État libvirt (pause / effacée) : check LENT (appel virsh) toutes
|
|
|
|
|
|
# les 10 s ; le tableau applique le cache à chaque tick (2 s).
|
2026-07-29 05:30:16 -04:00
|
|
|
|
self.set_interval(10.0, self._tick_domstate)
|
2026-07-30 01:28:06 -04:00
|
|
|
|
# Premier relevé domstate immédiat (async -> via worker).
|
|
|
|
|
|
self.run_worker(self._tick_domstate(), exclusive=False)
|
2026-07-28 00:27:25 -04:00
|
|
|
|
|
|
|
|
|
|
# -- 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:
|
2026-07-28 00:40:18 -04:00
|
|
|
|
bar.update(
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
f" {vm['ssh']} (s = SSH · w = web :8069 · "
|
2026-07-29 22:16:35 -04:00
|
|
|
|
"c = copier le log · d = détails erreurs · "
|
|
|
|
|
|
"Maj+glisser = sélectionner)\n"
|
2026-07-28 02:10:58 -04:00
|
|
|
|
f" Log : {vm['log']}"
|
2026-07-28 00:40:18 -04:00
|
|
|
|
)
|
2026-07-28 00:27:25 -04:00
|
|
|
|
|
|
|
|
|
|
def _load_selected_log(self, reset=False):
|
2026-07-30 01:31:00 -04:00
|
|
|
|
# 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.
|
2026-07-28 00:27:25 -04:00
|
|
|
|
vm = self._vm_by_name(self._selected)
|
|
|
|
|
|
if not vm:
|
|
|
|
|
|
return
|
2026-07-29 05:01:39 -04:00
|
|
|
|
name = vm["name"]
|
2026-07-28 00:27:25 -04:00
|
|
|
|
if reset:
|
|
|
|
|
|
self._log.clear()
|
2026-07-30 01:31:00 -04:00
|
|
|
|
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
|
2026-07-29 06:09:06 -04:00
|
|
|
|
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 _collect_tele(self):
|
|
|
|
|
|
"""(THREAD) chaîne de télémétrie hôte : CPU % + disque des images."""
|
2026-07-28 00:27:25 -04:00
|
|
|
|
try:
|
2026-07-29 06:09:06 -04:00
|
|
|
|
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."""
|
2026-07-30 01:52:55 -04:00
|
|
|
|
disks, status, errors, odoo = {}, {}, {}, {}
|
2026-07-29 06:09:06 -04:00
|
|
|
|
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"
|
|
|
|
|
|
):
|
2026-07-29 07:40:43 -04:00
|
|
|
|
st = read_status(vm["log"])
|
|
|
|
|
|
status[name] = st
|
|
|
|
|
|
# À la complétion (succès OU échec), on ANALYSE le log
|
|
|
|
|
|
# complet pour signaler les erreurs — même un « succès »
|
|
|
|
|
|
# peut contenir des erreurs passées inaperçues.
|
|
|
|
|
|
if st[0] in ("done", "failed") and name not in errors:
|
|
|
|
|
|
errors[name] = scan_log_errors(vm["log"])
|
2026-07-30 01:52:55 -04:00
|
|
|
|
# Odoo up ? On ne teste que celles pas encore confirmées up
|
|
|
|
|
|
# et non effacées (test TCP court sur :8069).
|
|
|
|
|
|
if name not in self._odoo_up and self._domstate.get(
|
|
|
|
|
|
name
|
|
|
|
|
|
) != "gone":
|
|
|
|
|
|
if _port_open(vm.get("ip"), 8069):
|
|
|
|
|
|
odoo[name] = True
|
|
|
|
|
|
return disks, status, self._collect_tele(), errors, odoo
|
2026-07-29 06:09:06 -04:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-30 01:52:55 -04:00
|
|
|
|
disks, status, tele, errors, odoo = await asyncio.to_thread(
|
2026-07-29 06:09:06 -04:00
|
|
|
|
self._collect_table
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
2026-07-28 00:27:25 -04:00
|
|
|
|
return
|
2026-07-29 07:40:43 -04:00
|
|
|
|
self._errcount.update(errors)
|
2026-07-30 01:52:55 -04:00
|
|
|
|
self._odoo_up.update(odoo)
|
2026-07-29 05:01:39 -04:00
|
|
|
|
try:
|
|
|
|
|
|
table = self.query_one("#vms", DataTable)
|
2026-07-29 05:13:56 -04:00
|
|
|
|
now = time.time()
|
2026-07-29 06:09:06 -04:00
|
|
|
|
remaining = []
|
2026-07-29 05:01:39 -04:00
|
|
|
|
for vm in vms:
|
|
|
|
|
|
name = vm["name"]
|
2026-07-29 06:09:06 -04:00
|
|
|
|
self._set_cell(table, name, "disk", disks.get(name, "-"))
|
2026-07-30 01:52:55 -04:00
|
|
|
|
# Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ».
|
|
|
|
|
|
self._set_cell(
|
|
|
|
|
|
table, name, "odoo",
|
|
|
|
|
|
"🟢" if name in self._odoo_up else "—",
|
|
|
|
|
|
)
|
2026-07-29 05:01:39 -04:00
|
|
|
|
if name in self._final:
|
2026-07-29 06:09:06 -04:00
|
|
|
|
continue
|
2026-07-29 05:30:16 -04:00
|
|
|
|
ds = self._domstate.get(name)
|
|
|
|
|
|
if ds == "gone":
|
|
|
|
|
|
self._final[name] = ("deleted", None, now - started)
|
|
|
|
|
|
self._set_cell(
|
|
|
|
|
|
table, name, "state", f"❌ {t('deleted')}"
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
2026-07-29 06:09:06 -04:00
|
|
|
|
st = status.get(name)
|
|
|
|
|
|
if st is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
state, code = st
|
2026-07-29 05:13:56 -04:00
|
|
|
|
if state in ("done", "failed"):
|
|
|
|
|
|
elapsed = now - started
|
|
|
|
|
|
self._final[name] = (state, code, elapsed)
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
if state == "done":
|
2026-07-29 06:46:21 -04:00
|
|
|
|
record_duration(
|
|
|
|
|
|
vm.get("distro"),
|
|
|
|
|
|
vm.get("version"),
|
|
|
|
|
|
vm.get("arch"),
|
|
|
|
|
|
elapsed,
|
|
|
|
|
|
)
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
self._stats = load_stats()
|
2026-07-29 05:13:56 -04:00
|
|
|
|
lbl = "✅" if state == "done" else f"❌ ({code})"
|
|
|
|
|
|
self._set_cell(table, name, "state", lbl)
|
|
|
|
|
|
self._set_cell(
|
|
|
|
|
|
table, name, "elapsed", self._fmt(elapsed)
|
2026-07-29 05:01:39 -04:00
|
|
|
|
)
|
2026-07-29 07:40:43 -04:00
|
|
|
|
# Colonne ⚠ (à gauche d'État) : erreurs détectées dans
|
|
|
|
|
|
# le log, y compris pour un « succès ».
|
|
|
|
|
|
self._set_cell(
|
|
|
|
|
|
table, name, "err",
|
|
|
|
|
|
self._err_label(self._errcount.get(name)),
|
|
|
|
|
|
)
|
2026-07-29 05:30:16 -04:00
|
|
|
|
elif ds == "paused":
|
|
|
|
|
|
self._set_cell(
|
|
|
|
|
|
table, name, "state", f"⏸ {t('paused')}"
|
|
|
|
|
|
)
|
2026-07-29 05:01:39 -04:00
|
|
|
|
else:
|
2026-07-29 05:13:56 -04:00
|
|
|
|
self._set_cell(table, name, "state", ICON[state])
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
ref = eta_reference(self._stats, vm.get("arch"))
|
|
|
|
|
|
if ref is not None:
|
|
|
|
|
|
remaining.append(max(0, ref - (now - started)))
|
2026-07-29 05:13:56 -04:00
|
|
|
|
done = len(self._final)
|
2026-07-29 06:09:06 -04:00
|
|
|
|
eta = (
|
|
|
|
|
|
f" · ETA ~{_fmt_secs(max(remaining))}" if remaining else ""
|
|
|
|
|
|
)
|
2026-07-29 05:13:56 -04:00
|
|
|
|
self.sub_title = (
|
|
|
|
|
|
f"{done}/{len(vms)} {t('completed')} · "
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
f"{self._fmt(now - started)}{eta}"
|
|
|
|
|
|
)
|
2026-07-29 06:09:06 -04:00
|
|
|
|
if tele:
|
|
|
|
|
|
self.query_one("#telemetry", Static).update(tele)
|
2026-07-29 07:40:43 -04:00
|
|
|
|
self._update_stats()
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-07-29 07:40:43 -04:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _err_label(counts):
|
|
|
|
|
|
"""Libellé de la colonne ⚠ : « ⚠N » si erreurs, « ⚡N » si seulement
|
|
|
|
|
|
des avertissements, « ✓ » si log propre."""
|
|
|
|
|
|
if not counts:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
nerr, nwarn = counts
|
|
|
|
|
|
if nerr:
|
|
|
|
|
|
return f"⚠{nerr}"
|
|
|
|
|
|
if nwarn:
|
|
|
|
|
|
return f"⚡{nwarn}"
|
|
|
|
|
|
return "✓"
|
|
|
|
|
|
|
|
|
|
|
|
def _stats_counts(self):
|
|
|
|
|
|
"""Compte par catégorie (terminées, échecs, erreurs, etc.)."""
|
|
|
|
|
|
done = fail = deleted = err_vms = warn_vms = 0
|
|
|
|
|
|
for name, (state, _code, _el) in self._final.items():
|
|
|
|
|
|
if state == "done":
|
|
|
|
|
|
done += 1
|
|
|
|
|
|
elif state == "failed":
|
|
|
|
|
|
fail += 1
|
|
|
|
|
|
elif state == "deleted":
|
|
|
|
|
|
deleted += 1
|
|
|
|
|
|
counts = self._errcount.get(name)
|
|
|
|
|
|
if counts:
|
|
|
|
|
|
if counts[0]:
|
|
|
|
|
|
err_vms += 1
|
|
|
|
|
|
elif counts[1]:
|
|
|
|
|
|
warn_vms += 1
|
|
|
|
|
|
running = paused = 0
|
|
|
|
|
|
for vm in vms:
|
|
|
|
|
|
if vm["name"] in self._final:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if self._domstate.get(vm["name"]) == "paused":
|
|
|
|
|
|
paused += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
running += 1
|
|
|
|
|
|
return {
|
|
|
|
|
|
"total": len(vms), "done": done, "fail": fail,
|
|
|
|
|
|
"deleted": deleted, "running": running, "paused": paused,
|
|
|
|
|
|
"err_vms": err_vms, "warn_vms": warn_vms,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _update_stats(self):
|
|
|
|
|
|
c = self._stats_counts()
|
|
|
|
|
|
line = (
|
|
|
|
|
|
f" 📊 {c['total']} VM · ✅ {c['done']} · ❌ {c['fail']} · "
|
|
|
|
|
|
f"⏳ {c['running']} · ⏸ {c['paused']} · 🗑 {c['deleted']} · "
|
|
|
|
|
|
f"⚠ {c['err_vms']} · ⚡ {c['warn_vms']} "
|
|
|
|
|
|
f"({t('click to expand')})"
|
|
|
|
|
|
)
|
|
|
|
|
|
self.query_one("#stats", Static).update(line)
|
|
|
|
|
|
if self._stats_open:
|
|
|
|
|
|
self.query_one("#statsdetail", Static).update(
|
|
|
|
|
|
self._render_stats_detail()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _render_stats_detail(self):
|
|
|
|
|
|
"""Détail déplié : VM avec erreurs/avertissements + moyennes hist."""
|
|
|
|
|
|
lines = []
|
|
|
|
|
|
for name, (state, code, el) in self._final.items():
|
|
|
|
|
|
counts = self._errcount.get(name)
|
|
|
|
|
|
if counts and (counts[0] or counts[1]):
|
|
|
|
|
|
lines.append(
|
|
|
|
|
|
f" • {name}: ⚠{counts[0]} erreurs, "
|
|
|
|
|
|
f"⚡{counts[1]} avert. ({self._fmt(el)})"
|
|
|
|
|
|
)
|
|
|
|
|
|
if not lines:
|
|
|
|
|
|
lines.append(f" {t('No error detected.')}")
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
2026-07-29 06:09:06 -04:00
|
|
|
|
async def _tick_log(self):
|
|
|
|
|
|
if not self._follow:
|
|
|
|
|
|
return
|
|
|
|
|
|
vm = self._vm_by_name(self._selected)
|
|
|
|
|
|
if not vm:
|
|
|
|
|
|
return
|
|
|
|
|
|
name = vm["name"]
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
try:
|
2026-07-29 06:09:06 -04:00
|
|
|
|
new, off = await asyncio.to_thread(
|
|
|
|
|
|
_read_new, vm["log"], self._offsets.get(name, 0)
|
2026-07-29 05:13:56 -04:00
|
|
|
|
)
|
2026-07-29 05:01:39 -04:00
|
|
|
|
except Exception:
|
2026-07-29 06:09:06 -04:00
|
|
|
|
return
|
|
|
|
|
|
self._offsets[name] = off
|
|
|
|
|
|
for line in new.splitlines():
|
|
|
|
|
|
if EXIT_MARKER not in line:
|
|
|
|
|
|
self._log.write(line)
|
|
|
|
|
|
|
|
|
|
|
|
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 »).
|
2026-07-29 05:30:16 -04:00
|
|
|
|
try:
|
2026-07-29 06:09:06 -04:00
|
|
|
|
states = await asyncio.to_thread(virsh_domstates)
|
2026-07-29 05:30:16 -04:00
|
|
|
|
except Exception:
|
2026-07-29 06:09:06 -04:00
|
|
|
|
return
|
|
|
|
|
|
for vm in vms:
|
|
|
|
|
|
self._domstate[vm["name"]] = states.get(vm["name"], "gone")
|
2026-07-29 05:30:16 -04:00
|
|
|
|
|
2026-07-28 00:27:25 -04:00
|
|
|
|
# -- events --------------------------------------------------------- #
|
|
|
|
|
|
def on_data_table_row_highlighted(self, event) -> None:
|
2026-07-30 01:31:00 -04:00
|
|
|
|
# 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.
|
2026-07-28 00:27:25 -04:00
|
|
|
|
name = event.row_key.value
|
2026-07-30 01:31:00 -04:00
|
|
|
|
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)
|
2026-07-28 00:27:25 -04:00
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
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
|
[IMP] qemu: table lisible/scrollable, guest-exec autorisé, service Odoo, choix navigateur
Suivi d'installation (qemu_install_monitor.py) :
- Table VM/État LISIBLE : largeurs de colonnes fixes (VM 26, ⚠ 4, État 12,
Durée 7, Disque 8) -> l'État n'est plus tronqué à 3-4 caractères
(« ❌ effacée », « ⏸ en pause » lisibles) ; table défilable (overflow-x,
height 1fr) pour les longs noms et les gros parcs.
- « w » web : offre désormais la LISTE des navigateurs CLI installés
(_choose_browser) pour choisir lequel utiliser, + option [i] installer.
Déploiement (deploy_qemu.py) :
- guest-exec AUTORISÉ : on vide la liste de blocage de qemu-ga
(block-rpcs/blacklist vides — pas allow-rpcs qui est une liste BLANCHE et
casserait les autres RPC), + neutralise /etc/sysconfig/qemu-ga (Fedora).
Installation (todo.py) :
- Profils AVEC Odoo (install_odoo*) UNIQUEMENT : Odoo est enregistré comme
service systemd (erplibre.service, inspiré de script/systemd/
install_daemon.sh) puis enable --now. Pas pour ERPLibre seul / mobile /
Déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 08:06:29 -04:00
|
|
|
|
browser = self._choose_browser()
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
if not browser:
|
[IMP] qemu: table lisible/scrollable, guest-exec autorisé, service Odoo, choix navigateur
Suivi d'installation (qemu_install_monitor.py) :
- Table VM/État LISIBLE : largeurs de colonnes fixes (VM 26, ⚠ 4, État 12,
Durée 7, Disque 8) -> l'État n'est plus tronqué à 3-4 caractères
(« ❌ effacée », « ⏸ en pause » lisibles) ; table défilable (overflow-x,
height 1fr) pour les longs noms et les gros parcs.
- « w » web : offre désormais la LISTE des navigateurs CLI installés
(_choose_browser) pour choisir lequel utiliser, + option [i] installer.
Déploiement (deploy_qemu.py) :
- guest-exec AUTORISÉ : on vide la liste de blocage de qemu-ga
(block-rpcs/blacklist vides — pas allow-rpcs qui est une liste BLANCHE et
casserait les autres RPC), + neutralise /etc/sysconfig/qemu-ga (Fedora).
Installation (todo.py) :
- Profils AVEC Odoo (install_odoo*) UNIQUEMENT : Odoo est enregistré comme
service systemd (erplibre.service, inspiré de script/systemd/
install_daemon.sh) puis enable --now. Pas pour ERPLibre seul / mobile /
Déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 08:06:29 -04:00
|
|
|
|
return
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
url = f"http://{vm['ip']}:8069"
|
|
|
|
|
|
with self.suspend():
|
2026-07-29 06:51:54 -04:00
|
|
|
|
print(f"→ {browser} {url}")
|
|
|
|
|
|
rc = os.system(f"{browser} {shlex.quote(url)}")
|
|
|
|
|
|
# Diagnostic : sinon le navigateur « clignote » et revient au
|
|
|
|
|
|
# TUI sans qu'on voie l'erreur (souvent Odoo pas démarré).
|
|
|
|
|
|
print(f"\n[{browser}] terminé (code {rc}).")
|
|
|
|
|
|
if rc != 0:
|
|
|
|
|
|
print(
|
|
|
|
|
|
"La page ne s'est peut-être pas affichée : Odoo n'est "
|
|
|
|
|
|
"pas démarré sur :8069, ou réseau/pare-feu. Vérifie que "
|
|
|
|
|
|
"le service Odoo tourne dans la VM (make run / systemd)."
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
input("Entrée pour revenir au suivi… ")
|
|
|
|
|
|
except EOFError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
[IMP] qemu: table lisible/scrollable, guest-exec autorisé, service Odoo, choix navigateur
Suivi d'installation (qemu_install_monitor.py) :
- Table VM/État LISIBLE : largeurs de colonnes fixes (VM 26, ⚠ 4, État 12,
Durée 7, Disque 8) -> l'État n'est plus tronqué à 3-4 caractères
(« ❌ effacée », « ⏸ en pause » lisibles) ; table défilable (overflow-x,
height 1fr) pour les longs noms et les gros parcs.
- « w » web : offre désormais la LISTE des navigateurs CLI installés
(_choose_browser) pour choisir lequel utiliser, + option [i] installer.
Déploiement (deploy_qemu.py) :
- guest-exec AUTORISÉ : on vide la liste de blocage de qemu-ga
(block-rpcs/blacklist vides — pas allow-rpcs qui est une liste BLANCHE et
casserait les autres RPC), + neutralise /etc/sysconfig/qemu-ga (Fedora).
Installation (todo.py) :
- Profils AVEC Odoo (install_odoo*) UNIQUEMENT : Odoo est enregistré comme
service systemd (erplibre.service, inspiré de script/systemd/
install_daemon.sh) puis enable --now. Pas pour ERPLibre seul / mobile /
Déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 08:06:29 -04:00
|
|
|
|
def _choose_browser(self):
|
|
|
|
|
|
"""Offre la LISTE des navigateurs CLI installés et laisse choisir
|
|
|
|
|
|
lequel utiliser pour voir la page. Si aucun n'est installé, propose
|
|
|
|
|
|
d'en installer un. Renvoie le binaire choisi, ou None."""
|
|
|
|
|
|
available = [b for b in CLI_BROWSERS if shutil.which(b)]
|
|
|
|
|
|
if not available:
|
|
|
|
|
|
browser = self._install_cli_browser()
|
|
|
|
|
|
if not browser:
|
|
|
|
|
|
self.notify(
|
|
|
|
|
|
"Aucun navigateur CLI disponible.",
|
|
|
|
|
|
title="Web",
|
|
|
|
|
|
severity="warning",
|
|
|
|
|
|
)
|
|
|
|
|
|
return browser
|
|
|
|
|
|
with self.suspend():
|
|
|
|
|
|
print("Quel navigateur utiliser pour voir la page ?")
|
|
|
|
|
|
for i, b in enumerate(available, 1):
|
|
|
|
|
|
print(f" [{i}] {b}{' *' if i == 1 else ''}")
|
|
|
|
|
|
print(" [i] Installer un autre navigateur")
|
|
|
|
|
|
sel = input(
|
|
|
|
|
|
f"Choix (numéro, vide = {available[0]}) : "
|
|
|
|
|
|
).strip().lower()
|
|
|
|
|
|
if sel == "i":
|
|
|
|
|
|
return self._install_cli_browser()
|
|
|
|
|
|
if not sel:
|
|
|
|
|
|
return available[0]
|
|
|
|
|
|
try:
|
|
|
|
|
|
idx = int(sel) - 1
|
|
|
|
|
|
if 0 <= idx < len(available):
|
|
|
|
|
|
return available[idx]
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return available[0]
|
|
|
|
|
|
|
2026-07-29 06:51:54 -04:00
|
|
|
|
def _install_cli_browser(self):
|
|
|
|
|
|
"""Demande QUEL navigateur CLI installer (w3m/lynx/links/elinks),
|
|
|
|
|
|
affiche la commande, l'exécute après validation. Renvoie le binaire
|
|
|
|
|
|
désormais disponible, ou None."""
|
|
|
|
|
|
with self.suspend():
|
|
|
|
|
|
print("Aucun navigateur CLI installé. Lequel installer ?")
|
|
|
|
|
|
for i, (b, desc) in enumerate(INSTALLABLE_BROWSERS, 1):
|
|
|
|
|
|
print(f" [{i}] {desc}{' *' if i == 1 else ''}")
|
|
|
|
|
|
sel = input("Choix (numéro, vide = w3m) : ").strip()
|
|
|
|
|
|
browser = INSTALLABLE_BROWSERS[0][0]
|
|
|
|
|
|
try:
|
|
|
|
|
|
idx = int(sel) - 1
|
|
|
|
|
|
if 0 <= idx < len(INSTALLABLE_BROWSERS):
|
|
|
|
|
|
browser = INSTALLABLE_BROWSERS[idx][0]
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
cmd = browser_install_command(browser)
|
|
|
|
|
|
if not cmd:
|
|
|
|
|
|
print(
|
|
|
|
|
|
"Gestionnaire de paquets inconnu : installez "
|
|
|
|
|
|
f"« {browser} » manuellement."
|
|
|
|
|
|
)
|
|
|
|
|
|
input("Entrée… ")
|
|
|
|
|
|
return None
|
|
|
|
|
|
printable = " ".join(cmd)
|
|
|
|
|
|
print(f"Commande : {printable}")
|
|
|
|
|
|
ans = input(
|
|
|
|
|
|
"Installer maintenant ? (o/N) : "
|
|
|
|
|
|
).strip().lower()
|
|
|
|
|
|
if ans not in ("o", "oui", "y", "yes"):
|
|
|
|
|
|
return None
|
|
|
|
|
|
rc = os.system(printable)
|
|
|
|
|
|
print(f"\nInstallation terminée (code {rc}).")
|
|
|
|
|
|
input("Entrée pour continuer… ")
|
|
|
|
|
|
return cli_browser()
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
|
2026-07-28 00:40:18 -04:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-29 22:16:35 -04:00
|
|
|
|
def action_details(self) -> None:
|
|
|
|
|
|
"""Ouvre une petite fenêtre avec les LIGNES d'erreurs/avertissements
|
|
|
|
|
|
de la VM sélectionnée (scan à la demande -> toujours à jour)."""
|
|
|
|
|
|
vm = self._vm_by_name(self._selected)
|
|
|
|
|
|
if not vm:
|
|
|
|
|
|
return
|
|
|
|
|
|
errs, warns = scan_log_error_lines(vm["log"])
|
|
|
|
|
|
self.push_screen(ErrorLinesScreen(vm["name"], errs, warns))
|
|
|
|
|
|
|
2026-07-29 07:40:43 -04:00
|
|
|
|
def on_click(self, event) -> None:
|
|
|
|
|
|
# Clic sur le sommaire de stats -> déplie / replie le détail.
|
|
|
|
|
|
w = getattr(event, "widget", None)
|
|
|
|
|
|
if w is not None and getattr(w, "id", None) == "stats":
|
|
|
|
|
|
self._stats_open = not self._stats_open
|
|
|
|
|
|
self.query_one("#statsdetail").display = self._stats_open
|
|
|
|
|
|
self._update_stats()
|
|
|
|
|
|
|
|
|
|
|
|
# -- pause / reprise de tout le parc -------------------------------- #
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _virsh_bulk(action, names):
|
|
|
|
|
|
for n in names:
|
|
|
|
|
|
try:
|
|
|
|
|
|
subprocess.run(
|
|
|
|
|
|
["sudo", "virsh", action, n],
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
timeout=30,
|
|
|
|
|
|
)
|
|
|
|
|
|
except (OSError, subprocess.SubprocessError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def action_pause_all(self) -> None:
|
|
|
|
|
|
"""Met en PAUSE (virsh suspend) toutes les VM en cours d'exécution.
|
|
|
|
|
|
L'install reprend là où elle en était après « Reprendre »."""
|
|
|
|
|
|
self.run_worker(self._bulk_worker("suspend"), exclusive=False)
|
|
|
|
|
|
|
|
|
|
|
|
def action_resume_all(self) -> None:
|
|
|
|
|
|
"""REPREND (virsh resume) toutes les VM en pause ; le suivi des
|
|
|
|
|
|
logs continue automatiquement (offsets conservés)."""
|
|
|
|
|
|
self.run_worker(self._bulk_worker("resume"), exclusive=False)
|
|
|
|
|
|
|
|
|
|
|
|
async def _bulk_worker(self, action):
|
|
|
|
|
|
want = "running" if action == "suspend" else "paused"
|
|
|
|
|
|
targets = [
|
|
|
|
|
|
vm["name"]
|
|
|
|
|
|
for vm in vms
|
|
|
|
|
|
if self._domstate.get(vm["name"]) == want
|
|
|
|
|
|
]
|
|
|
|
|
|
if not targets:
|
|
|
|
|
|
self.notify(
|
|
|
|
|
|
t("No running VM to pause.")
|
|
|
|
|
|
if action == "suspend"
|
|
|
|
|
|
else t("No paused VM to resume.")
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
await asyncio.to_thread(self._virsh_bulk, action, targets)
|
|
|
|
|
|
verb = t("paused") if action == "suspend" else t("resumed")
|
|
|
|
|
|
self.notify(f"{len(targets)} VM {verb}.")
|
2026-07-30 01:28:06 -04:00
|
|
|
|
# Rafraîchit tout de suite l'état libvirt (pause/reprise visible).
|
|
|
|
|
|
self.run_worker(self._tick_domstate(), exclusive=False)
|
2026-07-29 07:40:43 -04:00
|
|
|
|
|
[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>
2026-07-29 05:20:18 -04:00
|
|
|
|
app = Monitor()
|
|
|
|
|
|
if run_app:
|
|
|
|
|
|
app.run()
|
|
|
|
|
|
return app
|