[ADD] script todo: interactive Textual dashboard for ERPLibre installs
Deploying ERPLibre (single VM [1] and infra [4]) now asks "Interactive monitoring dashboard?". When yes, the installs run DETACHED (setsid, one log file + exit marker per VM) and a Textual dashboard opens: - left: a table of VMs with live status (⏳ running / ✅ done / ❌ failed with exit code) and elapsed time; - right: the selected VM's log, tailed live (f toggles follow); - footer: the VM's ssh command; s suspends the dashboard and SSHes in; - q quits back to the menu — the installs keep running detached, so you can leave before they finish and reopen later on the same log dir. New module qemu_install_monitor.py holds the detached launcher and the Textual app. The remote install script is factored into _qemu_erplibre_remote_cmd. Falls back gracefully if textual is missing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
62bf80d9b1
commit
d4d3d801c3
3 changed files with 344 additions and 25 deletions
232
script/todo/qemu_install_monitor.py
Normal file
232
script/todo/qemu_install_monitor.py
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
"""Dashboard Textual : suivi des installations ERPLibre parallèles sur VM.
|
||||||
|
|
||||||
|
Principe « détachable » : chaque installation tourne dans un processus
|
||||||
|
DÉTACHÉ (setsid) qui écrit un fichier log et, à la fin, un marqueur
|
||||||
|
« __ERPLIBRE_EXIT__ <code> ». Ce module ne fait que VISUALISER ces fichiers :
|
||||||
|
quitter le dashboard n'arrête rien, on peut le rouvrir pour ré-attacher.
|
||||||
|
|
||||||
|
- launch_installs(...) : lance les process détachés + écrit un manifeste JSON.
|
||||||
|
- run_monitor(manifest_path) : ouvre le dashboard Textual sur un manifeste.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
EXIT_MARKER = "__ERPLIBRE_EXIT__"
|
||||||
|
SSH_OPTS = (
|
||||||
|
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
|
||||||
|
"-o ConnectTimeout=8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def session_dir() -> Path:
|
||||||
|
"""Répertoire des logs/manifestes d'installation (créé au besoin)."""
|
||||||
|
base = Path(os.path.expanduser("~/.erplibre/qemu-install"))
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _launch_one(ip: str, remote_cmd: str, log_path: str) -> None:
|
||||||
|
"""Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise
|
||||||
|
la sortie puis écrit le marqueur de fin avec le code de sortie."""
|
||||||
|
wrapper = (
|
||||||
|
# Attente du sshd (jusqu'à ~5 min) pour éviter « Connection refused ».
|
||||||
|
f"for i in $(seq 1 60); do "
|
||||||
|
f"ssh {SSH_OPTS} -o BatchMode=yes erplibre@{ip} true "
|
||||||
|
f">/dev/null 2>&1 && break; sleep 5; done; "
|
||||||
|
f"ssh {SSH_OPTS} erplibre@{ip} {shlex.quote(remote_cmd)} "
|
||||||
|
f"> {shlex.quote(log_path)} 2>&1; "
|
||||||
|
f'echo "{EXIT_MARKER} $?" >> {shlex.quote(log_path)}'
|
||||||
|
)
|
||||||
|
# setsid -f : le process survit à la fermeture du menu / du dashboard.
|
||||||
|
subprocess.Popen(
|
||||||
|
["setsid", "-f", "bash", "-c", wrapper],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
|
||||||
|
"""vms : [{name, ip}]. Lance chaque install détachée, écrit un manifeste
|
||||||
|
et retourne son chemin. remote_cmd : script exécuté dans chaque VM."""
|
||||||
|
sdir = session_dir()
|
||||||
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||||
|
logdir = sdir / stamp
|
||||||
|
logdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
entries = []
|
||||||
|
for vm in vms:
|
||||||
|
log_path = str(logdir / f"{vm['name']}.log")
|
||||||
|
Path(log_path).write_text("") # log vide = état « en attente »
|
||||||
|
_launch_one(vm["ip"], remote_cmd, log_path)
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"name": vm["name"],
|
||||||
|
"ip": vm["ip"],
|
||||||
|
"log": log_path,
|
||||||
|
"ssh": f"ssh erplibre@{vm['ip']}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
manifest = {
|
||||||
|
"branch": branch,
|
||||||
|
"started": time.time(),
|
||||||
|
"vms": entries,
|
||||||
|
}
|
||||||
|
manifest_path = str(logdir / "session.json")
|
||||||
|
Path(manifest_path).write_text(json.dumps(manifest, indent=2))
|
||||||
|
return manifest_path
|
||||||
|
|
||||||
|
|
||||||
|
def read_status(log_path: str) -> tuple[str, int | None]:
|
||||||
|
"""(état, code) d'un log : pending / running / done / failed."""
|
||||||
|
try:
|
||||||
|
data = Path(log_path).read_text(errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return "pending", None
|
||||||
|
if not data.strip():
|
||||||
|
return "pending", None
|
||||||
|
for line in reversed(data.splitlines()):
|
||||||
|
if EXIT_MARKER in line:
|
||||||
|
try:
|
||||||
|
code = int(line.split()[-1])
|
||||||
|
except ValueError:
|
||||||
|
code = 1
|
||||||
|
return ("done" if code == 0 else "failed"), code
|
||||||
|
return "running", None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Dashboard Textual
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def run_monitor(manifest_path: str) -> None:
|
||||||
|
"""Ouvre le dashboard Textual sur un manifeste d'installation."""
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Horizontal
|
||||||
|
from textual.widgets import DataTable, Footer, Header, RichLog, Static
|
||||||
|
|
||||||
|
manifest = json.loads(Path(manifest_path).read_text())
|
||||||
|
started = manifest.get("started", time.time())
|
||||||
|
vms = manifest["vms"]
|
||||||
|
|
||||||
|
ICON = {
|
||||||
|
"pending": "⏳",
|
||||||
|
"running": "⏳",
|
||||||
|
"done": "✅",
|
||||||
|
"failed": "❌",
|
||||||
|
}
|
||||||
|
|
||||||
|
class Monitor(App):
|
||||||
|
CSS = """
|
||||||
|
DataTable { width: 40; border: solid $accent; }
|
||||||
|
RichLog { border: solid $accent; }
|
||||||
|
#sshbar { height: 1; color: $text-muted; }
|
||||||
|
"""
|
||||||
|
BINDINGS = [
|
||||||
|
("q", "quit", "Quitter (détaché)"),
|
||||||
|
("s", "ssh", "SSH"),
|
||||||
|
("f", "follow", "Suivre"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header(show_clock=True)
|
||||||
|
table = DataTable(id="vms", cursor_type="row")
|
||||||
|
# Clés de colonnes explicites : update_cell() les référence.
|
||||||
|
table.add_column("VM", key="vm")
|
||||||
|
table.add_column("État", key="state")
|
||||||
|
table.add_column("Durée", key="elapsed")
|
||||||
|
for vm in vms:
|
||||||
|
table.add_row(vm["name"], "⏳", "00:00", key=vm["name"])
|
||||||
|
self._log = RichLog(id="log", highlight=False, markup=False)
|
||||||
|
with Horizontal():
|
||||||
|
yield table
|
||||||
|
yield self._log
|
||||||
|
yield Static("", id="sshbar")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.title = "ERPLibre — suivi d'installation"
|
||||||
|
self._refresh_ssh()
|
||||||
|
self._load_selected_log(reset=True)
|
||||||
|
self.set_interval(1.0, self._tick)
|
||||||
|
|
||||||
|
# -- helpers -------------------------------------------------------- #
|
||||||
|
def _vm_by_name(self, name):
|
||||||
|
return next((v for v in vms if v["name"] == name), None)
|
||||||
|
|
||||||
|
def _refresh_ssh(self):
|
||||||
|
vm = self._vm_by_name(self._selected)
|
||||||
|
bar = self.query_one("#sshbar", Static)
|
||||||
|
if vm:
|
||||||
|
bar.update(f" {vm['ssh']} (s = ouvrir SSH)")
|
||||||
|
|
||||||
|
def _load_selected_log(self, reset=False):
|
||||||
|
vm = self._vm_by_name(self._selected)
|
||||||
|
if not vm:
|
||||||
|
return
|
||||||
|
if reset:
|
||||||
|
self._log.clear()
|
||||||
|
self._offsets[vm["name"]] = 0
|
||||||
|
try:
|
||||||
|
data = Path(vm["log"]).read_text(errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
off = self._offsets[vm["name"]]
|
||||||
|
new = data[off:]
|
||||||
|
if new:
|
||||||
|
for line in new.splitlines():
|
||||||
|
if EXIT_MARKER not in line:
|
||||||
|
self._log.write(line)
|
||||||
|
self._offsets[vm["name"]] = len(data)
|
||||||
|
|
||||||
|
def _tick(self):
|
||||||
|
table = self.query_one("#vms", DataTable)
|
||||||
|
for vm in vms:
|
||||||
|
state, code = read_status(vm["log"])
|
||||||
|
elapsed = time.time() - started
|
||||||
|
mm, ss = divmod(int(elapsed), 60)
|
||||||
|
label = ICON[state]
|
||||||
|
if state == "failed":
|
||||||
|
label = f"❌ ({code})"
|
||||||
|
elif state == "done":
|
||||||
|
label = "✅"
|
||||||
|
table.update_cell(vm["name"], "state", label)
|
||||||
|
if state in ("running", "pending"):
|
||||||
|
table.update_cell(
|
||||||
|
vm["name"], "elapsed", f"{mm:02d}:{ss:02d}"
|
||||||
|
)
|
||||||
|
if self._follow:
|
||||||
|
self._load_selected_log()
|
||||||
|
|
||||||
|
# -- events --------------------------------------------------------- #
|
||||||
|
def on_data_table_row_highlighted(self, event) -> None:
|
||||||
|
name = event.row_key.value
|
||||||
|
if name and name != self._selected:
|
||||||
|
self._selected = name
|
||||||
|
self._refresh_ssh()
|
||||||
|
self._load_selected_log(reset=True)
|
||||||
|
|
||||||
|
def action_follow(self) -> None:
|
||||||
|
self._follow = not self._follow
|
||||||
|
|
||||||
|
def action_ssh(self) -> None:
|
||||||
|
vm = self._vm_by_name(self._selected)
|
||||||
|
if not vm:
|
||||||
|
return
|
||||||
|
with self.suspend():
|
||||||
|
os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || true")
|
||||||
|
|
||||||
|
Monitor().run()
|
||||||
|
|
@ -1029,7 +1029,12 @@ class TODO:
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
branch = self._qemu_pick_branch()
|
branch = self._qemu_pick_branch()
|
||||||
self._qemu_install_erplibre_vm(name, ssh_key, branch)
|
if self._is_yes(
|
||||||
|
input(t("Interactive monitoring dashboard? (y/N): "))
|
||||||
|
):
|
||||||
|
self._qemu_install_erplibre_monitored([name], branch)
|
||||||
|
else:
|
||||||
|
self._qemu_install_erplibre_vm(name, ssh_key, branch)
|
||||||
|
|
||||||
# Proposer d'enregistrer la VM dans ~/.ssh/config (après le 1er boot).
|
# Proposer d'enregistrer la VM dans ~/.ssh/config (après le 1er boot).
|
||||||
self._qemu_offer_ssh_config(name, "erplibre")
|
self._qemu_offer_ssh_config(name, "erplibre")
|
||||||
|
|
@ -1662,25 +1667,10 @@ class TODO:
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _qemu_install_erplibre_vm(self, name, ssh_key, branch):
|
def _qemu_erplibre_remote_cmd(self, branch):
|
||||||
"""Clone ERPLibre (branche donnée) dans ~/git/erplibre de la VM puis
|
"""Script d'installation ERPLibre exécuté DANS la VM (curl/git/make
|
||||||
exécute « make install_os » et « make install_odoo_18 »."""
|
puis clone + make install_os + make install_odoo_18)."""
|
||||||
ip = self._qemu_vm_ip(name)
|
return (
|
||||||
if not ip:
|
|
||||||
print(
|
|
||||||
f" {name}: {t('no IP obtained, ERPLibre install skipped.')}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
# Attend que le SSH soit prêt (évite « Connection refused » quand
|
|
||||||
# l'install démarre avant le sshd de la VM).
|
|
||||||
print(f" {name} ({ip}): {t('waiting for SSH...')}")
|
|
||||||
if not self._qemu_wait_ssh(ip):
|
|
||||||
print(
|
|
||||||
f" {name} ({ip}): "
|
|
||||||
f"{t('SSH not reachable, ERPLibre install skipped.')}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
remote = (
|
|
||||||
"set -e; "
|
"set -e; "
|
||||||
# Outils d'amorçage (absents des images cloud minimales) :
|
# Outils d'amorçage (absents des images cloud minimales) :
|
||||||
# curl, git, make. Supporte apt (Debian/Ubuntu) et dnf/yum
|
# curl, git, make. Supporte apt (Debian/Ubuntu) et dnf/yum
|
||||||
|
|
@ -1702,6 +1692,60 @@ class TODO:
|
||||||
"cd ~/git/erplibre && make install_os && "
|
"cd ~/git/erplibre && make install_os && "
|
||||||
f"make {self.ERPLIBRE_ODOO_TARGET}"
|
f"make {self.ERPLIBRE_ODOO_TARGET}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _qemu_install_erplibre_monitored(self, names, branch):
|
||||||
|
"""Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre
|
||||||
|
le dashboard Textual. Quitter le dashboard n'arrête pas les installs.
|
||||||
|
"""
|
||||||
|
from script.todo.qemu_install_monitor import (
|
||||||
|
launch_installs,
|
||||||
|
run_monitor,
|
||||||
|
)
|
||||||
|
|
||||||
|
remote = self._qemu_erplibre_remote_cmd(branch)
|
||||||
|
vms = []
|
||||||
|
for name in names:
|
||||||
|
print(f" {name}: {t('resolving IP...')}")
|
||||||
|
ip = self._qemu_vm_ip(name)
|
||||||
|
if ip:
|
||||||
|
vms.append({"name": name, "ip": ip})
|
||||||
|
else:
|
||||||
|
print(f" {name}: {t('no IP, skipped.')}")
|
||||||
|
if not vms:
|
||||||
|
print(t("No VM to install."))
|
||||||
|
return
|
||||||
|
manifest = launch_installs(vms, branch, remote)
|
||||||
|
print(f"\n🖥 {t('Opening the interactive monitor...')}")
|
||||||
|
try:
|
||||||
|
run_monitor(manifest)
|
||||||
|
except ImportError:
|
||||||
|
# textual absent : les installs tournent déjà (détachées), on ne
|
||||||
|
# plante pas — on indique juste où sont les logs.
|
||||||
|
print(f" {t('Install textual for the dashboard (pip).')}")
|
||||||
|
print(
|
||||||
|
f"\n{t('Monitor closed. Installs keep running in the background.')}"
|
||||||
|
)
|
||||||
|
print(f" {t('Logs:')} {os.path.dirname(manifest)}")
|
||||||
|
|
||||||
|
def _qemu_install_erplibre_vm(self, name, ssh_key, branch):
|
||||||
|
"""Clone ERPLibre (branche donnée) dans ~/git/erplibre de la VM puis
|
||||||
|
exécute « make install_os » et « make install_odoo_18 » (streamé)."""
|
||||||
|
ip = self._qemu_vm_ip(name)
|
||||||
|
if not ip:
|
||||||
|
print(
|
||||||
|
f" {name}: {t('no IP obtained, ERPLibre install skipped.')}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# Attend que le SSH soit prêt (évite « Connection refused » quand
|
||||||
|
# l'install démarre avant le sshd de la VM).
|
||||||
|
print(f" {name} ({ip}): {t('waiting for SSH...')}")
|
||||||
|
if not self._qemu_wait_ssh(ip):
|
||||||
|
print(
|
||||||
|
f" {name} ({ip}): "
|
||||||
|
f"{t('SSH not reachable, ERPLibre install skipped.')}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
remote = self._qemu_erplibre_remote_cmd(branch)
|
||||||
ssh_opts = (
|
ssh_opts = (
|
||||||
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
|
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
|
||||||
"-o ConnectTimeout=15"
|
"-o ConnectTimeout=15"
|
||||||
|
|
@ -1819,11 +1863,15 @@ class TODO:
|
||||||
|
|
||||||
# 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM.
|
# 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM.
|
||||||
install_branch = None
|
install_branch = None
|
||||||
|
install_monitor = False
|
||||||
ans = input(
|
ans = input(
|
||||||
t("Install ERPLibre into ~/git/erplibre on each VM? (y/N): ")
|
t("Install ERPLibre into ~/git/erplibre on each VM? (y/N): ")
|
||||||
)
|
)
|
||||||
if self._is_yes(ans):
|
if self._is_yes(ans):
|
||||||
install_branch = self._qemu_pick_branch()
|
install_branch = self._qemu_pick_branch()
|
||||||
|
install_monitor = self._is_yes(
|
||||||
|
input(t("Interactive monitoring dashboard? (y/N): "))
|
||||||
|
)
|
||||||
|
|
||||||
add_ssh_config = self._is_yes(
|
add_ssh_config = self._is_yes(
|
||||||
input(t("Add each VM to ~/.ssh/config? (y/N): "))
|
input(t("Add each VM to ~/.ssh/config? (y/N): "))
|
||||||
|
|
@ -1916,11 +1964,18 @@ class TODO:
|
||||||
|
|
||||||
# 7) Installation ERPLibre (clone + make) si demandée.
|
# 7) Installation ERPLibre (clone + make) si demandée.
|
||||||
if install_branch:
|
if install_branch:
|
||||||
print(
|
if install_monitor:
|
||||||
f"\n{t('Installing ERPLibre on each VM')} ({install_branch})…"
|
# Installs détachées en parallèle + dashboard Textual.
|
||||||
)
|
self._qemu_install_erplibre_monitored(deployed, install_branch)
|
||||||
for name in deployed:
|
else:
|
||||||
self._qemu_install_erplibre_vm(name, ssh_key, install_branch)
|
print(
|
||||||
|
f"\n{t('Installing ERPLibre on each VM')} "
|
||||||
|
f"({install_branch})…"
|
||||||
|
)
|
||||||
|
for name in deployed:
|
||||||
|
self._qemu_install_erplibre_vm(
|
||||||
|
name, ssh_key, install_branch
|
||||||
|
)
|
||||||
|
|
||||||
print(f"\n✅ {t('ERPLibre infra deployment done.')}")
|
print(f"\n✅ {t('ERPLibre infra deployment done.')}")
|
||||||
print(f" {t('Default login:')} erplibre / erplibre")
|
print(f" {t('Default login:')} erplibre / erplibre")
|
||||||
|
|
|
||||||
|
|
@ -1415,6 +1415,38 @@ TRANSLATIONS = {
|
||||||
"fr": "SSH injoignable, installation ERPLibre ignorée.",
|
"fr": "SSH injoignable, installation ERPLibre ignorée.",
|
||||||
"en": "SSH not reachable, ERPLibre install skipped.",
|
"en": "SSH not reachable, ERPLibre install skipped.",
|
||||||
},
|
},
|
||||||
|
"Interactive monitoring dashboard? (y/N): ": {
|
||||||
|
"fr": "Suivi interactif (dashboard) ? (o/N) : ",
|
||||||
|
"en": "Interactive monitoring dashboard? (y/N): ",
|
||||||
|
},
|
||||||
|
"resolving IP...": {
|
||||||
|
"fr": "résolution de l'IP...",
|
||||||
|
"en": "resolving IP...",
|
||||||
|
},
|
||||||
|
"no IP, skipped.": {
|
||||||
|
"fr": "pas d'IP, ignorée.",
|
||||||
|
"en": "no IP, skipped.",
|
||||||
|
},
|
||||||
|
"No VM to install.": {
|
||||||
|
"fr": "Aucune VM à installer.",
|
||||||
|
"en": "No VM to install.",
|
||||||
|
},
|
||||||
|
"Opening the interactive monitor...": {
|
||||||
|
"fr": "Ouverture du suivi interactif...",
|
||||||
|
"en": "Opening the interactive monitor...",
|
||||||
|
},
|
||||||
|
"Monitor closed. Installs keep running in the background.": {
|
||||||
|
"fr": "Suivi fermé. Les installations continuent en arrière-plan.",
|
||||||
|
"en": "Monitor closed. Installs keep running in the background.",
|
||||||
|
},
|
||||||
|
"Logs:": {
|
||||||
|
"fr": "Logs :",
|
||||||
|
"en": "Logs:",
|
||||||
|
},
|
||||||
|
"Install textual for the dashboard (pip).": {
|
||||||
|
"fr": "Installez textual pour le dashboard (pip).",
|
||||||
|
"en": "Install textual for the dashboard (pip).",
|
||||||
|
},
|
||||||
"Parallel deployments (default:": {
|
"Parallel deployments (default:": {
|
||||||
"fr": "Déploiements en parallèle (défaut :",
|
"fr": "Déploiements en parallèle (défaut :",
|
||||||
"en": "Parallel deployments (default:",
|
"en": "Parallel deployments (default:",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue