[ADD] qemu monitor: act on a VM without leaving the dashboard
The monitor could only watch. The "a" key now opens the selected VM's actions: update, restart Odoo, delete. The update is chosen by parts, and the order is not free: system packages, then git repositories, then Python dependencies -- the latter compile against the former. Nothing is chained with "&&": one failing part must not take the others down. Deleting demands a second hand, a confirmation screen naming the disk to be erased. Two defects the dashboard revealed. Detached installs were stealing the shell's keystrokes, the child inheriting a terminal it had no business reading. And the elapsed time restarted from zero every time the dashboard was reopened, since it was counted from the view rather than from the run's own first write. --- FR --- Le suivi ne savait que regarder. La touche « a » ouvre désormais les actions de la VM sélectionnée : mettre à jour, redémarrer Odoo, supprimer. La mise à jour se choisit par parties, et l'ordre n'est pas libre : paquets système, puis dépôts git, puis dépendances Python — ces dernières compilent contre les premiers. Rien n'est enchaîné par « && » : une partie en échec ne doit pas emporter les autres. Supprimer exige une seconde main, un écran de confirmation nommant le disque à effacer. Deux défauts que le tableau de bord a révélés. Les installations détachées volaient les frappes du shell, l'enfant héritant d'un terminal qu'il n'avait pas à lire. Et la durée repartait de zéro à chaque réouverture, comptée depuis la vue plutôt que depuis la première écriture du run. Assisted-by: Claude Opus 5
This commit is contained in:
parent
3fda367151
commit
18f5ddd69f
1 changed files with 396 additions and 6 deletions
|
|
@ -37,6 +37,11 @@ SSH_OPTS = (
|
|||
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
|
||||
"-o ConnectTimeout=8"
|
||||
)
|
||||
# Pour les ssh NON INTERACTIFS : « -n » branche leur entrée sur /dev/null.
|
||||
# Ceinture et bretelles avec le stdin du processus détaché — un ssh qui lit le
|
||||
# terminal vole les frappes du shell, et le diagnostic est très difficile.
|
||||
# Jamais pour un ssh interactif (touche « s »), qui doit garder le clavier.
|
||||
SSH_OPTS_BATCH = f"{SSH_OPTS} -n"
|
||||
|
||||
|
||||
def session_dir() -> Path:
|
||||
|
|
@ -184,7 +189,7 @@ def _launch_one(
|
|||
f"seen=0; "
|
||||
f"for i in $(seq 1 240); do "
|
||||
f"{refresh}"
|
||||
f'st=$(ssh {SSH_OPTS} -o BatchMode=yes "erplibre@$ip" '
|
||||
f'st=$(ssh {SSH_OPTS_BATCH} -o BatchMode=yes "erplibre@$ip" '
|
||||
f"{shlex.quote(ci_probe)} 2>/dev/null); "
|
||||
f'case "$st" in '
|
||||
f"*done*|*disabled*|*error*|*degraded*|*nocloudinit*) seen=1; break;; "
|
||||
|
|
@ -200,13 +205,22 @@ def _launch_one(
|
|||
f"echo {shlex.quote('== ' + msg_ready + ' ==')} >> {log_q}; "
|
||||
f"else echo {shlex.quote('== ' + msg_giveup + ' ==')} >> {log_q}; fi; "
|
||||
f'echo " → $ip" >> {log_q}; '
|
||||
f'ssh {SSH_OPTS} "erplibre@$ip" {shlex.quote(remote_cmd)} '
|
||||
f'ssh {SSH_OPTS_BATCH} "erplibre@$ip" {shlex.quote(remote_cmd)} '
|
||||
f">> {log_q} 2>&1; "
|
||||
f'echo "{EXIT_MARKER} $?" >> {log_q}'
|
||||
)
|
||||
# setsid -f : le process survit à la fermeture du menu / du dashboard.
|
||||
# stdin sur /dev/null : SANS lui, le descripteur 0 du processus détaché
|
||||
# reste le TERMINAL. « setsid » lui retire le terminal de contrôle, mais ne
|
||||
# ferme aucun descripteur — et ssh, lui, LIT son entrée pour la transmettre
|
||||
# à la commande distante. Deux lecteurs se partagent alors le clavier : une
|
||||
# frappe sur deux part vers l'installation au lieu du shell, et il faut
|
||||
# appuyer plusieurs fois pour qu'une lettre arrive. Le symptôme survit à
|
||||
# todo.py, puisque l'installation continue une demi-heure après sa
|
||||
# fermeture — d'où un terminal qui « bogue » sans cause visible.
|
||||
subprocess.Popen(
|
||||
["setsid", "-f", "bash", "-c", wrapper],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
|
|
@ -277,6 +291,20 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
|
|||
return manifest_path
|
||||
|
||||
|
||||
def finished_at(log_path: str, fallback: float) -> float:
|
||||
"""Instant où l'installation s'est RÉELLEMENT arrêtée.
|
||||
|
||||
La dernière écriture dans le log, c'est-à-dire le marqueur de sortie. On
|
||||
ne peut pas prendre « maintenant » : le suivi est détachable, et il
|
||||
observe souvent l'état final longtemps après. Rouvrir le tableau de bord
|
||||
une heure plus tard ajoutait cette heure à la durée affichée, comme si
|
||||
l'installation avait tourné pendant tout ce temps."""
|
||||
try:
|
||||
return os.path.getmtime(log_path)
|
||||
except OSError:
|
||||
return fallback
|
||||
|
||||
|
||||
def read_status(log_path: str) -> tuple[str, int | None]:
|
||||
"""(état, code) d'un log : pending / running / done / failed. Ne lit que
|
||||
la FIN du fichier (le marqueur de sortie est sur la dernière ligne) : lire
|
||||
|
|
@ -814,13 +842,118 @@ def virsh_domstates() -> dict:
|
|||
# --------------------------------------------------------------------------- #
|
||||
# Dashboard Textual
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parties d'une remise à jour, dans l'ordre où elles doivent tourner : les
|
||||
# paquets système d'abord (compilateurs et en-têtes), le code ensuite, les
|
||||
# dépendances Python en dernier — elles se compilent contre les deux premiers.
|
||||
UPDATE_PARTS = (
|
||||
("system", "Paquets systeme (apt/dnf/pacman/zypper)"),
|
||||
("git", "Depots git (ERPLibre + addons)"),
|
||||
("python", "Dependances Python (poetry)"),
|
||||
)
|
||||
EL_DIR = "~/git/erplibre"
|
||||
|
||||
|
||||
def update_remote_cmd(parts) -> str:
|
||||
"""Commande exécutée DANS la VM pour remettre à jour ce qui est coché.
|
||||
|
||||
Rien n'est enchaîné par « && » : une partie qui échoue ne doit pas
|
||||
empêcher les suivantes, et son code de retour se lit dans la sortie.
|
||||
Chaque bloc s'annonce, sinon un log de mise à jour est illisible."""
|
||||
chosen = [p for p in parts if p in dict(UPDATE_PARTS)]
|
||||
if not chosen:
|
||||
return ""
|
||||
out = ["set -u"]
|
||||
if "system" in chosen:
|
||||
out.append(
|
||||
'echo "== Paquets systeme =="; '
|
||||
"if command -v apt-get >/dev/null 2>&1; then "
|
||||
"sudo apt-get -o DPkg::Lock::Timeout=600 update -qq "
|
||||
"&& sudo DEBIAN_FRONTEND=noninteractive "
|
||||
"apt-get -o DPkg::Lock::Timeout=600 -y upgrade; "
|
||||
"elif command -v dnf >/dev/null 2>&1; then "
|
||||
"sudo dnf -y upgrade --refresh; "
|
||||
"elif command -v pacman >/dev/null 2>&1; then "
|
||||
# Arch ne supporte pas la mise à jour partielle : -Syu, jamais -S.
|
||||
"pgrep -x pacman >/dev/null 2>&1 "
|
||||
"|| sudo rm -f /var/lib/pacman/db.lck; "
|
||||
"sudo pacman -Syu --noconfirm; "
|
||||
"elif command -v zypper >/dev/null 2>&1; then "
|
||||
". /etc/os-release; "
|
||||
'case "$ID" in *tumbleweed*) '
|
||||
"sudo zypper --non-interactive dup "
|
||||
"--auto-agree-with-licenses --allow-vendor-change;; "
|
||||
"*) sudo zypper --non-interactive up "
|
||||
"--auto-agree-with-licenses;; esac; "
|
||||
'else echo "Gestionnaire de paquets inconnu"; fi; '
|
||||
)
|
||||
if "git" in chosen:
|
||||
out.append(
|
||||
'echo "== Depots git =="; '
|
||||
f"cd {EL_DIR} || exit 1; "
|
||||
# --ff-only : une VM ne doit jamais fusionner toute seule. Un
|
||||
# historique divergent s'arrête ici, visiblement.
|
||||
"git pull --ff-only; "
|
||||
# Les addons viennent de Google Repo, pas de git : c'est le script
|
||||
# de l'installation qui sait les synchroniser.
|
||||
"./script/install/install_git_repo.sh; "
|
||||
)
|
||||
if "python" in chosen:
|
||||
out.append(
|
||||
'echo "== Dependances Python =="; '
|
||||
f"cd {EL_DIR} || exit 1; "
|
||||
# La phase « poetry » seule : ni venvs ni repo, juste les paquets.
|
||||
"EL_PHASE=poetry ./script/install/install_locally.sh; "
|
||||
)
|
||||
out.append('echo "== Mise a jour terminee =="')
|
||||
return "".join(out[:1]) + "; " + "".join(out[1:])
|
||||
|
||||
|
||||
def restart_odoo_cmd() -> str:
|
||||
"""Redémarre le service ERPLibre, ou le lance à la main s'il n'existe pas.
|
||||
|
||||
Une VM installée sans profil de production n'a pas forcément l'unité
|
||||
systemd : le repli dit quoi faire plutôt que d'échouer sans un mot."""
|
||||
return (
|
||||
"if systemctl list-unit-files 2>/dev/null | grep -q '^erplibre'; then "
|
||||
"sudo systemctl restart erplibre.service "
|
||||
"&& sudo systemctl --no-pager --lines=15 status erplibre.service; "
|
||||
"else echo 'Pas de service erplibre : lancez ./run.sh dans "
|
||||
f"{EL_DIR}'; fi"
|
||||
)
|
||||
|
||||
|
||||
def delete_vm_cmd(name: str, with_disks: bool) -> str:
|
||||
"""Efface la VM sur l'HÔTE. Même séquence que « TODO._qemu_delete_vm » :
|
||||
arrêt, retrait de la définition (nvram si UEFI, repli sinon), puis les
|
||||
disques à la demande."""
|
||||
q = shlex.quote(name)
|
||||
cmd = (
|
||||
f"sudo virsh destroy {q} 2>/dev/null; "
|
||||
f"sudo virsh undefine {q} --nvram 2>/dev/null "
|
||||
f"|| sudo virsh undefine {q}"
|
||||
)
|
||||
if with_disks:
|
||||
disk = shlex.quote(f"/var/lib/libvirt/images/{name}.qcow2")
|
||||
seed = shlex.quote(f"/var/lib/libvirt/images/iso/{name}-seed.iso")
|
||||
cmd += f"; sudo rm -f {disk} {seed}"
|
||||
return cmd
|
||||
|
||||
|
||||
def run_monitor(manifest_path: str, run_app: bool = True):
|
||||
"""Ouvre le dashboard Textual sur un manifeste d'installation. `run_app`
|
||||
à False renvoie l'instance sans la lancer (tests headless)."""
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import DataTable, Footer, Header, RichLog, Static
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
Checkbox,
|
||||
DataTable,
|
||||
Footer,
|
||||
Header,
|
||||
RichLog,
|
||||
Static,
|
||||
)
|
||||
|
||||
manifest = json.loads(Path(manifest_path).read_text())
|
||||
started = manifest.get("started", time.time())
|
||||
|
|
@ -869,6 +1002,84 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
def action_dismiss(self) -> None:
|
||||
self.dismiss()
|
||||
|
||||
class ConfirmScreen(ModalScreen):
|
||||
"""Confirmation d'une action irréversible. Le bouton dangereux n'est
|
||||
PAS le défaut : il faut le viser, pas juste appuyer sur Entrée."""
|
||||
|
||||
BINDINGS = [("escape", "cancel", "Annuler")]
|
||||
|
||||
def __init__(self, title, lines, danger_label):
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._lines = lines
|
||||
self._danger = danger_label
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="confbox"):
|
||||
yield Static(self._title, id="conftitle")
|
||||
for line in self._lines:
|
||||
yield Static(line)
|
||||
with Horizontal(id="confbtns"):
|
||||
yield Button("Annuler", variant="primary", id="c_no")
|
||||
yield Button(self._danger, variant="error", id="c_yes")
|
||||
|
||||
def on_button_pressed(self, event) -> None:
|
||||
self.dismiss(event.button.id == "c_yes")
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss(False)
|
||||
|
||||
class VmActionsScreen(ModalScreen):
|
||||
"""Les opérations d'une VM, rassemblées en un seul endroit.
|
||||
|
||||
Plutôt que trois touches de plus dans un pied de page qui en compte
|
||||
déjà neuf : on voit ce qu'on va faire, et sur quelle machine."""
|
||||
|
||||
BINDINGS = [("escape", "cancel", "Fermer")]
|
||||
|
||||
def __init__(self, vm):
|
||||
super().__init__()
|
||||
self._vm = vm
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="actbox"):
|
||||
yield Static(
|
||||
f"Actions — {self._vm['name']} ({self._vm.get('ip') or '?'})",
|
||||
id="acttitle",
|
||||
)
|
||||
yield Static("Remettre à jour", classes="actgroup")
|
||||
for key, label in UPDATE_PARTS:
|
||||
yield Checkbox(label, value=True, id=f"u_{key}")
|
||||
yield Button(
|
||||
"Lancer la mise à jour",
|
||||
variant="primary",
|
||||
id="a_update",
|
||||
)
|
||||
yield Static("Service", classes="actgroup")
|
||||
yield Button("Redémarrer Odoo", id="a_restart")
|
||||
yield Static("Irréversible", classes="actdanger")
|
||||
yield Button(
|
||||
"Supprimer la VM et ses disques",
|
||||
variant="error",
|
||||
id="a_delete",
|
||||
)
|
||||
|
||||
def on_button_pressed(self, event) -> None:
|
||||
if event.button.id == "a_update":
|
||||
parts = [
|
||||
k
|
||||
for k, _lbl in UPDATE_PARTS
|
||||
if self.query_one(f"#u_{k}", Checkbox).value
|
||||
]
|
||||
self.dismiss(("update", parts))
|
||||
elif event.button.id == "a_restart":
|
||||
self.dismiss(("restart", []))
|
||||
elif event.button.id == "a_delete":
|
||||
self.dismiss(("delete", []))
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
ICON = {
|
||||
"pending": "⏳",
|
||||
"running": "⏳",
|
||||
|
|
@ -891,6 +1102,21 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
}
|
||||
#errtitle { height: 1; color: $accent; text-style: bold; }
|
||||
#errlog { height: 1fr; border: solid $accent; }
|
||||
VmActionsScreen { align: center middle; }
|
||||
#actbox {
|
||||
width: 62; height: auto; padding: 1 2;
|
||||
border: thick $accent; background: $surface;
|
||||
}
|
||||
#acttitle { color: $accent; text-style: bold; padding-bottom: 1; }
|
||||
.actgroup { color: $accent; text-style: bold; padding: 1 0 0 0; }
|
||||
.actdanger { color: $error; text-style: bold; padding: 1 0 0 0; }
|
||||
ConfirmScreen { align: center middle; }
|
||||
#confbox {
|
||||
width: 66; height: auto; padding: 1 2;
|
||||
border: thick $error; background: $surface;
|
||||
}
|
||||
#conftitle { color: $error; text-style: bold; padding-bottom: 1; }
|
||||
#confbtns { height: auto; padding-top: 1; }
|
||||
"""
|
||||
BINDINGS = [
|
||||
("q", "quit", "Quitter (détaché)"),
|
||||
|
|
@ -900,6 +1126,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
("f", "follow", "Suivre"),
|
||||
("c", "copy_log", "Copier log"),
|
||||
("d", "details", "Détails erreurs"),
|
||||
("a", "vm_actions", "Actions VM"),
|
||||
("p", "pause_all", "Pause tout"),
|
||||
("o", "resume_all", "Reprendre tout"),
|
||||
]
|
||||
|
|
@ -1124,7 +1351,14 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
continue
|
||||
ds = self._domstate.get(name)
|
||||
if ds == "gone":
|
||||
self._final[name] = ("deleted", None, now - started)
|
||||
# Même bornage que les autres états terminaux : une
|
||||
# VM effacée ne « tourne » plus depuis la dernière
|
||||
# ligne de son log.
|
||||
self._final[name] = (
|
||||
"deleted",
|
||||
None,
|
||||
max(0.0, finished_at(vm["log"], now) - started),
|
||||
)
|
||||
# Icône seule (VM effacée) : évite « ❌ effacée » (10
|
||||
# cellules) qui forçait une colonne État large.
|
||||
self._set_cell(table, name, "state", "🗑")
|
||||
|
|
@ -1134,7 +1368,11 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
continue
|
||||
state, code = st
|
||||
if state in ("done", "failed"):
|
||||
elapsed = now - started
|
||||
# Bornée à la dernière écriture du log, pas à l'instant
|
||||
# présent : le suivi peut être rouvert bien plus tard.
|
||||
elapsed = max(
|
||||
0.0, finished_at(vm["log"], now) - started
|
||||
)
|
||||
self._final[name] = (state, code, elapsed)
|
||||
# Les échecs sont enregistrés eux aussi (ok=False) :
|
||||
# sans eux, aucun taux de réussite n'est calculable.
|
||||
|
|
@ -1179,7 +1417,8 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
maxd = f" · max {self._fmt(max_dur)}" if max_dur else ""
|
||||
self.sub_title = (
|
||||
f"{done}/{len(vms)} {t('completed')} · "
|
||||
f"{self._fmt(now - started)}{eta}{maxd}"
|
||||
f"{self._fmt(self._total_elapsed(now, started))}"
|
||||
f"{eta}{maxd}"
|
||||
)
|
||||
if tele:
|
||||
self.query_one("#telemetry", Static).update(tele)
|
||||
|
|
@ -1516,6 +1755,157 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# -- largeur des colonnes ---------------------------------------- #
|
||||
COL_STEP = 2
|
||||
COL_MIN = 3
|
||||
COL_MAX = 60
|
||||
|
||||
def _cursor_column(self):
|
||||
"""Colonne sous le curseur, ou None. DataTable expose ses colonnes
|
||||
dans un dictionnaire ORDONNÉ : le rang du curseur y donne la clé.
|
||||
"""
|
||||
table = self.query_one("#vms", DataTable)
|
||||
keys = list(table.columns)
|
||||
index = table.cursor_column
|
||||
if not (0 <= index < len(keys)):
|
||||
return None, None
|
||||
return table, keys[index]
|
||||
|
||||
def _apply_column_widths(self, table) -> None:
|
||||
"""Fait PRENDRE les largeurs à l'écran.
|
||||
|
||||
« refresh(layout=True) » ne suffit pas, et c'est le piège :
|
||||
mesuré sur Textual 8.2.8, la largeur de la colonne passe bien de
|
||||
22 à 34, mais la taille virtuelle du tableau reste à 81 — donc
|
||||
rien ne bouge. « clear_cached_dimensions » et « refresh_column »
|
||||
n'y changent rien non plus ; seul le recalcul des dimensions la
|
||||
porte à 93.
|
||||
|
||||
C'est une API privée, d'où le repli : une version future de
|
||||
Textual dégradera l'ajustement au lieu de casser le suivi."""
|
||||
try:
|
||||
table._update_dimensions(list(table.rows))
|
||||
except Exception:
|
||||
table.refresh(layout=True)
|
||||
|
||||
def _resize_column(self, delta) -> None:
|
||||
table, key = self._cursor_column()
|
||||
if key is None:
|
||||
return
|
||||
col = table.columns[key]
|
||||
width = max(
|
||||
self.COL_MIN, min(self.COL_MAX, (col.width or 0) + delta)
|
||||
)
|
||||
if width == col.width:
|
||||
# Butée atteinte : le SEUL cas où il faut le dire, puisque
|
||||
# rien ne bougera à l'écran pour l'expliquer.
|
||||
self.notify(
|
||||
f"{col.label} : {width} (butee {self.COL_MIN}-{self.COL_MAX})",
|
||||
severity="warning",
|
||||
)
|
||||
return
|
||||
col.width = width
|
||||
# auto_width écraserait la largeur au prochain rendu : on la coupe,
|
||||
# sinon le réglage ne survit pas à la première mise à jour.
|
||||
col.auto_width = False
|
||||
# Pas de notification quand ça marche : le changement se VOIT, et
|
||||
# une bulle par frappe rendait l'ajustement pénible.
|
||||
self._apply_column_widths(table)
|
||||
|
||||
def action_col_grow(self) -> None:
|
||||
self._resize_column(self.COL_STEP)
|
||||
|
||||
def action_col_shrink(self) -> None:
|
||||
self._resize_column(-self.COL_STEP)
|
||||
|
||||
def action_col_reset(self) -> None:
|
||||
"""Rend aux colonnes les largeurs déclarées à la construction."""
|
||||
table = self.query_one("#vms", DataTable)
|
||||
for key, width in COL_DEFAULT_WIDTHS.items():
|
||||
if key in table.columns:
|
||||
table.columns[key].width = width
|
||||
table.columns[key].auto_width = False
|
||||
self._apply_column_widths(table)
|
||||
|
||||
def _total_elapsed(self, now, started):
|
||||
"""Durée globale. Elle se FIGE quand plus rien ne tourne : sinon
|
||||
le total continuait de courir sur un parc entièrement terminé,
|
||||
et repartait de plus belle à chaque réouverture."""
|
||||
if len(self._final) < len(vms) or not self._final:
|
||||
return now - started
|
||||
return max(e for _s, _c, e in self._final.values())
|
||||
|
||||
def action_vm_actions(self) -> None:
|
||||
"""Ouvre les actions de la VM sélectionnée."""
|
||||
vm = self._vm_by_name(self._selected)
|
||||
if not vm:
|
||||
return
|
||||
|
||||
def chosen(result):
|
||||
if not result:
|
||||
return
|
||||
kind, parts = result
|
||||
if kind == "update":
|
||||
self._run_update(vm, parts)
|
||||
elif kind == "restart":
|
||||
self._run_in_vm(
|
||||
vm, restart_odoo_cmd(), "Redemarrage d'Odoo"
|
||||
)
|
||||
elif kind == "delete":
|
||||
self._ask_delete(vm)
|
||||
|
||||
self.push_screen(VmActionsScreen(vm), chosen)
|
||||
|
||||
def _run_in_vm(self, vm, cmd, title) -> None:
|
||||
"""Exécute une commande DANS la VM, terminal rendu.
|
||||
|
||||
« suspend() » comme pour SSH et la console : sudo peut demander son
|
||||
mot de passe et la sortie est longue ; la garder derrière Textual
|
||||
la rendrait illisible. La pause finale évite que l'écran reparte
|
||||
avant qu'on ait lu le résultat."""
|
||||
if not vm.get("ip"):
|
||||
self.notify("Pas d'IP pour cette VM.", severity="error")
|
||||
return
|
||||
with self.suspend():
|
||||
print(f"\n=== {title} — {vm['name']} ===")
|
||||
os.system(
|
||||
f"ssh {SSH_OPTS} erplibre@{vm['ip']} "
|
||||
f"{shlex.quote(cmd)} || true"
|
||||
)
|
||||
input("\nEntrée pour revenir au suivi… ")
|
||||
|
||||
def _run_update(self, vm, parts) -> None:
|
||||
cmd = update_remote_cmd(parts)
|
||||
if not cmd:
|
||||
self.notify("Rien de coché.", severity="warning")
|
||||
return
|
||||
self._run_in_vm(vm, cmd, "Mise a jour")
|
||||
|
||||
def _ask_delete(self, vm) -> None:
|
||||
"""Suppression : jamais sans une seconde main."""
|
||||
|
||||
def confirmed(yes):
|
||||
if not yes:
|
||||
return
|
||||
with self.suspend():
|
||||
print(f"\n=== Suppression — {vm['name']} ===")
|
||||
os.system(delete_vm_cmd(vm["name"], True) + " || true")
|
||||
input("\nEntrée pour revenir au suivi… ")
|
||||
|
||||
self.push_screen(
|
||||
ConfirmScreen(
|
||||
f"Supprimer {vm['name']} ?",
|
||||
[
|
||||
"La VM est arrêtée, sa définition retirée,",
|
||||
"et son disque qcow2 EFFACÉ. Rien n'est récupérable.",
|
||||
"",
|
||||
f" /var/lib/libvirt/images/{vm['name']}.qcow2",
|
||||
],
|
||||
"Supprimer définitivement",
|
||||
),
|
||||
confirmed,
|
||||
)
|
||||
|
||||
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 »."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue